Добавлена функциональность черновиков, доработана кнопка 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();
|
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 (
|
return (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<div className="border-b bg-gray-50 px-4 py-2">
|
<div className="border-b bg-gray-50 px-4 py-2">
|
||||||
@ -159,75 +331,7 @@ export function TipTapEditor({ initialContent, onContentChange }: TipTapEditorPr
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleClick}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
className="p-1 rounded hover:bg-gray-200"
|
className="p-1 rounded hover:bg-gray-200"
|
||||||
>
|
>
|
||||||
<Text size={18} />
|
<Text size={18} />
|
||||||
|
@ -7,7 +7,7 @@ import { CoverImageUpload } from '../components/ImageUpload/CoverImageUpload.tsx
|
|||||||
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
import { ImageUploader } from '../components/ImageUpload/ImageUploader';
|
||||||
import MinutesWord from '../components/MinutesWord';
|
import MinutesWord from '../components/MinutesWord';
|
||||||
import { GalleryImage, CategoryTitles, CityTitles, CategoryIds, CityIds, Article } from '../types';
|
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 [filterCityId, setFilterCityId] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
|
const [showDraftOnly, setShowDraftOnly] = useState(false);
|
||||||
|
|
||||||
// Загрузка статей
|
// Загрузка статей
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -43,7 +43,8 @@ export function AdminPage() {
|
|||||||
params: {
|
params: {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
categoryId: filterCategoryId,
|
categoryId: filterCategoryId,
|
||||||
cityId: filterCityId
|
cityId: filterCityId,
|
||||||
|
isDraft: showDraftOnly
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setArticles(response.data.articles);
|
setArticles(response.data.articles);
|
||||||
@ -55,7 +56,7 @@ export function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchArticles();
|
fetchArticles();
|
||||||
}, [currentPage, filterCategoryId, filterCityId, refreshArticles]);
|
}, [currentPage, filterCategoryId, filterCityId, showDraftOnly, refreshArticles]);
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
@ -100,6 +101,31 @@ export function AdminPage() {
|
|||||||
setShowDeleteModal(null);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -113,6 +139,8 @@ export function AdminPage() {
|
|||||||
readTime,
|
readTime,
|
||||||
gallery,
|
gallery,
|
||||||
content: content || '',
|
content: content || '',
|
||||||
|
importId: 0,
|
||||||
|
isActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@ -328,7 +356,7 @@ export function AdminPage() {
|
|||||||
type="submit"
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -361,8 +389,8 @@ export function AdminPage() {
|
|||||||
<div className="bg-white rounded-lg shadow-sm">
|
<div className="bg-white rounded-lg shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<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">
|
<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>
|
<h2 className="text-lg font-medium text-gray-900">Статьи</h2>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<select
|
<select
|
||||||
value={filterCategoryId}
|
value={filterCategoryId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -393,13 +421,25 @@ export function AdminPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="divide-y divide-gray-200">
|
<ul className="divide-y divide-gray-200">
|
||||||
{articles.map((article) => (
|
{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 items-center justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||||
@ -410,6 +450,15 @@ export function AdminPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 ml-4">
|
<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
|
<button
|
||||||
onClick={() => handleEdit(article.id)}
|
onClick={() => handleEdit(article.id)}
|
||||||
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
export interface Article {
|
export interface Article {
|
||||||
id: string;
|
id: string;
|
||||||
|
importId: number;
|
||||||
title: string;
|
title: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
content: string;
|
content: string;
|
||||||
@ -13,6 +14,7 @@ export interface Article {
|
|||||||
likes: number;
|
likes: number;
|
||||||
dislikes: number;
|
dislikes: number;
|
||||||
userReaction?: 'like' | 'dislike' | null;
|
userReaction?: 'like' | 'dislike' | null;
|
||||||
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Структура ответа на список статей
|
// Структура ответа на список статей
|
||||||
|
Loading…
x
Reference in New Issue
Block a user