Commit 0dbfa693 by Яков

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

parent 5c2effc1
...@@ -7,12 +7,20 @@ const App = () => { ...@@ -7,12 +7,20 @@ const App = () => {
return <div style={{padding:40}}> return <div style={{padding:40}}>
<QEditor <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={`<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)=>{ onChange={(value)=>{
// console.log(value); console.log(value);
}} }}
uploadOptions={{ uploadOptions={{
url: 'https://cdn.atmaguru.online/upload/?sid=atmacompany&md5=0cETbV4BquHkqAdG9cK9MA&expires=1742192970', url: '/upload',
errorMessage: 'Загрузка временно невозможна' errorMessage: 'Загрузка временно невозможна'
}} }}
style={{ 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 = ({ ...@@ -99,6 +99,7 @@ const QEditor = ({
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [recordType, setRecordType] = useState({video: true}) const [recordType, setRecordType] = useState({video: true})
let formRef = useRef(null);
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const getRgb = (hex) => { const getRgb = (hex) => {
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
...@@ -1280,6 +1281,7 @@ const QEditor = ({ ...@@ -1280,6 +1281,7 @@ const QEditor = ({
setWordGlossary(selectedText); setWordGlossary(selectedText);
setTimeout(()=>{ setTimeout(()=>{
setModalGlossaryIsOpen(true); setModalGlossaryIsOpen(true);
setTimeout(()=>{formRef.current.resetFields();},1)
}, 100) }, 100)
} }
// console.log(editor.chain().focus()); // console.log(editor.chain().focus());
...@@ -1318,6 +1320,7 @@ const QEditor = ({ ...@@ -1318,6 +1320,7 @@ const QEditor = ({
]} ]}
> >
<Form <Form
ref={formRef}
name={'form-glossary'} name={'form-glossary'}
initialValues={{word : wordGlossary}} initialValues={{word : wordGlossary}}
labelCol={{span: 8}} labelCol={{span: 8}}
......
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; 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"; 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 MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd'; const BORDER_COLOR = '#0096fd';
const ALIGN_OPTIONS = ['left', 'center', 'right'];
const ResizableImageTemplate = ({ node, updateAttributes }) => { const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
const containerRef = useRef(null);
const imgRef = useRef(null); const imgRef = useRef(null);
const wrapperRef = useRef(null);
const [editing, setEditing] = useState(false); 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(() => { useEffect(() => {
const handleClickOutside = (event) => { if (imgRef.current && !isInitialized.current) {
if (containerRef.current && !containerRef.current.contains(event.target)) { if (node.attrs.width && node.attrs.height) {
setEditing(false); 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 () => { const onMouseMove = (e) => {
document.removeEventListener('click', handleClickOutside); 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
});
}; };
}, [editing]);
const onMouseUp = () => {
const handleMouseDown = useEvent((event) => { window.removeEventListener('mousemove', onMouseMove);
if (!imgRef.current) return; window.removeEventListener('mouseup', onMouseUp);
event.preventDefault(); editor.commands.focus();
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 mouseMoveHandler = (event) => { window.addEventListener('mousemove', onMouseMove);
newWidth = Math.max(currentWidth + (transform * (event.clientX - initialXPosition)), MIN_WIDTH); window.addEventListener('mouseup', onMouseUp);
setResizingStyle({ width: newWidth });
if (!event.buttons) removeListeners();
}; };
window.addEventListener("mousemove", mouseMoveHandler); const handleAlign = (align) => {
window.addEventListener("mouseup", removeListeners); updateAttributes({ align });
}); setShowAlignMenu(false);
setTimeout(() => editor.commands.focus(), 100);
};
const dragCornerButton = (direction) => ( const getWrapperStyle = () => {
<div const baseStyle = {
role="button" display: 'inline-block',
tabIndex={0} position: 'relative',
onMouseDown={handleMouseDown} outline: editing ? `1px dashed ${BORDER_COLOR}` : 'none',
data-direction={direction} verticalAlign: 'top',
style={{ zIndex: 1
position: 'absolute', };
height: '10px',
width: '10px', switch(node.attrs.align) {
backgroundColor: BORDER_COLOR, case 'left':
...(direction[0] === 'n' ? { top: 0 } : { bottom: 0 }), return {
...(direction[1] === 'w' ? { left: 0 } : { right: 0 }), ...baseStyle,
cursor: `${direction}-resize`, float: 'left',
}} margin: '0 1rem 1rem 0'
> };
</div> 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 ( return (
<NodeViewWrapper <NodeViewWrapper
ref={containerRef} as="span"
as="div" draggable data-drag-handle style={getWrapperStyle()}
onClick={() => setEditing(true)} ref={wrapperRef}
onBlur={() => setEditing(false)} onClick={(e) => {
style={{ e.stopPropagation();
overflow: 'hidden', setEditing(true);
position: 'relative',
display: 'inline-block',
lineHeight: '0px',
}} }}
contentEditable={false}
data-image-wrapper
> >
<img <img
{...node.attrs} ref={imgRef} {...node.attrs}
ref={imgRef}
style={{ style={{
...resizingStyle, width: `${node.attrs.width}px`,
height: `${node.attrs.height}px`,
maxWidth: '100%',
display: 'block',
margin: '0 auto', // Центрируем изображение внутри wrapper'а
cursor: 'default', 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 && ( {editing && (
<> <Fragment>
{[ {['nw', 'ne', 'sw', 'se'].map(dir => (
{left: 0, top: 0, height: '100%', width: '1px'}, {right: 0, top: 0, height: '100%', width: '1px'}, <div
{top: 0, left: 0, width: '100%', height: '1px'}, {bottom: 0, left: 0, width: '100%', height: '1px'} key={dir}
].map((style, i) => ( onMouseDown={handleResizeStart(dir)}
<div key={i} style={{ position: 'absolute', backgroundColor: BORDER_COLOR, ...style }}></div> 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
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>
))} ))}
{dragCornerButton("nw")} </div>
{dragCornerButton("ne")} )}
{dragCornerButton("sw")}
{dragCornerButton("se")} <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> </NodeViewWrapper>
); );
...@@ -125,14 +278,71 @@ const ResizableImageExtension = TipTapImage.extend({ ...@@ -125,14 +278,71 @@ const ResizableImageExtension = TipTapImage.extend({
addAttributes() { addAttributes() {
return { return {
...this.parent?.(), ...this.parent?.(),
width: { renderHTML: ({ width }) => ({ width }) }, src: {
height: { renderHTML: ({ height }) => ({ height }) }, 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() { addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate); return ReactNodeViewRenderer(ResizableImageTemplate);
}, }
}).configure({ inline: true }); }).configure({
inline: true,
group: 'inline',
draggable: true,
selectable: false
});
export default ResizableImageExtension; 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