import React, { useEffect, useRef, useState } from "react"; import { Icon } from "@iconify/react"; interface FloatingToolbarProps { textareaRef: React.RefObject; onFormat: (before: string, after: string) => void; visible: boolean; position: { top: number; left: number }; onHide?: () => void; onInsertColor?: () => void; activeFormats?: { bold?: boolean; italic?: boolean; strikethrough?: boolean; }; hasSelection?: boolean; // Есть ли выделение текста } export const FloatingToolbar: React.FC = ({ textareaRef, onFormat, visible, position, onHide, onInsertColor, activeFormats = {}, hasSelection = false, }) => { const toolbarRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); const [scrollLeft, setScrollLeft] = useState(0); useEffect(() => { if (visible && toolbarRef.current) { // Небольшая задержка для корректного расчета размеров после рендера setTimeout(() => { if (!toolbarRef.current) return; const toolbar = toolbarRef.current; const rect = toolbar.getBoundingClientRect(); const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; const padding = 10; // Получаем внутренний контейнер с кнопками для определения реальной ширины const toolbarContent = toolbar.querySelector('.floating-toolbar') as HTMLElement; const toolbarContentWidth = toolbarContent ? toolbarContent.scrollWidth : rect.width; const availableWidth = windowWidth - padding * 2; let top = position.top - rect.height - padding; let left = position.left; // Если контент шире доступного пространства, устанавливаем maxWidth для wrapper if (toolbarContentWidth > availableWidth) { toolbar.style.maxWidth = `${availableWidth}px`; } // Если toolbar выходит за правую границу экрана if (left + rect.width > windowWidth - padding) { // Если контент шире экрана, используем прокрутку и позиционируем по левому краю if (toolbarContentWidth > availableWidth) { left = padding; } else { // Иначе выравниваем по правому краю left = Math.max(padding, windowWidth - rect.width - padding); } } // Если toolbar выходит за левую границу экрана if (left < padding) { left = padding; } // Если toolbar выходит за верхнюю границу экрана if (top < padding) { top = position.top + 30; // Показываем снизу от выделения } // Проверяем нижнюю границу if (top + rect.height > windowHeight - padding) { top = windowHeight - rect.height - padding; } toolbar.style.top = `${top}px`; toolbar.style.left = `${left}px`; }, 0); } }, [visible, position]); const handleMouseDown = (e: React.MouseEvent) => { // Не начинаем перетаскивание если кликнули на кнопку if ((e.target as HTMLElement).closest('.floating-toolbar-btn')) return; if (!toolbarRef.current) return; setIsDragging(true); setStartX(e.pageX - toolbarRef.current.offsetLeft); setScrollLeft(toolbarRef.current.scrollLeft); }; const handleMouseMove = (e: MouseEvent) => { if (!isDragging || !toolbarRef.current) return; e.preventDefault(); const x = e.pageX - toolbarRef.current.offsetLeft; const walk = (x - startX) * 2; // Увеличиваем скорость прокрутки toolbarRef.current.scrollLeft = scrollLeft - walk; }; const handleMouseUp = () => { setIsDragging(false); }; // Обработчики для document чтобы отслеживать mouseMove и mouseUp даже вне элемента useEffect(() => { if (isDragging) { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } else { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); } return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging]); const handleFormat = (before: string, after: string) => { onFormat(before, after); // Не скрываем toolbar - оставляем его видимым для дальнейших действий setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); // Обновляем выделение после применения форматирования const start = textareaRef.current.selectionStart; const end = textareaRef.current.selectionEnd; if (start !== end) { textareaRef.current.setSelectionRange(start, end); } } }, 0); }; const handleCopy = async () => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; if (start === end) return; // Нет выделения const selectedText = textarea.value.substring(start, end); try { await navigator.clipboard.writeText(selectedText); // Можно добавить уведомление об успешном копировании } catch (err) { // Fallback для старых браузеров const textArea = document.createElement("textarea"); textArea.value = selectedText; textArea.style.position = "fixed"; textArea.style.left = "-999999px"; document.body.appendChild(textArea); textArea.select(); document.execCommand("copy"); document.body.removeChild(textArea); } }; const handleCut = async () => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; if (start === end) return; // Нет выделения const selectedText = textarea.value.substring(start, end); try { await navigator.clipboard.writeText(selectedText); } catch (err) { // Fallback для старых браузеров const textArea = document.createElement("textarea"); textArea.value = selectedText; textArea.style.position = "fixed"; textArea.style.left = "-999999px"; document.body.appendChild(textArea); textArea.select(); document.execCommand("copy"); document.body.removeChild(textArea); } // Удаляем выделенный текст const newValue = textarea.value.substring(0, start) + textarea.value.substring(end); // Обновляем значение через React-совместимое событие const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, "value" )?.set; if (nativeInputValueSetter) { nativeInputValueSetter.call(textarea, newValue); const inputEvent = new Event("input", { bubbles: true }); textarea.dispatchEvent(inputEvent); } else { textarea.value = newValue; const inputEvent = new Event("input", { bubbles: true }); textarea.dispatchEvent(inputEvent); } textarea.setSelectionRange(start, start); textarea.focus(); }; const handlePaste = async () => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; try { const text = await navigator.clipboard.readText(); // Вставляем текст в позицию курсора или заменяем выделенный текст const newValue = textarea.value.substring(0, start) + text + textarea.value.substring(end); // Обновляем значение через React-совместимое событие const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, "value" )?.set; if (nativeInputValueSetter) { nativeInputValueSetter.call(textarea, newValue); const inputEvent = new Event("input", { bubbles: true }); textarea.dispatchEvent(inputEvent); } else { textarea.value = newValue; const inputEvent = new Event("input", { bubbles: true }); textarea.dispatchEvent(inputEvent); } // Устанавливаем курсор после вставленного текста const newCursorPos = start + text.length; textarea.setSelectionRange(newCursorPos, newCursorPos); textarea.focus(); } catch (err) { // Fallback: используем execCommand для вставки textarea.focus(); document.execCommand("paste"); } }; // Не показываем toolbar, если он не видим или нет выделения текста if (!visible || !hasSelection) return null; return (
toolbarRef.current.clientWidth ? 'grab' : 'default'), }} onMouseDown={(e) => { // Предотвращаем потерю выделения при клике на toolbar e.preventDefault(); handleMouseDown(e); }} onContextMenu={(e) => { // Предотвращаем появление контекстного меню браузера на toolbar e.preventDefault(); }} >
{hasSelection && ( <> )} {hasSelection && ( <>
)}
); };