diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 49ebada..f89a5dd 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "/index.html", - "revision": "0.9t777mpmecg" + "revision": "0.q2r0i2lj5ro" }], { "ignoreURLParametersMatching": [/^utm_/, /^fbclid$/] }); diff --git a/package-lock.json b/package-lock.json index 48534e2..02d997d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "axios": "^1.13.1", "clsx": "^2.1.1", "date-fns": "^2.30.0", + "highlight.js": "^11.11.1", "marked": "^16.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -4616,6 +4617,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", diff --git a/package.json b/package.json index e37d464..6721728 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "axios": "^1.13.1", "clsx": "^2.1.1", "date-fns": "^2.30.0", + "highlight.js": "^11.11.1", "marked": "^16.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/notes/GenerateTagsModal.tsx b/src/components/notes/GenerateTagsModal.tsx index 0f8344c..a9b4dff 100644 --- a/src/components/notes/GenerateTagsModal.tsx +++ b/src/components/notes/GenerateTagsModal.tsx @@ -254,7 +254,7 @@ export const GenerateTagsModal: React.FC = ({ > Применить ({selectedTags.length}) - diff --git a/src/components/notes/ImproveTextModal.tsx b/src/components/notes/ImproveTextModal.tsx index 3850689..484ae56 100644 --- a/src/components/notes/ImproveTextModal.tsx +++ b/src/components/notes/ImproveTextModal.tsx @@ -151,7 +151,7 @@ export const ImproveTextModal: React.FC = ({ > Применить - diff --git a/src/components/notes/MarkdownToolbar.tsx b/src/components/notes/MarkdownToolbar.tsx index 9375d57..f1d5358 100644 --- a/src/components/notes/MarkdownToolbar.tsx +++ b/src/components/notes/MarkdownToolbar.tsx @@ -21,6 +21,13 @@ export const MarkdownToolbar: React.FC = ({ onInsertColor, }) => { const [showHeaderDropdown, setShowHeaderDropdown] = useState(false); + const [showCodeModal, setShowCodeModal] = useState(false); + const [codeLanguage, setCodeLanguage] = useState(""); + const codeLanguageInputRef = useRef(null); + const [showLinkModal, setShowLinkModal] = useState(false); + const [linkText, setLinkText] = useState(""); + const [linkUrl, setLinkUrl] = useState(""); + const linkTextInputRef = useRef(null); const dispatch = useAppDispatch(); const dropdownRef = useRef(null); const buttonRef = useRef(null); @@ -51,8 +58,8 @@ export const MarkdownToolbar: React.FC = ({ if (buttonRef.current && showHeaderDropdown) { const rect = buttonRef.current.getBoundingClientRect(); setMenuPosition({ - top: rect.bottom + window.scrollY + 2, - left: rect.left + window.scrollX, + top: rect.bottom + 2, + left: rect.left, }); } }; @@ -115,6 +122,118 @@ export const MarkdownToolbar: React.FC = ({ }; }, [isDragging]); + // Фокус на поле ввода при открытии модального окна + useEffect(() => { + if (showCodeModal && codeLanguageInputRef.current) { + setTimeout(() => { + codeLanguageInputRef.current?.focus(); + }, 100); + } + }, [showCodeModal]); + + // Фокус на поле ввода текста ссылки при открытии модального окна + useEffect(() => { + if (showLinkModal && linkTextInputRef.current) { + setTimeout(() => { + linkTextInputRef.current?.focus(); + }, 100); + } + }, [showLinkModal]); + + // Обработка Escape для закрытия модального окна + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (showCodeModal) { + setShowCodeModal(false); + setCodeLanguage(""); + } + if (showLinkModal) { + setShowLinkModal(false); + setLinkText(""); + setLinkUrl(""); + } + } + }; + + if (showCodeModal || showLinkModal) { + document.addEventListener("keydown", handleEscape); + } + + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [showCodeModal, showLinkModal]); + + const handleCodeButtonClick = () => { + setShowCodeModal(true); + setCodeLanguage(""); + }; + + const handleCodeModalConfirm = () => { + const language = codeLanguage.trim(); + if (language) { + onInsert("```" + language + "\n", "\n```"); + } else { + onInsert("```\n", "\n```"); + } + setShowCodeModal(false); + setCodeLanguage(""); + }; + + const handleCodeModalClose = () => { + setShowCodeModal(false); + setCodeLanguage(""); + }; + + const handleCodeModalKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCodeModalConfirm(); + } + }; + + const handleLinkButtonClick = () => { + setShowLinkModal(true); + setLinkText(""); + setLinkUrl(""); + }; + + const handleLinkModalConfirm = () => { + const text = linkText.trim() || "текст ссылки"; + const url = linkUrl.trim() || "#"; + onInsert(`[${text}](`, `${url})`); + setShowLinkModal(false); + setLinkText(""); + setLinkUrl(""); + }; + + const handleLinkModalClose = () => { + setShowLinkModal(false); + setLinkText(""); + setLinkUrl(""); + }; + + const handleLinkTextKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + // Переход к полю URL + const urlInput = document.querySelector( + ".link-modal-url-input" + ) as HTMLInputElement; + if (urlInput) { + urlInput.focus(); + } + } + }; + + const handleLinkUrlKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleLinkModalConfirm(); + } + }; + const buttons: Array<{ id: string; icon: string; @@ -184,9 +303,10 @@ export const MarkdownToolbar: React.FC = ({ left: `${menuPosition.left}px`, }} > - {[1, 2, 3, 4, 5].map((level) => ( + {[1, 2, 3, 4, 5, 6].map((level) => ( ))} @@ -267,7 +387,7 @@ export const MarkdownToolbar: React.FC = ({ + + {/* Модальное окно для ввода языка программирования */} + {showCodeModal && ( +
+
e.stopPropagation()} + > +
+

Введите язык программирования

+ + × + +
+
+ setCodeLanguage(e.target.value)} + onKeyDown={handleCodeModalKeyDown} + style={{ width: "100%" }} + /> +
+
+ + +
+
+
+ )} + + {/* Модальное окно для ввода ссылки */} + {showLinkModal && ( +
+
e.stopPropagation()} + > +
+

Введите ссылку

+ + × + +
+
+
+ + setLinkText(e.target.value)} + onKeyDown={handleLinkTextKeyDown} + style={{ width: "100%" }} + /> +
+
+ + setLinkUrl(e.target.value)} + onKeyDown={handleLinkUrlKeyDown} + style={{ width: "100%" }} + /> +
+
+
+ + +
+
+
+ )} ); }; diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 630829a..95093c4 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -1119,6 +1119,7 @@ export const NoteEditor: React.FC = ({ onSave }) => { setShowTagsModal(false); setSuggestedTags([]); setTagsGenerationError(false); + setIsGeneratingTags(false); }} onSelectTags={handleSelectTags} suggestedTags={suggestedTags} @@ -1134,6 +1135,7 @@ export const NoteEditor: React.FC = ({ onSave }) => { setImprovedText(""); setImproveError(false); setImproveErrorMessage(""); + setIsAiLoading(false); }} onApply={handleApplyImprovedText} originalText={content} diff --git a/src/components/notes/NoteItem.tsx b/src/components/notes/NoteItem.tsx index 14f6aea..4be0d14 100644 --- a/src/components/notes/NoteItem.tsx +++ b/src/components/notes/NoteItem.tsx @@ -21,6 +21,7 @@ import { ImageUpload } from "./ImageUpload"; import { FileUpload } from "./FileUpload"; import { aiApi } from "../../api/aiApi"; import { GenerateTagsModal } from "./GenerateTagsModal"; +import { ImproveTextModal } from "./ImproveTextModal"; import { extractTags } from "../../utils/markdown"; interface NoteItemProps { @@ -67,6 +68,10 @@ export const NoteItem: React.FC = ({ const [suggestedTags, setSuggestedTags] = useState([]); const [isGeneratingTags, setIsGeneratingTags] = useState(false); const [tagsGenerationError, setTagsGenerationError] = useState(false); + const [showImproveModal, setShowImproveModal] = useState(false); + const [improvedText, setImprovedText] = useState(""); + const [improveError, setImproveError] = useState(false); + const [improveErrorMessage, setImproveErrorMessage] = useState(""); const editTextareaRef = useRef(null); const imageInputRef = useRef(null); const fileInputRef = useRef(null); @@ -188,18 +193,36 @@ export const NoteItem: React.FC = ({ } setIsAiLoading(true); + setImproveError(false); + setImproveErrorMessage(""); + setImprovedText(""); + setShowImproveModal(true); try { - const improvedText = await aiApi.improveText(editContent); - setEditContent(improvedText); - showNotification("Текст улучшен!", "success"); - } catch (error) { + const improved = await aiApi.improveText(editContent); + setImprovedText(improved); + setImproveError(false); + } catch (error: any) { console.error("Ошибка улучшения текста:", error); - showNotification("Ошибка улучшения текста", "error"); + setImproveError(true); + setImproveErrorMessage( + error.response?.data?.error || + error.message || + "Ошибка улучшения текста" + ); } finally { setIsAiLoading(false); } }; + const handleApplyImprovedText = (text: string) => { + setEditContent(text); + showNotification("Текст улучшен!", "success"); + setShowImproveModal(false); + setImprovedText(""); + setImproveError(false); + setImproveErrorMessage(""); + }; + const handleGenerateTags = async () => { if (!editContent.trim()) { showNotification("Введите текст для генерации тегов", "warning"); @@ -1629,6 +1652,7 @@ export const NoteItem: React.FC = ({ setShowTagsModal(false); setSuggestedTags([]); setTagsGenerationError(false); + setIsGeneratingTags(false); }} onSelectTags={handleSelectTags} suggestedTags={suggestedTags} @@ -1636,6 +1660,23 @@ export const NoteItem: React.FC = ({ isLoading={isGeneratingTags} hasError={tagsGenerationError} /> + + { + setShowImproveModal(false); + setImprovedText(""); + setImproveError(false); + setImproveErrorMessage(""); + setIsAiLoading(false); + }} + onApply={handleApplyImprovedText} + originalText={editContent} + improvedText={improvedText} + isLoading={isAiLoading} + hasError={improveError} + errorMessage={improveErrorMessage} + /> ); }; diff --git a/src/styles/style.css b/src/styles/style.css index 0ed4eb8..0d0be73 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -1424,6 +1424,94 @@ textarea:focus { word-break: break-word; } +/* Стили для блоков кода с подсветкой синтаксиса в заметках */ +.textNote .code-block { + position: relative; + margin: 15px 0; + background: #2d2d2d; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-primary); +} + +.textNote .code-block code { + display: block; + padding: 15px; + overflow-x: auto; + font-family: "Courier New", "Consolas", "Monaco", monospace; + font-size: 14px; + line-height: 1.5; + color: #f8f8f2; + background: transparent; + border: none; + border-radius: 0; + word-wrap: normal; + overflow-wrap: normal; + word-break: normal; + white-space: pre; +} + +.textNote .code-block .code-language { + position: absolute; + top: 8px; + right: 12px; + font-size: 11px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(0, 0, 0, 0.3); + padding: 4px 8px; + border-radius: 4px; + font-family: "Open Sans", sans-serif; + z-index: 1; +} + +/* Светлая тема для блоков кода в заметках */ +[data-theme="light"] .textNote .code-block, +.textNote .code-block { + background: #f5f5f5; + border-color: var(--border-primary); +} + +[data-theme="light"] .textNote .code-block code, +.textNote .code-block code { + color: #333; +} + +[data-theme="light"] .textNote .code-block .code-language, +.textNote .code-block .code-language { + color: #666; + background: rgba(255, 255, 255, 0.7); +} + +/* Темная тема для блоков кода в заметках */ +[data-theme="dark"] .textNote .code-block { + background: #1e1e1e; + border-color: var(--border-primary); +} + +[data-theme="dark"] .textNote .code-block code { + color: #d4d4d4; +} + +[data-theme="dark"] .textNote .code-block .code-language { + color: #999; + background: rgba(0, 0, 0, 0.5); +} + +/* Стили для inline кода в заметках (не блоки) */ +.textNote code:not(.code-block code) { + background-color: var(--bg-tertiary); + color: var(--text-primary); + padding: 2px 4px; + border-radius: 5px; + font-size: 14px; +} + +/* Стили highlight.js уже включены через импорт CSS файлов */ +/* Дополнительные стили для адаптации к теме приложения */ + /* Стили для чекбоксов (to-do списков) */ .textNote input[type="checkbox"] { cursor: pointer; @@ -1655,8 +1743,8 @@ textarea:focus { border-radius: 5px; box-shadow: 0 2px 8px var(--shadow-medium); z-index: 1001; - min-width: 60px; - max-width: 120px; + min-width: 140px; + max-width: 250px; } .header-dropdown-menu button { @@ -1667,10 +1755,9 @@ textarea:focus { background: none; text-align: left; cursor: pointer; - font-size: 14px; - font-weight: 500; color: var(--text-primary); transition: background 0.2s ease; + white-space: nowrap; } .header-dropdown-menu button:hover { @@ -1685,6 +1772,50 @@ textarea:focus { border-radius: 0 0 5px 5px; } +/* Стили для визуальных примеров заголовков */ +.header-dropdown-menu .header-level-1 { + font-size: 32px; + font-weight: 700; + line-height: 1.2; + padding: 12px 16px; +} + +.header-dropdown-menu .header-level-2 { + font-size: 28px; + font-weight: 700; + line-height: 1.2; + padding: 11px 16px; +} + +.header-dropdown-menu .header-level-3 { + font-size: 24px; + font-weight: 600; + line-height: 1.3; + padding: 10px 16px; +} + +.header-dropdown-menu .header-level-4 { + font-size: 20px; + font-weight: 600; + line-height: 1.3; + padding: 9px 16px; +} + +.header-dropdown-menu .header-level-5 { + font-size: 18px; + font-weight: 600; + line-height: 1.4; + padding: 8px 16px; +} + +.header-dropdown-menu .header-level-6 { + font-size: 16px; + font-weight: 600; + line-height: 1.4; + padding: 8px 16px; + color: var(--text-secondary); +} + /* Плавающая панель форматирования */ .floating-toolbar-wrapper { overflow-x: auto; @@ -3240,6 +3371,90 @@ textarea:focus { border-radius: 0; } +/* Стили для блоков кода с подсветкой синтаксиса */ +.note-preview-content .code-block { + position: relative; + margin: 15px 0; + background: #2d2d2d; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-primary); +} + +.note-preview-content .code-block code { + display: block; + padding: 15px; + overflow-x: auto; + font-family: "Courier New", "Consolas", "Monaco", monospace; + font-size: 14px; + line-height: 1.5; + color: #f8f8f2; + background: transparent; + border: none; + border-radius: 0; +} + +.note-preview-content .code-block .code-language { + position: absolute; + top: 8px; + right: 12px; + font-size: 11px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(0, 0, 0, 0.3); + padding: 4px 8px; + border-radius: 4px; + font-family: "Open Sans", sans-serif; + z-index: 1; +} + +/* Светлая тема для блоков кода */ +[data-theme="light"] .note-preview-content .code-block, +.note-preview-content .code-block { + background: #f5f5f5; + border-color: var(--border-primary); +} + +[data-theme="light"] .note-preview-content .code-block code, +.note-preview-content .code-block code { + color: #333; +} + +[data-theme="light"] .note-preview-content .code-block .code-language, +.note-preview-content .code-block .code-language { + color: #666; + background: rgba(255, 255, 255, 0.7); +} + +/* Темная тема для блоков кода */ +[data-theme="dark"] .note-preview-content .code-block { + background: #1e1e1e; + border-color: var(--border-primary); +} + +[data-theme="dark"] .note-preview-content .code-block code { + color: #d4d4d4; +} + +[data-theme="dark"] .note-preview-content .code-block .code-language { + color: #999; + background: rgba(0, 0, 0, 0.5); +} + +/* Стили для inline кода (не блоки) */ +.note-preview-content code:not(.code-block code) { + background: var(--bg-quaternary); + color: var(--text-primary); + padding: 2px 6px; + border-radius: 4px; + font-family: "Courier New", monospace; +} + +/* Стили highlight.js уже включены через импорт CSS файлов */ +/* Дополнительные стили для адаптации к теме приложения */ + .note-preview-content blockquote { border-left: 4px solid var(--accent-color, #007bff); padding-left: 15px; diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index c8ee080..8afd1f0 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -1,4 +1,8 @@ import { marked } from "marked"; +import hljs from "highlight.js"; +// Импортируем стили +import "highlight.js/styles/github-dark.css"; +import "highlight.js/styles/github.css"; // Расширение для спойлеров const spoilerExtension = { @@ -75,7 +79,72 @@ function renderTokens(tokens: any[], renderer: any): string { .join(""); } -// Кастомный renderer для внешних ссылок и чекбоксов +// Функция для экранирования HTML +const escapeHtml = (text: string): string => { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +}; + +// Маппинг алиасов языков +const languageAliases: Record = { + js: "javascript", + ts: "typescript", + py: "python", + sh: "bash", + shell: "bash", + zsh: "bash", + yml: "yaml", + md: "markdown", + html: "markup", + xml: "markup", + svg: "markup", + mathml: "markup", + ssml: "markup", + atom: "markup", + rss: "markup", +}; + +// Функция для нормализации названия языка +const normalizeLanguage = (lang: string): string => { + const normalized = lang.toLowerCase().trim(); + // Проверяем алиасы + return languageAliases[normalized] || normalized; +}; + +// Функция для подсветки синтаксиса кода +const highlightCode = (code: string, lang?: string): string => { + if (!lang) { + // Если язык не указан, просто экранируем HTML + return escapeHtml(code); + } + + // Нормализуем название языка + const normalizedLang = normalizeLanguage(lang); + + try { + // Используем highlight.js для подсветки синтаксиса + const highlighted = hljs.highlight(code, { language: normalizedLang }); + return highlighted.value; + } catch (e: any) { + // Если произошла ошибка, пробуем автоопределение + try { + const autoHighlighted = hljs.highlightAuto(code); + return autoHighlighted.value; + } catch (autoError: any) { + // Если автоопределение тоже не сработало, логируем и экранируем + console.warn( + "Highlight.js error:", + e?.message || e, + "Language:", + normalizedLang + ); + return escapeHtml(code); + } + } +}; + +// Кастомный renderer для внешних ссылок, чекбоксов и блоков кода const renderer: any = { link(token: any) { const href = token.href; @@ -123,6 +192,20 @@ const renderer: any = { } return `
  • ${content}
  • \n`; }, + // Кастомный renderer для блоков кода с подсветкой синтаксиса + code(token: any) { + const code = token.text || ""; + // В marked токен блока кода имеет поле lang + const lang = (token.lang || token.language || "").trim(); + + const highlightedCode = highlightCode(code, lang); + + // Если язык указан, добавляем класс для стилизации (highlight.js использует класс hljs) + const langClass = lang ? `language-${lang}` : ""; + const langLabel = lang ? `${lang}` : ""; + + return `
    ${langLabel}${highlightedCode}
    `; + }, }; // Настройка marked