Теперь работает вставка изображения в редактор с масштабированием.

This commit is contained in:
anibilag 2025-04-06 00:20:12 +03:00
parent a08eb412e4
commit 5a2bc6dbd3
16 changed files with 641 additions and 547 deletions

15
package-lock.json generated
View File

@ -23,6 +23,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"multer": "1.4.5-lts.1", "multer": "1.4.5-lts.1",
"react": "^18.3.1", "react": "^18.3.1",
@ -40,6 +41,7 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/lodash": "^4.17.16",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
@ -3642,6 +3644,13 @@
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/lodash": {
"version": "4.17.16",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": { "node_modules/@types/markdown-it": {
"version": "14.1.2", "version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
@ -6117,6 +6126,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",

View File

@ -31,6 +31,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"multer": "1.4.5-lts.1", "multer": "1.4.5-lts.1",
"react": "^18.3.1", "react": "^18.3.1",
@ -48,6 +49,7 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/lodash": "^4.17.16",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -1,38 +1,12 @@
//import { ResizableImage } from './TipTapEditor';
import { useEditor, EditorContent } from '@tiptap/react'; import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Blockquote from '@tiptap/extension-blockquote'; import Blockquote from '@tiptap/extension-blockquote';
import Image from '@tiptap/extension-blockquote'; import { CustomImage } from './CustomImageExtension';
interface ArticleContentProps { interface ArticleContentProps {
content: string; content: string;
} }
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: { default: null, },
alt: { default: null, },
title: { default: null, },
class: { default: 'max-w-full h-auto', },
width: {
default: 'auto',
parseHTML: (element) => element.getAttribute('width') || 'auto',
renderHTML: (attributes) => ({
width: attributes.width,
}),
},
height: {
default: 'auto',
parseHTML: (element) => element.getAttribute('height') || 'auto',
renderHTML: (attributes) => ({
height: attributes.height,
}),
},
};
},
});
// Показ контента статьи в редакторе, в режиме "только чтение" // Показ контента статьи в редакторе, в режиме "только чтение"
export function ArticleContent({ content }: ArticleContentProps) { export function ArticleContent({ content }: ArticleContentProps) {
@ -46,7 +20,7 @@ export function ArticleContent({ content }: ArticleContentProps) {
class: 'border-l-4 border-gray-300 pl-4 italic', class: 'border-l-4 border-gray-300 pl-4 italic',
}, },
}), }),
ResizableImage CustomImage
], ],
content, content,
editable: false, // Контент только для чтения editable: false, // Контент только для чтения

View File

@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, {useEffect, useState} from 'react';
import { TipTapEditor } from './TipTapEditor.tsx'; import {TipTapEditor} from './TipTapEditor.tsx';
import { CoverImageUpload } from './ImageUpload/CoverImageUpload.tsx'; import {CoverImageUpload} from './ImageUpload/CoverImageUpload.tsx';
import { ImageUploader } from './ImageUpload/ImageUploader.tsx'; import {ImageUploader} from './ImageUpload/ImageUploader.tsx';
import { GalleryManager } from './GalleryManager'; import {GalleryManager} from './GalleryManager';
import { useGallery } from '../hooks/useGallery'; import {useGallery} from '../hooks/useGallery';
import { CategoryTitles, CityTitles, Author, GalleryImage, ArticleData } from '../types'; import {ArticleData, Author, CategoryTitles, CityTitles, GalleryImage} from '../types';
import { useAuthStore } from '../stores/authStore'; import {useAuthStore} from '../stores/authStore';
import ConfirmModal from './ConfirmModal';
interface FormState { interface FormState {
title: string; title: string;
@ -56,9 +58,32 @@ export function ArticleForm({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState<boolean>(false); const [hasChanges, setHasChanges] = useState<boolean>(false);
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true); const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); // Добавляем флаг для отслеживания отправки
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); // Состояние для модального окна
const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || ''); const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || '');
// Добавляем обработку ошибок
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
console.error('Unhandled promise rejection in ArticleForm:', event.reason);
event.preventDefault(); // Предотвращаем "всплытие" ошибки
};
const handleError = (event: ErrorEvent) => {
console.error('Unhandled error in ArticleForm:', event.error);
event.preventDefault(); // Предотвращаем "всплытие" ошибки
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
window.addEventListener('error', handleError);
return () => {
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
window.removeEventListener('error', handleError);
};
}, []);
useEffect(() => { useEffect(() => {
if (editingId) { if (editingId) {
setDisplayedImages(galleryImages); setDisplayedImages(galleryImages);
@ -78,6 +103,7 @@ export function ArticleForm({
setContent(initialFormState.content); setContent(initialFormState.content);
setAuthorId(initialFormState.authorId); setAuthorId(initialFormState.authorId);
setDisplayedImages(initialFormState.galleryImages || []); setDisplayedImages(initialFormState.galleryImages || []);
console.log('Содержимое статьи при загрузке:', initialFormState.content);
} }
}, [initialFormState]); }, [initialFormState]);
@ -93,13 +119,11 @@ export function ArticleForm({
return isDifferent; return isDifferent;
} }
if (key === 'content') { if (key === 'content') {
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]); return JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
return isDifferent;
} }
const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState]; const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState];
const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState]; const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState];
const isDifferent = currentValue !== initialValue; return currentValue !== initialValue;
return isDifferent;
}); });
setHasChanges(hasFormChanges && areRequiredFieldsFilled); setHasChanges(hasFormChanges && areRequiredFieldsFilled);
@ -112,6 +136,14 @@ export function ArticleForm({
const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => { const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => {
e.preventDefault(); e.preventDefault();
console.log('Вызов handleSubmit:', { closeForm });
console.log('Содержимое статьи перед сохранением:', content);
if (isSubmitting) {
console.log('Форма уже отправляется, игнорируем повторную отправку');
return;
}
if (!title.trim() || !excerpt.trim()) { if (!title.trim() || !excerpt.trim()) {
setError('Пожалуйста, заполните обязательные поля: Заголовок и Краткое описание.'); setError('Пожалуйста, заполните обязательные поля: Заголовок и Краткое описание.');
return; return;
@ -120,7 +152,14 @@ export function ArticleForm({
if (!hasChanges) return; if (!hasChanges) return;
const selectedAuthor = editingId && isAdmin ? authors.find(a => a.id === authorId) || authors[0] : user; const selectedAuthor = editingId && isAdmin ? authors.find(a => a.id === authorId) || authors[0] : user;
const articleData = {
// Проверяем, что selectedAuthor существует и соответствует типу Author
if (!selectedAuthor) {
setError('Пожалуйста, выберите автора или войдите в систему.');
return;
}
const articleData: ArticleData = {
title, title,
excerpt, excerpt,
categoryId, categoryId,
@ -134,13 +173,38 @@ export function ArticleForm({
author: selectedAuthor, author: selectedAuthor,
}; };
await onSubmit(articleData, closeForm); try {
setIsSubmitting(true);
await onSubmit(articleData, closeForm);
} catch (error) {
console.error('Ошибка при сохранении статьи:', error);
setError('Не удалось сохранить статью. Пожалуйста, попробуйте снова.');
} finally {
setIsSubmitting(false);
}
}; };
const handleApply = (e: React.FormEvent) => { const handleApply = (e: React.FormEvent) => {
handleSubmit(e, false); handleSubmit(e, false);
}; };
const handleCancel = () => {
if (hasChanges) {
setIsConfirmModalOpen(true); // Открываем модальное окно
} else {
onCancel();
}
};
const handleConfirmCancel = () => {
setIsConfirmModalOpen(false);
onCancel();
};
const handleCloseModal = () => {
setIsConfirmModalOpen(false);
};
if (editingId && galleryLoading) { if (editingId && galleryLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
@ -184,7 +248,9 @@ export function ArticleForm({
</div> </div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div> <div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Категория</span></label> <label htmlFor="category" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Категория</span>
</label>
<select <select
id="category" id="category"
value={categoryId} value={categoryId}
@ -195,7 +261,9 @@ export function ArticleForm({
</select> </select>
</div> </div>
<div> <div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Столица</span></label> <label htmlFor="city" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Столица</span>
</label>
<select <select
id="city" id="city"
value={cityId} value={cityId}
@ -206,7 +274,9 @@ export function ArticleForm({
</select> </select>
</div> </div>
<div> <div>
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Время чтения (минуты)</span></label> <label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Время чтения (минуты)</span>
</label>
<input <input
type="number" type="number"
id="readTime" id="readTime"
@ -218,14 +288,19 @@ export function ArticleForm({
</div> </div>
{editingId && isAdmin && ( {editingId && isAdmin && (
<div> <div>
<label htmlFor="author" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Автор</span></label> <label htmlFor="author" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Автор</span>
</label>
<select <select
id="author" id="author"
value={authorId} value={authorId}
onChange={(e) => setAuthorId(e.target.value)} onChange={(e) => setAuthorId(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200" className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
> >
{authors.map(author => <option key={author.id} value={author.id}>{author.displayName}</option>)} {authors.map(author =>
<option key={author.id} value={author.id}>
{author.displayName}
</option>)}
</select> </select>
</div> </div>
)} )}
@ -238,14 +313,27 @@ export function ArticleForm({
allowUpload={!!editingId} allowUpload={!!editingId}
/> />
<div> <div>
<label className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Статья</span></label> <label className="block text-sm font-medium text-gray-700">
<TipTapEditor initialContent={content} onContentChange={setContent} atricleId={articleId} /> <span className="italic font-bold">Статья</span>
</label>
<TipTapEditor
initialContent={content}
onContentChange={setContent}
articleId={articleId}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Галерея</span></label> <label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Галерея</span>
</label>
{editingId ? ( {editingId ? (
<> <>
<ImageUploader onUploadComplete={(imageUrl) => { setFormNewImageUrl(imageUrl); }} articleId={articleId} /> <ImageUploader
onUploadComplete={(imageUrl) => {
setFormNewImageUrl(imageUrl);
}}
articleId={articleId}
/>
<GalleryManager <GalleryManager
images={displayedImages} images={displayedImages}
imageUrl={formNewImageUrl} imageUrl={formNewImageUrl}
@ -282,7 +370,7 @@ export function ArticleForm({
<div className="flex justify-end gap-4"> <div className="flex justify-end gap-4">
<button <button
type="button" type="button"
onClick={onCancel} onClick={handleCancel}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
> >
Отмена Отмена
@ -291,21 +379,27 @@ export function ArticleForm({
<button <button
type="button" type="button"
onClick={handleApply} onClick={handleApply}
disabled={!hasChanges} disabled={!hasChanges || isSubmitting}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`} className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
> >
Применить Применить
</button> </button>
)} )}
<button <button
type="submit" type="submit"
disabled={!hasChanges} disabled={!hasChanges || isSubmitting}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'} 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 ${hasChanges && !isSubmitting ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
> >
{editingId ? 'Изменить' : 'Сохранить черновик'} {editingId ? 'Изменить' : 'Сохранить черновик'}
</button> </button>
</div> </div>
</form> </form>
<ConfirmModal
isOpen={isConfirmModalOpen}
onConfirm={handleConfirmCancel}
onCancel={handleCloseModal}
message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?"
/>
</div> </div>
); );
} }

View File

@ -0,0 +1,36 @@
import React from 'react';
interface ConfirmModalProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
message: string;
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, onConfirm, onCancel, message }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
<p className="text-gray-700 mb-4">{message}</p>
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
>
Отмена
</button>
<button
onClick={onConfirm}
className="px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none"
>
Подтвердить
</button>
</div>
</div>
</div>
);
};
export default ConfirmModal;

View File

@ -1,103 +1,175 @@
import React, { useState, useEffect, useRef } from 'react'; import React from 'react';
import { Node, mergeAttributes } from '@tiptap/core'; import { Node, mergeAttributes, NodeViewProps } from '@tiptap/core';
import {Editor, ReactNodeViewRenderer } from '@tiptap/react'; import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
import { ZoomIn, ZoomOut } from 'lucide-react'; import { ZoomIn, ZoomOut, Trash2 } from 'lucide-react';
import { Node as ProseMirrorNode } from 'prosemirror-model'; import { Node as ProseMirrorNode } from 'prosemirror-model';
// Определяем тип для атрибутов узла customImage
interface ImageNodeViewProps { interface CustomImageAttributes {
node: ProseMirrorNode; src: string | undefined;
updateAttributes: (attrs: Record<string, number>) => void; alt: string | undefined;
editor: Editor; title: string | undefined;
getPos: () => number; scale: number;
} }
// React component for the image node view const ImageNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => {
const ImageNodeView: React.FC<ImageNodeViewProps> = ({ node, updateAttributes, editor, getPos }) => { // Утверждаем, что node имеет нужный тип
const [isSelected, setIsSelected] = useState(false); const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes };
const [scale, setScale] = useState(1);
const imageRef = useRef<HTMLImageElement>(null);
// Update selection state when the editor selection changes // Проверяем, что узел корректен
useEffect(() => { if (node.type.name !== 'customImage') {
const handleSelectionUpdate = ({ editor }: { editor: Editor }) => { console.error('Неподдерживаемый тип узла:', node.type.name);
const { state } = editor; return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: неподдерживаемый тип узла</NodeViewWrapper>;
const { selection } = state; }
const nodePos = getPos();
// Check if this node is selected // Проверяем, что src существует
const isNodeSelected = selection.$anchor.pos >= nodePos && if (!typedNode.attrs.src) {
selection.$anchor.pos <= nodePos + node.nodeSize; console.error('Изображение не загружено, src отсутствует:', typedNode.attrs);
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: изображение не загружено</NodeViewWrapper>;
}
setIsSelected(isNodeSelected); // Логируем атрибуты узла при загрузке
}; console.log('Атрибуты узла customImage при загрузке:', typedNode.attrs);
editor.on('selectionUpdate', handleSelectionUpdate);
return () => {
editor.off('selectionUpdate', handleSelectionUpdate);
};
}, [editor, getPos, node.nodeSize]);
// Handle zoom in
const handleZoomIn = (e: React.MouseEvent) => { const handleZoomIn = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setScale(prev => Math.min(prev + 0.1, 2)); try {
updateAttributes({ scale: Math.min(scale + 0.1, 2) }); if (!editor || editor.isDestroyed) {
}; console.error('Редактор уничтожен или недоступен');
return;
}
// Handle zoom out const pos = getPos();
const handleZoomOut = (e: React.MouseEvent) => { const currentNode = editor.state.doc.nodeAt(pos);
e.stopPropagation(); if (!currentNode || currentNode.type.name !== 'customImage') {
setScale(prev => Math.max(prev - 0.1, 0.5)); console.error('Узел больше не существует или изменён:', { pos, currentNode });
updateAttributes({ scale: Math.max(scale - 0.1, 0.5) }); return;
}; }
// Track current scale from attributes let currentScale = typedNode.attrs.scale;
useEffect(() => { if (currentScale <= 0 || isNaN(currentScale)) {
if (node.attrs.scale) { console.warn('Некорректное значение scale, устанавливаем значение по умолчанию:', { currentScale });
setScale(node.attrs.scale); currentScale = 1;
updateAttributes({ scale: 1 });
}
const newScale = Math.min(currentScale + 0.1, 2);
const roundedScale = Number(newScale.toFixed(2));
updateAttributes({ scale: roundedScale });
console.log('Масштаб увеличен через updateAttributes:', { currentScale, newScale: roundedScale });
} catch (error) {
console.error('Ошибка при увеличении масштаба:', error);
} }
}, [node.attrs.scale]); };
const handleZoomOut = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
if (!editor || editor.isDestroyed) {
console.error('Редактор уничтожен или недоступен');
return;
}
const pos = getPos();
const currentNode = editor.state.doc.nodeAt(pos);
if (!currentNode || currentNode.type.name !== 'customImage') {
console.error('Узел больше не существует или изменён:', { pos, currentNode });
return;
}
let currentScale = typedNode.attrs.scale;
if (currentScale <= 0 || isNaN(currentScale)) {
console.warn('Некорректное значение scale, устанавливаем значение по умолчанию:', { currentScale });
currentScale = 1;
updateAttributes({ scale: 1 });
}
const newScale = Math.max(currentScale - 0.1, 0.5);
const roundedScale = Number(newScale.toFixed(2));
updateAttributes({ scale: roundedScale });
console.log('Масштаб уменьшен через updateAttributes:', { currentScale, newScale: roundedScale });
} catch (error) {
console.error('Ошибка при уменьшении масштаба:', error);
}
};
const handleDelete = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
console.log('Перед вызовом deleteNode');
deleteNode();
console.log('После вызова deleteNode');
} catch (error) {
console.error('Ошибка при удалении изображения:', error);
}
};
const scale = typedNode.attrs.scale || 1;
const isEditable = editor.isEditable; // Проверяем, находится ли редактор в режиме редактирования
return ( return (
<div className="relative flex justify-center my-4"> <NodeViewWrapper className="relative flex justify-center my-4">
{/* The actual image */} {/* Оборачиваем изображение и панель в контейнер, который масштабируется */}
<div
className="relative inline-block"
style={{
transform: `scale(${scale})`,
transformOrigin: 'center',
}}
>
<img <img
ref={imageRef} src={typedNode.attrs.src}
src={node.attrs.src} alt={typedNode.attrs.alt}
alt={node.attrs.alt || ''} className={`max-w-full transition-transform ${selected && isEditable ? 'ring-2 ring-blue-500' : ''}`}
className={`max-w-full transition-transform ${isSelected ? 'ring-2 ring-blue-500' : ''}`} data-drag-handle=""
style={{
transform: `scale(${scale})`,
transformOrigin: 'center',
}}
data-drag-handle=""
/> />
{selected && isEditable && ( // Показываем панель только в режиме редактирования
{/* Zoom controls - only visible when selected */} <div
{isSelected && ( className="absolute top-2 right-2 flex space-x-2 bg-white bg-opacity-75 p-1 rounded shadow"
<div className="absolute top-2 right-2 flex space-x-2 bg-white bg-opacity-75 p-1 rounded shadow"> style={{
<button transform: `scale(${1 / scale})`, // Компенсируем масштабирование панели
onClick={handleZoomIn} transformOrigin: 'top right', // Устанавливаем точку масштабирования в верхний правый угол
className="p-1 rounded hover:bg-gray-200" }}
title="Zoom in" >
> <button
<ZoomIn size={16} /> type="button"
</button> onClick={handleZoomIn}
<button className="p-1 rounded hover:bg-gray-200"
onClick={handleZoomOut} title={`Zoom in (current scale: ${scale})`}
className="p-1 rounded hover:bg-gray-200" >
title="Zoom out" <ZoomIn size={16} />
> </button>
<ZoomOut size={16} /> <button
</button> type="button"
</div> onClick={handleZoomOut}
className="p-1 rounded hover:bg-gray-200"
title={`Zoom out (current scale: ${scale})`}
>
<ZoomOut size={16} />
</button>
<button
type="button"
onClick={handleDelete}
className="p-1 rounded hover:bg-red-200 text-red-600"
title="Delete image"
>
<Trash2 size={16} />
</button>
<span className="text-xs text-gray-600 px-2 py-1">
Масштаб: {scale}
</span>
</div>
)} )}
</div> </div>
</NodeViewWrapper>
); );
}; };
// Custom Image extension for TipTap
export const CustomImage = Node.create({ export const CustomImage = Node.create({
name: 'customImage', name: 'customImage',
group: 'block', group: 'block',
@ -109,16 +181,28 @@ export const CustomImage = Node.create({
addAttributes() { addAttributes() {
return { return {
src: { src: {
default: null, default: undefined,
}, },
alt: { alt: {
default: null, default: undefined,
}, },
title: { title: {
default: null, default: undefined,
}, },
scale: { scale: {
default: 1, default: 1,
parseHTML: (element) => {
const scale = Number(element.getAttribute('scale'));
const parsedScale = isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2))));
console.log('Парсинг scale из HTML:', { scale, parsedScale });
return parsedScale;
},
renderHTML: (attributes) => {
const scale = Number(attributes.scale);
const renderedScale = isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2))));
console.log('Рендеринг scale в HTML:', { scale, renderedScale });
return { scale: renderedScale };
},
}, },
}; };
}, },
@ -127,6 +211,15 @@ export const CustomImage = Node.create({
return [ return [
{ {
tag: 'img[src]', tag: 'img[src]',
getAttrs: (element) => {
const scale = element.getAttribute('scale');
return {
src: element.getAttribute('src'),
alt: element.getAttribute('alt'),
title: element.getAttribute('title'),
scale: scale ? Number(scale) : 1,
};
},
}, },
]; ];
}, },
@ -136,11 +229,17 @@ export const CustomImage = Node.create({
return [ return [
'div', 'div',
{ class: 'flex justify-center my-4' }, { class: 'flex justify-center my-4' },
['img', mergeAttributes(rest, { style: `transform: scale(${scale})` })], [
'img',
mergeAttributes(rest, {
scale: scale,
style: `transform: scale(${scale})`,
}),
],
]; ];
}, },
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(ImageNodeView); return ReactNodeViewRenderer(ImageNodeView);
}, },
}); });

View File

@ -20,22 +20,26 @@ export function ImageUploader({ onUploadComplete, articleId }: ImageUploaderProp
try { try {
setUploadProgress(prev => ({ setUploadProgress(prev => ({
...prev, ...prev,
[file.name]: { progress: 0, status: 'uploading' } [file.name]: { progress: 0, status: 'uploading' },
})); }));
const resolution = imageResolutions.find(r => r.id === selectedResolution); const resolution = imageResolutions.find(r => r.id === selectedResolution);
if (!resolution) throw new Error('Invalid resolution selected'); if (!resolution) throw new Error('Выбрано неправильное разрешение');
const uploadedImage = await uploadImageToS3(file, resolution, articleId, (progress) => { const uploadedImage = await uploadImageToS3(file, resolution, articleId, (progress) => {
setUploadProgress(prev => ({ setUploadProgress(prev => ({
...prev, ...prev,
[file.name]: { progress, status: 'uploading' } [file.name]: { progress, status: 'uploading' },
})); }));
}); });
setUploadProgress(prev => ({ setUploadProgress(prev => ({
...prev, ...prev,
[file.name]: { progress: 100, status: 'complete' } [file.name]: {
progress: 100,
status: 'complete',
imageUrl: uploadedImage.url, // Сохраняем URL изображения
},
})); }));
onUploadComplete(uploadedImage.url); onUploadComplete(uploadedImage.url);
@ -45,7 +49,7 @@ export function ImageUploader({ onUploadComplete, articleId }: ImageUploaderProp
[file.name]: { [file.name]: {
progress: 0, progress: 0,
status: 'error', status: 'error',
error: error instanceof Error ? error.message : 'Upload failed' error: error instanceof Error ? error.message : 'Upload failed',
} }
})); }));
} }

View File

@ -21,13 +21,13 @@ export function UploadProgress({ progress, fileName }: UploadProgressProps) {
const getStatusText = () => { const getStatusText = () => {
switch (progress.status) { switch (progress.status) {
case 'uploading': case 'uploading':
return 'Uploading...'; return 'Загрузка...';
case 'processing': case 'processing':
return 'Processing...'; return 'Обработка...';
case 'complete': case 'complete':
return 'Upload complete'; return 'Загрузка завершена';
case 'error': case 'error':
return progress.error || 'Upload failed'; return progress.error || 'Сбой загрузки';
} }
}; };
@ -35,9 +35,17 @@ export function UploadProgress({ progress, fileName }: UploadProgressProps) {
<div className="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg"> <div className="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg">
{getStatusIcon()} {getStatusIcon()}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate"> {progress.status === 'complete' && progress.imageUrl ? (
{fileName} <div className="relative w-48 h-32 rounded-lg overflow-hidden group">
</p> <img
src={progress.imageUrl}
alt="Превью загруженного изображения"
className="w-full h-full object-cover rounded-md"
/>
</div>
) : (
<p className="text-sm font-medium text-gray-900 truncate">{fileName}</p>
)}
<p className="text-sm text-gray-500">{getStatusText()}</p> <p className="text-sm text-gray-500">{getStatusText()}</p>
</div> </div>
{(progress.status === 'uploading' || progress.status === 'processing') && ( {(progress.status === 'uploading' || progress.status === 'processing') && (

View File

@ -2,7 +2,6 @@ import { useEditor, EditorContent } from '@tiptap/react';
import Highlight from '@tiptap/extension-highlight'; import Highlight from '@tiptap/extension-highlight';
import TextAlign from '@tiptap/extension-text-align'; import TextAlign from '@tiptap/extension-text-align';
import Blockquote from '@tiptap/extension-blockquote'; import Blockquote from '@tiptap/extension-blockquote';
import Image from '@tiptap/extension-image';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { import {
Bold, Bold,
@ -14,87 +13,25 @@ import {
Undo, Undo,
AlignLeft, AlignLeft,
AlignCenter, AlignCenter,
Plus,
Minus,
SquareUser, SquareUser,
ImagePlus
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { CustomImage } from "./CustomImageExtension"; import { CustomImage } from './CustomImageExtension';
import TipTapImageUploadButton from "./TipTapImageUploadButton"; import TipTapImageUploadButton from './TipTapImageUploadButton';
interface TipTapEditorProps { interface TipTapEditorProps {
initialContent: string; initialContent: string;
onContentChange: (content: string) => void; onContentChange: (content: string) => void;
atricleId: string; articleId: string;
} }
/* export function TipTapEditor({ initialContent, onContentChange, articleId }: TipTapEditorProps) {
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: { default: null, },
alt: { default: null, },
title: { default: null, },
class: { default: 'max-w-full h-auto', },
width: {
default: 'auto',
parseHTML: (element) => element.getAttribute('width') || 'auto',
renderHTML: (attributes) => ({
width: attributes.width,
class: selectedImage === attributes.src ? 'selected-image' : '',
}),
},
height: {
default: 'auto',
parseHTML: (element) => element.getAttribute('height') || 'auto',
renderHTML: (attributes) => ({
height: attributes.height,
}),
},
};
},
});
*/
export function TipTapEditor({ initialContent, onContentChange, atricleId }: TipTapEditorProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null);
/*
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: { default: null, },
alt: { default: null, },
title: { default: null, },
class: { default: 'max-w-full h-auto', },
width: {
default: 'auto',
parseHTML: (element) => element.getAttribute('width') || 'auto',
renderHTML: (attributes) => ({
width: attributes.width,
class: selectedImage === attributes.src ? 'selected-image' : '',
}),
},
height: {
default: 'auto',
parseHTML: (element) => element.getAttribute('height') || 'auto',
renderHTML: (attributes) => ({
height: attributes.height,
}),
},
};
},
});
*/
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
blockquote: false, // Отключаем дефолтный blockquote: false,
}), }),
Blockquote.configure({ Blockquote.configure({
HTMLAttributes: { HTMLAttributes: {
@ -118,9 +55,8 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
let foundImage = false; let foundImage = false;
editor.state.doc.nodesBetween(from, to, (node) => { editor.state.doc.nodesBetween(from, to, (node) => {
if (node.type.name === 'image') { if (node.type.name === 'customImage') {
setSelectedImage(node.attrs.src); setSelectedImage(node.attrs.src);
editor.commands.updateAttributes('image', { class: 'selected-image' });
foundImage = true; foundImage = true;
} }
}); });
@ -133,7 +69,6 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
}, },
}); });
// Обновление контента при изменении initialContent
useEffect(() => { useEffect(() => {
if (editor && editor.getHTML() !== initialContent) { if (editor && editor.getHTML() !== initialContent) {
editor.commands.setContent(initialContent); editor.commands.setContent(initialContent);
@ -151,36 +86,132 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [editor]); }, [editor]);
if (!editor) return null; if (!editor) return null;
const resizeImage = (delta: number) => { const handleClick = () => {
if (!editor.isActive('image')) return; const { state, dispatch } = editor.view;
const tr = state.tr;
const attrs = editor?.getAttributes('image'); state.doc.forEach((paragraphNode, paragraphOffset) => {
const newWidth = Math.max((parseInt(attrs?.width, 10) || 100) + delta, 50); if (paragraphNode.type.name !== 'paragraph') return;
editor.chain().focus().updateAttributes('image', {width: `${newWidth}px`}).run(); let paragraphText = '';
const textPositions: { start: number; end: number }[] = [];
paragraphNode.content.forEach((textNode, textOffset) => {
if (textNode.isText) {
const nodeText = textNode.text || '';
const start = paragraphOffset + 1 + textOffset;
textPositions.push({
start,
end: start + nodeText.length,
});
paragraphText += nodeText;
}
});
if (!paragraphText) return;
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;
}
if (lastIndex < paragraphText.length) {
sentences.push({
text: paragraphText.slice(lastIndex),
start: lastIndex,
end: paragraphText.length,
});
}
sentences.forEach((sentence) => {
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;
let isFirstWord = true;
for (let i = 0; i < words.length; i++) {
const word = words[i];
const currentWord = word.word;
if (/^[A-ZА-ЯЁ]/u.test(currentWord)) {
if (isFirstWord) {
isFirstWord = false;
continue;
}
let afterQuote = false;
if (i > 0) {
const prevWord = words[i - 1].word;
if (/[""«»''`*]$/.test(prevWord)) {
afterQuote = true;
} else {
const betweenWords = sentence.text.substring(
words[i - 1].end - sentence.start,
word.start - sentence.start
);
if (/\s[""«»''`]/.test(betweenWords) || /\s\*[«»]\*/.test(betweenWords) || /\s\*/.test(betweenWords)) {
afterQuote = true;
}
}
} else {
const sentencePrefix = sentence.text.substring(0, word.start - sentence.start).trim();
if (/^[""«»''`]/.test(sentencePrefix) || /^\*[«»]\*/.test(sentencePrefix) || /^\*/.test(sentencePrefix)) {
afterQuote = true;
}
}
if (!afterQuote) {
const docPositions = mapWordToDocumentPositions(word, textPositions, paragraphOffset);
if (docPositions) {
tr.addMark(docPositions.start, docPositions.end, state.schema.marks.bold.create());
}
}
}
isFirstWord = false;
}
});
});
dispatch(tr);
}; };
// Helper function to map paragraph word positions to document positions
const mapWordToDocumentPositions = ( const mapWordToDocumentPositions = (
word: { start: number, end: number }, word: { start: number; end: number },
textPositions: { start: number, end: number }[], textPositions: { start: number; end: number }[],
paragraphOffset: number paragraphOffset: number
) => { ) => {
let docStart = -1; let docStart = -1;
let docEnd = -1; let docEnd = -1;
// Find which text node(s) contain this word
for (const pos of textPositions) { for (const pos of textPositions) {
// Word starts in this text node
if (docStart === -1 && word.start < pos.end - paragraphOffset - 1) { if (docStart === -1 && word.start < pos.end - paragraphOffset - 1) {
const relativeStart = Math.max(0, word.start - (pos.start - paragraphOffset - 1)); const relativeStart = Math.max(0, word.start - (pos.start - paragraphOffset - 1));
docStart = pos.start + relativeStart; docStart = pos.start + relativeStart;
} }
// Word ends in this text node
if (docStart !== -1 && word.end <= pos.end - paragraphOffset - 1) { if (docStart !== -1 && word.end <= pos.end - paragraphOffset - 1) {
const relativeEnd = Math.min(pos.end - pos.start, word.end - (pos.start - paragraphOffset - 1)); const relativeEnd = Math.min(pos.end - pos.start, word.end - (pos.start - paragraphOffset - 1));
docEnd = pos.start + relativeEnd; docEnd = pos.start + relativeEnd;
@ -195,283 +226,111 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
return null; 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">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<button <button
type="button" type="button"
onClick={() => editor?.chain().focus().toggleBold().run()} onClick={() => editor?.chain().focus().toggleBold().run()}
className={`p-1 rounded hover:bg-gray-200 ${ className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('bold') ? 'bg-gray-200' : ''}`}
editor?.isActive('bold') ? 'bg-gray-200' : '' title="Выделение слов жирным шрифтом"
}`} >
title="Выделение слов жирным шрифтом" <Bold size={18} />
> </button>
<Bold size={18} /> <button
</button> type="button"
<button onClick={handleClick}
type="button" className="p-1 rounded hover:bg-gray-200"
onClick={handleClick} title="Автоматическое выделение жирным имен"
className="p-1 rounded hover:bg-gray-200" >
title="Автоматическое выделение жирным имен" <SquareUser size={18} />
> </button>
<SquareUser size={18} /> <button
</button> type="button"
<button onClick={() => editor?.chain().focus().toggleItalic().run()}
type="button" className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('italic') ? 'bg-gray-200' : ''}`}
onClick={() => editor?.chain().focus().toggleItalic().run()} title="Выделение слов наклонным шрифтом"
className={`p-1 rounded hover:bg-gray-200 ${ >
editor?.isActive('italic') ? 'bg-gray-200' : '' <Italic size={18} />
}`} </button>
title="Выделение слов наклонным шрифтом" <button
> type="button"
<Italic size={18} /> onClick={() => editor?.chain().focus().setParagraph().run()}
</button> className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('paragraph') ? 'bg-gray-200' : ''}`}
<button title="Выравнивание по левому краю"
type="button" >
onClick={() => editor?.chain().focus().setParagraph().run()} <AlignLeft size={18} />
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('paragraph') ? 'bg-gray-200' : ''}`} </button>
title="Выравнивание по левому краю" <button
> type="button"
<AlignLeft size={18} /> onClick={() => editor?.chain().focus().undo().run()}
</button> disabled={!editor?.can().chain().focus().undo().run()}
<button className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
type="button" title="Отменить действие"
onClick={() => editor?.chain().focus().undo().run()} >
disabled={!editor?.can().chain().focus().undo().run()} <Undo size={18} />
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50" </button>
title="Отменить действие" <button
> type="button"
<Undo size={18} /> onClick={() => editor?.chain().focus().redo().run()}
</button> disabled={!editor?.can().chain().focus().redo().run()}
<button className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
type="button" title="Вернуть действие"
onClick={() => editor?.chain().focus().redo().run()} >
disabled={!editor?.can().chain().focus().redo().run()} <Redo size={18} />
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50" </button>
title="Вернуть действие" <button
> type="button"
<Redo size={18} /> onClick={() => editor?.chain().focus().toggleBulletList().run()}
</button> className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('bulletList') ? 'bg-gray-200' : ''}`}
<button title="Создать список"
type="button" >
onClick={() => editor?.chain().focus().toggleBulletList().run()} <List size={18} />
className={`p-1 rounded hover:bg-gray-200 ${ </button>
editor?.isActive('bulletList') ? 'bg-gray-200' : '' <button
}`} type="button"
title="Создать список" onClick={() => editor?.chain().focus().toggleOrderedList().run()}
> className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('orderedList') ? 'bg-gray-200' : ''}`}
<List size={18} /> title="Создать упорядоченный список"
</button> >
<button <ListOrdered size={18} />
type="button" </button>
onClick={() => editor?.chain().focus().toggleOrderedList().run()} <button
className={`p-1 rounded hover:bg-gray-200 ${ type="button"
editor?.isActive('orderedList') ? 'bg-gray-200' : '' onClick={() => editor?.chain().focus().toggleBlockquote().run()}
}`} className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('blockquote') ? 'bg-gray-200' : ''}`}
title="Создать упорядоченный список" title="Цитирование (параграф)"
> >
<ListOrdered size={18} /> <Quote size={18} />
</button> </button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => editor?.chain().focus().setTextAlign('center').run()}
editor?.chain().focus().toggleBlockquote().run()}} className={`p-1 rounded hover:bg-gray-200 ${
className={`p-1 rounded hover:bg-gray-200 ${ editor?.getAttributes('textAlign')?.textAlign === 'center' ? 'bg-gray-200' : ''
editor?.isActive('blockquote') ? 'bg-gray-200' : '' }`}
}`} title="Выравнивание по центру"
title="Цитирование (параграф)" >
> <AlignCenter size={18} />
<Quote size={18} /> </button>
</button> <TipTapImageUploadButton
<button editor={editor}
type="button" articleId={articleId}
onClick={() => { />
editor?.chain().focus().setTextAlign('center').run()}} </div>
className={`p-1 rounded hover:bg-gray-200 ${
editor?.getAttributes('textAlign')?.textAlign === 'center' ? 'bg-gray-200' : ''
}`}
title="Выравнивание по центру"
>
<AlignCenter size={18} />
</button>
<button
type="button"
onClick={() => {
const url = prompt('Введите URL изображения');
if (url) {
editor?.chain().focus().setImage({ src: url }).run();
}
}}
title="Вставить изображение (старое)"
>
<ImagePlus size={18} />
</button>
<TipTapImageUploadButton editor={editor} articleId={atricleId} />
<button
type="button"
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.getAttributes('textAlign')?.textAlign === 'center' ? 'bg-gray-200' : ''
}`}
>
🏛 Центр
</button>
<button
type="button"
onClick={() => resizeImage(20)} className="p-1 rounded hover:bg-gray-200">
<Plus size={18} />
</button>
<button
type="button"
onClick={() => resizeImage(-20)} className="p-1 rounded hover:bg-gray-200">
<Minus size={18} />
</button>
</div> </div>
<div className="p-4">
<EditorContent editor={editor} />
</div>
{/* Добавляем отображение URL выбранного изображения */}
{selectedImage && (
<div className="p-4 border-t bg-gray-50">
<p className="text-sm text-gray-600">
Выбранное изображение: <a href={selectedImage} target="_blank" rel="noopener noreferrer" className="text-blue-500">{selectedImage}</a>
</p>
</div>
)}
</div> </div>
<div className="p-4">
<EditorContent editor={editor} />
</div>
</div>
); );
} }

View File

@ -1,12 +1,11 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { Image as ImageIcon } from 'lucide-react'; import { ImagePlus } from 'lucide-react';
import {Editor} from "@tiptap/react"; import { Editor } from '@tiptap/react';
import {imageResolutions} from "../config/imageResolutions.ts"; import { imageResolutions } from '../config/imageResolutions';
import axios from "axios"; import axios from 'axios';
interface TipTapImageUploadButtonProps { interface TipTapImageUploadButtonProps {
editor: Editor; // Replace with your TipTap editor type editor: Editor;
articleId: string; articleId: string;
} }
@ -17,54 +16,57 @@ const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ edito
return null; return null;
} }
// Обработка выбранного изображения
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
try { try {
// Проверка разрешения изображения const resolution = imageResolutions.find((r) => r.id === 'large');
const resolution = imageResolutions.find(r => r.id === 'large'); if (!resolution) throw new Error('Invalid resolution configuration');
if (!resolution)
throw new Error('Invalid resolution');
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('resolutionId', resolution.id); formData.append('resolutionId', resolution.id);
formData.append('folder', 'articles/' + articleId); formData.append('folder', `articles/${articleId}`);
// Отправка запроса на сервер
const response = await axios.post('/api/images/upload-url', formData, { const response = await axios.post('/api/images/upload-url', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации Authorization: `Bearer ${localStorage.getItem('token')}`,
}, },
}); });
if (response.data?.fileUrl) { const imageUrl = response.data?.fileUrl;
editor.chain().focus().setImage({src: response.data.fileUrl}).run(); if (imageUrl) {
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
const node = editor.schema.nodes.customImage.create({
src: imageUrl,
alt: file.name,
scale: 1, // Начальный масштаб
});
if (dispatch) {
tr.insert(editor.state.selection.anchor, node);
dispatch(tr);
}
return true;
})
.run();
} else {
throw new Error('No file URL returned from server');
} }
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Image upload failed:', error);
alert('Не удалось загрузить изображение. Проверьте консоль для деталей.');
} }
/*
const reader = new FileReader();
reader.onload = (e) => {
const src = e.target?.result as string;
// Insert the image with the TipTap editor
editor.chain().focus().setImage({src}).run();
};
reader.readAsDataURL(file);
*/
// Reset the file input
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}; };
// Function to trigger file input click
const handleButtonClick = () => { const handleButtonClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@ -77,7 +79,7 @@ const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ edito
className="p-1 rounded hover:bg-gray-200" className="p-1 rounded hover:bg-gray-200"
title="Вставить изображение" title="Вставить изображение"
> >
<ImageIcon size={18} /> <ImagePlus size={18} />
</button> </button>
<input <input
ref={fileInputRef} ref={fileInputRef}
@ -90,4 +92,4 @@ const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ edito
); );
}; };
export default TipTapImageUploadButton; export default TipTapImageUploadButton;

View File

@ -1,12 +1,12 @@
export const BackgroundImages: Record<number, string> = { export const BackgroundImages: Record<number, string> = {
1: '/images/gpt_film.webp?auto=format&fit=crop&q=80&w=2070', 1: '/images/pack/film-bn.webp?auto=format&fit=crop&q=80&w=2070',
2: '/images/gpt_theatre.webp?auto=format&fit=crop&q=80&w=2070', 2: '/images/gpt_theatre.webp?auto=format&fit=crop&q=80&w=2070',
3: '/images/bg-music.webp?auto=format&fit=crop&q=80&w=2070', 3: '/images/bg-music.webp?auto=format&fit=crop&q=80&w=2070',
4: '/images/bg-sport.webp?auto=format&fit=crop&q=80&w=2070', 4: '/images/pack/sport-bn.webp?auto=format&fit=crop&q=80&w=2070',
5: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070', 5: '/images/pack/art-bn.webp?auto=format&fit=crop&q=80&w=2070',
6: 'https://images.unsplash.com/photo-1608376630927-31bc8a3c9ee4?auto=format&fit=crop&q=80&w=2070', 6: 'https://images.unsplash.com/photo-1608376630927-31bc8a3c9ee4?auto=format&fit=crop&q=80&w=2070',
7: 'https://images.unsplash.com/photo-1530533718754-001d2668365a?auto=format&fit=crop&q=80&w=2070', 7: 'https://images.unsplash.com/photo-1530533718754-001d2668365a?auto=format&fit=crop&q=80&w=2070',
8: 'https://images.unsplash.com/photo-1494522358652-f30e61a60313?auto=format&fit=crop&q=80&w=2070', 8: 'https://images.unsplash.com/photo-1494522358652-f30e61a60313?auto=format&fit=crop&q=80&w=2070',
0: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070' 0: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8bу?auto=format&fit=crop&q=80&w=2070'
}; };

View File

@ -30,7 +30,7 @@ export function HomePage() {
const { main, sub, description } = getHeroTitle(); const { main, sub, description } = getHeroTitle();
return ( return (
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/main-bg.jpg')` }}> <div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/gpt_main-bg.webp')` }}>
<Header /> <Header />
<main> <main>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">

View File

@ -18,4 +18,5 @@ export interface ImageUploadProgress {
progress: number; progress: number;
status: 'uploading' | 'processing' | 'complete' | 'error'; status: 'uploading' | 'processing' | 'complete' | 'error';
error?: string; error?: string;
imageUrl?: string; // Поле для URL изображения
} }