Commit 0dbfa693 by Яков

выравнивание картинки, обтекание текста, ресайз

parent 5c2effc1
......@@ -7,12 +7,20 @@ const App = () => {
return <div style={{padding:40}}>
<QEditor
// 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>sdfsdfsdfsdfsdf</p>'}
value={'<p style="text-align: left"><span><span data-type="resizable-image" data-image-wrapper="true" style="\n' +
' display: inline-block;\n' +
' float: left;\n' +
' margin: 0 1rem 1rem 0;\n' +
' shape-outside: none;\n' +
' vertical-align: top;\n' +
' position: relative;\n' +
' z-index: 1;\n' +
' " data-align="left"><img src="https://cdn.atmaguru.online/2/demo/3/k/3kL9yGrVaNZibUZzoeIcSmPuLyK8mqr6EptFPU8HQJ5sCeFbYey3y8mAfoaK6f3H.png" width="289" height="302" data-align="left"></span>​</span>​ываыва</p><p style="text-align: left">ываыва</p><p style="text-align: left">ываыва</p><p style="text-align: left"></p>'}
onChange={(value)=>{
// console.log(value);
console.log(value);
}}
uploadOptions={{
url: 'https://cdn.atmaguru.online/upload/?sid=atmacompany&md5=0cETbV4BquHkqAdG9cK9MA&expires=1742192970',
url: '/upload',
errorMessage: 'Загрузка временно невозможна'
}}
style={{
......
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
'/upload',
createProxyMiddleware({
target: 'https://cdn.atmaguru.online',
changeOrigin: true,
pathRewrite: {
'^/upload': 'https://cdn.atmaguru.online/upload/?sid=demo&md5=HNxOMxidAMprpPLfAwdTAg&expires=1751035910',
},
onProxyReq: (proxyReq) => {
// Добавляем необходимые заголовки
},
})
);
};
......@@ -99,6 +99,7 @@ const QEditor = ({
const [isUploading, setIsUploading] = useState(false)
const [recordType, setRecordType] = useState({video: true})
let formRef = useRef(null);
// eslint-disable-next-line no-unused-vars
const getRgb = (hex) => {
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
......@@ -1280,6 +1281,7 @@ const QEditor = ({
setWordGlossary(selectedText);
setTimeout(()=>{
setModalGlossaryIsOpen(true);
setTimeout(()=>{formRef.current.resetFields();},1)
}, 100)
}
// console.log(editor.chain().focus());
......@@ -1318,6 +1320,7 @@ const QEditor = ({
]}
>
<Form
ref={formRef}
name={'form-glossary'}
initialValues={{word : wordGlossary}}
labelCol={{span: 8}}
......
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, Fragment } from "react";
import React, { useEffect, useRef, useState, Fragment } from "react";
import TipTapImage from "@tiptap/extension-image";
const useEvent = (handler) => {
const handlerRef = useRef(null);
useLayoutEffect(() => {
handlerRef.current = handler;
}, [handler]);
return useCallback((...args) => {
if (handlerRef.current === null) {
throw new Error('Handler is not assigned');
}
return handlerRef.current(...args);
}, []);
};
const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
const ALIGN_OPTIONS = ['left', 'center', 'right'];
const ResizableImageTemplate = ({ node, updateAttributes }) => {
const containerRef = useRef(null);
const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
const imgRef = useRef(null);
const wrapperRef = useRef(null);
const [editing, setEditing] = useState(false);
const [resizingStyle, setResizingStyle] = useState(undefined);
const [showAlignMenu, setShowAlignMenu] = useState(false);
const isInitialized = useRef(false);
const resizeData = useRef({
startWidth: 0,
startHeight: 0,
startX: 0,
startY: 0,
aspectRatio: 1
});
useEffect(() => {
if (!editor || !getPos) return;
const pos = getPos() + 1;
const doc = editor.state.doc;
if (doc.nodeSize > pos && doc.nodeAt(pos)?.textContent !== '\u200B') {
editor.commands.insertContentAt(pos, {
type: 'text',
text: '\u200B'
});
}
}, [editor, getPos]);
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setEditing(false);
if (imgRef.current && !isInitialized.current) {
if (node.attrs.width && node.attrs.height) {
isInitialized.current = true;
return;
}
const width = imgRef.current.naturalWidth;
const height = imgRef.current.naturalHeight;
updateAttributes({
width: Math.round(width),
height: Math.round(height)
});
isInitialized.current = true;
}
}, [node.attrs.width, node.attrs.height, updateAttributes]);
const handleResizeStart = (direction) => (e) => {
e.preventDefault();
e.stopPropagation();
const currentWidth = node.attrs.width;
const currentHeight = node.attrs.height;
resizeData.current = {
startWidth: currentWidth,
startHeight: currentHeight,
startX: e.clientX,
startY: e.clientY,
aspectRatio: currentWidth / currentHeight,
direction
};
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [editing]);
const handleMouseDown = useEvent((event) => {
if (!imgRef.current) return;
event.preventDefault();
const direction = event.currentTarget.dataset.direction || "--";
const initialXPosition = event.clientX;
const currentWidth = imgRef.current.width;
let newWidth = currentWidth;
const transform = direction[1] === "w" ? -1 : 1;
const removeListeners = () => {
window.removeEventListener("mousemove", mouseMoveHandler);
window.removeEventListener("mouseup", removeListeners);
updateAttributes({ width: newWidth });
setResizingStyle(undefined);
const onMouseMove = (e) => {
const { startWidth, startHeight, startX, startY, aspectRatio, direction } = resizeData.current;
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.round(newHeight * aspectRatio);
} else {
const scale = direction.includes('e') ? 1 : -1;
newWidth = Math.max(startWidth + deltaX * scale, MIN_WIDTH);
newHeight = Math.round(newWidth / aspectRatio);
}
} else {
if (direction.includes('e') || direction.includes('w')) {
const scale = direction.includes('e') ? 1 : -1;
newWidth = Math.max(startWidth + deltaX * scale, MIN_WIDTH);
newHeight = Math.round(newWidth / aspectRatio);
} else {
const scale = direction.includes('s') ? 1 : -1;
newHeight = Math.max(startHeight + deltaY * scale, MIN_WIDTH);
newWidth = Math.round(newHeight * aspectRatio);
}
}
updateAttributes({
width: newWidth,
height: newHeight
});
};
const mouseMoveHandler = (event) => {
newWidth = Math.max(currentWidth + (transform * (event.clientX - initialXPosition)), MIN_WIDTH);
setResizingStyle({ width: newWidth });
if (!event.buttons) removeListeners();
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
editor.commands.focus();
};
window.addEventListener("mousemove", mouseMoveHandler);
window.addEventListener("mouseup", removeListeners);
});
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const dragCornerButton = (direction) => (
<div
role="button"
tabIndex={0}
onMouseDown={handleMouseDown}
data-direction={direction}
style={{
position: 'absolute',
height: '10px',
width: '10px',
backgroundColor: BORDER_COLOR,
...(direction[0] === 'n' ? { top: 0 } : { bottom: 0 }),
...(direction[1] === 'w' ? { left: 0 } : { right: 0 }),
cursor: `${direction}-resize`,
}}
>
</div>
);
const handleAlign = (align) => {
updateAttributes({ align });
setShowAlignMenu(false);
setTimeout(() => editor.commands.focus(), 100);
};
const getWrapperStyle = () => {
const baseStyle = {
display: 'inline-block',
position: 'relative',
outline: editing ? `1px dashed ${BORDER_COLOR}` : 'none',
verticalAlign: 'top',
zIndex: 1
};
switch(node.attrs.align) {
case 'left':
return {
...baseStyle,
float: 'left',
margin: '0 1rem 1rem 0'
};
case 'right':
return {
...baseStyle,
float: 'right',
margin: '0 0 1rem 1rem'
};
case 'center':
return {
...baseStyle,
display: 'block',
margin: '0 auto',
float: 'none',
textAlign: 'center'
};
default:
return baseStyle;
}
};
return (
<NodeViewWrapper
ref={containerRef}
as="div" draggable data-drag-handle
onClick={() => setEditing(true)}
onBlur={() => setEditing(false)}
style={{
overflow: 'hidden',
position: 'relative',
display: 'inline-block',
lineHeight: '0px',
as="span"
style={getWrapperStyle()}
ref={wrapperRef}
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
contentEditable={false}
data-image-wrapper
>
<img
{...node.attrs} ref={imgRef}
{...node.attrs}
ref={imgRef}
style={{
...resizingStyle,
width: `${node.attrs.width}px`,
height: `${node.attrs.height}px`,
maxWidth: '100%',
display: 'block',
margin: '0 auto', // Центрируем изображение внутри wrapper'а
cursor: 'default',
userSelect: 'none'
}}
onLoad={() => {
if (imgRef.current && !isInitialized.current &&
(!node.attrs.width || !node.attrs.height)) {
const width = imgRef.current.naturalWidth;
const height = imgRef.current.naturalHeight;
updateAttributes({
width: Math.round(width),
height: Math.round(height)
});
isInitialized.current = true;
}
}}
/>
{editing && (
<>
{[
{left: 0, top: 0, height: '100%', width: '1px'}, {right: 0, top: 0, height: '100%', width: '1px'},
{top: 0, left: 0, width: '100%', height: '1px'}, {bottom: 0, left: 0, width: '100%', height: '1px'}
].map((style, i) => (
<div key={i} style={{ position: 'absolute', backgroundColor: BORDER_COLOR, ...style }}></div>
<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
}}
/>
))}
{dragCornerButton("nw")}
{dragCornerButton("ne")}
{dragCornerButton("sw")}
{dragCornerButton("se")}
</>
{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
key={align}
onClick={() => handleAlign(align)}
style={{
margin: '0 2px',
padding: '4px 8px',
background: node.attrs.align === align ? '#e6f7ff' : 'transparent',
border: '1px solid #d9d9d9',
borderRadius: 2,
cursor: 'pointer'
}}
>
{align}
</button>
))}
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
setShowAlignMenu(!showAlignMenu);
}}
style={{
position: 'absolute',
top: -30,
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'white',
border: `1px solid ${BORDER_COLOR}`,
borderRadius: 4,
padding: '2px 8px',
cursor: 'pointer',
fontSize: 12,
zIndex: 10
}}
>
Align
</button>
</Fragment>
)}
</NodeViewWrapper>
);
......@@ -125,14 +278,71 @@ const ResizableImageExtension = TipTapImage.extend({
addAttributes() {
return {
...this.parent?.(),
width: { renderHTML: ({ width }) => ({ width }) },
height: { renderHTML: ({ height }) => ({ height }) },
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
width: {
default: null,
parseHTML: element => {
const width = element.getAttribute('width');
return width ? parseInt(width, 10) : null;
},
renderHTML: attributes => attributes.width ? { width: attributes.width } : {}
},
height: {
default: null,
parseHTML: element => {
const height = element.getAttribute('height');
return height ? parseInt(height, 10) : null;
},
renderHTML: attributes => attributes.height ? { height: attributes.height } : {}
},
align: {
default: 'left',
parseHTML: element => element.getAttribute('data-align') || 'left',
renderHTML: attributes => ({ 'data-align': attributes.align })
}
};
},
renderHTML({ HTMLAttributes }) {
const align = HTMLAttributes.align || 'left';
return ['span', {
'data-type': 'resizable-image',
'data-image-wrapper': true,
style: `
display: ${align === 'center' ? 'block' : 'inline-block'};
float: ${['left', 'right'].includes(align) ? align : 'none'};
margin: ${align === 'left' ? '0 1rem 1rem 0' :
align === 'right' ? '0 0 1rem 1rem' :
align === 'center' ? '0 auto' : '0'};
text-align: ${align === 'center' ? 'center' : 'left'};
vertical-align: top;
position: relative;
z-index: 1;
`,
'data-align': align
}, ['img', {
...HTMLAttributes,
style: `width:${HTMLAttributes.width}px;height:${HTMLAttributes.height}px;max-width:100%;display:block;margin:0 auto;`
}]];
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate);
},
}).configure({ inline: true });
}
}).configure({
inline: true,
group: 'inline',
draggable: true,
selectable: false
});
export default ResizableImageExtension;
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