Добавлена функциональность черновиков, доработана кнопка TipTap для выделения жирным.

This commit is contained in:
anibilag 2025-02-26 23:33:29 +03:00
parent ae5e789f1b
commit 1d09dbadf3
3 changed files with 232 additions and 77 deletions

View File

@ -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 (
<div className="border rounded-lg overflow-hidden">
<div className="border-b bg-gray-50 px-4 py-2">
@ -159,75 +331,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
</button>
<button
type="button"
onClick={() => {
const { state, dispatch } = editor.view;
const tr = state.tr;
let inSentence = false;
let isNewParagraph = false;
state.doc.descendants((node, pos) => {
if (node.type.name === 'paragraph') {
isNewParagraph = true;
return true;
}
if (node.isText) {
const text = node.text || "";
const currentPos = pos;
let wordStart = -1;
let hasUpperCase = false;
let sentenceStart = true;
// Сброс контекста для нового параграфа
if (isNewParagraph) {
inSentence = false;
sentenceStart = true;
isNewParagraph = false;
}
for (let i = 0; i < text.length; i++) {
const char = text[i];
// Обновление статуса предложения при обнаружении пунктуации
if (/[.!?]/.test(char) && (i === text.length - 1 || /\s/.test(text[i + 1]))) {
sentenceStart = true;
inSentence = false;
}
if (wordStart === -1 && /\S/.test(char)) {
wordStart = i;
hasUpperCase = /^[A-ZА-ЯЁ]/u.test(char);
// Первое слово в предложении
if (sentenceStart && hasUpperCase) {
sentenceStart = false;
inSentence = true;
continue; // Пропускаем выделение
}
}
if (wordStart !== -1 && (/\s/.test(char) || i === text.length - 1)) {
const wordEnd = char === ' ' ? i : i + 1;
// Выделяем только слова внутри предложения, не первые
if (hasUpperCase && inSentence && !sentenceStart) {
tr.addMark(
currentPos + wordStart,
currentPos + wordEnd,
state.schema.marks.bold.create()
);
}
wordStart = -1;
hasUpperCase = false;
}
}
}
return true;
});
dispatch(tr);
}}
onClick={handleClick}
className="p-1 rounded hover:bg-gray-200"
>
<Text size={18} />

View File

@ -7,7 +7,7 @@ import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload.tsx
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
import MinutesWord from '../components/MinutesWord';
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article } from '../types';
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X } from 'lucide-react';
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, X, ToggleLeft, ToggleRight} from 'lucide-react';
// Обложка по умоланию для новых статей
@ -33,7 +33,7 @@ export function AdminPage() {
const [filterCityId, setFilterCityId] = useState(0);
const [error, setError] = useState<string | null>(null);
const [content, setContent] = useState('');
const [showDraftOnly, setShowDraftOnly] = useState(false);
// Загрузка статей
useEffect(() => {
@ -43,7 +43,8 @@ export function AdminPage() {
params: {
page: currentPage,
categoryId: filterCategoryId,
cityId: filterCityId
cityId: filterCityId,
isDraft: showDraftOnly
}
});
setArticles(response.data.articles);
@ -55,7 +56,7 @@ export function AdminPage() {
};
fetchArticles();
}, [currentPage, filterCategoryId, filterCityId, refreshArticles]);
}, [currentPage, filterCategoryId, filterCityId, showDraftOnly, refreshArticles]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
@ -100,6 +101,31 @@ export function AdminPage() {
setShowDeleteModal(null);
};
// Перевод статьи в черновик и обратно
const handleToggleActive = async (id: string) => {
const article = articles.find(a => a.id === id);
if (article) {
const articleData = {
isActive: article.isActive,
};
try {
await axios.put(`/api/articles/active/${id}`, articleData, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
setArticles(prev => prev.map(article =>
article.id === id ? {...article, isActive: !article.isActive} : article
));
} catch (error) {
setError('Не переключить статью в актив');
console.error(error);
}
}
};
// Создание новой статьи, сохранение существующей
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -113,6 +139,8 @@ export function AdminPage() {
readTime,
gallery,
content: content || '',
importId: 0,
isActive: false,
};
if (editingId) {
@ -328,7 +356,7 @@ export function AdminPage() {
type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{editingId ? 'Изменить статью' : 'Опубликовать статью'}
{editingId ? 'Изменить' : 'Созранить черновик'}
</button>
</div>
</form>
@ -361,8 +389,8 @@ export function AdminPage() {
<div className="bg-white rounded-lg shadow-sm">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-lg font-medium text-gray-900">Опубликованные статьи</h2>
<div className="flex gap-4">
<h2 className="text-lg font-medium text-gray-900">Статьи</h2>
<div className="flex flex-wrap gap-4">
<select
value={filterCategoryId}
onChange={(e) => {
@ -393,13 +421,25 @@ export function AdminPage() {
</option>
))}
</select>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={showDraftOnly}
onChange={(e) => setShowDraftOnly(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Только черновики</span>
</label>
</div>
</div>
</div>
<ul className="divide-y divide-gray-200">
{articles.map((article) => (
<li key={article.id} className="px-6 py-4">
<li
key={article.id}
className={`px-6 py-4 ${!article.isActive ? 'bg-gray-50' : ''}`}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900 truncate">
@ -410,6 +450,15 @@ export function AdminPage() {
</p>
</div>
<div className="flex items-center gap-4 ml-4">
<button
onClick={() => handleToggleActive(article.id)}
className={`p-2 rounded-full hover:bg-gray-100 ${
article.isActive ? 'text-green-600' : 'text-gray-400'
}`}
title={article.isActive ? 'Set as draft' : 'Publish article'}
>
{article.isActive ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
</button>
<button
onClick={() => handleEdit(article.id)}
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"

View File

@ -1,5 +1,6 @@
export interface Article {
id: string;
importId: number;
title: string;
excerpt: string;
content: string;
@ -13,6 +14,7 @@ export interface Article {
likes: number;
dislikes: number;
userReaction?: 'like' | 'dislike' | null;
isActive: boolean;
}
// Структура ответа на список статей