Добавлена функциональность черновиков, доработана кнопка TipTap для выделения жирным.
This commit is contained in:
parent
ae5e789f1b
commit
1d09dbadf3
@ -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} />
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
// Структура ответа на список статей
|
||||
|
Loading…
x
Reference in New Issue
Block a user