Commit 1f83ff59 by Яков

update

parent 98ec316b
......@@ -9,7 +9,7 @@ const App = () => {
// value={`<iframe src="https://cdn.atmaguru.online/2/atmacompany/8/b/8bTfGoWtAuv5waVabQRTtWaNOrve5uv8UBXFbGOH9cowQ1K56dYi7TFz6h5jUfzr.pdf" width="100%" /><iframe src="https://www.youtube.com/embed/YmZGP7YP8c4" frameborder="0" allowfullscreen="true"></iframe><video src="https://cdn.atmaguru.online/2/demo/V/k/VkrEXjkxnutXLgcJPt5CLXNgEj4RaL9Zk4SQhIMUjOeIRpu0dSKtQCIMl49pJM6N.webm" controls="true"></video><p>Так исторически сложилось, что взрослым людям стараются дать максимум материалов: часовые лекции, объемные массивы текста и должностных инструкций. Сотрудник изучает огромный объем информации. Пытается его запомнить, а потом в конце курса сдать большой аттестационный экзамен. Вы не учитывете при этом, что мозг взрослого человека перегружен, ему нужно выполнять обязанности по работе, думать о домашних делах, его постоянно отвлекают менеджеры и коллеги по работе… Единственный правильный способ — это давать информацию небольшими кусочками и после каждой порции проверять усвоена она или нет.</p><p></p><p>что-то новое о компании<br><a href="https://cdn.atmaguru.online/1/demo/T/G/TGvSAoLawONkteJ47yyNfmsC8zNe3ZRG4iO0ZfAjmvOIZkm20BWp8KdWCH5p1Rrx.gif" target="_blank" download="Редактор.gif" data-size="37 Мб">РСкачать книгу</a> <br></p>`}
// value={"<p style=\"text-align: left\"><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://telemost.yandex.ru/j/5911922929\">дшдлодлод</a></p><table data-bordered=\"true\" class=\"\" style=\"min-width: 75px\"><colgroup><col style=\"min-width: 25px\"><col style=\"min-width: 25px\"><col style=\"min-width: 25px\"></colgroup><tbody><tr><th colspan=\"1\" rowspan=\"1\"><p style=\"text-align: left\">sdfsdf</p></th><th colspan=\"1\" rowspan=\"1\"><p style=\"text-align: left\"></p></th><th colspan=\"1\" rowspan=\"1\"><p style=\"text-align: left\">sdfsdf</p></th></tr><tr><td colspan=\"1\" rowspan=\"1\"><p style=\"text-align: left\">sdfsdf</p></td><td colspan=\"1\" rowspan=\"1\"><p style=\"text-align: left\"></p></td><td colspan=\"1\" rowspan=\"1\"><p style=\"text-align: left\">sdfsdf</p></td></tr></tbody></table><p style=\"text-align: left\"></p>"}
// value={"<h2 style=\"text-align: center\">В данном разделе Вы сможете изучить профильные системы KRAUSS и их предназначения.</h2><h2 style=\"text-align: center\">Профильные системы с которыми мы работаем</h2><p style=\"text-align: left\"></p><p style=\"text-align: left\"><img src=\"https://cdn.atmaguru.online/1/galereya-okon/t/J/tJkxP0341Yu58FNTDNtan20IxGVVegvv4Ns8kQvn1SdiWbRBTKj009cPUNpYfD4S.png\" alt=\"fgdgdfdg&amp;#10;sdfsdf&amp;#10;SDfsdf&amp;#10;sdfsdf\" width=\"798\" height=\"449\" data-align=\"center\" style=\"display: block; margin-left: auto; margin-right: auto; width: 798px; height: 449px\" data-node-id=\"img-1752479121918-zsmot20zn\">​</p><p style=\"text-align: left\"><img src=\"https://cdn.atmaguru.online/1/galereya-okon/6/D/6DPwk96e0ja5vPaXFSDzz3SVmeJgWvMLeysYxxYXu5YgOjr57CxS21vzsMThgMgz.png\" alt=\"\" width=\"798\" height=\"431\" data-align=\"center\" style=\"display: block; margin-left: auto; margin-right: auto; width: 798px; height: 431px\" data-node-id=\"img-1752479121912-lk1uabgxv\">​</p><p style=\"text-align: left\"><span style=\"color: rgb(243, 76, 55)\"><strong>Примечание: профильные системы KRAUSS мы закупаем у компании \"Реалит\". Офис данной компании находится в г. Уфа.</strong></span></p><h2 style=\"text-align: left\"><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://disk.yandex.ru/d/KvB3VMNFKu4ULg\">Ссылка для скачивания видео-курсов от компании \"Реалит\" https://disk.yandex.ru/d/KvB3VMNFKu4ULg</a></h2><h2 style=\"text-align: left\">Файл для скачивания: <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://cdn.atmaguru.online/1/galereya-okon/4/N/4N30p3UR8gb4jph1m1u8KijlQmDp6H9DgHKucRjvxOPUnMprnJyBaK2S1dGZPqaD.pdf\">Профильные системы</a></h2><p style=\"text-align: left\"></p>"}
value={"<p style=\"text-align: left\"></p><h2 style=\"text-align: center\">Скрипт \"Вх.телефонного звонка потенциального клиента\"</h2><h2 style=\"text-align: center\"></h2><interactive-image src=\"https://cdn.atmaguru.online/1/demo/y/V/yVH7VsNRAYz5w8ORxax0VfMgF8iYAkGmc6VUF6rGy1NX3URC2GaOil8EHDuAm6cM.png\" data-points=\"[{&quot;x&quot;:19.703389830508474,&quot;y&quot;:36.440677966101696,&quot;text&quot;:&quot;ываываыва&quot;},{&quot;x&quot;:76.0593220338983,&quot;y&quot;:80.72033898305084,&quot;text&quot;:&quot;ываываываыва&quot;}]\"></interactive-image><h2 style=\"text-align: center\"><br><img src=\"https://cdn.atmaguru.online/1/demo/N/8/N80blh8vFcvZ8vxF5gM92NW0asDy1HPB0eXt40tzHdtfWBKRR2IKW3P539sxoebj.png\" alt=\"Rutube_icon.svg.png\" title=\"Rutube_icon.svg.png\" width=\"518\" height=\"518\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 518px; height: 518px\" data-node-id=\"img-1752484531717-671thw3tt\">​​​​<br><img src=\"https://cdn.atmaguru.online/2/galereya-okon/s/I/sIt4vIbMQqB72fNnirRzc4SFge6ZYluV2RiVxz4lnr8eCe72U3uexQMR7ZM9CldA.jpg\" alt=\"\" width=\"1064\" height=\"822\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 822px\" data-node-id=\"img-1751607113981-zg693aosm\">​​​​<br><img src=\"https://cdn.atmaguru.online/2/galereya-okon/4/9/497lE0e2jzPAoyCnk0g1omLYKXOqJEG7ApkfaT5lBTRfuwioJIfsIVUy7fcbG9l6.jpg\" alt=\"\" width=\"1064\" height=\"822\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 822px\" data-node-id=\"img-1751607113986-utmlo6wda\">​​​​​​​<br>Файл для скачивания: <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://cdn.atmaguru.online/2/galereya-okon/Z/H/ZHeH2tnX0RZXy6ibijHNHpXySW01JIJElYzx1xoBQ9EZjKwHJZWFQ6tX8RyoS7TD.xls\">скрипт вх.звонка</a></h2><p style=\"text-align: left\"></p><p style=\"text-align: center\"></p><p style=\"text-align: left\"></p><p style=\"text-align: left\"></p><p style=\"text-align: center\"><img src=\"https://cdn.atmaguru.online/2/galereya-okon/L/7/L7IOgSXhT54y82ezhUIYjXYVxoqFjOBELKmQrp66UjwEybnX0QRwwDRttNHQ1OME.jpg\" alt=\"\" width=\"1064\" height=\"1505\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 1505px\" data-node-id=\"img-1751607113981-cxqc0fbac\">​​​​</p><p style=\"text-align: center\"><img src=\"https://cdn.atmaguru.online/2/galereya-okon/0/B/0Bw7BxdBMLHprU3mtib3TFuXlnyrbarQF82XvUM3jBGD4v3lGPQQCp19of82EMDx.jpg\" alt=\"\" width=\"1064\" height=\"1505\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 1505px\" data-node-id=\"img-1751607113976-kfrhg4ziw\">​​​​</p><p style=\"text-align: center\"><img src=\"https://cdn.atmaguru.online/2/galereya-okon/D/W/DW5FvXAFSm6A8gEx8qDBoYCyj0r5ep9yuI2qnUSvpNg2C2SzV3tff7ScIlvWjelB.jpg\" alt=\"\" width=\"1064\" height=\"1505\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 1505px\" data-node-id=\"img-1751607113980-zcr1g75pv\">​​​​</p><p style=\"text-align: left\"></p><p style=\"text-align: left\">Файл для скачивания: <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://cdn.atmaguru.online/2/galereya-okon/F/O/FOvmHQ85dMkyLCIVWc4LK7Gxc7tVXAEdhL6soGR3EP9Fgo4d3ObVeUbtN9U18pmw.docx\">Скрипт при отказе клиента записываться на замер</a></p>"}
value={"<p style=\"text-align: left\"></p><h2 style=\"text-align: center\">Скрипт \"Вх.телефонного звонка потенциального клиента\"</h2><h2 style=\"text-align: center\"></h2><interactive-image src=\"https://cdn.atmaguru.online/1/demo/y/V/yVH7VsNRAYz5w8ORxax0VfMgF8iYAkGmc6VUF6rGy1NX3URC2GaOil8EHDuAm6cM.png\" width=\"798\" height=\"798\" style=\"display: block; margin-left: auto; margin-right: auto; width: 798px; height: 798px\" data-align=\"center\" data-points=\"[{&quot;x&quot;:57.180851063829785,&quot;y&quot;:20.877659574468087,&quot;text&quot;:&quot;qweqweqweqweqw\\neqweqweqwe\\nqweqweqwe\\nqweqwe&quot;,&quot;title&quot;:&quot;qweqweqweqwe&quot;},{&quot;x&quot;:73.00531914893617,&quot;y&quot;:45.87765957446808,&quot;text&quot;:&quot;sdfsndfsdfgbsdkfg\\nsdfgjnsdfkgjsdfg\\nsdfgjndskfjgsdfg&quot;,&quot;title&quot;:&quot;sdfsdfsdf&quot;}]\"></interactive-image><p style=\"text-align: left\">​</p><p style=\"text-align: left\">​</p><h2 style=\"text-align: center\"><br><img src=\"https://cdn.atmaguru.online/1/demo/N/8/N80blh8vFcvZ8vxF5gM92NW0asDy1HPB0eXt40tzHdtfWBKRR2IKW3P539sxoebj.png\" alt=\"Rutube_icon.svg.png\" title=\"Rutube_icon.svg.png\" width=\"518\" height=\"518\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 518px; height: 518px\" data-node-id=\"img-1752484531717-671thw3tt\">​​​​​​<br><img src=\"https://cdn.atmaguru.online/2/galereya-okon/s/I/sIt4vIbMQqB72fNnirRzc4SFge6ZYluV2RiVxz4lnr8eCe72U3uexQMR7ZM9CldA.jpg\" alt=\"\" width=\"1064\" height=\"822\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 822px\" data-node-id=\"img-1751607113981-zg693aosm\">​​​​​​<br><img src=\"https://cdn.atmaguru.online/2/galereya-okon/4/9/497lE0e2jzPAoyCnk0g1omLYKXOqJEG7ApkfaT5lBTRfuwioJIfsIVUy7fcbG9l6.jpg\" alt=\"\" width=\"1064\" height=\"822\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 822px\" data-node-id=\"img-1751607113986-utmlo6wda\">​​​​​​​​​<br>Файл для скачивания: <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://cdn.atmaguru.online/2/galereya-okon/Z/H/ZHeH2tnX0RZXy6ibijHNHpXySW01JIJElYzx1xoBQ9EZjKwHJZWFQ6tX8RyoS7TD.xls\">скрипт вх.звонка</a></h2><p style=\"text-align: left\"></p><p style=\"text-align: center\"></p><p style=\"text-align: left\"></p><p style=\"text-align: left\"></p><p style=\"text-align: center\"><img src=\"https://cdn.atmaguru.online/2/galereya-okon/L/7/L7IOgSXhT54y82ezhUIYjXYVxoqFjOBELKmQrp66UjwEybnX0QRwwDRttNHQ1OME.jpg\" alt=\"\" width=\"1064\" height=\"1505\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 1505px\" data-node-id=\"img-1751607113981-cxqc0fbac\">​​​​​​</p><p style=\"text-align: center\"><img src=\"https://cdn.atmaguru.online/2/galereya-okon/0/B/0Bw7BxdBMLHprU3mtib3TFuXlnyrbarQF82XvUM3jBGD4v3lGPQQCp19of82EMDx.jpg\" alt=\"\" width=\"1064\" height=\"1505\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 1505px\" data-node-id=\"img-1751607113976-kfrhg4ziw\">​​​​​​</p><p style=\"text-align: center\"><img src=\"https://cdn.atmaguru.online/2/galereya-okon/D/W/DW5FvXAFSm6A8gEx8qDBoYCyj0r5ep9yuI2qnUSvpNg2C2SzV3tff7ScIlvWjelB.jpg\" alt=\"\" width=\"1064\" height=\"1505\" data-align=\"left\" style=\"float: left; margin-right: 1rem; width: 1064px; height: 1505px\" data-node-id=\"img-1751607113980-zcr1g75pv\">​​​​​​</p><p style=\"text-align: left\"></p><p style=\"text-align: left\">Файл для скачивания: <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://cdn.atmaguru.online/2/galereya-okon/F/O/FOvmHQ85dMkyLCIVWc4LK7Gxc7tVXAEdhL6soGR3EP9Fgo4d3ObVeUbtN9U18pmw.docx\">Скрипт при отказе клиента записываться на замер</a></p>"}
onChange={(value)=>{
console.log(value);
}}
......
{
"name": "react-ag-qeditor",
"version": "1.1.10",
"version": "1.1.11",
"description": "WYSIWYG html editor",
"author": "atma",
"license": "MIT",
......
......@@ -1147,6 +1147,7 @@ const QEditor = ({
src: file.path,
width: Math.round(realWidth),
height: Math.round(realHeight),
align: 'center',
points: []
},
})
......
......@@ -31,6 +31,48 @@ const Audio = Node.create({
];
},
addNodeView() {
return ({ editor, node }) => {
const container = document.createElement('div')
container.style.position = 'relative'
const audio = document.createElement('audio')
audio.src = node.attrs.src
audio.controls = true
const closeBtn = document.createElement('button')
closeBtn.textContent = '×'
closeBtn.className = 'audio-delete-btn'
closeBtn.style.cssText = `
position: absolute;
top: -6px;
right: -6px;
z-index: 10;
background: white;
border: 1px solid #ccc;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 12px;
line-height: 1;
cursor: pointer;
`
closeBtn.addEventListener('click', function () {
const pos = editor.view.posAtDOM(container, 0)
editor.view.dispatch(
editor.view.state.tr.delete(pos, pos + node.nodeSize)
)
})
container.appendChild(closeBtn)
container.appendChild(audio)
return {
dom: container,
}
}
},
addCommands() {
return {
addVoiceMessage: (options) => ({ chain }) => {
......
......@@ -7,7 +7,7 @@ const Iframe = Node.create({
draggable: true,
atom: true,
addAttributes() {
addAttributes () {
return {
src: {
default: null
......@@ -24,7 +24,7 @@ const Iframe = Node.create({
}
},
parseHTML() {
parseHTML () {
return [
{
tag: 'iframe'
......@@ -32,12 +32,12 @@ const Iframe = Node.create({
]
},
renderHTML({ HTMLAttributes }) {
renderHTML ({HTMLAttributes}) {
return ['iframe', mergeAttributes(HTMLAttributes)]
},
addNodeView() {
return ({ editor, node, ...a }) => {
addNodeView () {
return ({editor, node, ...a}) => {
const container = document.createElement('div')
const iframe = document.createElement('iframe')
......@@ -48,8 +48,12 @@ const Iframe = Node.create({
const closeBtn = document.createElement('button')
closeBtn.textContent = 'X'
closeBtn.classList.add('closeBtn')
closeBtn.addEventListener('click', function () {
container.remove()
const pos = editor.view.posAtDOM(container, 0)
editor.view.dispatch(
editor.view.state.tr.delete(pos, pos + node.nodeSize)
)
})
// if (editor.isEditable) {
......@@ -64,12 +68,12 @@ const Iframe = Node.create({
}
},
addCommands() {
addCommands () {
return {
setIframe:
(options) =>
({ tr, dispatch }) => {
const { selection } = tr
({tr, dispatch}) => {
const {selection} = tr
const node = this.type.create(options)
if (dispatch) {
......
......@@ -402,6 +402,58 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
}}
/>
<Button
size="default"
shape={'circle'}
type={node.attrs.alt?.length > 0 ? 'primary' : 'default'}
onClick={(e) => {
e.stopPropagation();
setTempAlt(node.attrs.alt || '');
setAltModalVisible(true);
}}
style={{
position: 'absolute',
top: 4,
right: '30px',
zIndex: 15,
}}
>
<FontSizeOutlined />
</Button>
{selected && (
<Button
type="text"
danger
size="small"
onClick={(e) => {
e.stopPropagation()
const pos = getPos?.()
if (typeof pos === 'number') {
editor.view.dispatch(
editor.view.state.tr.delete(pos, pos + node.nodeSize)
)
}
}}
style={{
position: 'absolute',
top: 4,
right: 4,
zIndex: 30,
backgroundColor: 'white',
border: '1px solid #d9d9d9',
borderRadius: '50%',
width: 20,
height: 20,
fontSize: 12,
lineHeight: 1,
padding: '0px 0px 2px 0px',
cursor: 'pointer'
}}
>
×
</Button>
)}
{(selected || isResizing) && (
<Fragment>
{['nw', 'ne', 'sw', 'se'].map(dir => (
......@@ -479,25 +531,6 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
>
Align
</button>
<Button
size="default"
shape={'circle'}
type={node.attrs.alt?.length > 0 ? 'primary' : 'default'}
onClick={(e) => {
e.stopPropagation();
setTempAlt(node.attrs.alt || '');
setAltModalVisible(true);
}}
style={{
position: 'absolute',
top: 4,
right: 4,
zIndex: 15,
}}
>
<FontSizeOutlined />
</Button>
</Fragment>
)}
<Modal
......
import { Node, mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react'
import React, { useState } from 'react'
import React, { Fragment, useEffect, useRef, useState } from 'react'
import { NodeViewWrapper } from '@tiptap/react'
import { Button, Modal, Popconfirm, Input, Typography } from 'antd'
import { FontSizeOutlined } from "@ant-design/icons";
const {Text} = Typography;
const InteractiveImageView = ({ node, updateAttributes }) => {
const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
const ALIGN_OPTIONS = ['left', 'center', 'right', 'text'];
const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected }) => {
const [modalVisible, setModalVisible] = useState(false)
const [points, setPoints] = useState(node.attrs.points || [])
const [newPoint, setNewPoint] = useState(null)
const [newPointText, setNewPointText] = useState('')
const [newPointTitle, setNewPointTitle] = useState('')
const [editingIdx, setEditingIdx] = useState(null)
const [editingText, setEditingText] = useState('')
const [isResizing, setIsResizing] = useState(false);
const [showAlignMenu, setShowAlignMenu] = useState(false);
const [editingTitle, setEditingTitle] = useState('')
const imgRef = useRef(null);
const isInitialized = useRef(false);
const wrapperRef = useRef(null);
// Обработка кликов вне изображения
useEffect(() => {
const handleClickOutside = (event) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target) && selected) {
try {
const pos = getPos?.()
if (typeof pos === 'number') {
editor.commands.setNodeSelection(pos)
}
} catch (e) {
console.warn('getPos() failed:', e)
}
// editor.commands.setNodeSelection(getPos());
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [selected, editor, getPos]);
// Загрузка и инициализация изображения
useEffect(() => {
if (!imgRef.current || isInitialized.current) return;
const initImageSize = () => {
try {
// Если размеры уже заданы в атрибутах - используем их сразу
if (node.attrs.width && node.attrs.height) {
isInitialized.current = true;
return;
}
const { width: editorWidth } = getEditorDimensions();
const naturalWidth = imgRef.current.naturalWidth;
const naturalHeight = imgRef.current.naturalHeight;
if (naturalWidth <= 0 || naturalHeight <= 0) {
console.warn('Image has invalid natural dimensions, retrying...');
setTimeout(initImageSize, 100);
return;
}
let initialWidth = naturalWidth;
let initialHeight = naturalHeight;
if (initialWidth > editorWidth) {
const ratio = editorWidth / initialWidth;
initialWidth = editorWidth;
initialHeight = Math.round(initialHeight * ratio);
}
safeUpdateAttributes({
width: initialWidth,
height: initialHeight,
'data-node-id': node.attrs['data-node-id'] || `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
});
isInitialized.current = true;
} catch (error) {
console.warn('Error initializing image size:', error);
}
};
const handleLoad = () => {
// Если размеры уже заданы в атрибутах, пропускаем инициализацию
if (node.attrs.width && node.attrs.height) {
isInitialized.current = true;
return;
}
setTimeout(initImageSize, 50);
};
if (imgRef.current.complete) {
handleLoad();
} else {
imgRef.current.addEventListener('load', handleLoad);
}
return () => {
if (imgRef.current) {
imgRef.current.removeEventListener('load', handleLoad);
}
};
}, [node.attrs.width, node.attrs.height, node.attrs['data-node-id']]);
// Добавляем прозрачный нулевой пробел после изображения
useEffect(() => {
if (!editor || !getPos) return;
let pos;
try {
pos = getPos();
if (typeof pos !== 'number') return;
} catch (e) {
console.warn('getPos() failed:', e);
return;
}
const doc = editor.state.doc;
if (doc.nodeSize > pos && doc.nodeAt(pos)?.textContent !== '\u200B') {
editor.commands.insertContentAt(pos + 1, {
type: 'text',
text: '\u200B'
});
}
}, [editor, getPos]);
const addPoint = (e) => {
const rect = e.target.getBoundingClientRect()
......@@ -20,16 +138,24 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
setNewPoint({ x, y })
setNewPointText('')
setNewPointTitle('')
}
const confirmAddPoint = () => {
const newPoints = [...points, { ...newPoint, text: newPointText }]
const newPoints = [...points, {
...newPoint,
text: newPointText,
title: newPointTitle,
}]
setPoints(newPoints)
updateAttributes({ points: newPoints })
setNewPoint(null)
setNewPointText('')
setNewPointTitle('')
}
const cancelAddPoint = () => {
setNewPoint(null)
setNewPointText('')
......@@ -41,6 +167,170 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
updateAttributes({ points: newPoints })
}
// Обработка ресайза изображения
const handleResizeStart = (direction) => (e) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
try {
const pos = getPos?.()
if (typeof pos === 'number') {
editor.commands.setNodeSelection(pos)
}
} catch (e) {
console.warn('getPos() failed:', e)
}
// editor.commands.setNodeSelection(getPos());
const startWidth = node.attrs.width || imgRef.current.naturalWidth;
const startHeight = node.attrs.height || imgRef.current.naturalHeight;
const aspectRatio = startWidth / startHeight;
const startX = e.clientX;
const startY = e.clientY;
const { width: initialEditorWidth, availableSpace: initialAvailableSpace } = getEditorDimensions();
const onMouseMove = (e) => {
requestAnimationFrame(() => {
const maxWidth = node.attrs.align === 'center' ? initialEditorWidth : initialAvailableSpace;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
let newWidth, newHeight;
if (node.attrs.align === 'center') {
if (direction.includes('n') || direction.includes('s')) {
const scale = direction.includes('s') ? 1 : -1;
newHeight = Math.max(startHeight + deltaY * scale, MIN_WIDTH);
newWidth = Math.min(Math.round(newHeight * aspectRatio), maxWidth);
newHeight = Math.round(newWidth / aspectRatio);
} else {
const scale = direction.includes('e') ? 1 : -1;
newWidth = Math.min(
Math.max(startWidth + deltaX * scale, MIN_WIDTH),
maxWidth
);
newHeight = Math.round(newWidth / aspectRatio);
}
} else {
if (direction.includes('e') || direction.includes('w')) {
const scale = direction.includes('e') ? 1 : -1;
newWidth = Math.min(
Math.max(startWidth + deltaX * scale, MIN_WIDTH),
maxWidth
);
newHeight = Math.round(newWidth / aspectRatio);
} else {
const scale = direction.includes('s') ? 1 : -1;
newHeight = Math.max(startHeight + deltaY * scale, MIN_WIDTH);
newWidth = Math.min(
Math.round(newHeight * aspectRatio),
maxWidth
);
newHeight = Math.round(newWidth / aspectRatio);
}
}
safeUpdateAttributes({ width: newWidth, height: newHeight });
});
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
setIsResizing(false);
try {
const pos = getPos?.()
if (typeof pos === 'number') {
editor.commands.setNodeSelection(pos)
}
} catch (e) {
console.warn('getPos() failed:', e)
}
// editor.commands.setNodeSelection(getPos());
editor.commands.focus();
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const safeUpdateAttributes = (newAttrs) => {
const { width: editorWidth, availableSpace } = getEditorDimensions();
let { width, height, align } = { ...node.attrs, ...newAttrs };
const newAlign = newAttrs.align || align;
// При изменении выравнивания проверяем доступное пространство
if (newAlign && newAlign !== align) {
const maxWidth = availableSpace;
if (width > maxWidth) {
const ratio = maxWidth / width;
width = maxWidth;
height = Math.round(height * ratio);
}
} else {
// Для обычного обновления размеров
const maxWidth = availableSpace;
if (width > maxWidth) {
const ratio = maxWidth / width;
width = maxWidth;
height = Math.round(height * ratio);
}
}
// Проверяем минимальный размер
if (width < MIN_WIDTH) {
const ratio = MIN_WIDTH / width;
width = MIN_WIDTH;
height = Math.round(height * ratio);
}
updateAttributes({ width, height, ...newAttrs });
};
const getEditorDimensions = () => {
const editorContent = editor?.options?.element?.closest('.atma-editor-content');
if (!editorContent) return { width: Infinity, availableSpace: Infinity };
const fullEditorWidth = editorContent.clientWidth;
const editorStyles = window.getComputedStyle(editorContent);
const paddingLeft = parseFloat(editorStyles.paddingLeft) || 0;
const paddingRight = parseFloat(editorStyles.paddingRight) || 0;
const availableEditorWidth = fullEditorWidth - paddingLeft - paddingRight;
let container;
// при center — всегда редактор
if (node.attrs.align === 'center') {
container = editorContent;
} else {
// при других выравниваниях — ближайший блок
container = imgRef.current?.closest('li, blockquote, td, p, div') || editorContent;
}
const containerStyles = window.getComputedStyle(container);
const containerPaddingLeft = parseFloat(containerStyles.paddingLeft) || 0;
const containerPaddingRight = parseFloat(containerStyles.paddingRight) || 0;
const containerWidth = container.clientWidth - containerPaddingLeft - containerPaddingRight;
return {
width: containerWidth, // текущая ширина контейнера
availableSpace: availableEditorWidth // фиксированная доступная ширина
};
};
// Изменение выравнивания с автоматическим масштабированием
const handleAlign = (align) => {
safeUpdateAttributes({ align }); // первый вызов
setTimeout(() => {
safeUpdateAttributes({ align }); // повторный вызов с обновлёнными размерами
}, 50);
setShowAlignMenu(false);
editor.commands.focus();
};
const pointIcon = (
<svg width="10" height="14" viewBox="0 0 10 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11.2969C5.48281 11.2969 5.875 11.6891 5.875 12.1719C5.87476 12.6545 5.48267 13.0469 5 13.0469C4.51742 13.0468 4.12524 12.6544 4.125 12.1719C4.125 11.6891 4.51727 11.297 5 11.2969ZM5 0.953125C6.1342 0.953125 7.20454 1.36506 8.01074 2.11328C8.40761 2.48046 8.71875 2.91056 8.9375 3.38867C9.16558 3.887 9.2812 4.41809 9.28125 4.96484C9.28125 5.43031 9.19735 5.88841 9.03027 6.32422C8.86938 6.74441 8.6362 7.13211 8.33789 7.48047C7.7457 8.16797 6.91738 8.66094 6.00488 8.86719C5.76899 8.92034 5.59863 9.13558 5.59863 9.38086V9.87109C5.59863 9.9679 5.52061 10.0468 5.42383 10.0469H4.58008C4.4832 10.0469 4.40527 9.96797 4.40527 9.87109V9.38086C4.40582 8.99176 4.53698 8.6138 4.77832 8.30859C5.02044 8.00253 5.3627 7.78908 5.74219 7.70312C6.40618 7.55314 7.00665 7.19836 7.43164 6.70312C7.86133 6.20312 8.08789 5.60234 8.08789 4.96484C8.08769 3.41031 6.703 2.14648 5 2.14648C3.29709 2.14658 1.91329 3.41037 1.91309 4.96484V5.38672C1.91309 5.48359 1.83418 5.5625 1.7373 5.5625H0.893555C0.796854 5.5623 0.71875 5.48347 0.71875 5.38672V4.96484C0.718799 4.41813 0.834455 3.88854 1.0625 3.38867C1.28123 2.90903 1.59244 2.48103 1.98926 2.1123C2.79546 1.36547 3.86569 0.95317 5 0.953125Z" fill="white"/>
......@@ -48,19 +338,101 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
)
const getWrapperStyle = () => {
const baseStyle = {
display: 'inline-block',
lineHeight: 0,
position: 'relative',
outline: (selected || isResizing) ? `1px dashed ${BORDER_COLOR}` : 'none',
verticalAlign: 'top',
margin: '0.5rem 0',
};
if (node.attrs.align === 'center') {
return {
...baseStyle,
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
width: node.attrs.width ? `${node.attrs.width}px` : 'fit-content',
maxWidth: '100%',
textAlign: 'center'
};
}
return {
...baseStyle,
...(node.attrs.align === 'left' && {
float: 'left',
marginRight: '1rem',
width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
}),
...(node.attrs.align === 'right' && {
float: 'right',
marginLeft: '1rem',
width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
}),
...(node.attrs.align === 'text' && {
display: 'inline-block',
float: 'none',
margin: '0 0.2rem',
verticalAlign: 'middle',
width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
}),
};
};
const getImageStyle = () => ({
width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
height: 'auto',
maxWidth: '100%',
display: 'block',
cursor: 'default',
userSelect: 'none',
margin: node.attrs.align === 'center' ? '0 auto' : '0',
verticalAlign: node.attrs.align === 'text' ? 'middle' : 'top',
objectFit: 'contain'
});
return (
<NodeViewWrapper as="div" className="interactive-image-wrapper" contentEditable={false}>
<div style={{ position: 'relative', display: 'inline-block' }}>
<NodeViewWrapper ref={wrapperRef} as="div" className="interactive-image-wrapper" contentEditable={false}>
<div
style={getWrapperStyle()}
onClick={(e) => {
e.stopPropagation();
try {
const pos = getPos?.();
if (typeof pos === 'number') {
editor.commands.setNodeSelection(pos);
}
} catch {}
}}
>
<img
ref={imgRef}
src={node.attrs.src}
alt=""
style={{ maxWidth: '100%', display: 'block' }}
style={getImageStyle()}
onLoad={() => {
if (imgRef.current && !isInitialized.current && !node.attrs.width && !node.attrs.height) {
const { width: editorWidth } = getEditorDimensions();
const naturalWidth = imgRef.current.naturalWidth;
const naturalHeight = imgRef.current.naturalHeight;
safeUpdateAttributes({
width: naturalWidth,
height: naturalHeight,
'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
});
isInitialized.current = true;
}
}}
/>
<Button
size="default"
type="primary"
onClick={() => setModalVisible(true)}
style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}
style={{ position: 'absolute', top: '4px', right: '30px', zIndex: 10 }}
>
Редактировать
</Button>
......@@ -86,11 +458,124 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
border: 'none',
pointerEvents: 'none', // чтобы не блокировала выбор или драг
}}
title={point.text}
title={point.title || point.text}
>
{pointIcon}
</Button>
))}
{selected && (
<Button
type="text"
danger
size="small"
onClick={(e) => {
e.stopPropagation()
const pos = getPos?.()
if (typeof pos === 'number') {
editor.view.dispatch(
editor.view.state.tr.delete(pos, pos + node.nodeSize)
)
}
}}
style={{
position: 'absolute',
top: 4,
right: 4,
zIndex: 30,
backgroundColor: 'white',
border: '1px solid #d9d9d9',
borderRadius: '50%',
width: 20,
height: 20,
fontSize: 12,
lineHeight: 1,
padding: '0px 0px 2px 0px',
cursor: 'pointer'
}}
>
×
</Button>
)}
{(selected || isResizing) && (
<Fragment>
{['nw', 'ne', 'sw', 'se'].map(dir => (
<div
key={dir}
onMouseDown={handleResizeStart(dir)}
style={{
position: 'absolute',
width: 12,
height: 12,
backgroundColor: BORDER_COLOR,
border: '1px solid white',
[dir[0] === 'n' ? 'top' : 'bottom']: -6,
[dir[1] === 'w' ? 'left' : 'right']: node.attrs.align === 'center' ? '50%' : -6,
transform: node.attrs.align === 'center' ?
`translateX(${dir[1] === 'w' ? '-100%' : '0%'})` : 'none',
cursor: `${dir}-resize`,
zIndex: 10
}}
/>
))}
{showAlignMenu && (
<div style={{
position: 'absolute',
top: -40,
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'white',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
borderRadius: 4,
padding: 4,
zIndex: 20,
display: 'flex'
}}>
{ALIGN_OPTIONS.map(align => (
<button
type="button"
key={align}
onClick={() => handleAlign(align)}
style={{
margin: '0 2px',
padding: '10px 8px',
background: node.attrs.align === align ? '#e6f7ff' : 'transparent',
border: '1px solid #d9d9d9',
borderRadius: 2,
cursor: 'pointer'
}}
>
{align}
</button>
))}
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowAlignMenu(!showAlignMenu);
}}
style={{
position: 'absolute',
top: -30,
left: 'calc(50% - 6px)',
transform: 'translateX(-50%)',
backgroundColor: 'white',
border: `1px solid ${BORDER_COLOR}`,
borderRadius: 4,
padding: '8px 8px',
cursor: 'pointer',
fontSize: 12,
zIndex: 10
}}
>
Align
</button>
</Fragment>
)}
</div>
<Modal
......@@ -99,6 +584,7 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
onOk={() => setModalVisible(false)}
title="Редактировать точки"
footer={null}
width={800}
>
<div><Text>Нажмите на изображение, чтобы добавить маркер</Text></div>
<div style={{marginBottom: '10px'}}><Text>Нажмите на маркер, чтобы удалить или изменить текст</Text></div>
......@@ -114,10 +600,15 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
icon={null}
open={true}
title={
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Input
placeholder="Заголовок"
value={newPointTitle}
onChange={(e) => setNewPointTitle(e.target.value)}
/>
<Input.TextArea
autoSize
placeholder="Введите текст точки"
placeholder="Описание"
value={newPointText}
onChange={(e) => setNewPointText(e.target.value)}
/>
......@@ -159,6 +650,12 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
open={editingIdx === idx}
title={
<div style={{ maxWidth: 250 }}>
<Input
placeholder="Заголовок"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
style={{marginBottom: '8px'}}
/>
<Input.TextArea
autoSize
value={editingText}
......@@ -189,6 +686,7 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
type="primary"
onClick={() => {
const updated = [...points]
updated[idx].title = editingTitle
updated[idx].text = editingText
setPoints(updated)
updateAttributes({ points: updated })
......@@ -207,7 +705,8 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
type="primary"
size="small"
onClick={() => {
setEditingText(points[idx].text)
setEditingTitle(points[idx].title || '')
setEditingText(points[idx].text || '')
setEditingIdx(idx)
}}
style={{
......@@ -249,8 +748,23 @@ export const InteractiveImage = Node.create({
addAttributes() {
return {
src: { default: null },
width: {
default: null,
parseHTML: el => parseInt(el.getAttribute('width'), 10) || null,
renderHTML: attrs => attrs.width ? { width: attrs.width } : {},
},
height: {
default: null,
parseHTML: el => parseInt(el.getAttribute('height'), 10) || null,
renderHTML: attrs => attrs.height ? { height: attrs.height } : {},
},
align: {
default: 'left',
parseHTML: el => el.getAttribute('data-align') || 'left',
renderHTML: attrs => ({ 'data-align': attrs.align }),
},
points: {
default: [], // [{x: 120, y: 90, text: "Hello"}]
default: [],
parseHTML: el => JSON.parse(el.getAttribute('data-points') || '[]'),
renderHTML: attrs =>
attrs.points.length > 0
......@@ -264,8 +778,40 @@ export const InteractiveImage = Node.create({
return [{ tag: 'interactive-image' }]
},
renderHTML({ HTMLAttributes }) {
return ['interactive-image', mergeAttributes(HTMLAttributes)]
renderHTML({ node, HTMLAttributes }) {
const {
src, width, height
} = HTMLAttributes;
const style = [];
const align = node.attrs.align || 'left';
const points = node.attrs.points || [];
if (align === 'center') {
style.push('display: block', 'margin-left: auto', 'margin-right: auto');
} else if (align === 'left') {
style.push('float: left', 'margin-right: 1rem');
} else if (align === 'right') {
style.push('float: right', 'margin-left: 1rem');
} else if (align === 'text') {
style.push('display: inline-block', 'vertical-align: middle', 'margin: 0 0.2rem');
}
if (width) style.push(`width: ${width}px`);
if (height) style.push(`height: ${height}px`);
return [
'interactive-image',
{
src,
width,
height,
style: style.join('; '),
'data-align': align,
'data-points': JSON.stringify(points),
}
]
},
addNodeView() {
......
import { Node } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import React, { useEffect, useRef, useState } from 'react'
import { TextSelection } from 'prosemirror-state'
// React компонент NodeView
export const ToggleBlockComponent = ({ node, updateAttributes }) => {
export const ToggleBlockComponent = ({node, updateAttributes, getPos, editor}) => {
const open = node.attrs.open
const title = node.attrs.title
......@@ -11,7 +12,7 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => {
const [inputWidth, setInputWidth] = useState('100px')
const toggle = () => {
updateAttributes({ open: !open })
updateAttributes({open: ! open})
}
useEffect(() => {
......@@ -23,6 +24,40 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => {
return (
<NodeViewWrapper className="toggle-block" data-open={open}>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
try {
const pos = getPos?.()
if (typeof pos === 'number') {
editor.view.dispatch(
editor.view.state.tr.delete(pos, pos + node.nodeSize)
)
}
} catch (err) {
console.warn('Ошибка удаления toggleBlock:', err)
}
}}
style={{
position: 'absolute',
top: 4,
right: 4,
zIndex: 10,
background: 'white',
border: '1px solid #ccc',
borderRadius: '50%',
width: 20,
height: 20,
fontSize: 12,
lineHeight: 1,
cursor: 'pointer',
padding: 0,
}}
>
×
</button>
<div className="toggle-block-inner">
<div className="toggle-header-wrapper">
<span
......@@ -40,17 +75,56 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => {
onChange={(e) => updateAttributes({ title: e.target.value })}
placeholder="Заголовок..."
style={{ width: inputWidth }}
onFocus={(e) => {
if (title.trim() === 'Заголовок') {
// выделить весь текст
setTimeout(() => e.target.select(), 0)
}
}}
/>
</div>
</div>
<div
className="toggle-body"
data-collapsed={!open}
style={{
maxHeight: open ? '1000px' : '0',
}}
>
<div className="toggle-body-wrapper">
<NodeViewContent className="toggle-content" />
<div
className="toggle-body-wrapper"
onClick={(e) => {
e.stopPropagation()
try {
const pos = getPos?.()
if (typeof pos !== 'number') return
const doc = editor.state.doc
const toggleNode = doc.nodeAt(pos)
if (!toggleNode || toggleNode.childCount === 0) return
const firstBlock = toggleNode.child(0)
if (
firstBlock.type.name === 'paragraph' &&
firstBlock.textContent.trim() === 'Введите подробности...'
) {
const from = pos + 2 // +1 = paragraph, +1 = start of text inside it
const to = from + firstBlock.textContent.length
const tr = editor.state.tr.setSelection(
TextSelection.create(doc, from, to)
)
editor.view.dispatch(tr)
editor.view.focus()
}
} catch (err) {
console.warn('Ошибка выделения toggleBlock:', err)
}
}}
>
<NodeViewContent className="toggle-content"/>
</div>
</div>
</NodeViewWrapper>
......@@ -63,14 +137,14 @@ const ToggleBlock = Node.create({
group: 'block',
content: 'block+',
addAttributes() {
addAttributes () {
return {
title: { default: 'Заголовок' },
open: { default: false },
title: {default: 'Заголовок'},
open: {default: false},
}
},
parseHTML() {
parseHTML () {
return [{
tag: 'div.toggle-block',
getAttrs: (element) => {
......@@ -83,12 +157,12 @@ const ToggleBlock = Node.create({
titleEl.parentNode.removeChild(titleEl)
}
return { title }
return {title}
}
}]
},
renderHTML({ HTMLAttributes }) {
renderHTML ({HTMLAttributes}) {
return [
'div',
{
......@@ -96,15 +170,15 @@ const ToggleBlock = Node.create({
},
[
'div',
{ class: 'toggle-block-inner' },
['span', { class: 'toggle-button ' }],
['span', { class: 'toggle-header' }, HTMLAttributes.title || 'Заголовок'],
{class: 'toggle-block-inner'},
['span', {class: 'toggle-button '}],
['span', {class: 'toggle-header'}, HTMLAttributes.title || 'Заголовок'],
],
['div', { class: 'toggle-body' }, ['div', { class: 'toggle-content' }, 0]],
['div', {class: 'toggle-body'}, ['div', {class: 'toggle-content'}, 0]],
]
},
addNodeView() {
addNodeView () {
return ReactNodeViewRenderer(ToggleBlockComponent)
},
})
......
......@@ -49,8 +49,12 @@ const Video = Node.create({
closeBtn.textContent = 'X'
closeBtn.classList.add('closeBtn')
closeBtn.addEventListener('click', function () {
container.remove()
const pos = editor.view.posAtDOM(container, 0)
editor.view.dispatch(
editor.view.state.tr.delete(pos, pos + node.nodeSize)
)
})
container.append(closeBtn, video)
return {
......
......@@ -1102,14 +1102,14 @@ body{
display: flex;
justify-content: end;
border-radius: 50%;
border: none;
background-color: #2677e3;
color: #fff;
border: 1px solid rgb(217, 217, 217);
background-color: white;
color: #ff4d4f;
font-size: 0.5rem;
padding: 4px 6px;
top: 10px;
top: 4px;
cursor: pointer;
right: 8px;
right: 4px;
z-index: 9;
}
......@@ -1149,20 +1149,18 @@ body{
}
.toggle-body {
overflow: hidden;
transition: max-height 0.3s ease;
transition: max-height 0.5s ease, opacity 0.5s ease;
&[data-collapsed="true"] {
max-height: 0 !important;
opacity: 0;
}
}
.toggle-block[data-open="false"] .toggle-body {
max-height: 0;
padding: 0;
border: none;
}
.toggle-block[data-open="true"] .toggle-body {
max-height: 1000px;
border: 1px dashed #D9D9D9;
border-radius: 6px;
padding: 10px;
background: #FAFAFA;
}
.toggle-block {
margin-bottom: 12px;
}
......@@ -1205,12 +1203,21 @@ body{
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHZpZXdCb3g9IjAgMCAxNCAxNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzYxMDlfNDAzMjYpIj4KPHJlY3Qgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNi43NDc1NiA0LjIxMzMzQzYuODc3NDUgNC4wNjQzMSA3LjEyNDkxIDQuMDY0MzEgNy4yNTM0MiA0LjIxMzMzTDExLjc0MjcgOS40MTkzOEMxMS45MDk0IDkuNjEzNTIgMTEuNzU5MSA5Ljg5NzkgMTEuNDg5OCA5Ljg5NzlIMi41MTAyNkMyLjI0MDk5IDkuODk3OSAyLjA5MDcyIDkuNjEzNTIgMi4yNTczMyA5LjQxOTM4TDYuNzQ3NTYgNC4yMTMzM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODUiLz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF82MTA5XzQwMzI2Ij4KPHJlY3Qgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=);
background-repeat: no-repeat;
background-position: 4px 3px;
transform: rotate(0deg);
transform: rotate(180deg);
background-color: white;
cursor: pointer;
&.open {
transform: rotate(180deg) !important;
transform: rotate(0deg) !important;
}
}
.toggle-block {
position: relative;
}
.toggle-body-wrapper {
padding: 12px;
background: #f7f7f7;
border: 1px solid #d9d9d9;
border-radius: 4px;
transition: background 0.3s ease, border 0.3s ease;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment