706 lines
16 KiB
Vue
Executable File
706 lines
16 KiB
Vue
Executable File
<template>
|
||
<div>
|
||
<!-- تولبار ادیتور -->
|
||
<div class="editor-toolbar" v-if="editor">
|
||
<div
|
||
class="toolbar-group"
|
||
v-for="(group, gIndex) in toolbarGroups"
|
||
:key="gIndex"
|
||
>
|
||
<button
|
||
v-for="(btn, index) in group"
|
||
:key="index"
|
||
@click="btn.action(editor)"
|
||
:class="{ 'is-active': btn.isActive ? btn.isActive(editor) : false }"
|
||
:disabled="readOnly || (btn.disabled ? btn.disabled(editor) : false)"
|
||
:title="btn.title"
|
||
>
|
||
<span class="toolbar-icon">{{ btn.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: () => [],
|
||
},
|
||
readOnly: {
|
||
type: Boolean,
|
||
default: true,
|
||
},
|
||
});
|
||
|
||
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()),
|
||
);
|
||
}
|
||
const toolbarGroups = [
|
||
// گروه فرمت متن
|
||
[
|
||
{
|
||
title: "بولد",
|
||
icon: "𝐁",
|
||
action: (editor) => editor.chain().focus().toggleBold().run(),
|
||
isActive: (editor) => editor.isActive("bold"),
|
||
},
|
||
{
|
||
title: "ایتالیک",
|
||
icon: "𝐼",
|
||
action: (editor) => editor.chain().focus().toggleItalic().run(),
|
||
isActive: (editor) => editor.isActive("italic"),
|
||
},
|
||
{
|
||
title: "زیرخط",
|
||
icon: "𝑈",
|
||
action: (editor) => editor.chain().focus().toggleUnderline().run(),
|
||
isActive: (editor) => editor.isActive("underline"),
|
||
},
|
||
{
|
||
title: "خط خورده",
|
||
icon: "̶S̶",
|
||
action: (editor) => editor.chain().focus().toggleStrike().run(),
|
||
isActive: (editor) => editor.isActive("strike"),
|
||
},
|
||
],
|
||
// گروه هدینگها
|
||
[
|
||
{
|
||
title: "عنوان ۱",
|
||
icon: "H1",
|
||
action: (editor) =>
|
||
editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||
isActive: (editor) => editor.isActive("heading", { level: 1 }),
|
||
},
|
||
{
|
||
title: "عنوان ۲",
|
||
icon: "H2",
|
||
action: (editor) =>
|
||
editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||
isActive: (editor) => editor.isActive("heading", { level: 2 }),
|
||
},
|
||
{
|
||
title: "عنوان ۳",
|
||
icon: "H3",
|
||
action: (editor) =>
|
||
editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||
isActive: (editor) => editor.isActive("heading", { level: 3 }),
|
||
},
|
||
],
|
||
// گروه لیست و نقل قول
|
||
[
|
||
{
|
||
title: "لیست بولتدار",
|
||
icon: "•",
|
||
action: (editor) => editor.chain().focus().toggleBulletList().run(),
|
||
isActive: (editor) => editor.isActive("bulletList"),
|
||
},
|
||
{
|
||
title: "لیست شمارهدار",
|
||
icon: "1.",
|
||
action: (editor) => editor.chain().focus().toggleOrderedList().run(),
|
||
isActive: (editor) => editor.isActive("orderedList"),
|
||
},
|
||
{
|
||
title: "نقل قول",
|
||
icon: '"',
|
||
action: (editor) => editor.chain().focus().toggleBlockquote().run(),
|
||
isActive: (editor) => editor.isActive("blockquote"),
|
||
},
|
||
{
|
||
title: "کد",
|
||
icon: "{ }",
|
||
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
|
||
isActive: (editor) => editor.isActive("codeBlock"),
|
||
},
|
||
],
|
||
// گروه جزییات
|
||
[
|
||
{
|
||
title: "افزودن جزییات",
|
||
icon: "📋",
|
||
action: (editor) => editor.chain().focus().setDetails().run(),
|
||
disabled: (editor) => !editor.can().setDetails(),
|
||
},
|
||
{
|
||
title: "حذف جزییات",
|
||
icon: "🗑️",
|
||
action: (editor) => editor.chain().focus().unsetDetails().run(),
|
||
disabled: (editor) => !editor.can().unsetDetails(),
|
||
},
|
||
],
|
||
// گروه Undo/Redo
|
||
[
|
||
{
|
||
title: "بازگشت",
|
||
icon: "↩",
|
||
action: (editor) => editor.chain().focus().undo().run(),
|
||
disabled: (editor) => !editor.can().undo(),
|
||
},
|
||
{
|
||
title: "جلو",
|
||
icon: "↪",
|
||
action: (editor) => editor.chain().focus().redo().run(),
|
||
disabled: (editor) => !editor.can().redo(),
|
||
},
|
||
],
|
||
];
|
||
|
||
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 class="tag" > ${item.tag} </span>`;
|
||
}
|
||
|
||
html += `${item.title || "بدون عنوان"}`;
|
||
if (item.link_url) {
|
||
html += `<a class="link-url" href="${item.link_url}">${item.link_label}</a>`;
|
||
}
|
||
html += `</summary>`;
|
||
|
||
html += formatContent(item.content || "");
|
||
|
||
if (item.children && item.children.length > 0) {
|
||
item.children.forEach((child) => {
|
||
html += generateDetailsHtml(child);
|
||
});
|
||
}
|
||
|
||
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({
|
||
editable: !props.readOnly,
|
||
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: 1200px;
|
||
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: 1200px;
|
||
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:
|
||
"sahel",
|
||
"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.tag {
|
||
color: blue;
|
||
border-radius: var(--radius) var(--radius) 0 0;
|
||
}
|
||
|
||
summary.label {
|
||
color: chocolate;
|
||
font-weight: 700;
|
||
}
|
||
|
||
summary {
|
||
position: relative;
|
||
padding: 1.25rem 1.25rem 1.25rem 3.5rem;
|
||
background: linear-gradient(
|
||
to right,
|
||
var(--color-primary-100),
|
||
var(--color-white-normal)
|
||
);
|
||
color: var(--color-primary-700);
|
||
border-radius: var(--radius) var(--radius) 0 0;
|
||
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;
|
||
}
|
||
}
|
||
|
||
> 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);
|
||
}
|
||
.link-url {
|
||
color: blue;
|
||
margin-right: 0.5em;
|
||
}
|
||
}
|
||
|
||
@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>
|