Commit c8be1c9b by Яков

fix

parent 5b6b2868
{
"name": "react-ag-qeditor",
"version": "1.0.94",
"version": "1.0.95",
"description": "WYSIWYG html editor",
"author": "atma",
"license": "MIT",
......
......@@ -524,7 +524,9 @@ const QEditor = ({
},
onUploadError: (error) => {
console.error('Upload error:', error);
}
},
minDragDistance: 10, // Можно настроить под свои нужды
dragPreviewOpacity: 0.3 // Настройка прозрачности
})
],
content: value,
......
// DragAndDrop.js
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
import axios from 'axios';
import { NodeSelection } from 'prosemirror-state';
export const DragAndDrop = Extension.create({
name: 'dragAndDrop',
addOptions() {
return {
uploadUrl: '', // URL для загрузки файлов
uploadHandler: null, // Кастомный обработчик загрузки
allowedFileTypes: [ // Разрешенные MIME-типы
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'video/mp4',
'video/webm',
'audio/mpeg'
uploadUrl: '',
uploadHandler: null,
allowedFileTypes: [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'video/mp4', 'video/webm', 'audio/mpeg'
],
headers: {}, // Дополнительные заголовки
headers: {},
onUploadError: (error) => console.error('Upload failed:', error),
onUploadSuccess: () => {} // Колбек при успешной загрузке
onUploadSuccess: () => {},
minDragDistance: 10,
dragPreviewOpacity: 0.3
};
},
addProseMirrorPlugins() {
const extension = this;
// Проверяем, является ли файл реальным (не из Word)
const dragState = {
active: false,
sourceNode: null,
sourcePos: null,
nodeId: null
};
const isRealFile = (file) => {
if (!file || !file.type) return false;
// Игнорируем специфичные для Word типы
const wordTypes = [
'application/x-mso',
'ms-office',
'wordprocessingml',
'application/rtf',
'text/rtf',
'text/html'
'application/x-mso', 'ms-office', 'wordprocessingml',
'application/rtf', 'text/rtf', 'text/html'
];
if (wordTypes.some(type => file.type.includes(type))) {
return false;
}
// Проверяем разрешенные типы
return extension.options.allowedFileTypes.includes(file.type);
return !wordTypes.some(type => file.type.includes(type)) &&
extension.options.allowedFileTypes.includes(file.type);
};
// Определяем тип ноды для вставки
const getNodeType = (mimeType) => {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
......@@ -57,97 +50,167 @@ export const DragAndDrop = Extension.create({
return null;
};
// Обработчик загрузки файла
const handleFileUpload = async (file, view, position) => {
try {
let fileUrl;
if (!extension.options.uploadUrl && !extension.options.uploadHandler) {
console.error('No upload URL or handler provided');
return;
}
const nodeType = getNodeType(file.type);
if (!nodeType) return;
try {
let result;
if (extension.options.uploadHandler) {
fileUrl = await extension.options.uploadHandler(file);
result = await extension.options.uploadHandler(file);
} else {
const formData = new FormData();
formData.append('file', file);
const response = await axios.post(
global.uploadUrl,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
...extension.options.headers,
},
}
);
if (!response.data?.file_path) {
throw new Error('Invalid server response');
}
fileUrl = response.data.file_path;
const response = await axios.post(extension.options.uploadUrl, formData, {
headers: extension.options.headers
});
result = response.data;
}
if (!fileUrl) return;
if (!result?.url) throw new Error('Invalid response from server');
const { state, dispatch } = view;
const type = getNodeType(file.type);
if (!type) return;
const node = view.state.schema.nodes[nodeType].create({
src: result.url,
alt: file.name,
title: file.name,
'data-node-id': `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
});
const node = state.schema.nodes[type].create({ src: fileUrl });
dispatch(state.tr.insert(position, node));
extension.options.onUploadSuccess(fileUrl);
const tr = view.state.tr.insert(position || view.state.selection.from, node);
view.dispatch(tr);
extension.options.onUploadSuccess(result);
} catch (error) {
console.error('Upload failed:', error);
extension.options.onUploadError(error);
}
};
// Обработчик вставки (paste)
const handlePaste = (view, event) => {
const items = Array.from(event.clipboardData?.items || []);
const htmlData = event.clipboardData.getData('text/html');
// Если есть HTML и это контент из Word - пропускаем
if (htmlData.includes('urn:schemas-microsoft-com')) {
if (event.clipboardData.getData('text/html').includes('urn:schemas-microsoft-com')) {
return false;
}
// Фильтруем только реальные файлы
const files = items
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter(file => file && isRealFile(file));
if (files.length === 0) return false;
const file = items.find(item => isRealFile(item.getAsFile()))?.getAsFile();
if (!file) return false;
event.preventDefault();
const pos = view.state.selection.from;
handleFileUpload(file, view);
return true;
};
files.forEach(file => {
handleFileUpload(file, view, pos);
});
const handleDragStart = (event) => {
const target = event.target;
if (!target.matches('.ProseMirror img, .ProseMirror video, .ProseMirror audio')) return;
return true;
const view = extension.editor.view;
const pos = view.posAtDOM(target, 0);
if (pos === undefined || pos === null) return;
const node = view.state.doc.nodeAt(pos);
if (!node || !['image', 'video', 'audio'].includes(node.type.name)) return;
dragState.active = true;
dragState.sourceNode = node;
dragState.sourcePos = pos;
dragState.nodeId = node.attrs['data-node-id'];
const dragImage = new Image();
dragImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setData('text/plain', 'tiptap-drag');
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.setDragImage(dragImage, 0, 0);
target.style.opacity = extension.options.dragPreviewOpacity;
};
// Обработчик перетаскивания (drop)
const handleDrop = (view, event) => {
const handleDrop = (event) => {
event.preventDefault();
event.stopPropagation();
const view = extension.editor.view;
const coords = { left: event.clientX, top: event.clientY };
const dropPos = view.posAtCoords(coords)?.pos;
if (event.dataTransfer) {
event.dataTransfer.clearData?.();
}
if (!dragState.active) {
const files = Array.from(event.dataTransfer?.files || [])
.filter(file => isRealFile(file));
if (files.length === 0) return;
if (files.length === 0) return false;
handleFileUpload(files[0], view, dropPos || view.state.selection.from);
return;
}
event.preventDefault();
const pos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
})?.pos;
if (dropPos === undefined || dropPos === null) {
resetDragState();
return;
}
if (!pos) return false;
if (Math.abs(dropPos - dragState.sourcePos) < extension.options.minDragDistance) {
resetDragState();
return;
}
files.forEach(file => {
handleFileUpload(file, view, pos);
const { state } = view;
let actualPos = null;
let actualNode = null;
state.doc.descendants((node, pos) => {
if (node.attrs['data-node-id'] === dragState.nodeId) {
actualNode = node;
actualPos = pos;
return false;
}
});
return true;
if (!actualNode || actualPos === null) {
resetDragState();
return;
}
const tr = state.tr.delete(actualPos, actualPos + actualNode.nodeSize);
const insertPos = dropPos > actualPos ? dropPos - actualNode.nodeSize : dropPos;
tr.insert(insertPos, actualNode);
view.dispatch(tr);
// Обновим выделение на вставленный узел
const resolvedPos = view.state.doc.resolve(insertPos);
const nodeSelection = NodeSelection.create(view.state.doc, resolvedPos.pos);
view.dispatch(view.state.tr.setSelection(nodeSelection));
resetDragState();
};
const resetDragState = () => {
document.querySelectorAll('.ProseMirror img, .ProseMirror video, .ProseMirror audio')
.forEach(el => el.style.opacity = '1');
dragState.active = false;
dragState.sourceNode = null;
dragState.sourcePos = null;
dragState.nodeId = null;
};
const setupGlobalListeners = () => {
document.addEventListener('dragstart', handleDragStart, true);
document.addEventListener('drop', handleDrop, true);
document.addEventListener('dragend', resetDragState, true);
return () => {
document.removeEventListener('dragstart', handleDragStart, true);
document.removeEventListener('drop', handleDrop, true);
document.removeEventListener('dragend', resetDragState, true);
};
};
return [
......@@ -155,9 +218,41 @@ export const DragAndDrop = Extension.create({
key: new PluginKey('dragAndDrop'),
props: {
handlePaste,
handleDrop,
handleDOMEvents: {
dragstart: () => true,
drop: () => true,
dragover: (view, event) => {
if (dragState.active) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
return true;
}
const items = event.dataTransfer?.items;
if (items && Array.from(items).some(item => isRealFile(item.getAsFile()))) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
return true;
}
return false;
},
dragenter: (view, event) => {
if (
dragState.active ||
(event.dataTransfer?.items &&
Array.from(event.dataTransfer.items).some(item => isRealFile(item.getAsFile())))
) {
event.preventDefault();
return true;
}
return false;
}
}
},
view: () => ({
destroy: setupGlobalListeners()
})
}),
];
},
}
});
......@@ -6,51 +6,30 @@ const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
const ALIGN_OPTIONS = ['left', 'center', 'right', 'text'];
const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, selected }) => {
const imgRef = useRef(null);
const wrapperRef = useRef(null);
const [editing, setEditing] = useState(false);
const [showAlignMenu, setShowAlignMenu] = useState(false);
const isInitialized = useRef(false);
const resizeData = useRef({
startWidth: 0,
startHeight: 0,
startX: 0,
startY: 0,
aspectRatio: 1
});
// Генерация уникального ID при создании
useEffect(() => {
if (!editor?.isEditable || typeof getPos !== 'function') return;
const insertZeroWidthSpace = () => {
try {
const pos = getPos();
if (typeof pos !== 'number' || pos < 0) return;
const doc = editor.state.doc;
const insertPos = pos + 1;
if (insertPos >= doc.content.size) return;
const nextNode = doc.nodeAt(insertPos);
if (nextNode?.textContent === '\u200B') return;
setTimeout(() => {
if (editor.isDestroyed) return;
editor.commands.insertContentAt(insertPos, {
type: 'text',
text: '\u200B'
if (!node.attrs['data-node-id']) {
updateAttributes({
'data-node-id': `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
});
}, 50);
} catch (error) {
console.warn('Error inserting zero-width space:', error);
}
};
}, [node.attrs['data-node-id'], updateAttributes]);
const timer = setTimeout(insertZeroWidthSpace, 100);
return () => clearTimeout(timer);
}, [editor, getPos]);
useEffect(() => {
const handleClickOutside = (event) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target) && selected) {
editor.commands.setNodeSelection(getPos());
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [selected, editor, getPos]);
useEffect(() => {
if (!imgRef.current || isInitialized.current) return;
......@@ -59,11 +38,11 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
try {
const width = node.attrs.width || imgRef.current.naturalWidth;
const height = node.attrs.height || imgRef.current.naturalHeight;
if (width > 0 && height > 0) {
updateAttributes({
width: Math.round(width),
height: Math.round(height)
height: Math.round(height),
'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
});
isInitialized.current = true;
}
......@@ -79,42 +58,21 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
}
return () => {
if (imgRef.current) {
imgRef.current.onload = null;
}
if (imgRef.current) imgRef.current.onload = null;
};
}, [node.attrs.width, node.attrs.height, updateAttributes]);
}, [node.attrs.width, node.attrs.height, updateAttributes, node.attrs['data-node-id']]);
const handleResizeStart = (direction) => (e) => {
e.preventDefault();
e.stopPropagation();
const nodePos = typeof getPos === 'function' ? getPos() : null;
const currentWidth = node.attrs.width || imgRef.current.naturalWidth;
const currentHeight = node.attrs.height || imgRef.current.naturalHeight;
resizeData.current = {
startWidth: currentWidth,
startHeight: currentHeight,
startX: e.clientX,
startY: e.clientY,
aspectRatio: currentWidth / currentHeight,
direction,
nodePos
};
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 onMouseMove = (e) => {
const { startWidth, startHeight, startX, startY, aspectRatio, direction, nodePos } = resizeData.current;
if (typeof nodePos === 'number') {
try {
const resolvedPos = editor.view.state.doc.resolve(nodePos);
if (!resolvedPos?.nodeAfter) return;
} catch {
return;
}
}
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
......@@ -142,38 +100,13 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
}
}
// Сохраняем новые размеры в resizeData для использования в onMouseUp
resizeData.current.currentWidth = newWidth;
resizeData.current.currentHeight = newHeight;
updateAttributes({
width: newWidth,
height: newHeight
});
updateAttributes({ width: newWidth, height: newHeight });
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
// Фиксируем окончательные размеры
if (typeof resizeData.current.nodePos === 'number') {
updateAttributes({
width: resizeData.current.currentWidth,
height: resizeData.current.currentHeight
});
}
if (!editor.isDestroyed && editor.view) {
setTimeout(() => {
try {
editor.commands.focus();
editor.view.dispatch(editor.view.state.tr.setMeta('forceUpdate', true));
} catch (error) {
console.warn('Focus error after resize:', error);
}
}, 50);
}
};
window.addEventListener('mousemove', onMouseMove);
......@@ -183,48 +116,31 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
const handleAlign = (align) => {
updateAttributes({ align });
setShowAlignMenu(false);
setTimeout(() => editor.commands.focus(), 100);
editor.commands.focus();
};
const getWrapperStyle = () => {
const baseStyle = {
const getWrapperStyle = () => ({
display: 'inline-block',
lineHeight: 0,
margin: '0.5rem 0',
position: 'relative',
outline: editing ? `1px dashed ${BORDER_COLOR}` : 'none',
verticalAlign: 'top'
};
switch(node.attrs.align) {
case 'left':
return { ...baseStyle, float: 'left', marginRight: '1rem' };
case 'right':
return { ...baseStyle, float: 'right', marginLeft: '1rem' };
case 'center':
return {
...baseStyle,
outline: selected ? `1px dashed ${BORDER_COLOR}` : 'none',
verticalAlign: 'top',
...(node.attrs.align === 'left' && { float: 'left', marginRight: '1rem' }),
...(node.attrs.align === 'right' && { float: 'right', marginLeft: '1rem' }),
...(node.attrs.align === 'center' && {
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
textAlign: 'center'
};
case 'text':
return {
...baseStyle,
}),
...(node.attrs.align === 'text' && {
display: 'inline-block',
float: 'none',
margin: '0 0.2rem',
verticalAlign: 'middle'
};
case 'wrap-left':
return { ...baseStyle, float: 'left', margin: '0 1rem 1rem 0', shapeOutside: 'margin-box' };
case 'wrap-right':
return { ...baseStyle, float: 'right', margin: '0 0 1rem 1rem', shapeOutside: 'margin-box' };
default:
return baseStyle;
}
};
})
});
const getImageStyle = () => ({
width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
......@@ -244,7 +160,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
ref={wrapperRef}
onClick={(e) => {
e.stopPropagation();
setEditing(true);
editor.commands.setNodeSelection(getPos());
}}
contentEditable={false}
data-image-wrapper
......@@ -252,6 +168,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
<img
{...node.attrs}
ref={imgRef}
draggable={true} // обязательно true для работы dragstart
style={getImageStyle()}
onLoad={() => {
if (imgRef.current && !isInitialized.current) {
......@@ -259,14 +176,15 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
const height = imgRef.current.naturalHeight;
updateAttributes({
width: Math.round(width),
height: Math.round(height)
height: Math.round(height),
'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
});
isInitialized.current = true;
}
}}
/>
{editing && (
{selected && (
<Fragment>
{['nw', 'ne', 'sw', 'se'].map(dir => (
<div
......@@ -351,103 +269,48 @@ const ResizableImageExtension = TipTapImage.extend({
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
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;
},
parseHTML: element => parseInt(element.getAttribute('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;
},
parseHTML: element => parseInt(element.getAttribute('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 ||
HTMLAttributes['data-align'] ||
'left';
const floatValue = align.startsWith('wrap-') ? align.split('-')[1] :
['left', 'right'].includes(align) ? align :
'none';
let marginValue;
switch(align) {
case 'left':
case 'wrap-left':
marginValue = '0 1rem 1rem 0';
break;
case 'right':
case 'wrap-right':
marginValue = '0 0 1rem 1rem';
break;
case 'center':
marginValue = '0.5rem auto';
break;
case 'text':
marginValue = '0 0.2rem';
break;
default:
marginValue = '0';
'data-node-id': {
default: null,
parseHTML: element => element.getAttribute('data-node-id'),
renderHTML: attributes => ({ 'data-node-id': attributes['data-node-id'] })
}
return ['span', {
'data-type': 'resizable-image',
'data-image-wrapper': true,
'data-align': align,
style: `
display: ${align === 'center' ? 'block' : 'inline-block'};
float: ${floatValue};
margin: ${marginValue};
shape-outside: ${align.startsWith('wrap-') ? 'margin-box' : 'none'};
vertical-align: ${align === 'text' ? 'middle' : 'top'};
position: relative;
${align === 'center' ? 'width: 100%; text-align: center;' : ''}
`
}, ['img', {
...HTMLAttributes,
style: `
${HTMLAttributes.style || ''};
display: ${align === 'center' ? 'inline-block' : 'block'};
margin: ${align === 'center' ? '0 auto' : '0'};
max-width: 100%;
height: auto;
vertical-align: ${align === 'text' ? 'middle' : 'top'};
`,
'data-align': align
}]];
};
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate);
},
addKeyboardShortcuts() {
return {
'Mod-ArrowLeft': () => this.editor.commands.updateAttributes(this.type.name, { align: 'left' }),
'Mod-ArrowRight': () => this.editor.commands.updateAttributes(this.type.name, { align: 'right' }),
'Mod-ArrowDown': () => this.editor.commands.updateAttributes(this.type.name, { align: 'center' }),
};
}
}).configure({
inline: true,
group: 'inline',
draggable: true,
selectable: false
selectable: true
});
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