conflict-nuxt-4/app/components/auto-import/TiptapEditor.vue
2026-02-12 11:24:27 +03:30

2039 lines
49 KiB
Vue
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="editor-shell container pt-0">
<!-- نوار ابزار ثابت در بالای صفحه -->
<div v-if="editor" class="menu-bar-sticky">
<div class="menu-bar">
<!-- نوار ابزار یک خطی -->
<div class="toolbar-container">
<!-- بخش اول: متن -->
<div class="toolbar-section">
<button
@click="handleToolbarButtonClick({ action: 'toggleBold' })"
:class="{ 'is-active': editor.isActive('bold') }"
class="toolbar-btn"
title="پررنگ (Ctrl+B)"
>
<UIcon :name="'lucide-bold'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'toggleItalic' })"
:class="{ 'is-active': editor.isActive('italic') }"
class="toolbar-btn"
title="کج (Ctrl+I)"
>
<UIcon :name="'lucide-italic'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'toggleUnderline' })"
:class="{ 'is-active': editor.isActive('underline') }"
class="toolbar-btn"
title="زیرخط (Ctrl+U)"
>
<UIcon :name="'lucide-underline'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'toggleStrike' })"
:class="{ 'is-active': editor.isActive('strike') }"
class="toolbar-btn"
title="خط خورده (Ctrl+Shift+S)"
>
<UIcon :name="'lucide-strikethrough'" />
</button>
<!-- انتخاب رنگ متن -->
<div class="color-picker-wrapper">
<button class="toolbar-btn" title="رنگ متن">
<UIcon :name="'lucide-palette'" />
</button>
<input
type="color"
class="color-picker"
@change="setTextColor($event)"
/>
</div>
<!-- انتخاب سایز فونت -->
<select
class="font-size-select"
@change="setFontSize($event)"
title="سایز فونت"
>
<option value="12">12</option>
<option value="14" selected>14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="24">24</option>
<option value="28">28</option>
<option value="32">32</option>
</select>
</div>
<!-- جداکننده -->
<div class="toolbar-divider"></div>
<!-- بخش دوم: تراز و استایل -->
<div class="toolbar-section">
<button
@click="
handleToolbarButtonClick({
action: 'setTextAlign',
value: 'right',
})
"
:class="{ 'is-active': editor.isActive({ textAlign: 'right' }) }"
class="toolbar-btn"
title="تراز به راست"
>
<UIcon :name="'lucide-align-right'" />
</button>
<button
@click="
handleToolbarButtonClick({
action: 'setTextAlign',
value: 'center',
})
"
:class="{ 'is-active': editor.isActive({ textAlign: 'center' }) }"
class="toolbar-btn"
title="تراز به وسط"
>
<UIcon :name="'lucide-align-center'" />
</button>
<button
@click="
handleToolbarButtonClick({
action: 'setTextAlign',
value: 'left',
})
"
:class="{ 'is-active': editor.isActive({ textAlign: 'left' }) }"
class="toolbar-btn"
title="تراز به چپ"
>
<UIcon :name="'lucide-align-left'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'toggleBulletList' })"
:class="{ 'is-active': editor.isActive('bulletList') }"
class="toolbar-btn"
title="لیست نقطه‌ای"
>
<UIcon :name="'lucide-list'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'toggleOrderedList' })"
:class="{ 'is-active': editor.isActive('orderedList') }"
class="toolbar-btn"
title="لیست شماره‌ای"
>
<UIcon :name="'lucide-list-check'" />
</button>
</div>
<!-- جداکننده -->
<div class="toolbar-divider"></div>
<!-- بخش سوم: سرتیترها -->
<div class="toolbar-section">
<select
class="heading-select"
@change="setHeading($event)"
title="سرفصل"
>
<option value="paragraph">پاراگراف</option>
<option value="h1">h1</option>
<option value="h2">h2</option>
<option value="h3">h3</option>
<option value="h4">h4</option>
</select>
<button
@click="handleToolbarButtonClick({ action: 'toggleBlockquote' })"
:class="{ 'is-active': editor.isActive('blockquote') }"
class="toolbar-btn"
title="نقل قول"
>
<UIcon :name="'lucide-quote'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'toggleCodeBlock' })"
:class="{ 'is-active': editor.isActive('codeBlock') }"
class="toolbar-btn"
title="کد"
>
<UIcon :name="'lucide-code'" />
</button>
</div>
<!-- جداکننده -->
<div class="toolbar-divider"></div>
<!-- بخش چهارم: ابزارهای پیشرفته -->
<div class="toolbar-section">
<button
@click="toggleLink"
:class="{ 'is-active': editor.isActive('link') }"
class="toolbar-btn"
title="لینک (Ctrl+K)"
>
<UIcon :name="'lucide-link'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'setHorizontalRule' })"
class="toolbar-btn"
title="خط جداکننده"
>
<UIcon :name="'lucide-minus'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'undo' })"
:disabled="!editor.can().undo()"
class="toolbar-btn"
title="بازگردانی (Ctrl+Z)"
>
<UIcon :name="'lucide-rotate-ccw'" />
</button>
<button
@click="handleToolbarButtonClick({ action: 'redo' })"
:disabled="!editor.can().redo()"
class="toolbar-btn"
title="انجام مجدد (Ctrl+Y)"
>
<UIcon :name="'lucide-rotate-cw'" />
</button>
<!-- منوی بیشتر -->
<div class="more-menu-wrapper">
<button class="toolbar-btn more-btn" title="ابزارهای بیشتر">
<UIcon :name="'lucide-more-horizontal'" />
</button>
<div class="more-dropdown">
<button
@click="
handleToolbarButtonClick({ action: 'toggleTaskList' })
"
:class="{ 'is-active': editor.isActive('taskList') }"
class="dropdown-item"
>
<UIcon :name="'lucide-list-check'" />
<span>لیست کارها</span>
</button>
<button class="dropdown-item" @click="clearFormatting">
<UIcon :name="'lucide-eraser'" />
<span>پاک کردن فرمت</span>
</button>
<button class="dropdown-item" @click="copyToClipboard">
<UIcon :name="'lucide-copy'" />
<span>کپی با فرمت</span>
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="exportAsHTML">
<UIcon :name="'lucide-download'" />
<span>خروجی HTML</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- محتوای ویرایشگر با Context Menu -->
<UContextMenu
:items="contextMenuItems"
:ui="{
content: 'w-64',
itemTrailingIcon: 'hidden',
}"
dir="rtl"
@select="handleContextMenuSelect"
>
<!-- Custom slot برای آیکون چپ چرخان -->
<template #item-trailing="{ item }">
<UIcon
v-if="item.children"
name="i-lucide-chevron-left"
class="shrink-0 size-5 ms-auto"
:class="{
'rotate-180': !$dir === 'rtl',
}"
/>
<div v-else-if="item.kbds" class="flex gap-1 ms-auto">
<UKbd v-for="(kbd, index) in item.kbds" :key="index" :value="kbd" />
</div>
</template>
<div class="editor-content-wrapper" @mouseup="handleTextSelection">
<EditorContent :editor="editor" />
<!-- آیکون‌های بلوک (شبیه Notion) -->
<!-- <div
v-for="(block, index) in blocks"
:key="block.id"
class="block-icon-wrapper"
:style="getBlockIconStyle(block, index)"
@click="showBlockMenuAction(block, $event)"
>
<div class="block-icon">
{{ getBlockIcon(block.type) }}
</div>
</div> -->
</div>
</UContextMenu>
<!-- منوی بلوک -->
<BlockMenu
v-if="showBlockMenu"
:visible="showBlockMenu"
:position="blockMenuPosition"
:block-type="currentBlockType"
:block-data="currentBlockData"
@close="showBlockMenu = false"
@action="handleBlockMenuAction"
/>
<!-- مودال برای لینک -->
<div v-if="showLinkModal" class="modal-overlay" @click="closeLinkModal">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>افزودن لینک</h3>
<button @click="closeLinkModal" class="close-btn">×</button>
</div>
<div class="modal-body">
<input
v-model="linkUrl"
type="url"
placeholder="https://example.com"
class="link-input"
@keyup.enter="setLink"
ref="linkInput"
/>
<div class="link-example">مثال: https://google.com</div>
</div>
<div class="modal-footer">
<button @click="setLink" class="btn-primary">افزودن لینک</button>
<button
v-if="editor && editor.isActive('link')"
@click="unsetLink"
class="btn-danger"
>
حذف لینک
</button>
<button @click="closeLinkModal" class="btn-secondary">انصراف</button>
</div>
</div>
</div>
<!-- وضعیت پردازش AI -->
<div v-if="isAIProcessing" class="ai-processing-overlay">
<div class="ai-processing">
<div class="flex justify-center">
<img src="/robot.png" alt="" class="h-32 w-32 animate-pulse-glow" />
</div>
<div class="ai-text">در حال پردازش توسط هوش مصنوعی...</div>
<div class="ai-progress"></div>
</div>
</div>
</div>
</template>
<script setup>
import { EditorContent, useEditor } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TextAlign from "@tiptap/extension-text-align";
import DragHandle from "@tiptap/extension-drag-handle";
import { usePageStore } from "@/stores/page";
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from "vue";
import EditorSchema from "@/json/EditorSchema.json";
const pageStore = usePageStore();
// وضعیت‌ها
const showLinkModal = ref(false);
const linkUrl = ref("");
const lastSavedTime = ref("");
const linkInput = ref(null);
// منوها
const showBlockMenu = ref(false);
const blockMenuPosition = ref({ x: 0, y: 0 });
const selectedText = ref("");
const currentBlockType = ref("paragraph");
const currentBlockData = ref({});
const isAIProcessing = ref(false);
// داده‌های Schema
const toolbarSchema = EditorSchema.toolbarButtons;
const contextMenuSchema = EditorSchema.contextMenuItems;
const blockIcons = EditorSchema.blockIcons;
const blockTypeNames = EditorSchema.blockTypeNames;
const alignmentNames = EditorSchema.alignmentNames;
const editorConfig = EditorSchema.editorConfig;
// Context Menu Items برای Nuxt UI
const contextMenuItems = computed(() => {
const buildMenuItems = (items) => {
return items.map((item) => {
const menuItem = {
label: item.label,
icon: item.icon,
kbds: item.kbds,
color: item.color,
};
// اگر آیتم children داشته باشد (مانند ترجمه)
if (item.children && item.children.length > 0) {
menuItem.children = item.children.map((childRow) =>
childRow.map((child) => ({
label: child.label,
icon: child.icon,
kbds: child.kbds,
color: child.color,
// ذخیره داده‌های اضافی مانند lang
meta: {
action: child.action,
lang: child.lang,
},
onSelect: () =>
handleContextMenuItemClick(child.action, child.lang),
})),
);
} else {
// آیتم‌های عادی بدون children
menuItem.onSelect = () => handleContextMenuItemClick(item.action);
}
return menuItem;
});
};
const sections = [];
contextMenuSchema.forEach((section) => {
const sectionChildren = [];
section.items.forEach((row) => {
const rowItems = buildMenuItems(row);
sectionChildren.push(rowItems);
});
sections.push([
{
label: section.label,
icon: section.icon,
children: sectionChildren,
},
]);
});
return sections;
});
// ==================== توابع جدید برای نوار ابزار یک خطی ====================
// تنظیم سرتیتر
const setHeading = (event) => {
const value = event.target.value;
if (!editor.value) return;
switch (value) {
case "h1":
editor.value.chain().focus().toggleHeading({ level: 1 }).run();
break;
case "h2":
editor.value.chain().focus().toggleHeading({ level: 2 }).run();
break;
case "h3":
editor.value.chain().focus().toggleHeading({ level: 3 }).run();
break;
case "h4":
editor.value.chain().focus().toggleHeading({ level: 4 }).run();
break;
default:
editor.value.chain().focus().setParagraph().run();
}
};
// تنظیم سایز فونت
const setFontSize = (event) => {
const size = event.target.value;
if (!editor.value) return;
// اگر extension textStyle نصب شده باشد
editor.value
.chain()
.focus()
.setMark("textStyle", { fontSize: `${size}px` })
.run();
};
// تنظیم رنگ متن
const setTextColor = (event) => {
const color = event.target.value;
if (!editor.value) return;
editor.value.chain().focus().setColor(color).run();
};
// پاک کردن فرمت
const clearFormatting = () => {
if (!editor.value) return;
editor.value.chain().focus().clearNodes().unsetAllMarks().run();
};
// کپی با فرمت
const copyToClipboard = async () => {
if (!editor.value) return;
try {
const html = editor.value.getHTML();
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([editor.value.getText()], {
type: "text/plain",
}),
}),
]);
showToast("متن با فرمت کپی شد");
} catch (error) {
console.error("خطا در کپی:", error);
showToast("خطا در کپی کردن");
}
};
// خروجی HTML
const exportAsHTML = () => {
if (!editor.value) return;
const html = editor.value.getHTML();
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "متن-ویرایش-شده.html";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast("فایل HTML ذخیره شد");
};
// ==================== توابع مدیریت نوار ابزار ====================
// مدیریت کلیک دکمه‌های نوار ابزار
const handleToolbarButtonClick = (btn) => {
if (!editor.value) return;
switch (btn.action) {
case "toggleHeading":
editor.value.chain().focus().toggleHeading({ level: btn.level }).run();
break;
case "toggleBold":
editor.value.chain().focus().toggleBold().run();
break;
case "toggleItalic":
editor.value.chain().focus().toggleItalic().run();
break;
case "toggleUnderline":
editor.value.chain().focus().toggleUnderline().run();
break;
case "toggleStrike":
editor.value.chain().focus().toggleStrike().run();
break;
case "toggleLink":
toggleLink();
break;
case "toggleBulletList":
editor.value.chain().focus().toggleBulletList().run();
break;
case "toggleOrderedList":
editor.value.chain().focus().toggleOrderedList().run();
break;
case "toggleTaskList":
editor.value.chain().focus().toggleTaskList().run();
break;
case "toggleCodeBlock":
editor.value.chain().focus().toggleCodeBlock().run();
break;
case "toggleBlockquote":
editor.value.chain().focus().toggleBlockquote().run();
break;
case "setHorizontalRule":
editor.value.chain().focus().setHorizontalRule().run();
break;
case "setTextAlign":
editor.value.chain().focus().setTextAlign(btn.value).run();
break;
case "undo":
editor.value.chain().focus().undo().run();
break;
case "redo":
editor.value.chain().focus().redo().run();
break;
}
};
// ==================== توابع مدیریت Context Menu ====================
// مدیریت کلیک روی آیتم‌های Context Menu
const handleContextMenuItemClick = (action, lang = null) => {
if (!editor.value) return;
console.log("Context menu clicked:", {
action,
lang,
selectedText: selectedText.value,
});
switch (action) {
case "summarize":
case "improve":
case "continue":
case "explain":
case "simplify":
case "spellcheck":
case "simplify":
case "rewrite":
case "expand":
case "title":
handleAIAction(action, selectedText.value);
break;
case "translate":
if (lang) {
handleAIAction(action, selectedText.value, lang);
} else {
// اگر lang مشخص نشده، از کاربر بپرسید
showLanguageSelectionModal();
}
break;
// ... سایر caseها مانند قبل
}
};
// مدیریت paste
const handlePaste = async () => {
try {
const clipboardText = await navigator.clipboard.readText();
editor.value?.chain().focus().insertContent(clipboardText).run();
showToast("متن چسبانده شد");
} catch (error) {
console.error("خطا در خواندن کلیپ‌بورد:", error);
showToast("خطا در چسباندن متن");
}
};
// مدیریت انتخاب از Context Menu
const handleContextMenuSelect = (e, item) => {
console.log("Context menu item selected:", item.label);
if (item.onSelect) {
item.onSelect(e);
}
};
// ==================== ویرایشگر Tiptap ====================
const editor = useEditor({
extensions: [
StarterKit.configure({
history: false, // 👈 خیلی مهم
}),
Placeholder.configure({
placeholder: editorConfig.placeholder,
emptyEditorClass: editorConfig.emptyEditorClass,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: editorConfig.linkAttributes,
}),
TaskList,
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: "flex items-start gap-2",
},
}),
HorizontalRule.configure({
HTMLAttributes: {
class: "my-8",
},
}),
TextAlign.configure({
types: ["heading", "paragraph"],
alignments: ["right", "center", "left"],
defaultAlignment: editorConfig.defaultAlignment,
}),
DragHandle.configure({
draggable: true,
}),
],
content:
pageStore.blocks.length > 0
? pageStore.blocksToEditorContent()
: EditorSchema.welcomeContent,
editorProps: {
attributes: editorConfig.attributes,
handleKeyDown: (view, event) => {
// میانبرهای کیبورد
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
toggleLink();
event.preventDefault();
return true;
}
if ((event.ctrlKey || event.metaKey) && event.key === "z") {
if (event.shiftKey) {
editor.commands.redo();
} else {
editor.commands.undo();
}
event.preventDefault();
return true;
}
return false;
},
},
});
// ==================== توابع مدیریت ====================
// انتخاب متن
const handleTextSelection = () => {
const selection = window.getSelection();
selectedText.value = selection.toString().trim();
};
// نمایش منوی بلوک
const showBlockMenuAction = (block, event) => {
event.stopPropagation();
currentBlockType.value = block.type;
currentBlockData.value = block.data || {};
blockMenuPosition.value = {
x: event.clientX,
y: event.clientY,
};
showBlockMenu.value = true;
console.log("🧱 منوی بلوک باز شد:", {
type: block.type,
position: blockMenuPosition.value,
data: currentBlockData.value,
});
};
// مدیریت اکشن‌های منوی بلوک
const handleBlockMenuAction = ({ action, value, blockType, blockData }) => {
console.log("🎯 اکشن منوی بلوک:", action, value, blockType, blockData);
switch (action) {
case "color":
showToast(`رنگ بلوک به ${value} تغییر کرد`);
break;
case "align":
if (editor.value) {
editor.value.chain().focus().setTextAlign(value).run();
}
showToast(`تراز به ${getAlignmentName(value)} تغییر کرد`);
break;
case "convert":
convertBlockType(value);
showToast(`بلوک به ${getBlockTypeName(value)} تبدیل شد`);
break;
case "duplicate":
duplicateBlock(blockData);
showToast("بلوک تکثیر شد");
break;
case "comment":
const comment = prompt("نظر خود را وارد کنید:");
if (comment) {
showToast("نظر اضافه شد");
}
break;
case "move-up":
showToast("بلوک به بالا منتقل شد");
break;
case "move-down":
showToast("بلوک به پایین منتقل شد");
break;
case "delete-block":
if (confirm("آیا مطمئنید که می‌خواهید این بلوک را حذف کنید؟")) {
deleteBlock();
showToast("بلوک حذف شد");
}
break;
case "ai-rewrite":
case "ai-expand":
case "ai-simplify":
const aiAction = action.replace("ai-", "");
handleAIAction(aiAction, getBlockContent());
break;
}
showBlockMenu.value = false;
};
// ==================== توابع کمکی ====================
const getAlignmentName = (value) => {
return alignmentNames[value] || value;
};
const getBlockTypeName = (type) => {
return blockTypeNames[type] || "بلوک";
};
const convertBlockType = (newType) => {
if (!editor.value) return;
switch (newType) {
case "paragraph":
editor.value.chain().focus().setParagraph().run();
break;
case "heading1":
editor.value.chain().focus().toggleHeading({ level: 1 }).run();
break;
case "heading2":
editor.value.chain().focus().toggleHeading({ level: 2 }).run();
break;
case "heading3":
editor.value.chain().focus().toggleHeading({ level: 3 }).run();
break;
case "todo":
editor.value.chain().focus().toggleTaskList().run();
break;
case "bullet":
editor.value.chain().focus().toggleBulletList().run();
break;
case "number":
editor.value.chain().focus().toggleOrderedList().run();
break;
case "code":
editor.value.chain().focus().toggleCodeBlock().run();
break;
case "quote":
editor.value.chain().focus().toggleBlockquote().run();
break;
}
};
function duplicateBlock(block) {
const ed = editor.value;
if (!ed) return;
const node = ed.state.doc.nodeAt(block.position);
if (!node) return;
ed.commands.insertContentAt(block.position + node.nodeSize, node.toJSON());
}
const deleteBlock = () => {
if (editor.value) {
const { from, to } = editor.value.state.selection;
editor.value.chain().focus().deleteRange({ from, to }).run();
}
};
const getBlockContent = () => {
if (!editor.value) return "";
const { from, to } = editor.value.state.selection;
return editor.value.state.doc.textBetween(from, to, " ");
};
// ==================== توابع AI ====================
// تابع اتصال به LLM
const handleAIAction = async (action, text, targetLang = null) => {
if (!text || !text.trim()) {
showToast("لطفاً متنی را انتخاب کنید");
return;
}
isAIProcessing.value = true;
try {
// آماده‌سازی بدنه درخواست
const body = {
action,
block: {
type: "paragraph",
content: text,
attrs: {},
},
};
// فقط برای ترجمه زبان مقصد را اضافه کن
if (action === "translate" && targetLang) {
body.target_lang = targetLang;
}
// ارسال درخواست به بک
const response = await $fetch("http://localhost:8000/ai/block", {
method: "POST",
body,
});
console.log("🤖 پاسخ AI:", response);
if (response.content && editor.value) {
if (action === "title") {
const lines = response.content
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const title = lines[0];
const paragraph = lines.slice(1).join("\n");
editor.value
.chain()
.focus()
.deleteSelection()
.insertContent([
{
type: "paragraph",
content: [
{
type: "text",
text: title,
marks: [{ type: "bold" }],
},
],
},
{
type: "paragraph",
content: [{ type: "text", text: paragraph }],
},
])
.run();
showToast("✅ تیتر به‌صورت بولد اضافه شد");
} else if (action === "spellcheck") {
const highlightedContent = buildHighlightedContent(
text,
response.content,
);
editor.value
.chain()
.focus()
.deleteSelection()
.insertContent({
type: "paragraph",
content: highlightedContent,
})
.run();
showToast("✏️ اصلاحات املایی مشخص شد");
} else {
// بقیه اکشن‌ها مثل قبل
editor.value
.chain()
.focus()
.deleteSelection()
.insertContent(response.content)
.run();
showToast("✅ متن آماده شد");
}
}
} catch (error) {
console.error("❌ خطای AI:", error);
showToast("خطا در اتصال به AI");
} finally {
isAIProcessing.value = false;
}
};
const buildHighlightedContent = (original, corrected) => {
const origWords = original.split(/\s+/);
const newWords = corrected.split(/\s+/);
return newWords.map((word, index) => {
const isChanged = origWords[index] !== word;
return {
type: "text",
text: word + " ",
marks: isChanged
? [{ type: "bold" }] // ← امن و بدون ریسک
: [],
};
});
};
const applyAIResult = (action, result) => {
if (!editor.value) return;
switch (action) {
case "summarize":
editor.value
.chain()
.focus()
.insertContent(
`
<div class="ai-result summary">
<h4>📝 خلاصه AI:</h4>
<p>${result}</p>
</div>
`,
)
.run();
break;
case "improve":
case "continue":
case "spellcheck":
case "simplify":
case "rewrite":
const { from, to } = editor.value.state.selection;
editor.value
.chain()
.focus()
.deleteRange({ from, to })
.insertContent(result)
.run();
break;
case "translate":
editor.value
.chain()
.focus()
.insertContent(
`
<div class="ai-result translation">
<h4>🌍 ترجمه AI:</h4>
<p>${result}</p>
</div>
`,
)
.run();
break;
case "explain":
case "simplify":
editor.value
.chain()
.focus()
.insertContent(
`
<div class="ai-result explanation">
<h4>💡 توضیح AI:</h4>
<p>${result}</p>
</div>
`,
)
.run();
break;
case "continue":
case "expand":
editor.value
.chain()
.focus()
.insertContent(
`
<div class="ai-result expansion">
<h4>↪️ ادامه AI:</h4>
<p>${result}</p>
</div>
`,
)
.run();
break;
}
};
// ==================== مدیریت بلوک‌های قابل مشاهده ====================
const blocks = computed(() => {
if (!editor.value) return [];
const list = [];
editor.value.state.doc.forEach((node, pos) => {
list.push({
id: `${pos}-${node.type.name}`,
type: node.type.name,
position: pos,
content: node.textContent,
attrs: node.attrs || {},
});
});
return list;
});
const getBlockIconStyle = (block, index) => ({
top: `${index * 48 + 20}px`,
left: `-40px`,
});
const getBlockIcon = (type) => {
return blockIcons[type] || "📝";
};
// مدیریت لینک
const toggleLink = () => {
if (editor.isActive("link")) {
linkUrl.value = editor.getAttributes("link").href || "";
showLinkModal.value = true;
nextTick(() => {
if (linkInput.value) linkInput.value.focus();
});
} else {
showLinkModal.value = true;
linkUrl.value = "";
nextTick(() => {
if (linkInput.value) linkInput.value.focus();
});
}
};
const setLink = () => {
if (linkUrl.value) {
let url = linkUrl.value;
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://" + url;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}
closeLinkModal();
};
const unsetLink = () => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
closeLinkModal();
};
const closeLinkModal = () => {
showLinkModal.value = false;
linkUrl.value = "";
};
// نمایش toast
const showToast = (message) => {
const toast = document.createElement("div");
toast.className = "toast-message";
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #10b981;
color: white;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
animation: toastIn 0.3s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = "toastOut 0.3s ease-out forwards";
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
};
// میانبر Ctrl+K برای لینک
const handleKeyDown = (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault();
toggleLink();
}
};
onMounted(() => {
// اضافه کردن لینک Font Awesome
const link = document.createElement("link");
link.rel = "stylesheet";
link.href =
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css";
document.head.appendChild(link);
document.addEventListener("keydown", handleKeyDown);
pageStore.loadFromLocalStorage();
// اضافه کردن استایل toast و animation
const style = document.createElement("style");
style.textContent = `
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toastOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(20px);
}
}
`;
document.head.appendChild(style);
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeyDown);
if (editor.value) {
editor.value.destroy();
}
});
</script>
<style scoped>
.editor-shell {
padding: 1rem;
position: relative;
}
/* نوار ابزار یک خطی */
.menu-bar-sticky {
position: sticky;
top: 0;
background: white;
z-index: 1000;
padding: 0.5rem 0;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.menu-bar {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
padding: 0.5rem;
background: #f8fafc;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.toolbar-container {
display: flex;
align-items: center;
gap: 0.5rem;
height: 40px;
padding: 0 0.5rem;
}
.toolbar-section {
display: flex;
align-items: center;
gap: 2px;
height: 100%;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: #d1d5db;
margin: 0 4px;
}
.toolbar-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #4b5563;
transition: all 0.2s ease;
padding: 0;
}
.toolbar-btn:hover {
background: #f3f4f6;
border-color: #9ca3af;
color: #111827;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.toolbar-btn.is-active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.toolbar-btn i {
font-size: 14px;
}
/* انتخاب‌ها */
.heading-select,
.font-size-select {
height: 36px;
padding: 0 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
color: #4b5563;
font-size: 13px;
cursor: pointer;
min-width: 100px;
}
.heading-select:hover,
.font-size-select:hover {
border-color: #9ca3af;
}
.heading-select:focus,
.font-size-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* انتخاب رنگ */
.color-picker-wrapper {
position: relative;
display: inline-block;
}
.color-picker {
position: absolute;
top: 100%;
left: 0;
opacity: 0;
width: 100%;
height: 36px;
cursor: pointer;
z-index: 10;
}
/* منوی بیشتر */
.more-menu-wrapper {
position: relative;
display: inline-block;
}
.more-dropdown {
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
padding: 8px;
min-width: 180px;
display: none;
z-index: 1000;
margin-top: 4px;
}
.more-menu-wrapper:hover .more-dropdown {
display: block;
}
.dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: none;
border: none;
border-radius: 6px;
color: #4b5563;
font-size: 13px;
cursor: pointer;
text-align: right;
transition: background 0.2s;
}
.dropdown-item:hover {
background: #f3f4f6;
color: #111827;
}
.dropdown-divider {
height: 1px;
background: #e5e7eb;
margin: 8px 0;
}
/* وضعیت ذخیره */
.save-status {
text-align: center;
padding: 4px;
font-size: 12px;
color: #6b7280;
background: #f9fafb;
border-radius: 4px;
margin-top: 4px;
}
.saving {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.saved {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.check-icon {
color: #10b981;
font-weight: bold;
}
.dot-pulse {
position: relative;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #3b82f6;
animation: dot-pulse 1.5s infinite linear;
}
@keyframes dot-pulse {
0%,
60%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
30% {
opacity: 1;
transform: scale(1);
}
}
.time {
font-size: 11px;
color: #9ca3af;
}
/* محتوای ویرایشگر */
.editor-content-wrapper {
position: relative;
/* min-height: 60vh; */
margin-bottom: 80px;
}
/* آیکون بلوک */
.block-icon-wrapper {
position: absolute;
left: -40px;
cursor: pointer;
opacity: 0.3;
transition: opacity 0.2s;
z-index: 10;
}
.block-icon-wrapper:hover {
opacity: 1 !important;
}
.block-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
color: #6b7280;
transition: all 0.2s;
}
.block-icon:hover {
background: #f3f4f6;
border-color: #9ca3af;
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* وضعیت پردازش AI */
.ai-processing-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
backdrop-filter: blur(4px);
}
.ai-processing {
background: white;
padding: 32px;
border-radius: 16px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
min-width: 300px;
}
.ai-spinner {
width: 48px;
height: 48px;
border: 3px solid #e0e7ff;
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.ai-text {
font-size: 16px;
font-weight: 500;
color: #4f46e5;
margin-bottom: 12px;
}
.ai-progress {
height: 4px;
background: #e0e7ff;
border-radius: 2px;
overflow: hidden;
}
.ai-progress::after {
content: "";
display: block;
height: 100%;
width: 60%;
background: linear-gradient(90deg, #8b5cf6, #ec4899);
animation: progress 2s ease-in-out infinite;
}
@keyframes progress {
0%,
100% {
transform: translateX(-100%);
}
50% {
transform: translateX(100%);
}
}
/* مودال لینک */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 450px;
max-width: 90%;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
animation: modal-appear 0.3s ease-out;
}
@keyframes modal-appear {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #111827;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.close-btn:hover {
background: #f3f4f6;
color: #374151;
}
.modal-body {
padding: 1.5rem;
}
.link-input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 0.875rem;
direction: ltr;
transition: border-color 0.2s;
}
.link-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.link-example {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.5rem;
text-align: right;
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.btn-primary {
flex: 1;
padding: 0.625rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.btn-secondary {
flex: 1;
padding: 0.625rem 1rem;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-secondary:hover {
background: #e5e7eb;
}
.btn-danger {
flex: 1;
padding: 0.625rem 1rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-danger:hover {
background: #dc2626;
}
/* استایل‌های ویرایشگر */
:deep(.notion-editor) {
height: calc(100dvh - 15em);
overflow-y: auto;
outline: none;
/* min-height: 60vh; */
padding: 0 1rem;
}
:deep(.notion-editor p) {
margin: 1rem 0;
line-height: 1.8;
color: #374151;
font-size: 1rem;
}
:deep(.notion-editor h1) {
font-size: 2.25rem;
font-weight: 800;
margin: 2rem 0 1rem;
color: #111827;
/* border-bottom: 3px solid #f3f4f6; */
padding-bottom: 0.5rem;
}
:deep(.notion-editor h2) {
font-size: 1.875rem;
font-weight: 700;
margin: 1.75rem 0 0.75rem;
color: #1f2937;
}
:deep(.notion-editor h3) {
font-size: 1.5rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
color: #374151;
}
:deep(.notion-editor ul),
:deep(.notion-editor ol) {
padding-right: 1.75rem;
margin: 1rem 0;
}
:deep(.notion-editor li) {
margin: 0.5rem 0;
padding-right: 0.5rem;
}
:deep(.notion-editor .task-list) {
padding-right: 0;
list-style: none;
}
:deep(.notion-editor .task-item) {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.25rem 0;
}
:deep(.notion-editor .task-item input[type="checkbox"]) {
margin-top: 0.35rem;
cursor: pointer;
}
:deep(.notion-editor code) {
background: #f3f4f6;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-family: "JetBrains Mono", "Courier New", monospace;
font-size: 0.875rem;
color: #dc2626;
}
:deep(.notion-editor pre) {
background: #1f2937;
color: #f9fafb;
padding: 1.25rem;
border-radius: 0.5rem;
overflow-x: auto;
direction: ltr;
text-align: left;
margin: 1.5rem 0;
font-family: "JetBrains Mono", "Courier New", monospace;
font-size: 0.875rem;
line-height: 1.5;
}
:deep(.notion-editor blockquote) {
border-right: 4px solid #3b82f6;
padding-right: 1.5rem;
margin: 1.5rem 0;
color: #4b5563;
font-style: italic;
background: #f9fafb;
padding: 1.25rem;
border-radius: 0 0.75rem 0.75rem 0;
}
:deep(.notion-editor hr) {
border: none;
border-top: 2px solid #e5e7eb;
margin: 2rem 0;
}
:deep(.notion-editor a) {
color: #3b82f6;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: all 0.2s;
}
:deep(.notion-editor a:hover) {
color: #2563eb;
border-bottom-color: #2563eb;
}
/* نتایج AI */
:deep(.ai-result) {
background: #f0f9ff;
border: 1px solid #0ea5e9;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
position: relative;
}
:deep(.ai-result h4) {
color: #0369a1;
margin-top: 0;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
:deep(.ai-result p) {
margin: 0;
line-height: 1.6;
}
/* ترازبندی متن */
:deep(.notion-editor [data-text-align="right"]) {
text-align: right;
}
:deep(.notion-editor [data-text-align="center"]) {
text-align: center;
}
:deep(.notion-editor [data-text-align="left"]) {
text-align: left;
}
:deep(.ProseMirror-focused) {
outline: none;
}
:deep(.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: right;
color: #9ca3af;
pointer-events: none;
height: 0;
font-style: italic;
font-size: 1rem;
}
/* ریسپانسیو */
@media (max-width: 768px) {
.editor-shell {
padding: 0.5rem;
}
.menu-bar-sticky {
position: relative;
top: 0;
margin-bottom: 1rem;
padding: 0.75rem 0;
}
.menu-bar {
gap: 0.25rem;
padding: 0.25rem;
}
.toolbar-container {
gap: 0.25rem;
padding: 0 0.25rem;
}
.toolbar-btn {
width: 32px;
height: 32px;
font-size: 12px;
}
.heading-select,
.font-size-select {
height: 32px;
font-size: 12px;
min-width: 80px;
}
.toolbar-btn i {
font-size: 12px;
}
.more-dropdown {
left: auto;
right: 0;
}
.modal {
width: 95%;
}
.modal-footer {
flex-direction: column;
}
:deep(.notion-editor) {
padding: 0 0.5rem;
}
:deep(.notion-editor h1) {
font-size: 1.875rem;
}
:deep(.notion-editor h2) {
font-size: 1.5rem;
}
:deep(.notion-editor h3) {
font-size: 1.25rem;
}
.block-icon-wrapper {
left: -30px;
}
.test-controls {
left: 10px;
bottom: 10px;
}
.test-btn {
padding: 8px 12px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.toolbar-container {
gap: 2px;
}
.toolbar-divider {
display: none;
}
.heading-select,
.font-size-select {
min-width: 60px;
font-size: 11px;
padding: 0 4px;
}
}
/* اسکرول بار سفارشی */
.menu-bar::-webkit-scrollbar {
height: 4px;
}
.menu-bar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 2px;
}
.menu-bar::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 2px;
}
.menu-bar::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
.animate-pulse-glow {
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
</style>
<style lang="scss">
.ProseMirror [data-drag-handle] {
display: inline-block;
width: 18px;
height: 18px;
margin-left: -24px;
cursor: grab;
background: repeating-linear-gradient(
to bottom,
#999,
#999 2px,
transparent 2px,
transparent 4px
);
}
</style>