Добавлены функции для управления темой и цветом акцента, улучшена обработка выделения текста в редакторах заметок, добавлены новые стили для темной и светлой тем, а также улучшена логика отображения плавающей панели инструментов. Исправлены ошибки в обработке контекстного меню и добавлены новые функции для копирования, вырезания и вставки текста.

This commit is contained in:
Fovway 2025-10-31 23:29:23 +07:00
parent 3224cffafa
commit dc8f1f53fc
16 changed files with 908 additions and 278 deletions

5
.cursor/rules/rules.mdc Normal file
View File

@ -0,0 +1,5 @@
---
alwaysApply: true
---
Никогда самостоятельно не пытайся запустить сервер. Если это требуется, то попроси это сделать меня!

5
.cursor/worktrees.json Normal file
View File

@ -0,0 +1,5 @@
{
"setup-worktree": [
"npm install"
]
}

View File

@ -3,7 +3,52 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#667eea" />
<title>NoteJS Backend</title> <title>NoteJS Backend</title>
<!-- Предотвращение мерцания темы -->
<script>
(function () {
try {
// Получаем сохраненную тему
const savedTheme = localStorage.getItem("theme");
// Получаем системные предпочтения
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
// Определяем тему: сохраненная или системная
const theme = savedTheme || (systemPrefersDark ? "dark" : "light");
// Устанавливаем тему до загрузки CSS
if (theme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.setAttribute("data-theme", "light");
}
// Получаем и устанавливаем accentColor
const savedAccentColor = localStorage.getItem("accentColor");
const accentColor = savedAccentColor || "#667eea";
// Устанавливаем CSS переменную для accent цвета
document.documentElement.style.setProperty("--accent-color", accentColor);
// Устанавливаем цвет для meta theme-color
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute(
"content",
theme === "dark" ? "#1a1a1a" : accentColor
);
}
} catch (e) {
// В случае ошибки устанавливаем светлую тему по умолчанию
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.style.setProperty("--accent-color", "#667eea");
}
})();
</script>
<style> <style>
* { * {
margin: 0; margin: 0;

View File

@ -12,19 +12,85 @@
<script> <script>
(function () { (function () {
try { try {
// Получаем сохраненную тему
const savedTheme = localStorage.getItem("theme"); const savedTheme = localStorage.getItem("theme");
// Получаем системные предпочтения
const systemPrefersDark = window.matchMedia( const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)" "(prefers-color-scheme: dark)"
).matches; ).matches;
// Определяем тему: сохраненная или системная
const theme = savedTheme || (systemPrefersDark ? "dark" : "light"); const theme = savedTheme || (systemPrefersDark ? "dark" : "light");
// Функция для конвертации hex в RGB
function hexToRgb(hex) {
const cleanHex = hex.replace("#", "");
const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);
return `${r}, ${g}, ${b}`;
}
// Получаем и устанавливаем accentColor
const savedAccentColor = localStorage.getItem("accentColor");
const accentColor = savedAccentColor || "#007bff";
// Устанавливаем тему и переменные до загрузки CSS
if (theme === "dark") { if (theme === "dark") {
document.documentElement.setAttribute("data-theme", "dark"); document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.setAttribute("data-theme", "light");
} }
} catch (e) {}
// Устанавливаем CSS переменные для accent цвета
document.documentElement.style.setProperty("--accent-color", accentColor);
document.documentElement.style.setProperty("--accent-color-rgb", hexToRgb(accentColor));
// Устанавливаем цвет для meta theme-color
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute(
"content",
theme === "dark" ? "#1a1a1a" : accentColor
);
}
} catch (e) {
// В случае ошибки устанавливаем светлую тему по умолчанию
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.style.setProperty("--accent-color", "#007bff");
document.documentElement.style.setProperty("--accent-color-rgb", "0, 123, 255");
}
})(); })();
</script> </script>
<!-- Критические стили темы для предотвращения flash эффекта -->
<style>
:root {
--accent-color: #007bff;
/* Светлая тема (по умолчанию) */
--bg-primary: #f5f5f5;
--text-primary: #333333;
--border-primary: #e0e0e0;
--shadow-light: rgba(0, 0, 0, 0.1);
}
/* Темная тема */
[data-theme="dark"] {
--bg-primary: #1a1a1a;
--text-primary: #ffffff;
--border-primary: #404040;
--shadow-light: rgba(0, 0, 0, 0.3);
}
/* Применяем стили сразу к body для предотвращения flash */
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
</style>
<!-- PWA Meta Tags --> <!-- PWA Meta Tags -->
<meta <meta
name="description" name="description"

View File

@ -9,7 +9,7 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"server": "cd backend && node server.js", "server": "cd backend && node server.js",
"dev:all": "concurrently \"npm run dev\" \"npm run server\"", "dev:all": "concurrently \"npm run dev -- --host\" \"npm run server\"",
"start": "npm run dev:all" "start": "npm run dev:all"
}, },
"dependencies": { "dependencies": {

View File

@ -35,6 +35,8 @@ axiosClient.interceptors.response.use(
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Список URL, где 401 означает неправильный пароль, а не истечение сессии // Список URL, где 401 означает неправильный пароль, а не истечение сессии
const passwordProtectedUrls = [ const passwordProtectedUrls = [
"/login", // Страница входа
"/register", // Страница регистрации
"/notes/archived/all", // Удаление всех архивных заметок "/notes/archived/all", // Удаление всех архивных заметок
"/user/delete-account", // Удаление аккаунта "/user/delete-account", // Удаление аккаунта
]; ];

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
interface FloatingToolbarProps { interface FloatingToolbarProps {
@ -13,6 +13,7 @@ interface FloatingToolbarProps {
italic?: boolean; italic?: boolean;
strikethrough?: boolean; strikethrough?: boolean;
}; };
hasSelection?: boolean; // Есть ли выделение текста
} }
export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
@ -23,40 +24,109 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
onHide, onHide,
onInsertColor, onInsertColor,
activeFormats = {}, activeFormats = {},
hasSelection = false,
}) => { }) => {
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
useEffect(() => { useEffect(() => {
if (visible && toolbarRef.current) { if (visible && toolbarRef.current) {
// Корректируем позицию, чтобы toolbar не выходил за границы экрана // Небольшая задержка для корректного расчета размеров после рендера
const toolbar = toolbarRef.current; setTimeout(() => {
const rect = toolbar.getBoundingClientRect(); if (!toolbarRef.current) return;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight; const toolbar = toolbarRef.current;
const rect = toolbar.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const padding = 10;
let top = position.top - toolbar.offsetHeight - 10; // Получаем внутренний контейнер с кнопками для определения реальной ширины
let left = position.left; const toolbarContent = toolbar.querySelector('.floating-toolbar') as HTMLElement;
const toolbarContentWidth = toolbarContent ? toolbarContent.scrollWidth : rect.width;
const availableWidth = windowWidth - padding * 2;
// Если toolbar выходит за правую границу экрана let top = position.top - rect.height - padding;
if (left + rect.width > windowWidth) { let left = position.left;
left = windowWidth - rect.width - 10;
}
// Если toolbar выходит за левую границу экрана // Если контент шире доступного пространства, устанавливаем maxWidth для wrapper
if (left < 10) { if (toolbarContentWidth > availableWidth) {
left = 10; toolbar.style.maxWidth = `${availableWidth}px`;
} }
// Если toolbar выходит за верхнюю границу экрана // Если toolbar выходит за правую границу экрана
if (top < 10) { if (left + rect.width > windowWidth - padding) {
top = position.top + 30; // Показываем снизу от выделения // Если контент шире экрана, используем прокрутку и позиционируем по левому краю
} if (toolbarContentWidth > availableWidth) {
left = padding;
} else {
// Иначе выравниваем по правому краю
left = Math.max(padding, windowWidth - rect.width - padding);
}
}
toolbar.style.top = `${top}px`; // Если toolbar выходит за левую границу экрана
toolbar.style.left = `${left}px`; 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]); }, [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) => { const handleFormat = (before: string, after: string) => {
onFormat(before, after); onFormat(before, after);
// Не скрываем toolbar - оставляем его видимым для дальнейших действий // Не скрываем toolbar - оставляем его видимым для дальнейших действий
@ -73,82 +143,249 @@ export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
}, 0); }, 0);
}; };
if (!visible) return null; 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 ( return (
<div <div
ref={toolbarRef} ref={toolbarRef}
className="floating-toolbar" className="floating-toolbar-wrapper"
style={{ style={{
position: "fixed", position: "fixed",
top: `${position.top}px`, top: `${position.top}px`,
left: `${position.left}px`, left: `${position.left}px`,
zIndex: 1000, zIndex: 1000,
cursor: isDragging ? 'grabbing' : (toolbarRef.current && toolbarRef.current.scrollWidth > toolbarRef.current.clientWidth ? 'grab' : 'default'),
}} }}
onMouseDown={(e) => { onMouseDown={(e) => {
// Предотвращаем потерю выделения при клике на toolbar // Предотвращаем потерю выделения при клике на toolbar
e.preventDefault(); e.preventDefault();
handleMouseDown(e);
}}
onContextMenu={(e) => {
// Предотвращаем появление контекстного меню браузера на toolbar
e.preventDefault();
}} }}
> >
<button <div className="floating-toolbar">
className={`floating-toolbar-btn ${activeFormats.bold ? "active" : ""}`} <button
onClick={() => handleFormat("**", "**")} className="floating-toolbar-btn"
title="Жирный" onClick={onHide}
> title="Закрыть"
<Icon icon="mdi:format-bold" /> >
</button> <Icon icon="mdi:close" />
<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" /> {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>
</>
)}
<button {hasSelection && (
className="floating-toolbar-btn" <>
onClick={() => onInsertColor?.()} <div className="floating-toolbar-separator" />
title="Цвет текста"
>
<Icon icon="mdi:palette" />
</button>
<button <button
className="floating-toolbar-btn" className={`floating-toolbar-btn ${activeFormats.bold ? "active" : ""}`}
onClick={() => handleFormat("||", "||")} onClick={() => handleFormat("**", "**")}
title="Скрытый текст" title="Жирный"
> >
<Icon icon="mdi:eye-off" /> <Icon icon="mdi:format-bold" />
</button> </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>
<button <div className="floating-toolbar-separator" />
className="floating-toolbar-btn"
onClick={() => handleFormat("`", "`")}
title="Код"
>
<Icon icon="mdi:code-tags" />
</button>
<button <button
className="floating-toolbar-btn" className="floating-toolbar-btn"
onClick={() => handleFormat("> ", "")} onClick={() => onInsertColor?.()}
title="Цитата" title="Цвет текста"
> >
<Icon icon="mdi:format-quote-close" /> <Icon icon="mdi:palette" />
</button> </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> </div>
); );
}; };

View File

@ -21,6 +21,10 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
const [showHeaderDropdown, setShowHeaderDropdown] = useState(false); const [showHeaderDropdown, setShowHeaderDropdown] = useState(false);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -41,10 +45,54 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
}; };
}, [showHeaderDropdown]); }, [showHeaderDropdown]);
const handleMouseDown = (e: React.MouseEvent) => {
// Не начинаем перетаскивание если кликнули на кнопку
if ((e.target as HTMLElement).closest('.btnMarkdown')) return;
if (!containerRef.current) return;
setIsDragging(true);
setStartX(e.pageX - containerRef.current.offsetLeft);
setScrollLeft(containerRef.current.scrollLeft);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
e.preventDefault();
const x = e.pageX - containerRef.current.offsetLeft;
const walk = (x - startX) * 2; // Увеличиваем скорость прокрутки
containerRef.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 buttons = []; const buttons = [];
return ( return (
<div className="markdown-buttons"> <div
className="markdown-buttons"
ref={containerRef}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'grabbing' : (containerRef.current && containerRef.current.scrollWidth > containerRef.current.clientWidth ? 'grab' : 'default') }}
>
{buttons.map((btn) => ( {buttons.map((btn) => (
<button <button
key={btn.id} key={btn.id}

View File

@ -21,6 +21,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const [isAiLoading, setIsAiLoading] = useState(false); const [isAiLoading, setIsAiLoading] = useState(false);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 }); const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
const [hasSelection, setHasSelection] = useState(false);
const [activeFormats, setActiveFormats] = useState({ const [activeFormats, setActiveFormats] = useState({
bold: false, bold: false,
italic: false, italic: false,
@ -530,15 +531,11 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
const hasSelection = start !== end;
// Если нет выделения, скрываем toolbar // Используем середину выделения или позицию курсора для позиционирования
if (start === end) { const position = hasSelection ? Math.floor((start + end) / 2) : start;
return null; const text = textarea.value.substring(0, position);
}
// Используем середину выделения для позиционирования
const midPosition = Math.floor((start + end) / 2);
const text = textarea.value.substring(0, midPosition);
const lines = text.split("\n"); const lines = text.split("\n");
const lineNumber = lines.length - 1; const lineNumber = lines.length - 1;
const currentLineText = lines[lines.length - 1]; const currentLineText = lines[lines.length - 1];
@ -563,12 +560,12 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const textWidth = measureEl.offsetWidth; const textWidth = measureEl.offsetWidth;
document.body.removeChild(measureEl); document.body.removeChild(measureEl);
// Вычисляем позицию (середина выделения) // Вычисляем позицию (середина выделения или позиция курсора)
const top = const top =
rect.top + paddingTop + lineNumber * lineHeight + lineHeight / 2; rect.top + paddingTop + lineNumber * lineHeight + lineHeight / 2;
const left = rect.left + paddingLeft + textWidth; const left = rect.left + paddingLeft + textWidth;
return { top, left }; return { top, left, hasSelection };
}, []); }, []);
// Обработчик выделения текста // Обработчик выделения текста
@ -578,18 +575,27 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
return; return;
} }
// Проверяем, есть ли текст в редакторе
const hasText = content.trim().length > 0;
const position = getCursorPosition(); const position = getCursorPosition();
if (position) { if (position && hasText) {
setToolbarPosition(position); setToolbarPosition({ top: position.top, left: position.left });
setHasSelection(position.hasSelection);
setShowFloatingToolbar(true); setShowFloatingToolbar(true);
// Определяем активные форматы // Определяем активные форматы только если есть выделение
const formats = getActiveFormats(); if (position.hasSelection) {
setActiveFormats(formats); const formats = getActiveFormats();
setActiveFormats(formats);
} else {
setActiveFormats({ bold: false, italic: false, strikethrough: false });
}
} else { } else {
setShowFloatingToolbar(false); setShowFloatingToolbar(false);
setHasSelection(false);
setActiveFormats({ bold: false, italic: false, strikethrough: false }); setActiveFormats({ bold: false, italic: false, strikethrough: false });
} }
}, [isPreviewMode, getCursorPosition, getActiveFormats]); }, [isPreviewMode, content, getCursorPosition, getActiveFormats]);
// Отслеживание выделения текста // Отслеживание выделения текста
useEffect(() => { useEffect(() => {
@ -611,16 +617,36 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
setTimeout(handleSelection, 0); setTimeout(handleSelection, 0);
}; };
// Предотвращаем появление контекстного меню браузера при выделении текста
// Но разрешаем его, если редактор пустой (чтобы можно было вставить текст)
const handleContextMenu = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Проверяем, что событие происходит в textarea
if (target === textarea || textarea.contains(target)) {
const hasText = textarea.value.trim().length > 0;
const hasSelection = textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) {
e.preventDefault();
e.stopPropagation();
}
}
};
textarea.addEventListener("mouseup", handleMouseUp); textarea.addEventListener("mouseup", handleMouseUp);
textarea.addEventListener("mousemove", handleMouseMove); textarea.addEventListener("mousemove", handleMouseMove);
textarea.addEventListener("keyup", handleKeyUp); textarea.addEventListener("keyup", handleKeyUp);
textarea.addEventListener("contextmenu", handleContextMenu);
document.addEventListener("selectionchange", handleSelection); document.addEventListener("selectionchange", handleSelection);
document.addEventListener("contextmenu", handleContextMenu, true);
return () => { return () => {
textarea.removeEventListener("mouseup", handleMouseUp); textarea.removeEventListener("mouseup", handleMouseUp);
textarea.removeEventListener("mousemove", handleMouseMove); textarea.removeEventListener("mousemove", handleMouseMove);
textarea.removeEventListener("keyup", handleKeyUp); textarea.removeEventListener("keyup", handleKeyUp);
textarea.removeEventListener("contextmenu", handleContextMenu);
document.removeEventListener("selectionchange", handleSelection); document.removeEventListener("selectionchange", handleSelection);
document.removeEventListener("contextmenu", handleContextMenu, true);
}; };
}, [isPreviewMode, handleSelection]); }, [isPreviewMode, handleSelection]);
@ -660,10 +686,13 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const handleScroll = () => { const handleScroll = () => {
const position = getCursorPosition(); const position = getCursorPosition();
if (position) { if (position) {
setToolbarPosition(position); setToolbarPosition({ top: position.top, left: position.left });
// Обновляем активные форматы при прокрутке setHasSelection(position.hasSelection);
const formats = getActiveFormats(); // Обновляем активные форматы при прокрутке только если есть выделение
setActiveFormats(formats); if (position.hasSelection) {
const formats = getActiveFormats();
setActiveFormats(formats);
}
} }
}; };
@ -796,6 +825,19 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onContextMenu={(e) => {
// Предотвращаем появление контекстного меню браузера при выделении текста
// Но разрешаем его, если редактор пустой (чтобы можно было вставить текст)
const textarea = textareaRef.current;
if (textarea) {
const hasText = textarea.value.trim().length > 0;
const hasSelection = textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) {
e.preventDefault();
}
}
}}
/> />
<FloatingToolbar <FloatingToolbar
textareaRef={textareaRef} textareaRef={textareaRef}
@ -805,6 +847,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
onHide={() => setShowFloatingToolbar(false)} onHide={() => setShowFloatingToolbar(false)}
onInsertColor={insertColorMarkdown} onInsertColor={insertColorMarkdown}
activeFormats={activeFormats} activeFormats={activeFormats}
hasSelection={hasSelection}
/> />
</> </>
)} )}

View File

@ -46,6 +46,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const [isAiLoading, setIsAiLoading] = useState(false); const [isAiLoading, setIsAiLoading] = useState(false);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 }); const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
const [hasSelection, setHasSelection] = useState(false);
const [activeFormats, setActiveFormats] = useState({ const [activeFormats, setActiveFormats] = useState({
bold: false, bold: false,
italic: false, italic: false,
@ -484,15 +485,11 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
const hasSelection = start !== end;
// Если нет выделения, скрываем toolbar // Используем середину выделения или позицию курсора для позиционирования
if (start === end) { const position = hasSelection ? Math.floor((start + end) / 2) : start;
return null; const text = textarea.value.substring(0, position);
}
// Используем середину выделения для позиционирования
const midPosition = Math.floor((start + end) / 2);
const text = textarea.value.substring(0, midPosition);
const lines = text.split("\n"); const lines = text.split("\n");
const lineNumber = lines.length - 1; const lineNumber = lines.length - 1;
const currentLineText = lines[lines.length - 1]; const currentLineText = lines[lines.length - 1];
@ -517,12 +514,12 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const textWidth = measureEl.offsetWidth; const textWidth = measureEl.offsetWidth;
document.body.removeChild(measureEl); document.body.removeChild(measureEl);
// Вычисляем позицию (середина выделения) // Вычисляем позицию (середина выделения или позиция курсора)
const top = const top =
rect.top + paddingTop + lineNumber * lineHeight + lineHeight / 2; rect.top + paddingTop + lineNumber * lineHeight + lineHeight / 2;
const left = rect.left + paddingLeft + textWidth; const left = rect.left + paddingLeft + textWidth;
return { top, left }; return { top, left, hasSelection };
}, []); }, []);
// Обработчик выделения текста // Обработчик выделения текста
@ -532,18 +529,27 @@ export const NoteItem: React.FC<NoteItemProps> = ({
return; return;
} }
// Проверяем, есть ли текст в редакторе
const hasText = editContent.trim().length > 0;
const position = getCursorPosition(); const position = getCursorPosition();
if (position) { if (position && hasText) {
setToolbarPosition(position); setToolbarPosition({ top: position.top, left: position.left });
setHasSelection(position.hasSelection);
setShowFloatingToolbar(true); setShowFloatingToolbar(true);
// Определяем активные форматы // Определяем активные форматы только если есть выделение
const formats = getActiveFormats(); if (position.hasSelection) {
setActiveFormats(formats); const formats = getActiveFormats();
setActiveFormats(formats);
} else {
setActiveFormats({ bold: false, italic: false, strikethrough: false });
}
} else { } else {
setShowFloatingToolbar(false); setShowFloatingToolbar(false);
setHasSelection(false);
setActiveFormats({ bold: false, italic: false, strikethrough: false }); setActiveFormats({ bold: false, italic: false, strikethrough: false });
} }
}, [localPreviewMode, getCursorPosition, getActiveFormats]); }, [localPreviewMode, editContent, getCursorPosition, getActiveFormats]);
const handleImageButtonClick = () => { const handleImageButtonClick = () => {
imageInputRef.current?.click(); imageInputRef.current?.click();
@ -768,16 +774,36 @@ export const NoteItem: React.FC<NoteItemProps> = ({
setTimeout(handleSelection, 0); setTimeout(handleSelection, 0);
}; };
// Предотвращаем появление контекстного меню браузера при выделении текста
// Но разрешаем его, если редактор пустой (чтобы можно было вставить текст)
const handleContextMenu = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Проверяем, что событие происходит в textarea
if (target === textarea || textarea.contains(target)) {
const hasText = textarea.value.trim().length > 0;
const hasSelection = textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) {
e.preventDefault();
e.stopPropagation();
}
}
};
textarea.addEventListener("mouseup", handleMouseUp); textarea.addEventListener("mouseup", handleMouseUp);
textarea.addEventListener("mousemove", handleMouseMove); textarea.addEventListener("mousemove", handleMouseMove);
textarea.addEventListener("keyup", handleKeyUp); textarea.addEventListener("keyup", handleKeyUp);
textarea.addEventListener("contextmenu", handleContextMenu);
document.addEventListener("selectionchange", handleSelection); document.addEventListener("selectionchange", handleSelection);
document.addEventListener("contextmenu", handleContextMenu, true);
return () => { return () => {
textarea.removeEventListener("mouseup", handleMouseUp); textarea.removeEventListener("mouseup", handleMouseUp);
textarea.removeEventListener("mousemove", handleMouseMove); textarea.removeEventListener("mousemove", handleMouseMove);
textarea.removeEventListener("keyup", handleKeyUp); textarea.removeEventListener("keyup", handleKeyUp);
textarea.removeEventListener("contextmenu", handleContextMenu);
document.removeEventListener("selectionchange", handleSelection); document.removeEventListener("selectionchange", handleSelection);
document.removeEventListener("contextmenu", handleContextMenu, true);
}; };
}, [isEditing, isPreviewMode, handleSelection]); }, [isEditing, isPreviewMode, handleSelection]);
@ -819,10 +845,13 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const handleScroll = () => { const handleScroll = () => {
const position = getCursorPosition(); const position = getCursorPosition();
if (position) { if (position) {
setToolbarPosition(position); setToolbarPosition({ top: position.top, left: position.left });
// Обновляем активные форматы при прокрутке setHasSelection(position.hasSelection);
const formats = getActiveFormats(); // Обновляем активные форматы при прокрутке только если есть выделение
setActiveFormats(formats); if (position.hasSelection) {
const formats = getActiveFormats();
setActiveFormats(formats);
}
} }
}; };
@ -1015,6 +1044,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
onChange={(e) => setEditContent(e.target.value)} onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditKeyDown} onKeyDown={handleEditKeyDown}
style={{ minHeight: "100px" }} style={{ minHeight: "100px" }}
onContextMenu={(e) => {
// Предотвращаем появление контекстного меню браузера при выделении текста
// Но разрешаем его, если редактор пустой (чтобы можно было вставить текст)
const textarea = editTextareaRef.current;
if (textarea) {
const hasText = textarea.value.trim().length > 0;
const hasSelection = textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) {
e.preventDefault();
}
}
}}
/> />
<FloatingToolbar <FloatingToolbar
textareaRef={editTextareaRef} textareaRef={editTextareaRef}
@ -1024,6 +1066,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
onHide={() => setShowFloatingToolbar(false)} onHide={() => setShowFloatingToolbar(false)}
onInsertColor={insertColorMarkdown} onInsertColor={insertColorMarkdown}
activeFormats={activeFormats} activeFormats={activeFormats}
hasSelection={hasSelection}
/> />
</> </>
)} )}

View File

@ -1,6 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useAppSelector, useAppDispatch } from "../store/hooks"; import { useAppSelector, useAppDispatch } from "../store/hooks";
import { toggleTheme, setTheme } from "../store/slices/uiSlice"; import { toggleTheme, setTheme } from "../store/slices/uiSlice";
import { setAccentColor } from "../utils/colorUtils";
export const useTheme = () => { export const useTheme = () => {
const theme = useAppSelector((state) => state.ui.theme); const theme = useAppSelector((state) => state.ui.theme);
@ -9,13 +10,13 @@ export const useTheme = () => {
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute("data-theme", theme); document.documentElement.setAttribute("data-theme", theme);
document.documentElement.style.setProperty("--accent-color", accentColor); setAccentColor(accentColor);
const themeColorMeta = document.querySelector('meta[name="theme-color"]'); const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) { if (themeColorMeta) {
themeColorMeta.setAttribute( themeColorMeta.setAttribute(
"content", "content",
theme === "dark" ? "#1a1a1a" : "#007bff" theme === "dark" ? "#1a1a1a" : accentColor
); );
} }
}, [theme, accentColor]); }, [theme, accentColor]);

View File

@ -11,7 +11,6 @@ const LoginPage: React.FC = () => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
@ -27,20 +26,19 @@ const LoginPage: React.FC = () => {
useEffect(() => { useEffect(() => {
// Проверяем наличие ошибки в URL // Проверяем наличие ошибки в URL
if (searchParams.get("error") === "invalid_password") { if (searchParams.get("error") === "invalid_password") {
setErrorMessage("Неверный пароль!"); showNotification("Неверный пароль!", "error");
} }
}, [searchParams]); }, [searchParams, showNotification]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!username.trim() || !password) { if (!username.trim() || !password) {
setErrorMessage("Логин и пароль обязательны"); showNotification("Логин и пароль обязательны", "error");
return; return;
} }
setIsLoading(true); setIsLoading(true);
setErrorMessage("");
try { try {
console.log("Attempting login..."); console.log("Attempting login...");
@ -59,7 +57,7 @@ const LoginPage: React.FC = () => {
showNotification("Успешный вход!", "success"); showNotification("Успешный вход!", "success");
navigate("/notes"); navigate("/notes");
} else { } else {
setErrorMessage(data.error || "Ошибка входа"); showNotification(data.error || "Ошибка входа", "error");
} }
} catch (error: any) { } catch (error: any) {
console.error("Login error details:", error); console.error("Login error details:", error);
@ -81,7 +79,6 @@ const LoginPage: React.FC = () => {
errorMsg = error.message || "Ошибка соединения с сервером"; errorMsg = error.message || "Ошибка соединения с сервером";
} }
setErrorMessage(errorMsg);
showNotification(errorMsg, "error"); showNotification(errorMsg, "error");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -130,11 +127,6 @@ const LoginPage: React.FC = () => {
placeholder="Введите пароль" placeholder="Введите пароль"
/> />
</div> </div>
{errorMessage && (
<div className="error-message" style={{ display: "block" }}>
{errorMessage}
</div>
)}
<button type="submit" className="btnSave" disabled={isLoading}> <button type="submit" className="btnSave" disabled={isLoading}>
{isLoading ? "Вход..." : "Войти"} {isLoading ? "Вход..." : "Войти"}
</button> </button>

View File

@ -12,7 +12,6 @@ const RegisterPage: React.FC = () => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
@ -29,27 +28,26 @@ const RegisterPage: React.FC = () => {
// Клиентская валидация // Клиентская валидация
if (!username.trim() || !password || !confirmPassword) { if (!username.trim() || !password || !confirmPassword) {
setErrorMessage("Все поля обязательны"); showNotification("Все поля обязательны", "error");
return; return;
} }
if (username.length < 3) { if (username.length < 3) {
setErrorMessage("Логин должен быть не менее 3 символов"); showNotification("Логин должен быть не менее 3 символов", "error");
return; return;
} }
if (password.length < 6) { if (password.length < 6) {
setErrorMessage("Пароль должен быть не менее 6 символов"); showNotification("Пароль должен быть не менее 6 символов", "error");
return; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
setErrorMessage("Пароли не совпадают"); showNotification("Пароли не совпадают", "error");
return; return;
} }
setIsLoading(true); setIsLoading(true);
setErrorMessage("");
try { try {
console.log("Attempting registration..."); console.log("Attempting registration...");
@ -68,7 +66,7 @@ const RegisterPage: React.FC = () => {
showNotification("Регистрация успешна!", "success"); showNotification("Регистрация успешна!", "success");
navigate("/notes"); navigate("/notes");
} else { } else {
setErrorMessage(data.error || "Ошибка регистрации"); showNotification(data.error || "Ошибка регистрации", "error");
} }
} catch (error: any) { } catch (error: any) {
console.error("Register error details:", error); console.error("Register error details:", error);
@ -90,7 +88,6 @@ const RegisterPage: React.FC = () => {
errorMsg = error.message || "Ошибка соединения с сервером"; errorMsg = error.message || "Ошибка соединения с сервером";
} }
setErrorMessage(errorMsg);
showNotification(errorMsg, "error"); showNotification(errorMsg, "error");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -151,11 +148,6 @@ const RegisterPage: React.FC = () => {
placeholder="Подтвердите пароль" placeholder="Подтвердите пароль"
/> />
</div> </div>
{errorMessage && (
<div className="error-message" style={{ display: "block" }}>
{errorMessage}
</div>
)}
<button type="submit" className="btnSave" disabled={isLoading}> <button type="submit" className="btnSave" disabled={isLoading}>
{isLoading ? "Регистрация..." : "Зарегистрироваться"} {isLoading ? "Регистрация..." : "Зарегистрироваться"}
</button> </button>

View File

@ -6,7 +6,8 @@ import { userApi } from "../api/userApi";
import { notesApi, logsApi, Log } from "../api/notesApi"; import { notesApi, logsApi, Log } from "../api/notesApi";
import { Note } from "../types/note"; import { Note } from "../types/note";
import { setUser, setAiSettings } from "../store/slices/profileSlice"; import { setUser, setAiSettings } from "../store/slices/profileSlice";
import { setAccentColor } from "../store/slices/uiSlice"; import { setAccentColor as setAccentColorAction } from "../store/slices/uiSlice";
import { setAccentColor } from "../utils/colorUtils";
import { useNotification } from "../hooks/useNotification"; import { useNotification } from "../hooks/useNotification";
import { Modal } from "../components/common/Modal"; import { Modal } from "../components/common/Modal";
import { ThemeToggle } from "../components/common/ThemeToggle"; import { ThemeToggle } from "../components/common/ThemeToggle";
@ -79,8 +80,8 @@ const SettingsPage: React.FC = () => {
dispatch(setUser(userData)); dispatch(setUser(userData));
const accent = userData.accent_color || "#007bff"; const accent = userData.accent_color || "#007bff";
setSelectedAccentColor(accent); setSelectedAccentColor(accent);
dispatch(setAccentColor(accent)); dispatch(setAccentColorAction(accent));
document.documentElement.style.setProperty("--accent-color", accent); setAccentColor(accent);
// Загружаем AI настройки // Загружаем AI настройки
try { try {
@ -112,11 +113,8 @@ const SettingsPage: React.FC = () => {
await userApi.updateProfile({ await userApi.updateProfile({
accent_color: selectedAccentColor, accent_color: selectedAccentColor,
}); });
dispatch(setAccentColor(selectedAccentColor)); dispatch(setAccentColorAction(selectedAccentColor));
document.documentElement.style.setProperty( setAccentColor(selectedAccentColor);
"--accent-color",
selectedAccentColor
);
await loadUserInfo(); await loadUserInfo();
showNotification("Цветовой акцент успешно обновлен", "success"); showNotification("Цветовой акцент успешно обновлен", "success");
} catch (error: any) { } catch (error: any) {

View File

@ -1,5 +1,6 @@
:root { :root {
--accent-color: #007bff; --accent-color: #007bff;
--accent-color-rgb: 0, 123, 255;
/* Светлая тема (по умолчанию) */ /* Светлая тема (по умолчанию) */
--bg-primary: #f5f5f5; --bg-primary: #f5f5f5;
@ -15,7 +16,7 @@
--border-primary: #e0e0e0; --border-primary: #e0e0e0;
--border-secondary: #dddddd; --border-secondary: #dddddd;
--border-focus: #007bff; --border-focus: var(--accent-color);
--shadow-light: rgba(0, 0, 0, 0.1); --shadow-light: rgba(0, 0, 0, 0.1);
--shadow-medium: rgba(0, 0, 0, 0.15); --shadow-medium: rgba(0, 0, 0, 0.15);
@ -43,7 +44,7 @@
--border-primary: #404040; --border-primary: #404040;
--border-secondary: #555555; --border-secondary: #555555;
--border-focus: #4a9eff; --border-focus: var(--accent-color);
--shadow-light: rgba(0, 0, 0, 0.3); --shadow-light: rgba(0, 0, 0, 0.3);
--shadow-medium: rgba(0, 0, 0, 0.4); --shadow-medium: rgba(0, 0, 0, 0.4);
@ -62,64 +63,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
:root {
--accent-color: #007bff;
/* Светлая тема (по умолчанию) */
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--bg-tertiary: #f8f9fa;
--bg-quaternary: #e9ecef;
--bg-hover: #e7f3ff;
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--text-light: #757575;
--border-primary: #e0e0e0;
--border-secondary: #dddddd;
--border-focus: #007bff;
--shadow-light: rgba(0, 0, 0, 0.1);
--shadow-medium: rgba(0, 0, 0, 0.15);
/* Цвета иконок */
--icon-search: #2196f3;
--icon-tags: #4caf50;
--icon-notes: #ff9800;
--icon-user: #9c27b0;
--icon-danger: #dc3545;
}
/* Темная тема */
[data-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3a3a3a;
--bg-quaternary: #4a4a4a;
--bg-hover: #2a4a6b;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--text-muted: #999999;
--text-light: #aaaaaa;
--border-primary: #404040;
--border-secondary: #555555;
--border-focus: #4a9eff;
--shadow-light: rgba(0, 0, 0, 0.3);
--shadow-medium: rgba(0, 0, 0, 0.4);
/* Цвета иконок в темной теме */
--icon-search: #64b5f6;
--icon-tags: #81c784;
--icon-notes: #ffb74d;
--icon-user: #ba68c8;
--icon-danger: #f44336;
}
body { body {
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
padding: 0; padding: 0;
@ -415,7 +358,7 @@ header {
outline: none; outline: none;
border-color: var(--border-focus); border-color: var(--border-focus);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.25);
} }
.clear-search-btn { .clear-search-btn {
@ -491,7 +434,7 @@ header {
.user-avatar-mini:hover { .user-avatar-mini:hover {
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.2); box-shadow: 0 0 0 3px rgba(var(--accent-color-rgb), 0.2);
} }
.user-avatar-mini img { .user-avatar-mini img {
@ -617,6 +560,17 @@ header {
transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
} }
/* Убираем margin-top у заметок, так как gap уже обеспечивает отступы */
.notes-container .container {
margin-top: 0;
}
/* Добавляем отступ сверху для контейнера заметок, чтобы расстояние между блоком "Мои заметки" и первой заметкой было таким же, как между заметками */
.notes-container {
margin-top: 8px;
}
.login-form { .login-form {
margin-top: 20px; margin-top: 20px;
} }
@ -648,7 +602,7 @@ header {
outline: none; outline: none;
border-color: var(--border-focus); border-color: var(--border-focus);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.25);
} }
/* Toggle Switch Styles */ /* Toggle Switch Styles */
@ -787,6 +741,11 @@ textarea {
margin-bottom: 5px; margin-bottom: 5px;
overflow-y: hidden; overflow-y: hidden;
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
/* Разрешаем выделение текста */
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
} }
textarea:focus { textarea:focus {
@ -1135,7 +1094,7 @@ textarea:focus {
/* Hover эффект для чекбоксов */ /* Hover эффект для чекбоксов */
.textNote .task-list-item:hover { .textNote .task-list-item:hover {
background-color: rgba(0, 123, 255, 0.05); background-color: rgba(var(--accent-color-rgb), 0.05);
border-radius: 4px; border-radius: 4px;
padding-left: 4px; padding-left: 4px;
margin-left: -24px; margin-left: -24px;
@ -1173,7 +1132,7 @@ textarea:focus {
} }
.textNote li:has(input[type="checkbox"]):hover { .textNote li:has(input[type="checkbox"]):hover {
background-color: rgba(0, 123, 255, 0.05); background-color: rgba(var(--accent-color-rgb), 0.05);
border-radius: 4px; border-radius: 4px;
padding-left: 4px; padding-left: 4px;
margin-left: -24px; margin-left: -24px;
@ -1185,6 +1144,8 @@ textarea:focus {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-bottom: 60px; /* Отступ снизу для footer */ padding-bottom: 60px; /* Отступ снизу для footer */
margin-top: 16px; /* Отступ сверху для одинакового расстояния между блоком "Мои заметки" и первой заметкой */
gap: 16px; /* Отступ между заметками */
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@ -1199,19 +1160,32 @@ textarea:focus {
.markdown-buttons { .markdown-buttons {
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
overflow: visible; overflow-x: auto;
overflow-y: hidden;
position: relative; position: relative;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
scrollbar-width: none;
}
.markdown-buttons::-webkit-scrollbar {
display: none;
} }
/* Кнопки markdown в редакторе редактирования: те же отступы и поведение */ /* Кнопки markdown в редакторе редактирования: те же отступы и поведение */
.markdown-buttons.markdown-buttons--edit { .markdown-buttons.markdown-buttons--edit {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: 8px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.markdown-buttons.markdown-buttons--edit::-webkit-scrollbar {
display: none;
} }
.markdown-buttons.markdown-buttons--edit .btnMarkdown { .markdown-buttons.markdown-buttons--edit .btnMarkdown {
@ -1328,6 +1302,21 @@ textarea:focus {
} }
/* Плавающая панель форматирования */ /* Плавающая панель форматирования */
.floating-toolbar-wrapper {
overflow-x: auto;
overflow-y: hidden;
/* Скрываем скроллбар */
scrollbar-width: none;
/* Плавная прокрутка */
scroll-behavior: smooth;
/* Максимальная ширина с учетом отступов */
max-width: calc(100vw - 20px);
}
.floating-toolbar-wrapper::-webkit-scrollbar {
display: none;
}
.floating-toolbar { .floating-toolbar {
display: flex; display: flex;
gap: 4px; gap: 4px;
@ -1338,6 +1327,10 @@ textarea:focus {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fadeIn 0.2s ease-out; animation: fadeIn 0.2s ease-out;
align-items: center; align-items: center;
/* Предотвращаем сжатие кнопок */
flex-shrink: 0;
min-width: fit-content;
width: max-content;
} }
@keyframes fadeIn { @keyframes fadeIn {
@ -1375,6 +1368,12 @@ textarea:focus {
transform: scale(0.95); transform: scale(0.95);
} }
.floating-toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.floating-toolbar-btn .iconify { .floating-toolbar-btn .iconify {
font-size: 18px; font-size: 18px;
} }
@ -1560,7 +1559,7 @@ textarea:focus {
} }
.notification-info { .notification-info {
background-color: #007bff; background-color: var(--accent-color, #007bff);
} }
/* Состояние видимости уведомления */ /* Состояние видимости уведомления */
@ -1632,7 +1631,7 @@ textarea:focus {
transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
} }
/* Мини-календарь */ /* Мини-календарь - базовые стили для всех устройств */
.mini-calendar { .mini-calendar {
width: 100%; width: 100%;
} }
@ -1648,6 +1647,8 @@ textarea:focus {
font-size: 13px; font-size: 13px;
font-weight: bold; font-weight: bold;
color: var(--text-primary); color: var(--text-primary);
text-align: center;
flex: 1;
} }
.calendar-nav { .calendar-nav {
@ -1661,7 +1662,8 @@ textarea:focus {
} }
.calendar-nav:hover { .calendar-nav:hover {
color: #0056b3; color: var(--accent-color, #007bff);
opacity: 0.8;
} }
.calendar-weekdays { .calendar-weekdays {
@ -1715,9 +1717,76 @@ textarea:focus {
} }
.calendar-day.selected { .calendar-day.selected {
background-color: #0056b3; background-color: var(--accent-color, #007bff);
color: white; color: white;
font-weight: bold; font-weight: bold;
opacity: 0.9;
}
/* Стили календаря для PC версии (темный стиль как на изображении) */
@media (min-width: 769px) {
.mini-calendar,
.container-leftside .mini-calendar {
background-color: transparent;
}
.calendar-month-year {
font-size: 14px;
font-weight: 600;
color: #ffffff;
}
.calendar-nav {
font-size: 20px;
color: #ffffff;
font-weight: 400;
}
.calendar-nav:hover {
opacity: 0.8;
}
.calendar-weekdays {
margin-bottom: 5px;
}
.calendar-weekday {
font-size: 10px;
font-weight: 400;
color: #999999;
text-transform: none;
}
.calendar-day {
font-size: 12px;
border-radius: 4px;
color: #ffffff;
font-weight: 400;
background-color: transparent;
}
.calendar-day:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.calendar-day.other-month {
color: #999999;
background-color: transparent;
}
.calendar-day.selected {
background-color: #007bff;
color: white;
font-weight: 500;
opacity: 1;
border-radius: 4px;
}
.calendar-day.today {
background-color: #007bff;
color: white;
font-weight: 500;
}
} }
/* Индикатор для дней с заметками (зеленый кружок) */ /* Индикатор для дней с заметками (зеленый кружок) */
@ -1797,38 +1866,61 @@ textarea:focus {
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5); box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
} }
/* Темная тема для календаря */ /* Темная тема для календаря (PC версия) */
[data-theme="dark"] .calendar-day { @media (min-width: 769px) {
background-color: var(--bg-tertiary); [data-theme="dark"] .mini-calendar,
color: var(--text-primary); [data-theme="dark"] .container-leftside .mini-calendar {
} background-color: transparent;
}
[data-theme="dark"] .calendar-day:hover { [data-theme="dark"] .calendar-day {
background-color: var(--bg-quaternary); background-color: transparent;
} color: #ffffff;
}
[data-theme="dark"] .calendar-day.other-month { [data-theme="dark"] .calendar-day:hover {
background-color: var(--bg-primary); background-color: rgba(255, 255, 255, 0.1);
color: var(--text-muted); }
}
[data-theme="dark"] .calendar-day.today { [data-theme="dark"] .calendar-day.other-month {
background-color: var(--accent-color, #007bff); background-color: transparent;
color: white; color: #999999;
} }
[data-theme="dark"] .calendar-day.selected { [data-theme="dark"] .calendar-day.today {
background-color: var(--accent-color, #007bff); background-color: #007bff;
color: white; color: white;
opacity: 0.9; }
}
[data-theme="dark"] .calendar-nav:hover { [data-theme="dark"] .calendar-day.selected {
color: var(--border-focus); background-color: #007bff;
} color: white;
opacity: 1;
}
[data-theme="dark"] .calendar-weekday { [data-theme="dark"] .calendar-nav {
color: var(--text-secondary); color: #ffffff;
}
[data-theme="dark"] .calendar-nav:hover {
opacity: 0.8;
}
[data-theme="dark"] .calendar-weekday {
color: #999999;
}
[data-theme="dark"] .calendar-month-year {
color: #ffffff;
}
[data-theme="dark"] .calendar-day.has-notes::after {
background-color: #28a745;
}
[data-theme="dark"] .calendar-day.has-edited-notes::before {
background-color: #ffc107;
}
} }
/* Индикаторы заметок в темной теме */ /* Индикаторы заметок в темной теме */
@ -1918,7 +2010,7 @@ textarea:focus {
background-color: var(--accent-color, #007bff); background-color: var(--accent-color, #007bff);
color: white; color: white;
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.3); box-shadow: 0 2px 4px rgba(var(--accent-color-rgb), 0.3);
} }
/* Стили для подсветки результатов поиска */ /* Стили для подсветки результатов поиска */
@ -2047,7 +2139,8 @@ textarea:focus {
} }
.mobile-sidebar .calendar-nav:hover { .mobile-sidebar .calendar-nav:hover {
color: #0056b3; color: var(--accent-color, #007bff);
opacity: 0.8;
} }
/* Календарь дней в слайдере */ /* Календарь дней в слайдере */
@ -2085,9 +2178,10 @@ textarea:focus {
} }
.mobile-sidebar .calendar-day.selected { .mobile-sidebar .calendar-day.selected {
background-color: #0056b3; background-color: var(--accent-color, #007bff);
color: white; color: white;
font-weight: bold; font-weight: bold;
opacity: 0.9;
} }
.mobile-sidebar .calendar-day.other-month { .mobile-sidebar .calendar-day.other-month {
@ -2171,7 +2265,7 @@ textarea:focus {
outline: none; outline: none;
border-color: var(--accent-color, #007bff); border-color: var(--accent-color, #007bff);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.25);
} }
/* Теги в слайдере */ /* Теги в слайдере */
@ -2312,10 +2406,25 @@ textarea:focus {
.container { .container {
width: 100%; width: 100%;
max-width: none; max-width: none;
margin-top: 10px; margin-top: 5px;
padding: 10px; padding: 10px;
box-sizing: border-box; box-sizing: border-box;
} }
/* Уменьшаем отступ сверху для первого блока "Мои заметки" на мобильных */
.center > .container:first-child {
margin-top: 3px;
}
/* Убираем margin-top у заметок на мобильных, так как gap уже обеспечивает отступы */
.notes-container .container {
margin-top: 0;
}
/* Добавляем отступ сверху для контейнера заметок на мобильных */
.notes-container {
margin-top: 5px;
}
/* Адаптируем заголовок заметок */ /* Адаптируем заголовок заметок */
.notes-header { .notes-header {
@ -2374,10 +2483,17 @@ textarea:focus {
/* Адаптируем кнопки markdown */ /* Адаптируем кнопки markdown */
.markdown-buttons { .markdown-buttons {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.markdown-buttons::-webkit-scrollbar {
display: none;
} }
.markdown-buttons .btnMarkdown { .markdown-buttons .btnMarkdown {
@ -2407,7 +2523,8 @@ textarea:focus {
} }
.btnSave, .btnSave,
.btnAI { .btnAI,
.btn-secondary {
width: 100%; width: 100%;
text-align: center; text-align: center;
justify-content: center; justify-content: center;
@ -2650,11 +2767,11 @@ textarea:focus {
/* Специальные стили для темной темы в предпросмотре */ /* Специальные стили для темной темы в предпросмотре */
[data-theme="dark"] .note-preview-container { [data-theme="dark"] .note-preview-container {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-color: var(--accent-color, #4a9eff); border-color: var(--accent-color);
} }
[data-theme="dark"] .note-preview-header { [data-theme="dark"] .note-preview-header {
color: var(--accent-color, #4a9eff); color: var(--accent-color);
border-bottom-color: var(--border-primary); border-bottom-color: var(--border-primary);
} }
@ -2683,7 +2800,7 @@ textarea:focus {
[data-theme="dark"] .note-preview-content blockquote { [data-theme="dark"] .note-preview-content blockquote {
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
border-left-color: var(--accent-color, #4a9eff); border-left-color: var(--accent-color);
color: var(--text-secondary); color: var(--text-secondary);
} }
@ -2749,7 +2866,7 @@ textarea:focus {
} }
.note-preview-content .task-list-item:hover { .note-preview-content .task-list-item:hover {
background-color: rgba(0, 123, 255, 0.05); background-color: rgba(var(--accent-color-rgb), 0.05);
border-radius: 4px; border-radius: 4px;
padding-left: 4px; padding-left: 4px;
margin-left: -24px; margin-left: -24px;
@ -2799,7 +2916,7 @@ textarea:focus {
} }
.note-preview-content li:has(input[type="checkbox"]):hover { .note-preview-content li:has(input[type="checkbox"]):hover {
background-color: rgba(0, 123, 255, 0.05); background-color: rgba(var(--accent-color-rgb), 0.05);
border-radius: 4px; border-radius: 4px;
padding-left: 4px; padding-left: 4px;
margin-left: -24px; margin-left: -24px;
@ -3399,7 +3516,7 @@ textarea:focus {
.settings-tab:hover { .settings-tab:hover {
color: var(--accent-color, #007bff); color: var(--accent-color, #007bff);
background: rgba(0, 123, 255, 0.05); background: rgba(var(--accent-color-rgb), 0.05);
} }
.settings-tab.active { .settings-tab.active {
@ -3496,9 +3613,9 @@ textarea:focus {
/* Стили для новых кнопок */ /* Стили для новых кнопок */
.btn-primary { .btn-primary {
background-color: #007bff; background-color: var(--accent-color, #007bff);
color: white; color: white;
border: 1px solid #007bff; border: 1px solid var(--accent-color, #007bff);
padding: 10px 20px; padding: 10px 20px;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
@ -3510,8 +3627,10 @@ textarea:focus {
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #0056b3; background-color: var(--accent-color, #007bff);
border-color: #004085; border-color: var(--accent-color, #007bff);
opacity: 0.9;
filter: brightness(0.9);
} }
.btn-primary:disabled { .btn-primary:disabled {
@ -4336,15 +4455,26 @@ textarea:focus {
max-width: 100%; max-width: 100%;
margin-top: 60px; /* Отступ для кнопки меню */ margin-top: 60px; /* Отступ для кнопки меню */
} }
/* Уменьшаем отступ сверху для первого блока "Мои заметки" на мобильных */
.center > .container:first-child {
margin-top: 3px;
}
/* Убираем margin-top у заметок на мобильных, так как gap уже обеспечивает отступы */
.notes-container .container {
margin-top: 0;
}
/* Заметки */ /* Заметки */
.notes-container { .notes-container {
padding-bottom: 80px; padding-bottom: 80px;
margin-top: 16px; /* Отступ сверху для одинакового расстояния между блоком "Мои заметки" и первой заметкой на мобильных */
gap: 16px; /* Отступ между заметками на мобильных */
} }
/* Header адаптация */ /* Header адаптация */
.notes-header { .notes-header {
flex-direction: column;
gap: 10px; gap: 10px;
align-items: flex-start; align-items: flex-start;
} }
@ -4357,7 +4487,6 @@ textarea:focus {
} }
.user-info { .user-info {
width: 100%;
justify-content: flex-end; justify-content: flex-end;
} }
@ -4378,7 +4507,7 @@ textarea:focus {
/* Textarea */ /* Textarea */
.textInput { .textInput {
font-size: 14px; font-size: 14px;
min-height: 150px; min-height: 100px;
} }
/* Изображения */ /* Изображения */
@ -4433,7 +4562,4 @@ textarea:focus {
font-size: 12px; font-size: 12px;
} }
.notes-header-left span {
font-size: 14px;
}
} }

27
src/utils/colorUtils.ts Normal file
View File

@ -0,0 +1,27 @@
/**
* Конвертирует hex цвет в RGB значения
* @param hex - hex цвет (например, "#007bff" или "007bff")
* @returns строка с RGB значениями (например, "0, 123, 255")
*/
export const hexToRgb = (hex: string): string => {
// Убираем # если есть
const cleanHex = hex.replace("#", "");
// Парсим hex
const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);
return `${r}, ${g}, ${b}`;
};
/**
* Устанавливает цвет акцента и его RGB значение в CSS переменные
* @param color - hex цвет акцента
*/
export const setAccentColor = (color: string) => {
document.documentElement.style.setProperty("--accent-color", color);
const rgb = hexToRgb(color);
document.documentElement.style.setProperty("--accent-color-rgb", rgb);
};