688 lines
17 KiB
Vue
Executable File
688 lines
17 KiB
Vue
Executable File
<template>
|
||
<div>
|
||
<!-- تولبار ادیتور -->
|
||
<div v-if="editor" class="editor-toolbar">
|
||
<div class="toolbar-group">
|
||
<button
|
||
@click="editor.chain().focus().toggleBold().run()"
|
||
:class="{ 'is-active': editor.isActive('bold') }"
|
||
title="بولد"
|
||
>
|
||
<span class="toolbar-icon">𝐁</span>
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().toggleItalic().run()"
|
||
:class="{ 'is-active': editor.isActive('italic') }"
|
||
title="ایتالیک"
|
||
>
|
||
<span class="toolbar-icon">𝐼</span>
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().toggleUnderline().run()"
|
||
:class="{ 'is-active': editor.isActive('underline') }"
|
||
title="زیرخط"
|
||
>
|
||
<span class="toolbar-icon">𝑈</span>
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().toggleStrike().run()"
|
||
:class="{ 'is-active': editor.isActive('strike') }"
|
||
title="خط خورده"
|
||
>
|
||
<span class="toolbar-icon">̶S̶</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<button
|
||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||
title="عنوان ۱"
|
||
>
|
||
H1
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||
title="عنوان ۲"
|
||
>
|
||
H2
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||
title="عنوان ۳"
|
||
>
|
||
H3
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<button
|
||
@click="editor.chain().focus().toggleBulletList().run()"
|
||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||
title="لیست بولتدار"
|
||
>
|
||
<span class="toolbar-icon">•</span>
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||
title="لیست شمارهدار"
|
||
>
|
||
<span class="toolbar-icon">1.</span>
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||
title="نقل قول"
|
||
>
|
||
<span class="toolbar-icon">"</span>
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||
:class="{ 'is-active': editor.isActive('codeBlock') }"
|
||
title="کد"
|
||
>
|
||
<span class="toolbar-icon">{ }</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<button
|
||
@click="editor.chain().focus().setDetails().run()"
|
||
:disabled="!editor.can().setDetails()"
|
||
title="افزودن جزییات"
|
||
>
|
||
<span class="toolbar-icon">📋</span>
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().unsetDetails().run()"
|
||
:disabled="!editor.can().unsetDetails()"
|
||
title="حذف جزییات"
|
||
>
|
||
<span class="toolbar-icon">🗑️</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<button
|
||
@click="editor.chain().focus().undo().run()"
|
||
:disabled="!editor.can().undo()"
|
||
title="بازگشت"
|
||
>
|
||
<span class="toolbar-icon">↩</span>
|
||
</button>
|
||
<button
|
||
@click="editor.chain().focus().redo().run()"
|
||
:disabled="!editor.can().redo()"
|
||
title="جلو"
|
||
>
|
||
<span class="toolbar-icon">↪</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- محتوای ادیتور -->
|
||
<editor-content :editor="editor" class="editor-content" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, nextTick, onMounted, onBeforeUnmount, watch } from "vue";
|
||
import {
|
||
Details,
|
||
DetailsContent,
|
||
DetailsSummary,
|
||
} from "@tiptap/extension-details";
|
||
import { Placeholder } from "@tiptap/extensions";
|
||
import StarterKit from "@tiptap/starter-kit";
|
||
import Underline from "@tiptap/extension-underline";
|
||
import { Editor, EditorContent } from "@tiptap/vue-3";
|
||
|
||
const props = defineProps({
|
||
accordionData: {
|
||
type: Array,
|
||
default: () => [],
|
||
},
|
||
});
|
||
|
||
const editor = ref(null);
|
||
|
||
function isList(text) {
|
||
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
||
return lines.some(
|
||
(line) =>
|
||
line.trim().startsWith("*") ||
|
||
line.trim().startsWith("-") ||
|
||
/^\d+\./.test(line.trim())
|
||
);
|
||
}
|
||
|
||
function formatListToHtml(text) {
|
||
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
||
let inList = false;
|
||
let listType = "";
|
||
let html = "";
|
||
|
||
lines.forEach((line) => {
|
||
const trimmedLine = line.trim();
|
||
|
||
if (trimmedLine.startsWith("*") || trimmedLine.startsWith("-")) {
|
||
if (!inList || listType !== "ul") {
|
||
if (inList) html += `</${listType}>`;
|
||
listType = "ul";
|
||
html += "<ul>";
|
||
inList = true;
|
||
}
|
||
const cleanLine = trimmedLine.replace(/^[*\-]\s*/, "");
|
||
html += `<li>${escapeHtml(cleanLine)}</li>`;
|
||
} else if (/^\d+\./.test(trimmedLine)) {
|
||
if (!inList || listType !== "ol") {
|
||
if (inList) html += `</${listType}>`;
|
||
listType = "ol";
|
||
html += "<ol>";
|
||
inList = true;
|
||
}
|
||
const cleanLine = trimmedLine.replace(/^\d+\.\s*/, "");
|
||
html += `<li>${escapeHtml(cleanLine)}</li>`;
|
||
} else {
|
||
if (inList) {
|
||
html += `</${listType}>`;
|
||
inList = false;
|
||
}
|
||
html += `<p>${escapeHtml(trimmedLine)}</p>`;
|
||
}
|
||
});
|
||
|
||
if (inList) {
|
||
html += `</${listType}>`;
|
||
}
|
||
|
||
return html;
|
||
}
|
||
|
||
function formatMultilineToHtml(text) {
|
||
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
||
return lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("");
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (!text) return "";
|
||
const map = {
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
"`": "`",
|
||
"=": "=",
|
||
"/": "/",
|
||
"(": "(",
|
||
")": ")",
|
||
};
|
||
return String(text).replace(/[&<>"'`=\/()]/g, (m) => map[m]);
|
||
}
|
||
|
||
function formatContent(content) {
|
||
if (!content) return "<p></p>";
|
||
|
||
if (isList(content)) {
|
||
return formatListToHtml(content);
|
||
}
|
||
|
||
if (content.includes("\n")) {
|
||
return formatMultilineToHtml(content);
|
||
}
|
||
|
||
return `<p>${escapeHtml(content)}</p>`;
|
||
}
|
||
|
||
function generateDetailsHtml(item) {
|
||
let html = `<details class="custom-details" ${item.isOpen ? "open" : ""}>`;
|
||
html += `<summary>`;
|
||
if (item.tag) {
|
||
html += `<span href="${item.tag}"/>`;
|
||
}
|
||
|
||
html += `${escapeHtml(item.title) || "بدون عنوان"}`;
|
||
if (item.link_url) {
|
||
html += `<a href="${item.link_url}">${item.link_label}</a>`;
|
||
}
|
||
html += "</summary>";
|
||
|
||
const formattedContent = formatContent(item.content || "");
|
||
html += `<div class="details-content">${formattedContent}</div>`;
|
||
|
||
if (item.children && item.children.length > 0) {
|
||
html += '<div class="children-container">';
|
||
item.children.forEach((child) => {
|
||
html += generateDetailsHtml(child);
|
||
});
|
||
html += "</div>";
|
||
}
|
||
|
||
html += `</details>`;
|
||
return html;
|
||
}
|
||
|
||
function loadFromJson() {
|
||
if (!editor.value) return;
|
||
|
||
let html =
|
||
'<p style="margin-bottom: 1.5rem; color: var(--color-dark-primary-700);"></p>';
|
||
|
||
props.accordionData.forEach((item) => {
|
||
html += generateDetailsHtml(item);
|
||
});
|
||
|
||
editor.value.commands.setContent(html);
|
||
}
|
||
watch(
|
||
() => props.accordionData,
|
||
(newVal) => {
|
||
if (!editor.value) return;
|
||
if (!newVal || newVal.length === 0) return;
|
||
|
||
console.log("accordionData updated:", newVal);
|
||
|
||
nextTick(() => {
|
||
loadFromJson();
|
||
});
|
||
},
|
||
{ deep: true, immediate: true }
|
||
);
|
||
onMounted(() => {
|
||
editor.value = new Editor({
|
||
extensions: [
|
||
StarterKit,
|
||
Underline,
|
||
Details.configure({
|
||
persist: true,
|
||
HTMLAttributes: {
|
||
class: "custom-details",
|
||
},
|
||
}),
|
||
DetailsSummary,
|
||
DetailsContent,
|
||
Placeholder.configure({
|
||
includeChildren: true,
|
||
placeholder: ({ node }) => {
|
||
if (node.type.name === "detailsSummary") {
|
||
return "عنوان جزییات را وارد کنید...";
|
||
}
|
||
return null;
|
||
},
|
||
}),
|
||
],
|
||
content: `<p style="margin-bottom: 1.5rem; color: var(--color-dark-primary-700);">✨ در حال بارگذاری...</p>`,
|
||
});
|
||
|
||
nextTick(() => {
|
||
loadFromJson();
|
||
});
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (editor.value) {
|
||
editor.value.destroy();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
:root {
|
||
/* فقط متغیرهای غیررنگ (سایه، انحنا، RGB اصلی) – رنگها در main.css تعریف شدهاند */
|
||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||
--radius: 0.75rem;
|
||
--radius-sm: 0.5rem;
|
||
--radius-lg: 1rem;
|
||
--color-primary-rgb: 0, 186, 186; /* مقدار RGB #00baba (primary-500) */
|
||
}
|
||
|
||
/* تولبار ادیتور */
|
||
.editor-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
padding: 0.75rem 1.25rem;
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--color-primary-50) 0%,
|
||
var(--color-primary-100) 100%
|
||
);
|
||
border: 1px solid var(--color-primary-200);
|
||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||
margin: 2rem auto 0;
|
||
max-width: 900px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.toolbar-group {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
padding: 0.25rem;
|
||
background: var(--color-white-normal);
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--color-dark-primary-200);
|
||
|
||
button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 2.25rem;
|
||
height: 2.25rem;
|
||
padding: 0 0.5rem;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--color-dark-primary-600);
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
|
||
&:hover {
|
||
background: var(--color-primary-100);
|
||
color: var(--color-primary-700);
|
||
}
|
||
|
||
&.is-active {
|
||
background: var(--color-primary);
|
||
color: var(--color-white-normal);
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
|
||
&:hover {
|
||
background: transparent;
|
||
color: var(--color-dark-primary-400);
|
||
}
|
||
}
|
||
|
||
.toolbar-icon {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
}
|
||
|
||
.editor-content {
|
||
max-width: 900px;
|
||
margin: 0 auto 2rem;
|
||
padding: 2rem;
|
||
background: var(--color-white-normal);
|
||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||
box-shadow: var(--shadow-md);
|
||
min-height: 500px;
|
||
border: 1px solid var(--color-dark-primary-200);
|
||
border-top: none;
|
||
}
|
||
|
||
.tiptap {
|
||
font-family: "Vazir", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||
Roboto, sans-serif;
|
||
color: var(--color-dark-primary-700);
|
||
line-height: 1.7;
|
||
|
||
&:focus {
|
||
outline: none;
|
||
}
|
||
|
||
h1,
|
||
h2,
|
||
h3,
|
||
h4,
|
||
h5,
|
||
h6 {
|
||
margin: 1.5rem 0 0.75rem;
|
||
color: var(--color-dark-primary-800);
|
||
font-weight: 700;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 2rem;
|
||
}
|
||
h2 {
|
||
font-size: 1.5rem;
|
||
}
|
||
h3 {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
ul,
|
||
ol {
|
||
padding-right: 1.5rem;
|
||
margin: 0.75rem 0;
|
||
}
|
||
|
||
li {
|
||
margin: 0.25rem 0;
|
||
}
|
||
|
||
blockquote {
|
||
border-right: 4px solid var(--color-primary);
|
||
padding: 0.75rem 1.25rem;
|
||
margin: 1rem 0;
|
||
background: var(--color-dark-primary-50);
|
||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||
color: var(--color-dark-primary-600);
|
||
font-style: italic;
|
||
}
|
||
|
||
pre {
|
||
background: var(--color-dark-primary-800);
|
||
color: var(--color-dark-primary-100);
|
||
padding: 1rem;
|
||
border-radius: var(--radius-sm);
|
||
overflow-x: auto;
|
||
font-family: "Courier New", monospace;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
code {
|
||
background: var(--color-dark-primary-100);
|
||
color: var(--color-primary-700);
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
font-family: "Courier New", monospace;
|
||
font-size: 0.9rem;
|
||
}
|
||
}
|
||
|
||
/* استایل آکاردئونها */
|
||
.custom-details {
|
||
position: relative;
|
||
background: var(--color-white-normal);
|
||
border: 1px solid var(--color-dark-primary-200);
|
||
border-radius: 0.25em;
|
||
margin: 1.25rem 0;
|
||
transition: all 0.2s ease;
|
||
box-shadow: var(--shadow-sm);
|
||
|
||
&:hover {
|
||
border-color: var(--color-primary-300);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
> button {
|
||
position: absolute;
|
||
left: 2rem;
|
||
top: 3rem;
|
||
width: 2rem;
|
||
height: 2rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--color-primary-100);
|
||
border: 1px solid var(--color-primary-300);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--color-primary-700);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
z-index: 10;
|
||
|
||
&:hover {
|
||
background: var(--color-primary);
|
||
color: var(--color-white-normal);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
&::before {
|
||
content: "▼";
|
||
font-size: 0.75rem;
|
||
transition: transform 0.2s ease;
|
||
}
|
||
}
|
||
|
||
summary {
|
||
position: relative;
|
||
padding: 1.25rem 1.25rem 1.25rem 3.5rem;
|
||
background: linear-gradient(
|
||
to right,
|
||
var(--color-dark-primary-50),
|
||
var(--color-white-normal)
|
||
);
|
||
border-radius: var(--radius) var(--radius) 0 0;
|
||
font-weight: 700;
|
||
font-size: 1.1rem;
|
||
color: var(--color-dark-primary-800);
|
||
cursor: pointer;
|
||
list-style: none;
|
||
user-select: none;
|
||
transition: all 0.2s ease;
|
||
|
||
&::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
|
||
&:hover {
|
||
background: linear-gradient(
|
||
to right,
|
||
var(--color-primary-100),
|
||
var(--color-white-normal)
|
||
);
|
||
color: var(--color-primary-700);
|
||
}
|
||
}
|
||
|
||
> div {
|
||
padding: 1.5rem;
|
||
background: linear-gradient(
|
||
to bottom,
|
||
var(--color-white-normal),
|
||
var(--color-dark-primary-50)
|
||
);
|
||
// border-radius: 0 0 var(--radius) var(--radius);
|
||
animation: slideDown 0.25s ease-out;
|
||
|
||
p {
|
||
margin: 0.5rem 0;
|
||
color: var(--color-dark-primary-600);
|
||
|
||
&:first-child {
|
||
margin-top: 0;
|
||
}
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
ul,
|
||
ol {
|
||
margin: 0.5rem 0;
|
||
padding-right: 1.5rem;
|
||
|
||
li {
|
||
margin: 0.25rem 0;
|
||
color: var(--color-dark-primary-600);
|
||
|
||
&::marker {
|
||
color: var(--color-primary);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
&.is-open {
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.1);
|
||
|
||
> button {
|
||
background: var(--color-primary);
|
||
color: var(--color-white-normal);
|
||
|
||
&::before {
|
||
transform: rotate(180deg);
|
||
}
|
||
}
|
||
|
||
> summary {
|
||
border-bottom: 2px solid var(--color-primary-100);
|
||
color: var(--color-primary-700);
|
||
}
|
||
}
|
||
|
||
.children-container {
|
||
margin-top: 1rem;
|
||
padding-right: 1rem;
|
||
border-right: 2px dashed var(--color-primary-100);
|
||
}
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-8px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
/* ریسپانسیو */
|
||
@media (max-width: 768px) {
|
||
.editor-toolbar {
|
||
margin: 1rem 1rem 0;
|
||
padding: 0.5rem;
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.toolbar-group {
|
||
flex-wrap: wrap;
|
||
|
||
button {
|
||
min-width: 2rem;
|
||
height: 2rem;
|
||
}
|
||
}
|
||
|
||
.editor-content {
|
||
margin: 0 1rem 1rem;
|
||
padding: 1.25rem;
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.custom-details {
|
||
summary {
|
||
padding: 1rem 1rem 1rem 3rem;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
> button {
|
||
left: 0.75rem;
|
||
top: 1rem;
|
||
width: 1.75rem;
|
||
height: 1.75rem;
|
||
}
|
||
}
|
||
}
|
||
</style>
|