Добавлен модуль подсветки синтаксиса с использованием highlight.js для улучшения отображения кода в заметках. Обновлены компоненты MarkdownToolbar и NoteItem для поддержки новых модальных окон для вставки кода и ссылок. Изменены стили для блоков кода и добавлены адаптивные стили для улучшения пользовательского интерфейса.

This commit is contained in:
Fovway 2025-11-07 23:55:35 +07:00
parent 05a9275253
commit 14cef88a6b
10 changed files with 598 additions and 19 deletions

View File

@ -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$/]
});

10
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -254,7 +254,7 @@ export const GenerateTagsModal: React.FC<GenerateTagsModalProps> = ({
>
Применить ({selectedTags.length})
</button>
<button className="btn-secondary" onClick={onClose} disabled={isLoading}>
<button className="btn-secondary" onClick={onClose}>
Отмена
</button>
</div>

View File

@ -151,7 +151,7 @@ export const ImproveTextModal: React.FC<ImproveTextModalProps> = ({
>
Применить
</button>
<button className="btn-secondary" onClick={onClose} disabled={isLoading}>
<button className="btn-secondary" onClick={onClose}>
Отмена
</button>
</div>

View File

@ -21,6 +21,13 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
onInsertColor,
}) => {
const [showHeaderDropdown, setShowHeaderDropdown] = useState(false);
const [showCodeModal, setShowCodeModal] = useState(false);
const [codeLanguage, setCodeLanguage] = useState("");
const codeLanguageInputRef = useRef<HTMLInputElement>(null);
const [showLinkModal, setShowLinkModal] = useState(false);
const [linkText, setLinkText] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const linkTextInputRef = useRef<HTMLInputElement>(null);
const dispatch = useAppDispatch();
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
@ -51,8 +58,8 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
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<MarkdownToolbarProps> = ({
};
}, [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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleLinkModalConfirm();
}
};
const buttons: Array<{
id: string;
icon: string;
@ -184,9 +303,10 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
left: `${menuPosition.left}px`,
}}
>
{[1, 2, 3, 4, 5].map((level) => (
{[1, 2, 3, 4, 5, 6].map((level) => (
<button
key={level}
className={`header-dropdown-item header-level-${level}`}
onClick={(e) => {
e.stopPropagation();
onInsert("#".repeat(level) + " ", "");
@ -194,7 +314,7 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
setMenuPosition(null);
}}
>
H{level}
Заголовок {level}
</button>
))}
</div>
@ -267,7 +387,7 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
<button
className="btnMarkdown"
onClick={() => onInsert("`", "`")}
onClick={handleCodeButtonClick}
title="Код"
>
<Icon icon="mdi:code-tags" />
@ -275,7 +395,7 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
<button
className="btnMarkdown"
onClick={() => onInsert("[текст ссылки](", ")")}
onClick={handleLinkButtonClick}
title="Ссылка"
>
<Icon icon="mdi:link" />
@ -312,6 +432,113 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
>
<Icon icon="mdi:monitor-eye" />
</button>
{/* Модальное окно для ввода языка программирования */}
{showCodeModal && (
<div
className="modal"
style={{ display: "block" }}
onClick={handleCodeModalClose}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Введите язык программирования</h3>
<span className="modal-close" onClick={handleCodeModalClose}>
&times;
</span>
</div>
<div className="modal-body">
<input
ref={codeLanguageInputRef}
type="text"
className="modal-password-input"
placeholder="Например: javascript, python, typescript..."
value={codeLanguage}
onChange={(e) => setCodeLanguage(e.target.value)}
onKeyDown={handleCodeModalKeyDown}
style={{ width: "100%" }}
/>
</div>
<div className="modal-footer">
<button
className="btn-primary"
onClick={handleCodeModalConfirm}
>
Вставить
</button>
<button className="btn-secondary" onClick={handleCodeModalClose}>
Отмена
</button>
</div>
</div>
</div>
)}
{/* Модальное окно для ввода ссылки */}
{showLinkModal && (
<div
className="modal"
style={{ display: "block" }}
onClick={handleLinkModalClose}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Введите ссылку</h3>
<span className="modal-close" onClick={handleLinkModalClose}>
&times;
</span>
</div>
<div className="modal-body">
<div style={{ marginBottom: "15px" }}>
<label style={{ display: "block", marginBottom: "5px", fontWeight: "500" }}>
Текст ссылки:
</label>
<input
ref={linkTextInputRef}
type="text"
className="modal-password-input"
placeholder="Текст ссылки"
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
onKeyDown={handleLinkTextKeyDown}
style={{ width: "100%" }}
/>
</div>
<div>
<label style={{ display: "block", marginBottom: "5px", fontWeight: "500" }}>
URL:
</label>
<input
type="text"
className="modal-password-input link-modal-url-input"
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={handleLinkUrlKeyDown}
style={{ width: "100%" }}
/>
</div>
</div>
<div className="modal-footer">
<button
className="btn-primary"
onClick={handleLinkModalConfirm}
>
Вставить
</button>
<button className="btn-secondary" onClick={handleLinkModalClose}>
Отмена
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -1119,6 +1119,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
setShowTagsModal(false);
setSuggestedTags([]);
setTagsGenerationError(false);
setIsGeneratingTags(false);
}}
onSelectTags={handleSelectTags}
suggestedTags={suggestedTags}
@ -1134,6 +1135,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
setImprovedText("");
setImproveError(false);
setImproveErrorMessage("");
setIsAiLoading(false);
}}
onApply={handleApplyImprovedText}
originalText={content}

View File

@ -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<NoteItemProps> = ({
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
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<string>("");
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -188,18 +193,36 @@ export const NoteItem: React.FC<NoteItemProps> = ({
}
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<NoteItemProps> = ({
setShowTagsModal(false);
setSuggestedTags([]);
setTagsGenerationError(false);
setIsGeneratingTags(false);
}}
onSelectTags={handleSelectTags}
suggestedTags={suggestedTags}
@ -1636,6 +1660,23 @@ export const NoteItem: React.FC<NoteItemProps> = ({
isLoading={isGeneratingTags}
hasError={tagsGenerationError}
/>
<ImproveTextModal
isOpen={showImproveModal}
onClose={() => {
setShowImproveModal(false);
setImprovedText("");
setImproveError(false);
setImproveErrorMessage("");
setIsAiLoading(false);
}}
onApply={handleApplyImprovedText}
originalText={editContent}
improvedText={improvedText}
isLoading={isAiLoading}
hasError={improveError}
errorMessage={improveErrorMessage}
/>
</>
);
};

View File

@ -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;

View File

@ -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<string, string> = {
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 `<li>${content}</li>\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 ? `<span class="code-language">${lang}</span>` : "";
return `<pre class="code-block">${langLabel}<code class="hljs ${langClass}">${highlightedCode}</code></pre>`;
},
};
// Настройка marked