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