283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
import React from 'react';
|
||
import {mergeAttributes, Node, NodeViewProps } from '@tiptap/core';
|
||
import {NodeViewWrapper, ReactNodeViewRenderer} from '@tiptap/react';
|
||
import {Trash2, ZoomIn, ZoomOut} from 'lucide-react';
|
||
import {Node as ProseMirrorNode, DOMOutputSpec} from 'prosemirror-model';
|
||
|
||
// Определяем тип для атрибутов узла customImage
|
||
interface CustomImageAttributes {
|
||
src: string | undefined;
|
||
alt: string | undefined;
|
||
title: string | undefined;
|
||
scale: number;
|
||
caption: string; // Добавляем атрибут caption
|
||
}
|
||
|
||
const ImageNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => {
|
||
// Утверждаем, что node имеет нужный тип
|
||
const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes };
|
||
|
||
// Проверяем, что узел корректен
|
||
if (node.type.name !== 'customImage') {
|
||
console.error('Неподдерживаемый тип узла:', node.type.name);
|
||
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: неподдерживаемый тип узла</NodeViewWrapper>;
|
||
}
|
||
|
||
// Проверяем, что src существует
|
||
if (!typedNode.attrs.src) {
|
||
console.error('Изображение не загружено, src отсутствует:', typedNode.attrs);
|
||
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: изображение не загружено</NodeViewWrapper>;
|
||
}
|
||
|
||
// Логируем атрибуты узла при загрузке
|
||
//console.log('Атрибуты узла customImage при загрузке:', typedNode.attrs);
|
||
|
||
const handleZoomIn = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
try {
|
||
if (!editor || editor.isDestroyed) {
|
||
console.error('Редактор уничтожен или недоступен');
|
||
return;
|
||
}
|
||
|
||
const pos = getPos();
|
||
const currentNode = editor.state.doc.nodeAt(pos);
|
||
if (!currentNode || currentNode.type.name !== 'customImage') {
|
||
console.error('Узел больше не существует или изменён:', { pos, currentNode });
|
||
return;
|
||
}
|
||
|
||
let currentScale = typedNode.attrs.scale;
|
||
if (currentScale <= 0 || isNaN(currentScale)) {
|
||
console.warn('Некорректное значение scale, устанавливаем значение по умолчанию:', { currentScale });
|
||
currentScale = 1;
|
||
updateAttributes({ scale: 1 });
|
||
}
|
||
|
||
const newScale = Math.min(currentScale + 0.1, 2);
|
||
const roundedScale = Number(newScale.toFixed(2));
|
||
updateAttributes({ scale: roundedScale });
|
||
|
||
console.log('Масштаб увеличен через updateAttributes:', { currentScale, newScale: roundedScale });
|
||
} catch (error) {
|
||
console.error('Ошибка при увеличении масштаба:', error);
|
||
}
|
||
};
|
||
|
||
const handleZoomOut = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
try {
|
||
if (!editor || editor.isDestroyed) {
|
||
console.error('Редактор уничтожен или недоступен');
|
||
return;
|
||
}
|
||
|
||
const pos = getPos();
|
||
const currentNode = editor.state.doc.nodeAt(pos);
|
||
if (!currentNode || currentNode.type.name !== 'customImage') {
|
||
console.error('Узел больше не существует или изменён:', { pos, currentNode });
|
||
return;
|
||
}
|
||
|
||
let currentScale = typedNode.attrs.scale;
|
||
if (currentScale <= 0 || isNaN(currentScale)) {
|
||
console.warn('Некорректное значение scale, устанавливаем значение по умолчанию:', { currentScale });
|
||
currentScale = 1;
|
||
updateAttributes({ scale: 1 });
|
||
}
|
||
|
||
const newScale = Math.max(currentScale - 0.1, 0.5);
|
||
const roundedScale = Number(newScale.toFixed(2));
|
||
updateAttributes({ scale: roundedScale });
|
||
|
||
console.log('Масштаб уменьшен через updateAttributes:', { currentScale, newScale: roundedScale });
|
||
} catch (error) {
|
||
console.error('Ошибка при уменьшении масштаба:', error);
|
||
}
|
||
};
|
||
|
||
const handleDelete = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
try {
|
||
deleteNode();
|
||
} catch (error) {
|
||
console.error('Ошибка при удалении изображения:', error);
|
||
}
|
||
};
|
||
|
||
const scale = typedNode.attrs.scale || 1;
|
||
const caption = typedNode.attrs.caption || '';
|
||
const isEditable = editor.isEditable; // Проверяем, находится ли редактор в режиме редактирования
|
||
|
||
return (
|
||
//<NodeViewWrapper className="relative flex justify-center my-4">
|
||
<NodeViewWrapper className="relative flex flex-col items-center my-4">
|
||
|
||
{/* Оборачиваем изображение и панель в контейнер, который масштабируется */}
|
||
<div
|
||
className="relative inline-block"
|
||
style={{
|
||
transform: `scale(${scale})`,
|
||
transformOrigin: 'center',
|
||
}}
|
||
>
|
||
<img
|
||
src={typedNode.attrs.src}
|
||
alt={typedNode.attrs.alt}
|
||
className={`max-w-full transition-transform ${selected && isEditable ? 'ring-2 ring-blue-500' : ''}`}
|
||
data-drag-handle=""
|
||
/>
|
||
{selected && isEditable && ( // Показываем панель только в режиме редактирования
|
||
<div
|
||
className="absolute top-2 right-2 flex space-x-2 bg-white bg-opacity-75 p-1 rounded shadow"
|
||
style={{
|
||
transform: `scale(${1 / scale})`, // Компенсируем масштабирование панели
|
||
transformOrigin: 'top right', // Устанавливаем точку масштабирования в верхний правый угол
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={handleZoomIn}
|
||
className="p-1 rounded hover:bg-gray-200"
|
||
title={`Zoom in (current scale: ${scale})`}
|
||
>
|
||
<ZoomIn size={16} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleZoomOut}
|
||
className="p-1 rounded hover:bg-gray-200"
|
||
title={`Zoom out (current scale: ${scale})`}
|
||
>
|
||
<ZoomOut size={16} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleDelete}
|
||
className="p-1 rounded hover:bg-red-200 text-red-600"
|
||
title="Delete image"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
<span className="text-xs text-gray-600 px-2 py-1">
|
||
Масштаб: {scale}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{caption && (
|
||
<p className="image-caption mt-2 text-sm text-gray-600 italic text-center">
|
||
{caption}
|
||
</p>
|
||
)}
|
||
</NodeViewWrapper>
|
||
);
|
||
};
|
||
|
||
export const CustomImage = Node.create({
|
||
name: 'customImage',
|
||
group: 'block',
|
||
inline: false,
|
||
selectable: true,
|
||
draggable: true,
|
||
atom: true,
|
||
|
||
addAttributes() {
|
||
return {
|
||
src: {
|
||
default: undefined,
|
||
},
|
||
alt: {
|
||
default: undefined,
|
||
},
|
||
title: {
|
||
default: undefined,
|
||
},
|
||
scale: {
|
||
default: 1,
|
||
parseHTML: (element) => {
|
||
const scale = Number(element.getAttribute('scale'));
|
||
return isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2))));
|
||
},
|
||
renderHTML: (attributes) => {
|
||
const scale = Number(attributes.scale);
|
||
const renderedScale = isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2))));
|
||
return { scale: renderedScale };
|
||
},
|
||
},
|
||
caption: { // Добавляем атрибут caption
|
||
default: '',
|
||
parseHTML: (element) => element.getAttribute('data-caption') || '',
|
||
renderHTML: (attributes) => {
|
||
if (!attributes.caption) {
|
||
return {};
|
||
}
|
||
return {
|
||
'data-caption': attributes.caption,
|
||
};
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
parseHTML() {
|
||
return [
|
||
{
|
||
tag: 'div.image-container',
|
||
getAttrs: (element: HTMLElement) => {
|
||
const img = element.querySelector('img');
|
||
const caption = element.querySelector('p.image-caption')?.textContent || '';
|
||
return {
|
||
src: img?.getAttribute('src'),
|
||
alt: img?.getAttribute('alt'),
|
||
title: img?.getAttribute('title'),
|
||
scale: img ? Number(img.getAttribute('scale')) || 1 : 1,
|
||
caption,
|
||
};
|
||
},
|
||
},
|
||
{
|
||
tag: 'img[src]',
|
||
getAttrs: (element) => {
|
||
const scale = element.getAttribute('scale');
|
||
const caption = element.getAttribute('data-caption') || '';
|
||
return {
|
||
src: element.getAttribute('src'),
|
||
alt: element.getAttribute('alt'),
|
||
title: element.getAttribute('title'),
|
||
scale: scale ? Number(scale) : 1,
|
||
caption,
|
||
};
|
||
},
|
||
},
|
||
];
|
||
},
|
||
|
||
renderHTML({ HTMLAttributes }): DOMOutputSpec {
|
||
const { scale, caption, ...rest } = HTMLAttributes;
|
||
|
||
const imgElement: DOMOutputSpec = [
|
||
'img',
|
||
mergeAttributes(rest, {
|
||
scale: scale,
|
||
'data-caption': caption,
|
||
style: `transform: scale(${scale})`,
|
||
}),
|
||
];
|
||
|
||
const children: DOMOutputSpec[] = [imgElement];
|
||
|
||
if (caption) {
|
||
children.push(['p', { class: 'image-caption mt-2 text-sm text-gray-600 italic text-center' }, caption]);
|
||
}
|
||
|
||
return ['div', { class: 'image-container flex flex-col items-center my-4' }, ...children];
|
||
},
|
||
|
||
addNodeView() {
|
||
return ReactNodeViewRenderer(ImageNodeView);
|
||
},
|
||
}); |