Commit 1f83ff59 by Яков

update

parent 98ec316b
...@@ -9,7 +9,7 @@ const App = () => { ...@@ -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={`<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={"<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={"<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)=>{ onChange={(value)=>{
console.log(value); console.log(value);
}} }}
......
{ {
"name": "react-ag-qeditor", "name": "react-ag-qeditor",
"version": "1.1.10", "version": "1.1.11",
"description": "WYSIWYG html editor", "description": "WYSIWYG html editor",
"author": "atma", "author": "atma",
"license": "MIT", "license": "MIT",
......
...@@ -1147,6 +1147,7 @@ const QEditor = ({ ...@@ -1147,6 +1147,7 @@ const QEditor = ({
src: file.path, src: file.path,
width: Math.round(realWidth), width: Math.round(realWidth),
height: Math.round(realHeight), height: Math.round(realHeight),
align: 'center',
points: [] points: []
}, },
}) })
......
...@@ -31,6 +31,48 @@ const Audio = Node.create({ ...@@ -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() { addCommands() {
return { return {
addVoiceMessage: (options) => ({ chain }) => { addVoiceMessage: (options) => ({ chain }) => {
......
...@@ -7,7 +7,7 @@ const Iframe = Node.create({ ...@@ -7,7 +7,7 @@ const Iframe = Node.create({
draggable: true, draggable: true,
atom: true, atom: true,
addAttributes() { addAttributes () {
return { return {
src: { src: {
default: null default: null
...@@ -24,7 +24,7 @@ const Iframe = Node.create({ ...@@ -24,7 +24,7 @@ const Iframe = Node.create({
} }
}, },
parseHTML() { parseHTML () {
return [ return [
{ {
tag: 'iframe' tag: 'iframe'
...@@ -32,12 +32,12 @@ const Iframe = Node.create({ ...@@ -32,12 +32,12 @@ const Iframe = Node.create({
] ]
}, },
renderHTML({ HTMLAttributes }) { renderHTML ({HTMLAttributes}) {
return ['iframe', mergeAttributes(HTMLAttributes)] return ['iframe', mergeAttributes(HTMLAttributes)]
}, },
addNodeView() { addNodeView () {
return ({ editor, node, ...a }) => { return ({editor, node, ...a}) => {
const container = document.createElement('div') const container = document.createElement('div')
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
...@@ -48,8 +48,12 @@ const Iframe = Node.create({ ...@@ -48,8 +48,12 @@ const Iframe = Node.create({
const closeBtn = document.createElement('button') const closeBtn = document.createElement('button')
closeBtn.textContent = 'X' closeBtn.textContent = 'X'
closeBtn.classList.add('closeBtn') closeBtn.classList.add('closeBtn')
closeBtn.addEventListener('click', function () { 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) { // if (editor.isEditable) {
...@@ -64,12 +68,12 @@ const Iframe = Node.create({ ...@@ -64,12 +68,12 @@ const Iframe = Node.create({
} }
}, },
addCommands() { addCommands () {
return { return {
setIframe: setIframe:
(options) => (options) =>
({ tr, dispatch }) => { ({tr, dispatch}) => {
const { selection } = tr const {selection} = tr
const node = this.type.create(options) const node = this.type.create(options)
if (dispatch) { if (dispatch) {
......
...@@ -402,6 +402,58 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -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) && ( {(selected || isResizing) && (
<Fragment> <Fragment>
{['nw', 'ne', 'sw', 'se'].map(dir => ( {['nw', 'ne', 'sw', 'se'].map(dir => (
...@@ -479,25 +531,6 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -479,25 +531,6 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
> >
Align Align
</button> </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> </Fragment>
)} )}
<Modal <Modal
......
import { Node, mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react' 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 { NodeViewWrapper } from '@tiptap/react'
import { Button, Modal, Popconfirm, Input, Typography } from 'antd' import { Button, Modal, Popconfirm, Input, Typography } from 'antd'
import { FontSizeOutlined } from "@ant-design/icons";
const {Text} = Typography; 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 [modalVisible, setModalVisible] = useState(false)
const [points, setPoints] = useState(node.attrs.points || []) const [points, setPoints] = useState(node.attrs.points || [])
const [newPoint, setNewPoint] = useState(null) const [newPoint, setNewPoint] = useState(null)
const [newPointText, setNewPointText] = useState('') const [newPointText, setNewPointText] = useState('')
const [newPointTitle, setNewPointTitle] = useState('')
const [editingIdx, setEditingIdx] = useState(null) const [editingIdx, setEditingIdx] = useState(null)
const [editingText, setEditingText] = useState('') 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 addPoint = (e) => {
const rect = e.target.getBoundingClientRect() const rect = e.target.getBoundingClientRect()
...@@ -20,16 +138,24 @@ const InteractiveImageView = ({ node, updateAttributes }) => { ...@@ -20,16 +138,24 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
setNewPoint({ x, y }) setNewPoint({ x, y })
setNewPointText('') setNewPointText('')
setNewPointTitle('')
} }
const confirmAddPoint = () => { const confirmAddPoint = () => {
const newPoints = [...points, { ...newPoint, text: newPointText }] const newPoints = [...points, {
...newPoint,
text: newPointText,
title: newPointTitle,
}]
setPoints(newPoints) setPoints(newPoints)
updateAttributes({ points: newPoints }) updateAttributes({ points: newPoints })
setNewPoint(null) setNewPoint(null)
setNewPointText('') setNewPointText('')
setNewPointTitle('')
} }
const cancelAddPoint = () => { const cancelAddPoint = () => {
setNewPoint(null) setNewPoint(null)
setNewPointText('') setNewPointText('')
...@@ -41,6 +167,170 @@ const InteractiveImageView = ({ node, updateAttributes }) => { ...@@ -41,6 +167,170 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
updateAttributes({ points: newPoints }) 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 = ( const pointIcon = (
<svg width="10" height="14" viewBox="0 0 10 14" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <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 }) => { ...@@ -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 ( return (
<NodeViewWrapper as="div" className="interactive-image-wrapper" contentEditable={false}> <NodeViewWrapper ref={wrapperRef} as="div" className="interactive-image-wrapper" contentEditable={false}>
<div style={{ position: 'relative', display: 'inline-block' }}> <div
style={getWrapperStyle()}
onClick={(e) => {
e.stopPropagation();
try {
const pos = getPos?.();
if (typeof pos === 'number') {
editor.commands.setNodeSelection(pos);
}
} catch {}
}}
>
<img <img
ref={imgRef}
src={node.attrs.src} src={node.attrs.src}
alt="" 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 <Button
size="default" size="default"
type="primary" type="primary"
onClick={() => setModalVisible(true)} onClick={() => setModalVisible(true)}
style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }} style={{ position: 'absolute', top: '4px', right: '30px', zIndex: 10 }}
> >
Редактировать Редактировать
</Button> </Button>
...@@ -86,11 +458,124 @@ const InteractiveImageView = ({ node, updateAttributes }) => { ...@@ -86,11 +458,124 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
border: 'none', border: 'none',
pointerEvents: 'none', // чтобы не блокировала выбор или драг pointerEvents: 'none', // чтобы не блокировала выбор или драг
}} }}
title={point.text} title={point.title || point.text}
> >
{pointIcon} {pointIcon}
</Button> </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> </div>
<Modal <Modal
...@@ -99,6 +584,7 @@ const InteractiveImageView = ({ node, updateAttributes }) => { ...@@ -99,6 +584,7 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
onOk={() => setModalVisible(false)} onOk={() => setModalVisible(false)}
title="Редактировать точки" title="Редактировать точки"
footer={null} footer={null}
width={800}
> >
<div><Text>Нажмите на изображение, чтобы добавить маркер</Text></div> <div><Text>Нажмите на изображение, чтобы добавить маркер</Text></div>
<div style={{marginBottom: '10px'}}><Text>Нажмите на маркер, чтобы удалить или изменить текст</Text></div> <div style={{marginBottom: '10px'}}><Text>Нажмите на маркер, чтобы удалить или изменить текст</Text></div>
...@@ -114,10 +600,15 @@ const InteractiveImageView = ({ node, updateAttributes }) => { ...@@ -114,10 +600,15 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
icon={null} icon={null}
open={true} open={true}
title={ title={
<div> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Input
placeholder="Заголовок"
value={newPointTitle}
onChange={(e) => setNewPointTitle(e.target.value)}
/>
<Input.TextArea <Input.TextArea
autoSize autoSize
placeholder="Введите текст точки" placeholder="Описание"
value={newPointText} value={newPointText}
onChange={(e) => setNewPointText(e.target.value)} onChange={(e) => setNewPointText(e.target.value)}
/> />
...@@ -159,6 +650,12 @@ const InteractiveImageView = ({ node, updateAttributes }) => { ...@@ -159,6 +650,12 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
open={editingIdx === idx} open={editingIdx === idx}
title={ title={
<div style={{ maxWidth: 250 }}> <div style={{ maxWidth: 250 }}>
<Input
placeholder="Заголовок"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
style={{marginBottom: '8px'}}
/>
<Input.TextArea <Input.TextArea
autoSize autoSize
value={editingText} value={editingText}
...@@ -189,6 +686,7 @@ const InteractiveImageView = ({ node, updateAttributes }) => { ...@@ -189,6 +686,7 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
type="primary" type="primary"
onClick={() => { onClick={() => {
const updated = [...points] const updated = [...points]
updated[idx].title = editingTitle
updated[idx].text = editingText updated[idx].text = editingText
setPoints(updated) setPoints(updated)
updateAttributes({ points: updated }) updateAttributes({ points: updated })
...@@ -207,7 +705,8 @@ const InteractiveImageView = ({ node, updateAttributes }) => { ...@@ -207,7 +705,8 @@ const InteractiveImageView = ({ node, updateAttributes }) => {
type="primary" type="primary"
size="small" size="small"
onClick={() => { onClick={() => {
setEditingText(points[idx].text) setEditingTitle(points[idx].title || '')
setEditingText(points[idx].text || '')
setEditingIdx(idx) setEditingIdx(idx)
}} }}
style={{ style={{
...@@ -249,8 +748,23 @@ export const InteractiveImage = Node.create({ ...@@ -249,8 +748,23 @@ export const InteractiveImage = Node.create({
addAttributes() { addAttributes() {
return { return {
src: { default: null }, 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: { points: {
default: [], // [{x: 120, y: 90, text: "Hello"}] default: [],
parseHTML: el => JSON.parse(el.getAttribute('data-points') || '[]'), parseHTML: el => JSON.parse(el.getAttribute('data-points') || '[]'),
renderHTML: attrs => renderHTML: attrs =>
attrs.points.length > 0 attrs.points.length > 0
...@@ -264,8 +778,40 @@ export const InteractiveImage = Node.create({ ...@@ -264,8 +778,40 @@ export const InteractiveImage = Node.create({
return [{ tag: 'interactive-image' }] return [{ tag: 'interactive-image' }]
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ node, HTMLAttributes }) {
return ['interactive-image', mergeAttributes(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() { addNodeView() {
......
import { Node } from '@tiptap/core' import { Node } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { TextSelection } from 'prosemirror-state'
// React компонент NodeView // React компонент NodeView
export const ToggleBlockComponent = ({ node, updateAttributes }) => { export const ToggleBlockComponent = ({node, updateAttributes, getPos, editor}) => {
const open = node.attrs.open const open = node.attrs.open
const title = node.attrs.title const title = node.attrs.title
...@@ -11,7 +12,7 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => { ...@@ -11,7 +12,7 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => {
const [inputWidth, setInputWidth] = useState('100px') const [inputWidth, setInputWidth] = useState('100px')
const toggle = () => { const toggle = () => {
updateAttributes({ open: !open }) updateAttributes({open: ! open})
} }
useEffect(() => { useEffect(() => {
...@@ -23,6 +24,40 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => { ...@@ -23,6 +24,40 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => {
return ( return (
<NodeViewWrapper className="toggle-block" data-open={open}> <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-block-inner">
<div className="toggle-header-wrapper"> <div className="toggle-header-wrapper">
<span <span
...@@ -40,17 +75,56 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => { ...@@ -40,17 +75,56 @@ export const ToggleBlockComponent = ({ node, updateAttributes }) => {
onChange={(e) => updateAttributes({ title: e.target.value })} onChange={(e) => updateAttributes({ title: e.target.value })}
placeholder="Заголовок..." placeholder="Заголовок..."
style={{ width: inputWidth }} style={{ width: inputWidth }}
onFocus={(e) => {
if (title.trim() === 'Заголовок') {
// выделить весь текст
setTimeout(() => e.target.select(), 0)
}
}}
/> />
</div> </div>
</div> </div>
<div <div
className="toggle-body" className="toggle-body"
data-collapsed={!open}
style={{ style={{
maxHeight: open ? '1000px' : '0', maxHeight: open ? '1000px' : '0',
}} }}
> >
<div className="toggle-body-wrapper"> <div
<NodeViewContent className="toggle-content" /> 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>
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
...@@ -63,14 +137,14 @@ const ToggleBlock = Node.create({ ...@@ -63,14 +137,14 @@ const ToggleBlock = Node.create({
group: 'block', group: 'block',
content: 'block+', content: 'block+',
addAttributes() { addAttributes () {
return { return {
title: { default: 'Заголовок' }, title: {default: 'Заголовок'},
open: { default: false }, open: {default: false},
} }
}, },
parseHTML() { parseHTML () {
return [{ return [{
tag: 'div.toggle-block', tag: 'div.toggle-block',
getAttrs: (element) => { getAttrs: (element) => {
...@@ -83,12 +157,12 @@ const ToggleBlock = Node.create({ ...@@ -83,12 +157,12 @@ const ToggleBlock = Node.create({
titleEl.parentNode.removeChild(titleEl) titleEl.parentNode.removeChild(titleEl)
} }
return { title } return {title}
} }
}] }]
}, },
renderHTML({ HTMLAttributes }) { renderHTML ({HTMLAttributes}) {
return [ return [
'div', 'div',
{ {
...@@ -96,15 +170,15 @@ const ToggleBlock = Node.create({ ...@@ -96,15 +170,15 @@ const ToggleBlock = Node.create({
}, },
[ [
'div', 'div',
{ class: 'toggle-block-inner' }, {class: 'toggle-block-inner'},
['span', { class: 'toggle-button ' }], ['span', {class: 'toggle-button '}],
['span', { class: 'toggle-header' }, HTMLAttributes.title || 'Заголовок'], ['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) return ReactNodeViewRenderer(ToggleBlockComponent)
}, },
}) })
......
...@@ -49,8 +49,12 @@ const Video = Node.create({ ...@@ -49,8 +49,12 @@ const Video = Node.create({
closeBtn.textContent = 'X' closeBtn.textContent = 'X'
closeBtn.classList.add('closeBtn') closeBtn.classList.add('closeBtn')
closeBtn.addEventListener('click', function () { 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) container.append(closeBtn, video)
return { return {
......
...@@ -1102,14 +1102,14 @@ body{ ...@@ -1102,14 +1102,14 @@ body{
display: flex; display: flex;
justify-content: end; justify-content: end;
border-radius: 50%; border-radius: 50%;
border: none; border: 1px solid rgb(217, 217, 217);
background-color: #2677e3; background-color: white;
color: #fff; color: #ff4d4f;
font-size: 0.5rem; font-size: 0.5rem;
padding: 4px 6px; padding: 4px 6px;
top: 10px; top: 4px;
cursor: pointer; cursor: pointer;
right: 8px; right: 4px;
z-index: 9; z-index: 9;
} }
...@@ -1149,20 +1149,18 @@ body{ ...@@ -1149,20 +1149,18 @@ body{
} }
.toggle-body { .toggle-body {
overflow: hidden; 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 { .toggle-block[data-open="false"] .toggle-body {
max-height: 0; max-height: 0;
padding: 0; padding: 0;
border: none; 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 { .toggle-block {
margin-bottom: 12px; margin-bottom: 12px;
} }
...@@ -1205,12 +1203,21 @@ body{ ...@@ -1205,12 +1203,21 @@ body{
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHZpZXdCb3g9IjAgMCAxNCAxNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzYxMDlfNDAzMjYpIj4KPHJlY3Qgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNi43NDc1NiA0LjIxMzMzQzYuODc3NDUgNC4wNjQzMSA3LjEyNDkxIDQuMDY0MzEgNy4yNTM0MiA0LjIxMzMzTDExLjc0MjcgOS40MTkzOEMxMS45MDk0IDkuNjEzNTIgMTEuNzU5MSA5Ljg5NzkgMTEuNDg5OCA5Ljg5NzlIMi41MTAyNkMyLjI0MDk5IDkuODk3OSAyLjA5MDcyIDkuNjEzNTIgMi4yNTczMyA5LjQxOTM4TDYuNzQ3NTYgNC4yMTMzM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODUiLz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF82MTA5XzQwMzI2Ij4KPHJlY3Qgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=); background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHZpZXdCb3g9IjAgMCAxNCAxNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzYxMDlfNDAzMjYpIj4KPHJlY3Qgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNi43NDc1NiA0LjIxMzMzQzYuODc3NDUgNC4wNjQzMSA3LjEyNDkxIDQuMDY0MzEgNy4yNTM0MiA0LjIxMzMzTDExLjc0MjcgOS40MTkzOEMxMS45MDk0IDkuNjEzNTIgMTEuNzU5MSA5Ljg5NzkgMTEuNDg5OCA5Ljg5NzlIMi41MTAyNkMyLjI0MDk5IDkuODk3OSAyLjA5MDcyIDkuNjEzNTIgMi4yNTczMyA5LjQxOTM4TDYuNzQ3NTYgNC4yMTMzM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODUiLz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF82MTA5XzQwMzI2Ij4KPHJlY3Qgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=);
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 4px 3px; background-position: 4px 3px;
transform: rotate(0deg); transform: rotate(180deg);
background-color: white; background-color: white;
cursor: pointer; cursor: pointer;
&.open { &.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