392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
||
import { Icon } from "@iconify/react";
|
||
|
||
interface FloatingToolbarProps {
|
||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||
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<FloatingToolbarProps> = ({
|
||
textareaRef,
|
||
onFormat,
|
||
visible,
|
||
position,
|
||
onHide,
|
||
onInsertColor,
|
||
activeFormats = {},
|
||
hasSelection = false,
|
||
}) => {
|
||
const toolbarRef = useRef<HTMLDivElement>(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 (
|
||
<div
|
||
ref={toolbarRef}
|
||
className="floating-toolbar-wrapper"
|
||
style={{
|
||
position: "fixed",
|
||
top: `${position.top}px`,
|
||
left: `${position.left}px`,
|
||
zIndex: 1000,
|
||
cursor: isDragging ? 'grabbing' : (toolbarRef.current && toolbarRef.current.scrollWidth > toolbarRef.current.clientWidth ? 'grab' : 'default'),
|
||
}}
|
||
onMouseDown={(e) => {
|
||
// Предотвращаем потерю выделения при клике на toolbar
|
||
e.preventDefault();
|
||
handleMouseDown(e);
|
||
}}
|
||
onContextMenu={(e) => {
|
||
// Предотвращаем появление контекстного меню браузера на toolbar
|
||
e.preventDefault();
|
||
}}
|
||
>
|
||
<div className="floating-toolbar">
|
||
<button
|
||
className="floating-toolbar-btn"
|
||
onClick={onHide}
|
||
title="Закрыть"
|
||
>
|
||
<Icon icon="mdi:close" />
|
||
</button>
|
||
|
||
{hasSelection && (
|
||
<>
|
||
<button
|
||
className="floating-toolbar-btn"
|
||
onClick={handleCopy}
|
||
title="Копировать"
|
||
>
|
||
<Icon icon="mdi:content-copy" />
|
||
</button>
|
||
<button
|
||
className="floating-toolbar-btn"
|
||
onClick={handleCut}
|
||
title="Вырезать"
|
||
>
|
||
<Icon icon="mdi:content-cut" />
|
||
</button>
|
||
<button
|
||
className="floating-toolbar-btn"
|
||
onClick={handlePaste}
|
||
title="Вставить"
|
||
>
|
||
<Icon icon="mdi:content-paste" />
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{hasSelection && (
|
||
<>
|
||
<div className="floating-toolbar-separator" />
|
||
|
||
<button
|
||
className={`floating-toolbar-btn ${activeFormats.bold ? "active" : ""}`}
|
||
onClick={() => handleFormat("**", "**")}
|
||
title="Жирный"
|
||
>
|
||
<Icon icon="mdi:format-bold" />
|
||
</button>
|
||
<button
|
||
className={`floating-toolbar-btn ${
|
||
activeFormats.italic ? "active" : ""
|
||
}`}
|
||
onClick={() => handleFormat("*", "*")}
|
||
title="Курсив"
|
||
>
|
||
<Icon icon="mdi:format-italic" />
|
||
</button>
|
||
<button
|
||
className={`floating-toolbar-btn ${
|
||
activeFormats.strikethrough ? "active" : ""
|
||
}`}
|
||
onClick={() => handleFormat("~~", "~~")}
|
||
title="Зачеркнутый"
|
||
>
|
||
<Icon icon="mdi:format-strikethrough" />
|
||
</button>
|
||
|
||
<div className="floating-toolbar-separator" />
|
||
|
||
<button
|
||
className="floating-toolbar-btn"
|
||
onClick={() => onInsertColor?.()}
|
||
title="Цвет текста"
|
||
>
|
||
<Icon icon="mdi:palette" />
|
||
</button>
|
||
|
||
<button
|
||
className="floating-toolbar-btn"
|
||
onClick={() => handleFormat("||", "||")}
|
||
title="Скрытый текст"
|
||
>
|
||
<Icon icon="mdi:eye-off" />
|
||
</button>
|
||
|
||
<button
|
||
className="floating-toolbar-btn"
|
||
onClick={() => handleFormat("`", "`")}
|
||
title="Код"
|
||
>
|
||
<Icon icon="mdi:code-tags" />
|
||
</button>
|
||
|
||
<button
|
||
className="floating-toolbar-btn"
|
||
onClick={() => handleFormat("> ", "")}
|
||
title="Цитата"
|
||
>
|
||
<Icon icon="mdi:format-quote-close" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|