russ_react/src/components/TipTapEditor.tsx

477 lines
16 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 { useEditor, EditorContent } from '@tiptap/react';
import Highlight from '@tiptap/extension-highlight';
import TextAlign from '@tiptap/extension-text-align';
import Blockquote from '@tiptap/extension-blockquote';
import Image from '@tiptap/extension-image';
import StarterKit from '@tiptap/starter-kit';
import {
Bold,
Italic,
List,
ListOrdered,
Quote,
Redo,
Undo,
AlignLeft,
AlignCenter,
Plus,
Minus,
SquareUser,
ImagePlus
} from 'lucide-react';
import { useEffect, useState } from "react";
import { CustomImage } from "./CustomImageExtension";
import TipTapImageUploadButton from "./TipTapImageUploadButton";
interface TipTapEditorProps {
initialContent: string;
onContentChange: (content: string) => void;
atricleId: string;
}
/*
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: { default: null, },
alt: { default: null, },
title: { default: null, },
class: { default: 'max-w-full h-auto', },
width: {
default: 'auto',
parseHTML: (element) => element.getAttribute('width') || 'auto',
renderHTML: (attributes) => ({
width: attributes.width,
class: selectedImage === attributes.src ? 'selected-image' : '',
}),
},
height: {
default: 'auto',
parseHTML: (element) => element.getAttribute('height') || 'auto',
renderHTML: (attributes) => ({
height: attributes.height,
}),
},
};
},
});
*/
export function TipTapEditor({ initialContent, onContentChange, atricleId }: TipTapEditorProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
/*
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: { default: null, },
alt: { default: null, },
title: { default: null, },
class: { default: 'max-w-full h-auto', },
width: {
default: 'auto',
parseHTML: (element) => element.getAttribute('width') || 'auto',
renderHTML: (attributes) => ({
width: attributes.width,
class: selectedImage === attributes.src ? 'selected-image' : '',
}),
},
height: {
default: 'auto',
parseHTML: (element) => element.getAttribute('height') || 'auto',
renderHTML: (attributes) => ({
height: attributes.height,
}),
},
};
},
});
*/
const editor = useEditor({
extensions: [
StarterKit.configure({
blockquote: false, // Отключаем дефолтный
}),
Blockquote.configure({
HTMLAttributes: {
class: 'border-l-4 border-gray-300 pl-4 italic',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
CustomImage,
Highlight,
],
content: initialContent || '',
editorProps: {
attributes: {
class: 'prose prose-lg focus:outline-none min-h-[300px] max-w-none',
},
},
onUpdate: ({ editor }) => {
const { from, to } = editor.state.selection;
let foundImage = false;
editor.state.doc.nodesBetween(from, to, (node) => {
if (node.type.name === 'image') {
setSelectedImage(node.attrs.src);
editor.commands.updateAttributes('image', { class: 'selected-image' });
foundImage = true;
}
});
if (!foundImage) {
setSelectedImage(null);
}
onContentChange(editor.getHTML());
},
});
// Обновление контента при изменении initialContent
useEffect(() => {
if (editor && editor.getHTML() !== initialContent) {
editor.commands.setContent(initialContent);
}
}, [initialContent, editor]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!editor?.view.dom.contains(event.target as Node)) {
setSelectedImage(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [editor]);
if (!editor) return null;
const resizeImage = (delta: number) => {
if (!editor.isActive('image')) return;
const attrs = editor?.getAttributes('image');
const newWidth = Math.max((parseInt(attrs?.width, 10) || 100) + delta, 50);
editor.chain().focus().updateAttributes('image', {width: `${newWidth}px`}).run();
};
// Helper function to map paragraph word positions to document positions
const mapWordToDocumentPositions = (
word: { start: number, end: number },
textPositions: { start: number, end: number }[],
paragraphOffset: number
) => {
let docStart = -1;
let docEnd = -1;
// Find which text node(s) contain this word
for (const pos of textPositions) {
// Word starts in this text node
if (docStart === -1 && word.start < pos.end - paragraphOffset - 1) {
const relativeStart = Math.max(0, word.start - (pos.start - paragraphOffset - 1));
docStart = pos.start + relativeStart;
}
// Word ends in this text node
if (docStart !== -1 && word.end <= pos.end - paragraphOffset - 1) {
const relativeEnd = Math.min(pos.end - pos.start, word.end - (pos.start - paragraphOffset - 1));
docEnd = pos.start + relativeEnd;
break;
}
}
if (docStart !== -1 && docEnd !== -1) {
return { start: docStart, end: docEnd };
}
return null;
};
const handleClick = () => {
const { state, dispatch } = editor.view;
const tr = state.tr;
// Process each paragraph separately to avoid context issues
state.doc.forEach((paragraphNode, paragraphOffset) => {
if (paragraphNode.type.name !== 'paragraph') return;
// Get the full text of this paragraph
let paragraphText = '';
const textPositions: { start: number, end: number }[] = [];
paragraphNode.content.forEach((textNode, textOffset) => {
if (textNode.isText) {
const nodeText = textNode.text || ""; // Handle undefined text
const start = paragraphOffset + 1 + textOffset; // +1 to account for paragraph tag
textPositions.push({
start,
end: start + nodeText.length
});
paragraphText += nodeText;
}
});
if (!paragraphText) return;
// Find all valid sentences in the paragraph
const sentenceRegex = /[^.!?]+[.!?]+/g;
const sentences: { text: string, start: number, end: number }[] = [];
let match;
let lastIndex = 0;
while ((match = sentenceRegex.exec(paragraphText)) !== null) {
sentences.push({
text: match[0],
start: match.index,
end: sentenceRegex.lastIndex
});
lastIndex = sentenceRegex.lastIndex;
}
// Handle text after the last punctuation if it exists
if (lastIndex < paragraphText.length) {
sentences.push({
text: paragraphText.slice(lastIndex),
start: lastIndex,
end: paragraphText.length
});
}
// For each sentence, find capitalized words NOT at the beginning and not after quotes
sentences.forEach(sentence => {
// Split into words, keeping track of positions
const words = [];
const wordRegex = /\S+/g;
let wordMatch;
while ((wordMatch = wordRegex.exec(sentence.text)) !== null) {
words.push({
word: wordMatch[0],
start: sentence.start + wordMatch.index,
end: sentence.start + wordRegex.lastIndex
});
}
if (words.length === 0) return;
// Mark the first word as sentence start (shouldn't be bolded if capitalized)
let isFirstWord = true;
for (let i = 0; i < words.length; i++) {
const word = words[i];
const currentWord = word.word;
// Check if the word starts with a capital letter
if (/^[A-ZА-ЯЁ]/u.test(currentWord)) {
// Skip if it's the first word in the sentence
if (isFirstWord) {
isFirstWord = false;
continue;
}
// Check for various quotation patterns
let afterQuote = false;
if (i > 0) {
const prevWord = words[i-1].word;
// Check if previous word ends with quotation characters
// Including standard, curly, angular quotes and special formats like *«*
if (/["""«»''`*]$/.test(prevWord)) {
afterQuote = true;
} else {
// Check for whitespace + quotation mark combination in the text between words
const betweenWords = sentence.text.substring(
words[i-1].end - sentence.start,
word.start - sentence.start
);
// Check for standard quotes and special patterns like *«*
if (/\s["""«»''`]/.test(betweenWords) ||
/\s\*[«»]\*/.test(betweenWords) ||
/\s\*/.test(betweenWords)) {
afterQuote = true;
}
}
} else {
// If it's the first word but after the sentence detection,
// check if the sentence starts with a quotation mark or special pattern
const sentencePrefix = sentence.text.substring(0, word.start - sentence.start).trim();
if (/^["""«»''`]/.test(sentencePrefix) ||
/^\*[«»]\*/.test(sentencePrefix) ||
/^\*/.test(sentencePrefix)) {
afterQuote = true;
}
}
// If not after a quote, bold the word
if (!afterQuote) {
// Map the word position back to the document
const docPositions = mapWordToDocumentPositions(word, textPositions, paragraphOffset);
if (docPositions) {
tr.addMark(
docPositions.start,
docPositions.end,
state.schema.marks.bold.create()
);
}
}
}
// Always mark as no longer first word after processing a word
isFirstWord = false;
}
});
});
dispatch(tr);
};
return (
<div className="border rounded-lg overflow-hidden">
<div className="border-b bg-gray-50 px-4 py-2">
<div className="flex items-center space-x-4">
<button
type="button"
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('bold') ? 'bg-gray-200' : ''
}`}
title="Выделение слов жирным шрифтом"
>
<Bold size={18} />
</button>
<button
type="button"
onClick={handleClick}
className="p-1 rounded hover:bg-gray-200"
title="Автоматическое выделение жирным имен"
>
<SquareUser size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('italic') ? 'bg-gray-200' : ''
}`}
title="Выделение слов наклонным шрифтом"
>
<Italic size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().setParagraph().run()}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('paragraph') ? 'bg-gray-200' : ''}`}
title="Выравнивание по левому краю"
>
<AlignLeft size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().undo().run()}
disabled={!editor?.can().chain().focus().undo().run()}
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
title="Отменить действие"
>
<Undo size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().redo().run()}
disabled={!editor?.can().chain().focus().redo().run()}
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
title="Вернуть действие"
>
<Redo size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleBulletList().run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('bulletList') ? 'bg-gray-200' : ''
}`}
title="Создать список"
>
<List size={18} />
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('orderedList') ? 'bg-gray-200' : ''
}`}
title="Создать упорядоченный список"
>
<ListOrdered size={18} />
</button>
<button
type="button"
onClick={() => {
editor?.chain().focus().toggleBlockquote().run()}}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('blockquote') ? 'bg-gray-200' : ''
}`}
title="Цитирование (параграф)"
>
<Quote size={18} />
</button>
<button
type="button"
onClick={() => {
editor?.chain().focus().setTextAlign('center').run()}}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.getAttributes('textAlign')?.textAlign === 'center' ? 'bg-gray-200' : ''
}`}
title="Выравнивание по центру"
>
<AlignCenter size={18} />
</button>
<button
type="button"
onClick={() => {
const url = prompt('Введите URL изображения');
if (url) {
editor?.chain().focus().setImage({ src: url }).run();
}
}}
title="Вставить изображение (старое)"
>
<ImagePlus size={18} />
</button>
<TipTapImageUploadButton editor={editor} articleId={atricleId} />
<button
type="button"
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.getAttributes('textAlign')?.textAlign === 'center' ? 'bg-gray-200' : ''
}`}
>
🏛 Центр
</button>
<button
type="button"
onClick={() => resizeImage(20)} className="p-1 rounded hover:bg-gray-200">
<Plus size={18} />
</button>
<button
type="button"
onClick={() => resizeImage(-20)} className="p-1 rounded hover:bg-gray-200">
<Minus size={18} />
</button>
</div>
</div>
<div className="p-4">
<EditorContent editor={editor} />
</div>
</div>
);
}