diff --git a/src/components/TipTapEditor.tsx b/src/components/TipTapEditor.tsx index a44855b..081eb3d 100644 --- a/src/components/TipTapEditor.tsx +++ b/src/components/TipTapEditor.tsx @@ -144,6 +144,178 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr 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 (
@@ -159,75 +331,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
@@ -361,8 +389,8 @@ export function AdminPage() {
-

Опубликованные статьи

-
+

Статьи

+
+
    {articles.map((article) => ( -
  • +
  • @@ -410,6 +450,15 @@ export function AdminPage() {

    +