Compare commits

..

No commits in common. "80c42f8df01a1d4f5512d9109796ed391d528cbb" and "e3b98ea8d3dccf1158e17356f1cefbb620bd030d" have entirely different histories.

11 changed files with 247 additions and 524 deletions

View File

@ -502,28 +502,6 @@ function runMigrations() {
); );
} }
// Проверяем существование колонки floating_toolbar_enabled
const hasFloatingToolbarEnabled = columns.some(
(col) => col.name === "floating_toolbar_enabled"
);
if (!hasFloatingToolbarEnabled) {
db.run(
"ALTER TABLE users ADD COLUMN floating_toolbar_enabled INTEGER DEFAULT 1",
(err) => {
if (err) {
console.error(
"Ошибка добавления колонки floating_toolbar_enabled:",
err.message
);
} else {
console.log(
"Колонка floating_toolbar_enabled добавлена в таблицу users"
);
}
}
);
}
// Проверяем существование колонки ai_enabled // Проверяем существование колонки ai_enabled
const hasAiEnabled = columns.some((col) => col.name === "ai_enabled"); const hasAiEnabled = columns.some((col) => col.name === "ai_enabled");
if (!hasAiEnabled) { if (!hasAiEnabled) {
@ -793,7 +771,7 @@ app.get("/api/user", requireApiAuth, (req, res) => {
} }
const sql = const sql =
"SELECT username, email, avatar, accent_color, show_edit_date, colored_icons, floating_toolbar_enabled FROM users WHERE id = ?"; "SELECT username, email, avatar, accent_color, show_edit_date, colored_icons FROM users WHERE id = ?";
db.get(sql, [req.session.userId], (err, user) => { db.get(sql, [req.session.userId], (err, user) => {
if (err) { if (err) {
console.error("Ошибка получения данных пользователя:", err.message); console.error("Ошибка получения данных пользователя:", err.message);
@ -1622,7 +1600,6 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => {
accent_color, accent_color,
show_edit_date, show_edit_date,
colored_icons, colored_icons,
floating_toolbar_enabled,
} = req.body; } = req.body;
const userId = req.session.userId; const userId = req.session.userId;
@ -1698,11 +1675,6 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => {
params.push(colored_icons ? 1 : 0); params.push(colored_icons ? 1 : 0);
} }
if (floating_toolbar_enabled !== undefined) {
updateFields.push("floating_toolbar_enabled = ?");
params.push(floating_toolbar_enabled ? 1 : 0);
}
if (newPassword) { if (newPassword) {
const hashedPassword = await bcrypt.hash(newPassword, 10); const hashedPassword = await bcrypt.hash(newPassword, 10);
updateFields.push("password = ?"); updateFields.push("password = ?");

View File

@ -82,7 +82,7 @@ define(['./workbox-9dc17825'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.2vg2p27g3bg" "revision": "0.nsn25edhihg"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -14,7 +14,6 @@ export const userApi = {
accent_color?: string; accent_color?: string;
show_edit_date?: boolean; show_edit_date?: boolean;
colored_icons?: boolean; colored_icons?: boolean;
floating_toolbar_enabled?: boolean;
} }
) => { ) => {
const { data } = await axiosClient.put("/user/profile", profile); const { data } = await axiosClient.put("/user/profile", profile);

View File

@ -5,14 +5,7 @@ interface FloatingToolbarProps {
textareaRef: React.RefObject<HTMLTextAreaElement>; textareaRef: React.RefObject<HTMLTextAreaElement>;
onFormat: (before: string, after: string) => void; onFormat: (before: string, after: string) => void;
visible: boolean; visible: boolean;
position: { position: { top: number; left: number };
top: number;
left: number;
selectionTop?: number;
selectionBottom?: number;
selectionLeft?: number;
selectionRight?: number;
};
onHide?: () => void; onHide?: () => void;
onInsertColor?: () => void; onInsertColor?: () => void;
activeFormats?: { activeFormats?: {
@ -20,7 +13,7 @@ interface FloatingToolbarProps {
italic?: boolean; italic?: boolean;
strikethrough?: boolean; strikethrough?: boolean;
}; };
hasSelection?: boolean; hasSelection?: boolean; // Есть ли выделение текста
} }
export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
@ -40,85 +33,53 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
useEffect(() => { useEffect(() => {
if (visible && toolbarRef.current) { if (visible && toolbarRef.current) {
// Небольшая задержка для корректного расчета размеров после рендера
setTimeout(() => { setTimeout(() => {
if (!toolbarRef.current) return; if (!toolbarRef.current) return;
const toolbar = toolbarRef.current; const toolbar = toolbarRef.current;
const rect = toolbar.getBoundingClientRect(); const rect = toolbar.getBoundingClientRect();
const windowWidth = window.innerWidth; const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
const padding = 10; const padding = 10;
const offset = 8; // Отступ от выделенного текста
// Получаем внутренний контейнер с кнопками // Получаем внутренний контейнер с кнопками для определения реальной ширины
const toolbarContent = toolbar.querySelector( const toolbarContent = toolbar.querySelector('.floating-toolbar') as HTMLElement;
".floating-toolbar" const toolbarContentWidth = toolbarContent ? toolbarContent.scrollWidth : rect.width;
) as HTMLElement;
const toolbarContentWidth = toolbarContent
? toolbarContent.scrollWidth
: rect.width;
const availableWidth = windowWidth - padding * 2; const availableWidth = windowWidth - padding * 2;
const toolbarHeight = rect.height;
// Используем границы выделения, если они есть let top = position.top - rect.height - padding;
const selectionTop = position.selectionTop ?? position.top; let left = position.left;
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 // Если контент шире доступного пространства, устанавливаем maxWidth для wrapper
if (toolbarContentWidth > availableWidth) { if (toolbarContentWidth > availableWidth) {
toolbar.style.maxWidth = `${availableWidth}px`; toolbar.style.maxWidth = `${availableWidth}px`;
left = padding; // Выравниваем по левому краю }
} else {
// Проверяем границы экрана // Если toolbar выходит за правую границу экрана
if (left + toolbarContentWidth > windowWidth - padding) { if (left + rect.width > windowWidth - padding) {
// Выравниваем по правому краю // Если контент шире экрана, используем прокрутку и позиционируем по левому краю
left = Math.max( if (toolbarContentWidth > availableWidth) {
padding,
windowWidth - toolbarContentWidth - padding
);
}
if (left < padding) {
left = padding; left = padding;
} else {
// Иначе выравниваем по правому краю
left = Math.max(padding, windowWidth - rect.width - padding);
} }
} }
// Финальная проверка вертикальных границ // Если toolbar выходит за левую границу экрана
if (top < padding) { if (left < padding) {
top = padding; left = padding;
} }
if (top + toolbarHeight > windowHeight - padding) {
top = windowHeight - toolbarHeight - 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.top = `${top}px`;
@ -127,9 +88,10 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
} }
}, [visible, position]); }, [visible, position]);
const handleMouseDown = (e: React.MouseEvent) => { 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; if (!toolbarRef.current) return;
setIsDragging(true); setIsDragging(true);
@ -152,16 +114,16 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
// Обработчики для document чтобы отслеживать mouseMove и mouseUp даже вне элемента // Обработчики для document чтобы отслеживать mouseMove и mouseUp даже вне элемента
useEffect(() => { useEffect(() => {
if (isDragging) { if (isDragging) {
document.addEventListener("mousemove", handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
} else { } else {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
} }
return () => { return () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
}, [isDragging]); }, [isDragging]);
@ -187,11 +149,11 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
if (start === end) return; // Нет выделения if (start === end) return; // Нет выделения
const selectedText = textarea.value.substring(start, end); const selectedText = textarea.value.substring(start, end);
try { try {
await navigator.clipboard.writeText(selectedText); await navigator.clipboard.writeText(selectedText);
// Можно добавить уведомление об успешном копировании // Можно добавить уведомление об успешном копировании
@ -214,11 +176,11 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
if (start === end) return; // Нет выделения if (start === end) return; // Нет выделения
const selectedText = textarea.value.substring(start, end); const selectedText = textarea.value.substring(start, end);
try { try {
await navigator.clipboard.writeText(selectedText); await navigator.clipboard.writeText(selectedText);
} catch (err) { } catch (err) {
@ -232,17 +194,16 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(textArea); document.body.removeChild(textArea);
} }
// Удаляем выделенный текст // Удаляем выделенный текст
const newValue = const newValue = textarea.value.substring(0, start) + textarea.value.substring(end);
textarea.value.substring(0, start) + textarea.value.substring(end);
// Обновляем значение через React-совместимое событие // Обновляем значение через React-совместимое событие
const nativeInputValueSetter = Object.getOwnPropertyDescriptor( const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, window.HTMLTextAreaElement.prototype,
"value" "value"
)?.set; )?.set;
if (nativeInputValueSetter) { if (nativeInputValueSetter) {
nativeInputValueSetter.call(textarea, newValue); nativeInputValueSetter.call(textarea, newValue);
const inputEvent = new Event("input", { bubbles: true }); const inputEvent = new Event("input", { bubbles: true });
@ -252,7 +213,7 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
const inputEvent = new Event("input", { bubbles: true }); const inputEvent = new Event("input", { bubbles: true });
textarea.dispatchEvent(inputEvent); textarea.dispatchEvent(inputEvent);
} }
textarea.setSelectionRange(start, start); textarea.setSelectionRange(start, start);
textarea.focus(); textarea.focus();
}; };
@ -266,19 +227,19 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
// Вставляем текст в позицию курсора или заменяем выделенный текст // Вставляем текст в позицию курсора или заменяем выделенный текст
const newValue = const newValue =
textarea.value.substring(0, start) + textarea.value.substring(0, start) +
text + text +
textarea.value.substring(end); textarea.value.substring(end);
// Обновляем значение через React-совместимое событие // Обновляем значение через React-совместимое событие
const nativeInputValueSetter = Object.getOwnPropertyDescriptor( const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, window.HTMLTextAreaElement.prototype,
"value" "value"
)?.set; )?.set;
if (nativeInputValueSetter) { if (nativeInputValueSetter) {
nativeInputValueSetter.call(textarea, newValue); nativeInputValueSetter.call(textarea, newValue);
const inputEvent = new Event("input", { bubbles: true }); const inputEvent = new Event("input", { bubbles: true });
@ -288,7 +249,7 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
const inputEvent = new Event("input", { bubbles: true }); const inputEvent = new Event("input", { bubbles: true });
textarea.dispatchEvent(inputEvent); textarea.dispatchEvent(inputEvent);
} }
// Устанавливаем курсор после вставленного текста // Устанавливаем курсор после вставленного текста
const newCursorPos = start + text.length; const newCursorPos = start + text.length;
textarea.setSelectionRange(newCursorPos, newCursorPos); textarea.setSelectionRange(newCursorPos, newCursorPos);
@ -312,12 +273,7 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
top: `${position.top}px`, top: `${position.top}px`,
left: `${position.left}px`, left: `${position.left}px`,
zIndex: 1000, zIndex: 1000,
cursor: isDragging cursor: isDragging ? 'grabbing' : (toolbarRef.current && toolbarRef.current.scrollWidth > toolbarRef.current.clientWidth ? 'grab' : 'default'),
? "grabbing"
: toolbarRef.current &&
toolbarRef.current.scrollWidth > toolbarRef.current.clientWidth
? "grab"
: "default",
}} }}
onMouseDown={(e) => { onMouseDown={(e) => {
// Предотвращаем потерю выделения при клике на toolbar // Предотвращаем потерю выделения при клике на toolbar
@ -338,83 +294,97 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
<Icon icon="mdi:close" /> <Icon icon="mdi:close" />
</button> </button>
{hasSelection && ( {hasSelection && (
<> <>
<button <button
className="floating-toolbar-btn" className="floating-toolbar-btn"
onClick={handleCopy} onClick={handleCopy}
title="Копировать" title="Копировать"
> >
<Icon icon="mdi:content-copy" /> <Icon icon="mdi:content-copy" />
</button> </button>
<button <button
className="floating-toolbar-btn" className="floating-toolbar-btn"
onClick={handleCut} onClick={handleCut}
title="Вырезать" title="Вырезать"
> >
<Icon icon="mdi:content-cut" /> <Icon icon="mdi:content-cut" />
</button> </button>
<button <button
className="floating-toolbar-btn" className="floating-toolbar-btn"
onClick={handlePaste} onClick={handlePaste}
title="Вставить" title="Вставить"
> >
<Icon icon="mdi:content-paste" /> <Icon icon="mdi:content-paste" />
</button> </button>
</> </>
)} )}
{hasSelection && ( {hasSelection && (
<> <>
<div className="floating-toolbar-separator" /> <div className="floating-toolbar-separator" />
<button <button
className={`floating-toolbar-btn ${ className={`floating-toolbar-btn ${activeFormats.bold ? "active" : ""}`}
activeFormats.bold ? "active" : "" onClick={() => handleFormat("**", "**")}
}`} title="Жирный"
onClick={() => handleFormat("**", "**")} >
title="Жирный" <Icon icon="mdi:format-bold" />
> </button>
<Icon icon="mdi:format-bold" /> <button
</button> className={`floating-toolbar-btn ${
<button activeFormats.italic ? "active" : ""
className={`floating-toolbar-btn ${ }`}
activeFormats.italic ? "active" : "" onClick={() => handleFormat("*", "*")}
}`} title="Курсив"
onClick={() => handleFormat("*", "*")} >
title="Курсив" <Icon icon="mdi:format-italic" />
> </button>
<Icon icon="mdi:format-italic" /> <button
</button> className={`floating-toolbar-btn ${
<button activeFormats.strikethrough ? "active" : ""
className={`floating-toolbar-btn ${ }`}
activeFormats.strikethrough ? "active" : "" onClick={() => handleFormat("~~", "~~")}
}`} title="Зачеркнутый"
onClick={() => handleFormat("~~", "~~")} >
title="Зачеркнутый" <Icon icon="mdi:format-strikethrough" />
> </button>
<Icon icon="mdi:format-strikethrough" />
</button>
<div className="floating-toolbar-separator" /> <div className="floating-toolbar-separator" />
<button <button
className="floating-toolbar-btn" className="floating-toolbar-btn"
onClick={() => onInsertColor?.()} onClick={() => onInsertColor?.()}
title="Цвет текста" title="Цвет текста"
> >
<Icon icon="mdi:palette" /> <Icon icon="mdi:palette" />
</button> </button>
<button <button
className="floating-toolbar-btn" className="floating-toolbar-btn"
onClick={() => handleFormat("||", "||")} onClick={() => handleFormat("||", "||")}
title="Скрытый текст" title="Скрытый текст"
> >
<Icon icon="mdi:eye-off" /> <Icon icon="mdi:eye-off" />
</button> </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>
</div> </div>
); );

View File

@ -9,7 +9,6 @@ interface MarkdownToolbarProps {
onFileClick?: () => void; onFileClick?: () => void;
onPreviewToggle?: () => void; onPreviewToggle?: () => void;
isPreviewMode?: boolean; isPreviewMode?: boolean;
onInsertColor?: () => void;
} }
export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
@ -18,7 +17,6 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
onFileClick, onFileClick,
onPreviewToggle, onPreviewToggle,
isPreviewMode, isPreviewMode,
onInsertColor,
}) => { }) => {
const [showHeaderDropdown, setShowHeaderDropdown] = useState(false); const [showHeaderDropdown, setShowHeaderDropdown] = useState(false);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -210,46 +208,6 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
<Icon icon="mdi:format-list-numbered" /> <Icon icon="mdi:format-list-numbered" />
</button> </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 <button
className="btnMarkdown" className="btnMarkdown"
onClick={() => onInsert("> ", "")} onClick={() => onInsert("> ", "")}

View File

@ -4,7 +4,7 @@ import { FloatingToolbar } from "./FloatingToolbar";
import { NotePreview } from "./NotePreview"; import { NotePreview } from "./NotePreview";
import { ImageUpload } from "./ImageUpload"; import { ImageUpload } from "./ImageUpload";
import { FileUpload } from "./FileUpload"; import { FileUpload } from "./FileUpload";
import { useAppSelector } from "../../store/hooks"; import { useAppSelector, useAppDispatch } from "../../store/hooks";
import { useNotification } from "../../hooks/useNotification"; import { useNotification } from "../../hooks/useNotification";
import { offlineNotesApi } from "../../api/offlineNotesApi"; import { offlineNotesApi } from "../../api/offlineNotesApi";
import { aiApi } from "../../api/aiApi"; import { aiApi } from "../../api/aiApi";
@ -31,13 +31,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode); const isPreviewMode = useAppSelector((state) => state.ui.isPreviewMode);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const aiEnabled = useAppSelector((state) => state.profile.aiEnabled); const aiEnabled = useAppSelector((state) => state.profile.aiEnabled);
const user = useAppSelector((state) => state.profile.user); const dispatch = useAppDispatch();
// Проверяем, включена ли плавающая панель
const floatingToolbarEnabled =
user?.floating_toolbar_enabled !== undefined
? user.floating_toolbar_enabled === 1
: true;
const handleSave = async () => { const handleSave = async () => {
if (!content.trim()) { if (!content.trim()) {
@ -334,9 +328,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
} }
} else { } else {
// Проверяем, является ли это форматированием списка или цитаты // Проверяем, является ли это форматированием списка или цитаты
const isListFormatting = /^[-*+]\s|^\d+\.\s|^- \[ \]\s|^>\s/.test( const isListFormatting = /^[-*+]\s|^\d+\.\s|^- \[ \]\s|^>\s/.test(before);
before
);
const isMultiline = selectedText.includes("\n"); const isMultiline = selectedText.includes("\n");
if (isListFormatting && isMultiline) { if (isListFormatting && isMultiline) {
@ -349,7 +341,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
const trimmedLine = line.trim(); const trimmedLine = line.trim();
// Пропускаем пустые строки // Пропускаем пустые строки
if (trimmedLine === "") { if (trimmedLine === "") {
processedLines.push(line); processedLines.push(line);
@ -605,6 +597,13 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
const hasSelection = start !== end; 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 // Получаем размеры textarea
const rect = textarea.getBoundingClientRect(); const rect = textarea.getBoundingClientRect();
const styles = window.getComputedStyle(textarea); const styles = window.getComputedStyle(textarea);
@ -612,85 +611,30 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const paddingTop = parseInt(styles.paddingTop) || 0; const paddingTop = parseInt(styles.paddingTop) || 0;
const paddingLeft = parseInt(styles.paddingLeft) || 0; const paddingLeft = parseInt(styles.paddingLeft) || 0;
const fontSize = parseInt(styles.fontSize) || 14; 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"); const measureEl = document.createElement("span");
measureEl.style.position = "absolute"; measureEl.style.position = "absolute";
measureEl.style.visibility = "hidden"; measureEl.style.visibility = "hidden";
measureEl.style.whiteSpace = "pre"; measureEl.style.whiteSpace = "pre";
measureEl.style.font = styles.font; measureEl.style.font = styles.font;
measureEl.textContent = currentLineText;
document.body.appendChild(measureEl); 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); document.body.removeChild(measureEl);
// Вычисляем вертикальные координаты // Вычисляем позицию (середина выделения или позиция курсора)
const startTop = const top =
rect.top + paddingTop + startLineNumber * lineHeight - scrollTop; rect.top + paddingTop + lineNumber * lineHeight + lineHeight / 2;
const endTop = const left = rect.left + paddingLeft + textWidth;
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,
};
}
}, []); }, []);
// Обработчик выделения текста // Обработчик выделения текста
const handleSelection = useCallback(() => { const handleSelection = useCallback(() => {
if (isPreviewMode || !floatingToolbarEnabled) { if (isPreviewMode) {
setShowFloatingToolbar(false); setShowFloatingToolbar(false);
return; return;
} }
@ -715,13 +659,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
setHasSelection(false); setHasSelection(false);
setActiveFormats({ bold: false, italic: false, strikethrough: false }); setActiveFormats({ bold: false, italic: false, strikethrough: false });
} }
}, [ }, [isPreviewMode, content, getCursorPosition, getActiveFormats]);
isPreviewMode,
content,
getCursorPosition,
getActiveFormats,
floatingToolbarEnabled,
]);
// Отслеживание выделения текста // Отслеживание выделения текста
useEffect(() => { useEffect(() => {
@ -919,7 +857,6 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
onInsert={insertMarkdown} onInsert={insertMarkdown}
onImageClick={handleImageButtonClick} onImageClick={handleImageButtonClick}
onFileClick={handleFileButtonClick} onFileClick={handleFileButtonClick}
onInsertColor={insertColorMarkdown}
/> />
<input <input
@ -967,18 +904,16 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
} }
}} }}
/> />
{floatingToolbarEnabled && ( <FloatingToolbar
<FloatingToolbar textareaRef={textareaRef}
textareaRef={textareaRef} onFormat={insertMarkdown}
onFormat={insertMarkdown} visible={showFloatingToolbar}
visible={showFloatingToolbar} position={toolbarPosition}
position={toolbarPosition} onHide={() => setShowFloatingToolbar(false)}
onHide={() => setShowFloatingToolbar(false)} onInsertColor={insertColorMarkdown}
onInsertColor={insertColorMarkdown} activeFormats={activeFormats}
activeFormats={activeFormats} hasSelection={hasSelection}
hasSelection={hasSelection} />
/>
)}
</> </>
)} )}

View File

@ -67,12 +67,6 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useMarkdown({ onNoteUpdate: onReload }); // Инициализируем обработчики спойлеров, внешних ссылок и чекбоксов useMarkdown({ onNoteUpdate: onReload }); // Инициализируем обработчики спойлеров, внешних ссылок и чекбоксов
// Проверяем, включена ли плавающая панель
const floatingToolbarEnabled =
user?.floating_toolbar_enabled !== undefined
? user.floating_toolbar_enabled === 1
: true;
const handleEdit = () => { const handleEdit = () => {
setIsEditing(true); setIsEditing(true);
@ -600,7 +594,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
// Обработчик выделения текста // Обработчик выделения текста
const handleSelection = useCallback(() => { const handleSelection = useCallback(() => {
if (localPreviewMode || !floatingToolbarEnabled) { if (localPreviewMode) {
setShowFloatingToolbar(false); setShowFloatingToolbar(false);
return; return;
} }
@ -625,7 +619,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
setHasSelection(false); setHasSelection(false);
setActiveFormats({ bold: false, italic: false, strikethrough: false }); setActiveFormats({ bold: false, italic: false, strikethrough: false });
} }
}, [localPreviewMode, editContent, getCursorPosition, getActiveFormats, floatingToolbarEnabled]); }, [localPreviewMode, editContent, getCursorPosition, getActiveFormats]);
const handleImageButtonClick = () => { const handleImageButtonClick = () => {
imageInputRef.current?.click(); imageInputRef.current?.click();
@ -1227,18 +1221,16 @@ export const NoteItem: React.FC<NoteItemProps> = ({
} }
}} }}
/> />
{floatingToolbarEnabled && ( <FloatingToolbar
<FloatingToolbar textareaRef={editTextareaRef}
textareaRef={editTextareaRef} onFormat={insertMarkdown}
onFormat={insertMarkdown} visible={showFloatingToolbar}
visible={showFloatingToolbar} position={toolbarPosition}
position={toolbarPosition} onHide={() => setShowFloatingToolbar(false)}
onHide={() => setShowFloatingToolbar(false)} onInsertColor={insertColorMarkdown}
onInsertColor={insertColorMarkdown} activeFormats={activeFormats}
activeFormats={activeFormats} hasSelection={hasSelection}
hasSelection={hasSelection} />
/>
)}
</> </>
)} )}

View File

@ -46,7 +46,6 @@ const SettingsPage: React.FC = () => {
const [selectedAccentColor, setSelectedAccentColor] = useState("#007bff"); const [selectedAccentColor, setSelectedAccentColor] = useState("#007bff");
const [showEditDate, setShowEditDate] = useState(true); const [showEditDate, setShowEditDate] = useState(true);
const [coloredIcons, setColoredIcons] = useState(true); const [coloredIcons, setColoredIcons] = useState(true);
const [floatingToolbarEnabled, setFloatingToolbarEnabled] = useState(true);
// AI settings // AI settings
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
@ -135,11 +134,6 @@ const SettingsPage: React.FC = () => {
: true; : true;
setColoredIcons(coloredIconsValue); setColoredIcons(coloredIconsValue);
updateColoredIconsClass(coloredIconsValue); updateColoredIconsClass(coloredIconsValue);
const floatingToolbarValue =
userData.floating_toolbar_enabled !== undefined
? userData.floating_toolbar_enabled === 1
: true;
setFloatingToolbarEnabled(floatingToolbarValue);
// Загружаем AI настройки // Загружаем AI настройки
try { try {
@ -172,7 +166,6 @@ const SettingsPage: React.FC = () => {
accent_color: selectedAccentColor, accent_color: selectedAccentColor,
show_edit_date: showEditDate, show_edit_date: showEditDate,
colored_icons: coloredIcons, colored_icons: coloredIcons,
floating_toolbar_enabled: floatingToolbarEnabled,
}); });
dispatch(setAccentColorAction(selectedAccentColor)); dispatch(setAccentColorAction(selectedAccentColor));
setAccentColor(selectedAccentColor); setAccentColor(selectedAccentColor);
@ -679,31 +672,6 @@ const SettingsPage: React.FC = () => {
</label> </label>
</div> </div>
<div className="form-group ai-toggle-group">
<label className="ai-toggle-label">
<div className="toggle-label-content">
<span className="toggle-text-main">
Плавающая панель редактирования
</span>
<span className="toggle-text-desc">
{floatingToolbarEnabled
? "Показывать плавающую панель инструментов при выделении текста в редакторе"
: "Скрывать плавающую панель инструментов при выделении текста"}
</span>
</div>
<div className="toggle-switch-wrapper">
<input
type="checkbox"
id="floating-toolbar-toggle"
className="toggle-checkbox"
checked={floatingToolbarEnabled}
onChange={(e) => setFloatingToolbarEnabled(e.target.checked)}
/>
<span className="toggle-slider"></span>
</div>
</label>
</div>
<button className="btnSave" onClick={handleUpdateAppearance}> <button className="btnSave" onClick={handleUpdateAppearance}>
Сохранить изменения Сохранить изменения
</button> </button>

View File

@ -671,8 +671,7 @@ header {
/* Анимация пульсации для offline индикатора */ /* Анимация пульсации для offline индикатора */
@keyframes pulse { @keyframes pulse {
0%, 0%, 100% {
100% {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
} }
@ -4410,11 +4409,7 @@ textarea:focus {
/* Стили для скрытого текста (спойлеров) */ /* Стили для скрытого текста (спойлеров) */
.spoiler { .spoiler {
background: linear-gradient( background: linear-gradient(45deg, #f0f0f0, #e8e8e8);
45deg,
rgba(var(--accent-color-rgb), 0.15),
rgba(var(--accent-color-rgb), 0.25)
);
color: transparent; color: transparent;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
@ -4422,15 +4417,11 @@ textarea:focus {
user-select: none; user-select: none;
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative; position: relative;
display: inline-block; border: 1px solid #ddd;
vertical-align: baseline;
border: 1px solid rgba(var(--accent-color-rgb), 0.3);
font-weight: 500; font-weight: 500;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
text-shadow: 0 0 8px rgba(var(--accent-color-rgb), 0.3); text-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
isolation: isolate;
z-index: 0;
} }
.spoiler::before { .spoiler::before {
@ -4440,38 +4431,27 @@ textarea:focus {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(var(--accent-color-rgb), 0.1); background: rgba(255, 255, 255, 0.8);
border-radius: 4px; border-radius: 4px;
filter: blur(1px); filter: blur(1px);
z-index: -1; z-index: -1;
box-sizing: border-box;
pointer-events: none;
} }
.spoiler:hover { .spoiler:hover {
background: linear-gradient( background: linear-gradient(45deg, #e8e8e8, #d8d8d8);
45deg,
rgba(var(--accent-color-rgb), 0.25),
rgba(var(--accent-color-rgb), 0.35)
);
transform: scale(1.02); transform: scale(1.02);
box-shadow: 0 2px 8px rgba(var(--accent-color-rgb), 0.25); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-color: rgba(var(--accent-color-rgb), 0.4);
} }
.spoiler:hover::before { .spoiler:hover::before {
background: rgba(var(--accent-color-rgb), 0.15); background: rgba(255, 255, 255, 0.9);
} }
.spoiler.revealed { .spoiler.revealed {
background: linear-gradient( background: linear-gradient(45deg, #e8f5e8, #d4edda);
45deg, color: #155724;
rgba(var(--accent-color-rgb), 0.15), border-color: #c3e6cb;
rgba(var(--accent-color-rgb), 0.2) box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.25);
);
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);
text-shadow: none; text-shadow: none;
user-select: text; user-select: text;
-webkit-user-select: text; -webkit-user-select: text;
@ -4483,12 +4463,8 @@ textarea:focus {
} }
.spoiler.revealed:hover { .spoiler.revealed:hover {
background: linear-gradient( background: linear-gradient(45deg, #d4edda, #c3e6cb);
45deg, box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.35);
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);
} }
/* Стили для файлов */ /* Стили для файлов */
@ -5102,44 +5078,3 @@ textarea:focus {
[data-theme="dark"] .loading-content { [data-theme="dark"] .loading-content {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); 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);
}
}
}

View File

@ -5,7 +5,6 @@ export interface User {
accent_color: string; accent_color: string;
show_edit_date?: number; show_edit_date?: number;
colored_icons?: number; colored_icons?: number;
floating_toolbar_enabled?: number;
} }
export interface AuthResponse { export interface AuthResponse {

View File

@ -25,54 +25,49 @@ const spoilerExtension = {
// Функция для рендеринга вложенных токенов // Функция для рендеринга вложенных токенов
function renderTokens(tokens: any[], renderer: any): string { function renderTokens(tokens: any[], renderer: any): string {
return tokens return tokens.map((token) => {
.map((token) => { // Используем кастомный renderer если он есть
// Используем кастомный renderer если он есть if (renderer[token.type]) {
if (renderer[token.type]) { return renderer[token.type](token);
return renderer[token.type](token); }
// Fallback для встроенных типов токенов
if (token.type === 'text') {
return token.text || '';
}
if (token.type === 'strong') {
return `<strong>${renderTokens(token.tokens || [], renderer)}</strong>`;
}
if (token.type === 'em') {
return `<em>${renderTokens(token.tokens || [], renderer)}</em>`;
}
if (token.type === 'codespan') {
return `<code>${token.text || ''}</code>`;
}
if (token.type === 'del') {
return `<del>${renderTokens(token.tokens || [], renderer)}</del>`;
}
if (token.type === 'link') {
// Для ссылок используем кастомный renderer если доступен
if (renderer.link) {
return renderer.link(token);
} }
// Fallback для встроенных типов токенов // Fallback для встроенных ссылок
if (token.type === "text") { const href = token.href || '';
return token.text || ""; const title = token.title ? ` title="${token.title}"` : '';
const text = token.tokens && token.tokens.length > 0
? renderTokens(token.tokens, renderer)
: (token.text || '');
return `<a href="${href}"${title}>${text}</a>`;
}
if (token.type === 'spoiler') {
// Для спойлеров используем кастомный renderer если доступен
if (renderer.spoiler) {
return renderer.spoiler(token);
} }
if (token.type === "strong") { return `<span class="spoiler" title="Нажмите, чтобы показать">${token.text || ''}</span>`;
return `<strong>${renderTokens(token.tokens || [], renderer)}</strong>`; }
} return token.text || '';
if (token.type === "em") { }).join('');
return `<em>${renderTokens(token.tokens || [], renderer)}</em>`;
}
if (token.type === "codespan") {
return `<code>${token.text || ""}</code>`;
}
if (token.type === "del") {
return `<del>${renderTokens(token.tokens || [], renderer)}</del>`;
}
if (token.type === "link") {
// Для ссылок используем кастомный renderer если доступен
if (renderer.link) {
return renderer.link(token);
}
// Fallback для встроенных ссылок
const href = token.href || "";
const title = token.title ? ` title="${token.title}"` : "";
const text =
token.tokens && token.tokens.length > 0
? renderTokens(token.tokens, renderer)
: token.text || "";
return `<a href="${href}"${title}>${text}</a>`;
}
if (token.type === "spoiler") {
// Для спойлеров используем кастомный renderer если доступен
if (renderer.spoiler) {
return renderer.spoiler(token);
}
return `<span class="spoiler" title="Нажмите, чтобы показать">${
token.text || ""
}</span>`;
}
return token.text || "";
})
.join("");
} }
// Кастомный renderer для внешних ссылок и чекбоксов // Кастомный renderer для внешних ссылок и чекбоксов
@ -80,9 +75,9 @@ const renderer: any = {
link(token: any) { link(token: any) {
const href = token.href; const href = token.href;
const title = token.title; const title = token.title;
// Правильно обрабатываем вложенные токены для форматирования внутри ссылок // Правильно обрабатываем вложенные токены для форматирования внутри ссылок
let text = ""; let text = '';
if (token.tokens && token.tokens.length > 0) { if (token.tokens && token.tokens.length > 0) {
text = renderTokens(token.tokens, this); text = renderTokens(token.tokens, this);
} else if (token.text) { } else if (token.text) {
@ -106,9 +101,9 @@ const renderer: any = {
listitem(token: any) { listitem(token: any) {
const task = token.task; const task = token.task;
const checked = token.checked; const checked = token.checked;
// Правильно обрабатываем вложенные токены для форматирования // Правильно обрабатываем вложенные токены для форматирования
let content = ""; let content = '';
if (token.tokens && token.tokens.length > 0) { if (token.tokens && token.tokens.length > 0) {
// Рендерим вложенные токены используя наш renderer // Рендерим вложенные токены используя наш renderer
content = renderTokens(token.tokens, this); content = renderTokens(token.tokens, this);
@ -179,7 +174,7 @@ export const extractTags = (content: string): string[] => {
const tag = match[1]; const tag = match[1];
// Проверяем, есть ли уже тег с таким же именем (регистронезависимо) // Проверяем, есть ли уже тег с таким же именем (регистронезависимо)
if (!tags.some((t) => t.toLowerCase() === tag.toLowerCase())) { if (!tags.some(t => t.toLowerCase() === tag.toLowerCase())) {
tags.push(tag); tags.push(tag);
} }
} }