Теперь работает вставка изображения в редактор с масштабированием.
This commit is contained in:
parent
a08eb412e4
commit
5a2bc6dbd3
15
package-lock.json
generated
15
package-lock.json
generated
@ -23,6 +23,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.344.0",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"react": "^18.3.1",
|
||||
@ -40,6 +41,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^18.3.5",
|
||||
@ -3642,6 +3644,13 @@
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"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": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
@ -6117,6 +6126,12 @@
|
||||
"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": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
|
@ -31,6 +31,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.344.0",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"react": "^18.3.1",
|
||||
@ -48,6 +49,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^18.3.5",
|
||||
|
BIN
public/images/pack/art-bn.webp
Normal file
BIN
public/images/pack/art-bn.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
BIN
public/images/pack/film-bn.webp
Normal file
BIN
public/images/pack/film-bn.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 207 KiB |
BIN
public/images/pack/sport-bn.webp
Normal file
BIN
public/images/pack/sport-bn.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 67 KiB |
@ -1,38 +1,12 @@
|
||||
//import { ResizableImage } from './TipTapEditor';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Blockquote from '@tiptap/extension-blockquote';
|
||||
import Image from '@tiptap/extension-blockquote';
|
||||
import { CustomImage } from './CustomImageExtension';
|
||||
|
||||
|
||||
interface ArticleContentProps {
|
||||
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) {
|
||||
@ -46,7 +20,7 @@ export function ArticleContent({ content }: ArticleContentProps) {
|
||||
class: 'border-l-4 border-gray-300 pl-4 italic',
|
||||
},
|
||||
}),
|
||||
ResizableImage
|
||||
CustomImage
|
||||
],
|
||||
content,
|
||||
editable: false, // Контент только для чтения
|
||||
|
@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TipTapEditor } from './TipTapEditor.tsx';
|
||||
import { CoverImageUpload } from './ImageUpload/CoverImageUpload.tsx';
|
||||
import { ImageUploader } from './ImageUpload/ImageUploader.tsx';
|
||||
import { GalleryManager } from './GalleryManager';
|
||||
import { useGallery } from '../hooks/useGallery';
|
||||
import { CategoryTitles, CityTitles, Author, GalleryImage, ArticleData } from '../types';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {TipTapEditor} from './TipTapEditor.tsx';
|
||||
import {CoverImageUpload} from './ImageUpload/CoverImageUpload.tsx';
|
||||
import {ImageUploader} from './ImageUpload/ImageUploader.tsx';
|
||||
import {GalleryManager} from './GalleryManager';
|
||||
import {useGallery} from '../hooks/useGallery';
|
||||
import {ArticleData, Author, CategoryTitles, CityTitles, GalleryImage} from '../types';
|
||||
import {useAuthStore} from '../stores/authStore';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
|
||||
interface FormState {
|
||||
title: string;
|
||||
@ -56,9 +58,32 @@ export function ArticleForm({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState<boolean>(false);
|
||||
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 || '');
|
||||
|
||||
// Добавляем обработку ошибок
|
||||
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(() => {
|
||||
if (editingId) {
|
||||
setDisplayedImages(galleryImages);
|
||||
@ -78,6 +103,7 @@ export function ArticleForm({
|
||||
setContent(initialFormState.content);
|
||||
setAuthorId(initialFormState.authorId);
|
||||
setDisplayedImages(initialFormState.galleryImages || []);
|
||||
console.log('Содержимое статьи при загрузке:', initialFormState.content);
|
||||
}
|
||||
}, [initialFormState]);
|
||||
|
||||
@ -93,13 +119,11 @@ export function ArticleForm({
|
||||
return isDifferent;
|
||||
}
|
||||
if (key === 'content') {
|
||||
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||||
return isDifferent;
|
||||
return JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||||
}
|
||||
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 isDifferent = currentValue !== initialValue;
|
||||
return isDifferent;
|
||||
return currentValue !== initialValue;
|
||||
});
|
||||
|
||||
setHasChanges(hasFormChanges && areRequiredFieldsFilled);
|
||||
@ -112,6 +136,14 @@ export function ArticleForm({
|
||||
const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => {
|
||||
e.preventDefault();
|
||||
|
||||
console.log('Вызов handleSubmit:', { closeForm });
|
||||
console.log('Содержимое статьи перед сохранением:', content);
|
||||
|
||||
if (isSubmitting) {
|
||||
console.log('Форма уже отправляется, игнорируем повторную отправку');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title.trim() || !excerpt.trim()) {
|
||||
setError('Пожалуйста, заполните обязательные поля: Заголовок и Краткое описание.');
|
||||
return;
|
||||
@ -120,7 +152,14 @@ export function ArticleForm({
|
||||
if (!hasChanges) return;
|
||||
|
||||
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,
|
||||
excerpt,
|
||||
categoryId,
|
||||
@ -134,13 +173,38 @@ export function ArticleForm({
|
||||
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) => {
|
||||
handleSubmit(e, false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (hasChanges) {
|
||||
setIsConfirmModalOpen(true); // Открываем модальное окно
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmCancel = () => {
|
||||
setIsConfirmModalOpen(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsConfirmModalOpen(false);
|
||||
};
|
||||
|
||||
if (editingId && galleryLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@ -184,7 +248,9 @@ export function ArticleForm({
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<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
|
||||
id="category"
|
||||
value={categoryId}
|
||||
@ -195,7 +261,9 @@ export function ArticleForm({
|
||||
</select>
|
||||
</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
|
||||
id="city"
|
||||
value={cityId}
|
||||
@ -206,7 +274,9 @@ export function ArticleForm({
|
||||
</select>
|
||||
</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
|
||||
type="number"
|
||||
id="readTime"
|
||||
@ -218,14 +288,19 @@ export function ArticleForm({
|
||||
</div>
|
||||
{editingId && isAdmin && (
|
||||
<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
|
||||
id="author"
|
||||
value={authorId}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
@ -238,14 +313,27 @@ export function ArticleForm({
|
||||
allowUpload={!!editingId}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Статья</span></label>
|
||||
<TipTapEditor initialContent={content} onContentChange={setContent} atricleId={articleId} />
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<span className="italic font-bold">Статья</span>
|
||||
</label>
|
||||
<TipTapEditor
|
||||
initialContent={content}
|
||||
onContentChange={setContent}
|
||||
articleId={articleId}
|
||||
/>
|
||||
</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 ? (
|
||||
<>
|
||||
<ImageUploader onUploadComplete={(imageUrl) => { setFormNewImageUrl(imageUrl); }} articleId={articleId} />
|
||||
<ImageUploader
|
||||
onUploadComplete={(imageUrl) => {
|
||||
setFormNewImageUrl(imageUrl);
|
||||
}}
|
||||
articleId={articleId}
|
||||
/>
|
||||
<GalleryManager
|
||||
images={displayedImages}
|
||||
imageUrl={formNewImageUrl}
|
||||
@ -282,7 +370,7 @@ export function ArticleForm({
|
||||
<div className="flex justify-end gap-4">
|
||||
<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"
|
||||
>
|
||||
Отмена
|
||||
@ -291,21 +379,27 @@ export function ArticleForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={!hasChanges}
|
||||
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`}
|
||||
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 && !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
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
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`}
|
||||
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 && !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 ? 'Изменить' : 'Сохранить черновик'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
onConfirm={handleConfirmCancel}
|
||||
onCancel={handleCloseModal}
|
||||
message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
36
src/components/ConfirmModal.tsx
Normal file
36
src/components/ConfirmModal.tsx
Normal 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;
|
@ -1,103 +1,175 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import {Editor, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Node, mergeAttributes, NodeViewProps } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
|
||||
import { ZoomIn, ZoomOut, Trash2 } from 'lucide-react';
|
||||
import { Node as ProseMirrorNode } from 'prosemirror-model';
|
||||
|
||||
|
||||
interface ImageNodeViewProps {
|
||||
node: ProseMirrorNode;
|
||||
updateAttributes: (attrs: Record<string, number>) => void;
|
||||
editor: Editor;
|
||||
getPos: () => number;
|
||||
// Определяем тип для атрибутов узла customImage
|
||||
interface CustomImageAttributes {
|
||||
src: string | undefined;
|
||||
alt: string | undefined;
|
||||
title: string | undefined;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
// React component for the image node view
|
||||
const ImageNodeView: React.FC<ImageNodeViewProps> = ({ node, updateAttributes, editor, getPos }) => {
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
const [scale, setScale] = useState(1);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const ImageNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => {
|
||||
// Утверждаем, что node имеет нужный тип
|
||||
const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes };
|
||||
|
||||
// Update selection state when the editor selection changes
|
||||
useEffect(() => {
|
||||
const handleSelectionUpdate = ({ editor }: { editor: Editor }) => {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const nodePos = getPos();
|
||||
// Проверяем, что узел корректен
|
||||
if (node.type.name !== 'customImage') {
|
||||
console.error('Неподдерживаемый тип узла:', node.type.name);
|
||||
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: неподдерживаемый тип узла</NodeViewWrapper>;
|
||||
}
|
||||
|
||||
// Check if this node is selected
|
||||
const isNodeSelected = selection.$anchor.pos >= nodePos &&
|
||||
selection.$anchor.pos <= nodePos + node.nodeSize;
|
||||
// Проверяем, что src существует
|
||||
if (!typedNode.attrs.src) {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setScale(prev => Math.min(prev + 0.1, 2));
|
||||
updateAttributes({ scale: Math.min(scale + 0.1, 2) });
|
||||
};
|
||||
try {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error('Редактор уничтожен или недоступен');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle zoom out
|
||||
const handleZoomOut = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setScale(prev => Math.max(prev - 0.1, 0.5));
|
||||
updateAttributes({ scale: Math.max(scale - 0.1, 0.5) });
|
||||
};
|
||||
const pos = getPos();
|
||||
const currentNode = editor.state.doc.nodeAt(pos);
|
||||
if (!currentNode || currentNode.type.name !== 'customImage') {
|
||||
console.error('Узел больше не существует или изменён:', { pos, currentNode });
|
||||
return;
|
||||
}
|
||||
|
||||
// Track current scale from attributes
|
||||
useEffect(() => {
|
||||
if (node.attrs.scale) {
|
||||
setScale(node.attrs.scale);
|
||||
let currentScale = typedNode.attrs.scale;
|
||||
if (currentScale <= 0 || isNaN(currentScale)) {
|
||||
console.warn('Некорректное значение scale, устанавливаем значение по умолчанию:', { currentScale });
|
||||
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 (
|
||||
<div className="relative flex justify-center my-4">
|
||||
{/* The actual image */}
|
||||
<NodeViewWrapper className="relative flex justify-center my-4">
|
||||
{/* Оборачиваем изображение и панель в контейнер, который масштабируется */}
|
||||
<div
|
||||
className="relative inline-block"
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={node.attrs.src}
|
||||
alt={node.attrs.alt || ''}
|
||||
className={`max-w-full transition-transform ${isSelected ? 'ring-2 ring-blue-500' : ''}`}
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
data-drag-handle=""
|
||||
src={typedNode.attrs.src}
|
||||
alt={typedNode.attrs.alt}
|
||||
className={`max-w-full transition-transform ${selected && isEditable ? 'ring-2 ring-blue-500' : ''}`}
|
||||
data-drag-handle=""
|
||||
/>
|
||||
|
||||
{/* Zoom controls - only visible when selected */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 flex space-x-2 bg-white bg-opacity-75 p-1 rounded shadow">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 rounded hover:bg-gray-200"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 rounded hover:bg-gray-200"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{selected && isEditable && ( // Показываем панель только в режиме редактирования
|
||||
<div
|
||||
className="absolute top-2 right-2 flex space-x-2 bg-white bg-opacity-75 p-1 rounded shadow"
|
||||
style={{
|
||||
transform: `scale(${1 / scale})`, // Компенсируем масштабирование панели
|
||||
transformOrigin: 'top right', // Устанавливаем точку масштабирования в верхний правый угол
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 rounded hover:bg-gray-200"
|
||||
title={`Zoom in (current scale: ${scale})`}
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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({
|
||||
name: 'customImage',
|
||||
group: 'block',
|
||||
@ -109,16 +181,28 @@ export const CustomImage = Node.create({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
default: undefined,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
default: undefined,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
default: undefined,
|
||||
},
|
||||
scale: {
|
||||
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 [
|
||||
{
|
||||
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,7 +229,13 @@ export const CustomImage = Node.create({
|
||||
return [
|
||||
'div',
|
||||
{ class: 'flex justify-center my-4' },
|
||||
['img', mergeAttributes(rest, { style: `transform: scale(${scale})` })],
|
||||
[
|
||||
'img',
|
||||
mergeAttributes(rest, {
|
||||
scale: scale,
|
||||
style: `transform: scale(${scale})`,
|
||||
}),
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
|
@ -20,22 +20,26 @@ export function ImageUploader({ onUploadComplete, articleId }: ImageUploaderProp
|
||||
try {
|
||||
setUploadProgress(prev => ({
|
||||
...prev,
|
||||
[file.name]: { progress: 0, status: 'uploading' }
|
||||
[file.name]: { progress: 0, status: 'uploading' },
|
||||
}));
|
||||
|
||||
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) => {
|
||||
setUploadProgress(prev => ({
|
||||
...prev,
|
||||
[file.name]: { progress, status: 'uploading' }
|
||||
[file.name]: { progress, status: 'uploading' },
|
||||
}));
|
||||
});
|
||||
|
||||
setUploadProgress(prev => ({
|
||||
...prev,
|
||||
[file.name]: { progress: 100, status: 'complete' }
|
||||
[file.name]: {
|
||||
progress: 100,
|
||||
status: 'complete',
|
||||
imageUrl: uploadedImage.url, // Сохраняем URL изображения
|
||||
},
|
||||
}));
|
||||
|
||||
onUploadComplete(uploadedImage.url);
|
||||
@ -45,7 +49,7 @@ export function ImageUploader({ onUploadComplete, articleId }: ImageUploaderProp
|
||||
[file.name]: {
|
||||
progress: 0,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Upload failed'
|
||||
error: error instanceof Error ? error.message : 'Upload failed',
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
@ -21,13 +21,13 @@ export function UploadProgress({ progress, fileName }: UploadProgressProps) {
|
||||
const getStatusText = () => {
|
||||
switch (progress.status) {
|
||||
case 'uploading':
|
||||
return 'Uploading...';
|
||||
return 'Загрузка...';
|
||||
case 'processing':
|
||||
return 'Processing...';
|
||||
return 'Обработка...';
|
||||
case 'complete':
|
||||
return 'Upload complete';
|
||||
return 'Загрузка завершена';
|
||||
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">
|
||||
{getStatusIcon()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{fileName}
|
||||
</p>
|
||||
{progress.status === 'complete' && progress.imageUrl ? (
|
||||
<div className="relative w-48 h-32 rounded-lg overflow-hidden group">
|
||||
<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>
|
||||
</div>
|
||||
{(progress.status === 'uploading' || progress.status === 'processing') && (
|
||||
|
@ -2,7 +2,6 @@ import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Blockquote from '@tiptap/extension-blockquote';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import {
|
||||
Bold,
|
||||
@ -14,87 +13,25 @@ import {
|
||||
Undo,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
Plus,
|
||||
Minus,
|
||||
SquareUser,
|
||||
ImagePlus
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from "react";
|
||||
import { CustomImage } from "./CustomImageExtension";
|
||||
import TipTapImageUploadButton from "./TipTapImageUploadButton";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CustomImage } from './CustomImageExtension';
|
||||
import TipTapImageUploadButton from './TipTapImageUploadButton';
|
||||
|
||||
interface TipTapEditorProps {
|
||||
initialContent: string;
|
||||
onContentChange: (content: string) => void;
|
||||
atricleId: string;
|
||||
articleId: 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,
|
||||
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) {
|
||||
export function TipTapEditor({ initialContent, onContentChange, articleId }: TipTapEditorProps) {
|
||||
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({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
blockquote: false, // Отключаем дефолтный
|
||||
blockquote: false,
|
||||
}),
|
||||
Blockquote.configure({
|
||||
HTMLAttributes: {
|
||||
@ -118,9 +55,8 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
|
||||
let foundImage = false;
|
||||
|
||||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||||
if (node.type.name === 'image') {
|
||||
if (node.type.name === 'customImage') {
|
||||
setSelectedImage(node.attrs.src);
|
||||
editor.commands.updateAttributes('image', { class: 'selected-image' });
|
||||
foundImage = true;
|
||||
}
|
||||
});
|
||||
@ -133,7 +69,6 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
|
||||
},
|
||||
});
|
||||
|
||||
// Обновление контента при изменении initialContent
|
||||
useEffect(() => {
|
||||
if (editor && editor.getHTML() !== initialContent) {
|
||||
editor.commands.setContent(initialContent);
|
||||
@ -151,36 +86,132 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [editor]);
|
||||
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const resizeImage = (delta: number) => {
|
||||
if (!editor.isActive('image')) return;
|
||||
const handleClick = () => {
|
||||
const { state, dispatch } = editor.view;
|
||||
const tr = state.tr;
|
||||
|
||||
const attrs = editor?.getAttributes('image');
|
||||
const newWidth = Math.max((parseInt(attrs?.width, 10) || 100) + delta, 50);
|
||||
state.doc.forEach((paragraphNode, paragraphOffset) => {
|
||||
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 = (
|
||||
word: { start: number, end: number },
|
||||
textPositions: { start: number, end: number }[],
|
||||
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;
|
||||
@ -195,283 +226,111 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
|
||||
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">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('bold') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
title="Выделение слов жирным шрифтом"
|
||||
>
|
||||
<Bold size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="p-1 rounded hover:bg-gray-200"
|
||||
title="Автоматическое выделение жирным имен"
|
||||
>
|
||||
<SquareUser size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('italic') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
title="Выделение слов наклонным шрифтом"
|
||||
>
|
||||
<Italic size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().setParagraph().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('paragraph') ? 'bg-gray-200' : ''}`}
|
||||
title="Выравнивание по левому краю"
|
||||
>
|
||||
<AlignLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().undo().run()}
|
||||
disabled={!editor?.can().chain().focus().undo().run()}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
title="Отменить действие"
|
||||
>
|
||||
<Undo size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().redo().run()}
|
||||
disabled={!editor?.can().chain().focus().redo().run()}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
title="Вернуть действие"
|
||||
>
|
||||
<Redo size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('bulletList') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
title="Создать список"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('orderedList') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
title="Создать упорядоченный список"
|
||||
>
|
||||
<ListOrdered size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor?.chain().focus().toggleBlockquote().run()}}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${
|
||||
editor?.isActive('blockquote') ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
title="Цитирование (параграф)"
|
||||
>
|
||||
<Quote size={18} />
|
||||
</button>
|
||||
<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' : ''
|
||||
}`}
|
||||
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 className="border rounded-lg overflow-hidden">
|
||||
<div className="border-b bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('bold') ? 'bg-gray-200' : ''}`}
|
||||
title="Выделение слов жирным шрифтом"
|
||||
>
|
||||
<Bold size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="p-1 rounded hover:bg-gray-200"
|
||||
title="Автоматическое выделение жирным имен"
|
||||
>
|
||||
<SquareUser size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('italic') ? 'bg-gray-200' : ''}`}
|
||||
title="Выделение слов наклонным шрифтом"
|
||||
>
|
||||
<Italic size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().setParagraph().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('paragraph') ? 'bg-gray-200' : ''}`}
|
||||
title="Выравнивание по левому краю"
|
||||
>
|
||||
<AlignLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().undo().run()}
|
||||
disabled={!editor?.can().chain().focus().undo().run()}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
title="Отменить действие"
|
||||
>
|
||||
<Undo size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().redo().run()}
|
||||
disabled={!editor?.can().chain().focus().redo().run()}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
title="Вернуть действие"
|
||||
>
|
||||
<Redo size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('bulletList') ? 'bg-gray-200' : ''}`}
|
||||
title="Создать список"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('orderedList') ? 'bg-gray-200' : ''}`}
|
||||
title="Создать упорядоченный список"
|
||||
>
|
||||
<ListOrdered size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('blockquote') ? 'bg-gray-200' : ''}`}
|
||||
title="Цитирование (параграф)"
|
||||
>
|
||||
<Quote size={18} />
|
||||
</button>
|
||||
<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' : ''
|
||||
}`}
|
||||
title="Выравнивание по центру"
|
||||
>
|
||||
<AlignCenter size={18} />
|
||||
</button>
|
||||
<TipTapImageUploadButton
|
||||
editor={editor}
|
||||
articleId={articleId}
|
||||
/>
|
||||
</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 className="p-4">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,12 +1,11 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Image as ImageIcon } from 'lucide-react';
|
||||
import {Editor} from "@tiptap/react";
|
||||
import {imageResolutions} from "../config/imageResolutions.ts";
|
||||
import axios from "axios";
|
||||
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { imageResolutions } from '../config/imageResolutions';
|
||||
import axios from 'axios';
|
||||
|
||||
interface TipTapImageUploadButtonProps {
|
||||
editor: Editor; // Replace with your TipTap editor type
|
||||
editor: Editor;
|
||||
articleId: string;
|
||||
}
|
||||
|
||||
@ -17,54 +16,57 @@ const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ edito
|
||||
return null;
|
||||
}
|
||||
|
||||
// Обработка выбранного изображения
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
// Проверка разрешения изображения
|
||||
const resolution = imageResolutions.find(r => r.id === 'large');
|
||||
if (!resolution)
|
||||
throw new Error('Invalid resolution');
|
||||
const resolution = imageResolutions.find((r) => r.id === 'large');
|
||||
if (!resolution) throw new Error('Invalid resolution configuration');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
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, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.fileUrl) {
|
||||
editor.chain().focus().setImage({src: response.data.fileUrl}).run();
|
||||
const imageUrl = response.data?.fileUrl;
|
||||
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) {
|
||||
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) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Function to trigger file input click
|
||||
const handleButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
@ -77,7 +79,7 @@ const TipTapImageUploadButton: React.FC<TipTapImageUploadButtonProps> = ({ edito
|
||||
className="p-1 rounded hover:bg-gray-200"
|
||||
title="Вставить изображение"
|
||||
>
|
||||
<ImageIcon size={18} />
|
||||
<ImagePlus size={18} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
@ -1,12 +1,12 @@
|
||||
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',
|
||||
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',
|
||||
5: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070',
|
||||
4: '/images/pack/sport-bn.webp?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',
|
||||
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',
|
||||
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'
|
||||
};
|
||||
|
||||
|
@ -30,7 +30,7 @@ export function HomePage() {
|
||||
const { main, sub, description } = getHeroTitle();
|
||||
|
||||
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 />
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
|
@ -18,4 +18,5 @@ export interface ImageUploadProgress {
|
||||
progress: number;
|
||||
status: 'uploading' | 'processing' | 'complete' | 'error';
|
||||
error?: string;
|
||||
imageUrl?: string; // Поле для URL изображения
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user