noteJS-react/src/components/notes/FloatingToolbar.tsx

392 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};