Обновлены компоненты FloatingToolbar и MarkdownToolbar для улучшения работы с выделением текста и добавления новых функций форматирования. Расширены свойства позиционирования панели инструментов, добавлена поддержка цветного текста. Обновлен файл service worker с новой версией для кэширования. Оптимизированы стили для мобильных устройств.
This commit is contained in:
parent
30f9daaec8
commit
e49b9dc865
@ -82,7 +82,7 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.b1jpidvaji"
|
||||
"revision": "0.b944c6vblpo"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@ -5,7 +5,14 @@ interface FloatingToolbarProps {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
onFormat: (before: string, after: string) => void;
|
||||
visible: boolean;
|
||||
position: { top: number; left: number };
|
||||
position: {
|
||||
top: number;
|
||||
left: number;
|
||||
selectionTop?: number;
|
||||
selectionBottom?: number;
|
||||
selectionLeft?: number;
|
||||
selectionRight?: number;
|
||||
};
|
||||
onHide?: () => void;
|
||||
onInsertColor?: () => void;
|
||||
activeFormats?: {
|
||||
@ -13,7 +20,7 @@ interface FloatingToolbarProps {
|
||||
italic?: boolean;
|
||||
strikethrough?: boolean;
|
||||
};
|
||||
hasSelection?: boolean; // Есть ли выделение текста
|
||||
hasSelection?: boolean;
|
||||
}
|
||||
|
||||
export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
@ -33,7 +40,6 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && toolbarRef.current) {
|
||||
// Небольшая задержка для корректного расчета размеров после рендера
|
||||
setTimeout(() => {
|
||||
if (!toolbarRef.current) return;
|
||||
|
||||
@ -42,44 +48,77 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const padding = 10;
|
||||
const offset = 8; // Отступ от выделенного текста
|
||||
|
||||
// Получаем внутренний контейнер с кнопками для определения реальной ширины
|
||||
const toolbarContent = toolbar.querySelector('.floating-toolbar') as HTMLElement;
|
||||
const toolbarContentWidth = toolbarContent ? toolbarContent.scrollWidth : rect.width;
|
||||
// Получаем внутренний контейнер с кнопками
|
||||
const toolbarContent = toolbar.querySelector(
|
||||
".floating-toolbar"
|
||||
) as HTMLElement;
|
||||
const toolbarContentWidth = toolbarContent
|
||||
? toolbarContent.scrollWidth
|
||||
: rect.width;
|
||||
const availableWidth = windowWidth - padding * 2;
|
||||
const toolbarHeight = rect.height;
|
||||
|
||||
let top = position.top - rect.height - padding;
|
||||
let left = position.left;
|
||||
// Используем границы выделения, если они есть
|
||||
const selectionTop = position.selectionTop ?? position.top;
|
||||
const selectionBottom = position.selectionBottom ?? position.top + 20;
|
||||
|
||||
// Вычисляем пространство сверху и снизу от выделения
|
||||
const spaceAbove = selectionTop - padding;
|
||||
const spaceBelow = windowHeight - selectionBottom - padding;
|
||||
|
||||
// Определяем, где больше места и где не будет перекрытия
|
||||
let top: number;
|
||||
|
||||
// Проверяем, помещается ли панель сверху
|
||||
if (spaceAbove >= toolbarHeight + offset) {
|
||||
// Помещается сверху - размещаем над выделением
|
||||
top = selectionTop - toolbarHeight - offset;
|
||||
} else if (spaceBelow >= toolbarHeight + offset) {
|
||||
// Помещается снизу - размещаем под выделением
|
||||
top = selectionBottom + offset;
|
||||
} else {
|
||||
// Не помещается ни сверху, ни снизу - выбираем сторону с большим пространством
|
||||
if (spaceAbove > spaceBelow) {
|
||||
// Больше места сверху, но панель может частично выйти за границы
|
||||
top = Math.max(padding, selectionTop - toolbarHeight - offset);
|
||||
} else {
|
||||
// Больше места снизу
|
||||
top = Math.min(
|
||||
windowHeight - toolbarHeight - padding,
|
||||
selectionBottom + offset
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Горизонтальное позиционирование
|
||||
let left = position.left - toolbarContentWidth / 2; // Центрируем относительно середины выделения
|
||||
|
||||
// Если контент шире доступного пространства, устанавливаем maxWidth для wrapper
|
||||
if (toolbarContentWidth > availableWidth) {
|
||||
toolbar.style.maxWidth = `${availableWidth}px`;
|
||||
}
|
||||
|
||||
// Если toolbar выходит за правую границу экрана
|
||||
if (left + rect.width > windowWidth - padding) {
|
||||
// Если контент шире экрана, используем прокрутку и позиционируем по левому краю
|
||||
if (toolbarContentWidth > availableWidth) {
|
||||
left = padding;
|
||||
left = padding; // Выравниваем по левому краю
|
||||
} else {
|
||||
// Иначе выравниваем по правому краю
|
||||
left = Math.max(padding, windowWidth - rect.width - padding);
|
||||
// Проверяем границы экрана
|
||||
if (left + toolbarContentWidth > windowWidth - padding) {
|
||||
// Выравниваем по правому краю
|
||||
left = Math.max(
|
||||
padding,
|
||||
windowWidth - toolbarContentWidth - 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;
|
||||
// Финальная проверка вертикальных границ
|
||||
if (top < padding) {
|
||||
top = padding;
|
||||
}
|
||||
if (top + toolbarHeight > windowHeight - padding) {
|
||||
top = windowHeight - toolbarHeight - padding;
|
||||
}
|
||||
|
||||
toolbar.style.top = `${top}px`;
|
||||
@ -88,10 +127,9 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
}
|
||||
}, [visible, position]);
|
||||
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// Не начинаем перетаскивание если кликнули на кнопку
|
||||
if ((e.target as HTMLElement).closest('.floating-toolbar-btn')) return;
|
||||
if ((e.target as HTMLElement).closest(".floating-toolbar-btn")) return;
|
||||
|
||||
if (!toolbarRef.current) return;
|
||||
setIsDragging(true);
|
||||
@ -114,16 +152,16 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
// Обработчики для document чтобы отслеживать mouseMove и mouseUp даже вне элемента
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
} else {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
@ -196,7 +234,8 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
}
|
||||
|
||||
// Удаляем выделенный текст
|
||||
const newValue = textarea.value.substring(0, start) + textarea.value.substring(end);
|
||||
const newValue =
|
||||
textarea.value.substring(0, start) + textarea.value.substring(end);
|
||||
|
||||
// Обновляем значение через React-совместимое событие
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
@ -273,7 +312,12 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
zIndex: 1000,
|
||||
cursor: isDragging ? 'grabbing' : (toolbarRef.current && toolbarRef.current.scrollWidth > toolbarRef.current.clientWidth ? 'grab' : 'default'),
|
||||
cursor: isDragging
|
||||
? "grabbing"
|
||||
: toolbarRef.current &&
|
||||
toolbarRef.current.scrollWidth > toolbarRef.current.clientWidth
|
||||
? "grab"
|
||||
: "default",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Предотвращаем потерю выделения при клике на toolbar
|
||||
@ -325,7 +369,9 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
<div className="floating-toolbar-separator" />
|
||||
|
||||
<button
|
||||
className={`floating-toolbar-btn ${activeFormats.bold ? "active" : ""}`}
|
||||
className={`floating-toolbar-btn ${
|
||||
activeFormats.bold ? "active" : ""
|
||||
}`}
|
||||
onClick={() => handleFormat("**", "**")}
|
||||
title="Жирный"
|
||||
>
|
||||
@ -367,22 +413,6 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
>
|
||||
<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>
|
||||
|
||||
@ -9,6 +9,7 @@ interface MarkdownToolbarProps {
|
||||
onFileClick?: () => void;
|
||||
onPreviewToggle?: () => void;
|
||||
isPreviewMode?: boolean;
|
||||
onInsertColor?: () => void;
|
||||
}
|
||||
|
||||
export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
|
||||
@ -17,6 +18,7 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
|
||||
onFileClick,
|
||||
onPreviewToggle,
|
||||
isPreviewMode,
|
||||
onInsertColor,
|
||||
}) => {
|
||||
const [showHeaderDropdown, setShowHeaderDropdown] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
@ -208,6 +210,46 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
|
||||
<Icon icon="mdi:format-list-numbered" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btnMarkdown"
|
||||
onClick={() => onInsert("**", "**")}
|
||||
title="Жирный"
|
||||
>
|
||||
<Icon icon="mdi:format-bold" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btnMarkdown"
|
||||
onClick={() => onInsert("*", "*")}
|
||||
title="Курсив"
|
||||
>
|
||||
<Icon icon="mdi:format-italic" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btnMarkdown"
|
||||
onClick={() => onInsert("~~", "~~")}
|
||||
title="Зачеркнутый"
|
||||
>
|
||||
<Icon icon="mdi:format-strikethrough" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btnMarkdown"
|
||||
onClick={() => onInsertColor?.()}
|
||||
title="Цвет текста"
|
||||
>
|
||||
<Icon icon="mdi:palette" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btnMarkdown"
|
||||
onClick={() => onInsert("||", "||")}
|
||||
title="Скрытый текст"
|
||||
>
|
||||
<Icon icon="mdi:eye-off" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btnMarkdown"
|
||||
onClick={() => onInsert("> ", "")}
|
||||
|
||||
@ -328,7 +328,9 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
}
|
||||
} else {
|
||||
// Проверяем, является ли это форматированием списка или цитаты
|
||||
const isListFormatting = /^[-*+]\s|^\d+\.\s|^- \[ \]\s|^>\s/.test(before);
|
||||
const isListFormatting = /^[-*+]\s|^\d+\.\s|^- \[ \]\s|^>\s/.test(
|
||||
before
|
||||
);
|
||||
const isMultiline = selectedText.includes("\n");
|
||||
|
||||
if (isListFormatting && isMultiline) {
|
||||
@ -597,13 +599,6 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
const end = textarea.selectionEnd;
|
||||
const hasSelection = start !== end;
|
||||
|
||||
// Используем середину выделения или позицию курсора для позиционирования
|
||||
const position = hasSelection ? Math.floor((start + end) / 2) : start;
|
||||
const text = textarea.value.substring(0, position);
|
||||
const lines = text.split("\n");
|
||||
const lineNumber = lines.length - 1;
|
||||
const currentLineText = lines[lines.length - 1];
|
||||
|
||||
// Получаем размеры textarea
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const styles = window.getComputedStyle(textarea);
|
||||
@ -611,25 +606,80 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
const paddingTop = parseInt(styles.paddingTop) || 0;
|
||||
const paddingLeft = parseInt(styles.paddingLeft) || 0;
|
||||
const fontSize = parseInt(styles.fontSize) || 14;
|
||||
const scrollTop = textarea.scrollTop;
|
||||
|
||||
// Более точный расчет ширины символа
|
||||
// Создаем временный элемент для измерения
|
||||
// Вычисляем координаты начала выделения
|
||||
const textBeforeStart = textarea.value.substring(0, start);
|
||||
const linesBeforeStart = textBeforeStart.split("\n");
|
||||
const startLineNumber = linesBeforeStart.length - 1;
|
||||
const startLineText = linesBeforeStart[startLineNumber];
|
||||
|
||||
// Создаем временный элемент для измерения ширины текста
|
||||
const measureEl = document.createElement("span");
|
||||
measureEl.style.position = "absolute";
|
||||
measureEl.style.visibility = "hidden";
|
||||
measureEl.style.whiteSpace = "pre";
|
||||
measureEl.style.font = styles.font;
|
||||
measureEl.textContent = currentLineText;
|
||||
document.body.appendChild(measureEl);
|
||||
const textWidth = measureEl.offsetWidth;
|
||||
|
||||
measureEl.textContent = startLineText;
|
||||
const startTextWidth = measureEl.offsetWidth;
|
||||
|
||||
// Вычисляем координаты конца выделения
|
||||
const textBeforeEnd = textarea.value.substring(0, end);
|
||||
const linesBeforeEnd = textBeforeEnd.split("\n");
|
||||
const endLineNumber = linesBeforeEnd.length - 1;
|
||||
const endLineText = linesBeforeEnd[endLineNumber];
|
||||
|
||||
measureEl.textContent = endLineText;
|
||||
const endTextWidth = measureEl.offsetWidth;
|
||||
|
||||
document.body.removeChild(measureEl);
|
||||
|
||||
// Вычисляем позицию (середина выделения или позиция курсора)
|
||||
const top =
|
||||
rect.top + paddingTop + lineNumber * lineHeight + lineHeight / 2;
|
||||
const left = rect.left + paddingLeft + textWidth;
|
||||
// Вычисляем вертикальные координаты
|
||||
const startTop =
|
||||
rect.top + paddingTop + startLineNumber * lineHeight - scrollTop;
|
||||
const endTop =
|
||||
rect.top + paddingTop + endLineNumber * lineHeight - scrollTop;
|
||||
|
||||
return { top, left, hasSelection };
|
||||
// Горизонтальные координаты
|
||||
const startLeft = rect.left + paddingLeft + startTextWidth;
|
||||
const endLeft = rect.left + paddingLeft + endTextWidth;
|
||||
|
||||
// Если есть выделение, используем его границы
|
||||
if (hasSelection) {
|
||||
// Вычисляем середину выделения по горизонтали
|
||||
const left = Math.min(startLeft, endLeft);
|
||||
const right = Math.max(startLeft, endLeft);
|
||||
const centerLeft = (left + right) / 2;
|
||||
|
||||
// Вертикальная позиция - середина выделения
|
||||
const top = (startTop + endTop) / 2;
|
||||
|
||||
return {
|
||||
top,
|
||||
left: centerLeft,
|
||||
hasSelection,
|
||||
selectionTop: Math.min(startTop, endTop),
|
||||
selectionBottom: Math.max(startTop, endTop) + lineHeight,
|
||||
selectionLeft: left,
|
||||
selectionRight: right,
|
||||
};
|
||||
} else {
|
||||
// Для курсора без выделения
|
||||
const top = startTop;
|
||||
const left = startLeft;
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
hasSelection,
|
||||
selectionTop: top,
|
||||
selectionBottom: top + lineHeight,
|
||||
selectionLeft: left,
|
||||
selectionRight: left,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Обработчик выделения текста
|
||||
@ -857,6 +907,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
onInsert={insertMarkdown}
|
||||
onImageClick={handleImageButtonClick}
|
||||
onFileClick={handleFileButtonClick}
|
||||
onInsertColor={insertColorMarkdown}
|
||||
/>
|
||||
|
||||
<input
|
||||
|
||||
@ -4410,7 +4410,11 @@ textarea:focus {
|
||||
|
||||
/* Стили для скрытого текста (спойлеров) */
|
||||
.spoiler {
|
||||
background: linear-gradient(45deg, rgba(var(--accent-color-rgb), 0.15), rgba(var(--accent-color-rgb), 0.25));
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
rgba(var(--accent-color-rgb), 0.15),
|
||||
rgba(var(--accent-color-rgb), 0.25)
|
||||
);
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
@ -4445,7 +4449,11 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.spoiler:hover {
|
||||
background: linear-gradient(45deg, rgba(var(--accent-color-rgb), 0.25), rgba(var(--accent-color-rgb), 0.35));
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
rgba(var(--accent-color-rgb), 0.25),
|
||||
rgba(var(--accent-color-rgb), 0.35)
|
||||
);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 2px 8px rgba(var(--accent-color-rgb), 0.25);
|
||||
border-color: rgba(var(--accent-color-rgb), 0.4);
|
||||
@ -4456,7 +4464,11 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.spoiler.revealed {
|
||||
background: linear-gradient(45deg, rgba(var(--accent-color-rgb), 0.15), rgba(var(--accent-color-rgb), 0.2));
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
rgba(var(--accent-color-rgb), 0.15),
|
||||
rgba(var(--accent-color-rgb), 0.2)
|
||||
);
|
||||
color: var(--accent-color);
|
||||
border-color: rgba(var(--accent-color-rgb), 0.4);
|
||||
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.25);
|
||||
@ -4471,7 +4483,11 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.spoiler.revealed:hover {
|
||||
background: linear-gradient(45deg, rgba(var(--accent-color-rgb), 0.2), rgba(var(--accent-color-rgb), 0.25));
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
rgba(var(--accent-color-rgb), 0.2),
|
||||
rgba(var(--accent-color-rgb), 0.25)
|
||||
);
|
||||
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.35);
|
||||
}
|
||||
|
||||
@ -5086,3 +5102,44 @@ textarea:focus {
|
||||
[data-theme="dark"] .loading-content {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Адаптивность для плавающей панели */
|
||||
@media (max-width: 768px) {
|
||||
.floating-toolbar {
|
||||
padding: 8px;
|
||||
gap: 6px;
|
||||
border-radius: 12px;
|
||||
/* Увеличиваем размер для удобства на touch-устройствах */
|
||||
}
|
||||
|
||||
.floating-toolbar-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.floating-toolbar-btn .iconify {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Улучшаем прокрутку на мобильных */
|
||||
.floating-toolbar-wrapper {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация появления снизу для лучшего UX на мобильных */
|
||||
@media (max-width: 768px) {
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user