russ_react/src/components/CustomImageExtension.tsx

283 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
},
});