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() {
+