477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
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>
|
||
);
|
||
} |