Compare commits
2 Commits
06400d6e97
...
14cef88a6b
| Author | SHA1 | Date | |
|---|---|---|---|
| 14cef88a6b | |||
| 05a9275253 |
@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "/index.html",
|
||||
"revision": "0.9eood2uf828"
|
||||
"revision": "0.q2r0i2lj5ro"
|
||||
}], {
|
||||
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
|
||||
});
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
×
|
||||
</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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -14,7 +14,7 @@ import { ImproveTextModal } from "./ImproveTextModal";
|
||||
import { extractTags } from "../../utils/markdown";
|
||||
|
||||
interface NoteEditorProps {
|
||||
onSave: () => void;
|
||||
onSave: (noteId?: number | string) => void;
|
||||
}
|
||||
|
||||
export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
@ -80,7 +80,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
setContent("");
|
||||
setImages([]);
|
||||
setFiles([]);
|
||||
onSave();
|
||||
onSave(note.id);
|
||||
} catch (error) {
|
||||
console.error("Ошибка сохранения заметки:", error);
|
||||
showNotification("Ошибка сохранения заметки", "error");
|
||||
@ -106,7 +106,9 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
console.error("Ошибка улучшения текста:", error);
|
||||
setImproveError(true);
|
||||
setImproveErrorMessage(
|
||||
error.response?.data?.error || error.message || "Ошибка улучшения текста"
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
"Ошибка улучшения текста"
|
||||
);
|
||||
} finally {
|
||||
setIsAiLoading(false);
|
||||
@ -147,7 +149,10 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
console.error("Детали ошибки:", error.response?.data);
|
||||
setTagsGenerationError(true);
|
||||
setShowTagsModal(false);
|
||||
const errorMessage = error.response?.data?.error || error.message || "Ошибка генерации тегов";
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
"Ошибка генерации тегов";
|
||||
showNotification(errorMessage, "error");
|
||||
} finally {
|
||||
setIsGeneratingTags(false);
|
||||
@ -159,13 +164,19 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
|
||||
const existingTags = extractTags(content);
|
||||
const tagsToAdd = tags
|
||||
.filter((tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase()))
|
||||
.filter(
|
||||
(tag) =>
|
||||
!existingTags.some(
|
||||
(existing) => existing.toLowerCase() === tag.toLowerCase()
|
||||
)
|
||||
)
|
||||
.map((tag) => `#${tag}`)
|
||||
.join(" ");
|
||||
|
||||
if (tagsToAdd) {
|
||||
// Добавляем теги в конец заметки
|
||||
const newContent = content.trim() + (content.trim() ? "\n\n" : "") + tagsToAdd;
|
||||
const newContent =
|
||||
content.trim() + (content.trim() ? "\n\n" : "") + tagsToAdd;
|
||||
setContent(newContent);
|
||||
showNotification(`Добавлено тегов: ${tags.length}`, "success");
|
||||
} else {
|
||||
@ -1108,6 +1119,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
setShowTagsModal(false);
|
||||
setSuggestedTags([]);
|
||||
setTagsGenerationError(false);
|
||||
setIsGeneratingTags(false);
|
||||
}}
|
||||
onSelectTags={handleSelectTags}
|
||||
suggestedTags={suggestedTags}
|
||||
@ -1123,6 +1135,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
setImprovedText("");
|
||||
setImproveError(false);
|
||||
setImproveErrorMessage("");
|
||||
setIsAiLoading(false);
|
||||
}}
|
||||
onApply={handleApplyImprovedText}
|
||||
originalText={content}
|
||||
|
||||
@ -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 {
|
||||
@ -31,6 +32,7 @@ interface NoteItemProps {
|
||||
onReload: () => void;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (id: number | string) => void;
|
||||
onFocusNote?: (id: number | string) => void;
|
||||
}
|
||||
|
||||
export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
@ -41,6 +43,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
onReload,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
onFocusNote,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(note.content);
|
||||
@ -65,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);
|
||||
@ -138,6 +145,13 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
setDeletedImageIds([]);
|
||||
setDeletedFileIds([]);
|
||||
onReload();
|
||||
// Устанавливаем фокус на заметку после сохранения
|
||||
if (onFocusNote) {
|
||||
// Небольшая задержка для завершения обновления DOM
|
||||
setTimeout(() => {
|
||||
onFocusNote(note.id);
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка обновления заметки:", error);
|
||||
showNotification("Ошибка обновления заметки", "error");
|
||||
@ -179,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");
|
||||
@ -1620,6 +1652,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
setShowTagsModal(false);
|
||||
setSuggestedTags([]);
|
||||
setTagsGenerationError(false);
|
||||
setIsGeneratingTags(false);
|
||||
}}
|
||||
onSelectTags={handleSelectTags}
|
||||
suggestedTags={suggestedTags}
|
||||
@ -1627,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -8,6 +8,7 @@ import { extractTags } from "../../utils/markdown";
|
||||
|
||||
export interface NotesListRef {
|
||||
reloadNotes: () => void;
|
||||
focusNote: (noteId: number | string) => void;
|
||||
}
|
||||
|
||||
interface NotesListProps {
|
||||
@ -81,8 +82,34 @@ export const NotesList = forwardRef<NotesListRef, NotesListProps>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, searchQuery, selectedDate, selectedTag]);
|
||||
|
||||
const focusNote = (noteId: number | string) => {
|
||||
// Используем data-атрибут для поиска элемента заметки
|
||||
const noteElement = document.querySelector(
|
||||
`[data-note-id="${noteId}"]`
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
if (noteElement) {
|
||||
// Прокручиваем к верхней части заметки
|
||||
noteElement.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" });
|
||||
// Добавляем визуальное выделение на короткое время
|
||||
// Очищаем предыдущий таймер, если он был установлен
|
||||
const existingTimeout = (noteElement as any).__focusTimeout;
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
noteElement.style.transition = "box-shadow 0.3s ease";
|
||||
noteElement.style.boxShadow = "0 0 0 3px rgba(33, 150, 243, 0.5)";
|
||||
const timeout = setTimeout(() => {
|
||||
noteElement.style.boxShadow = "";
|
||||
(noteElement as any).__focusTimeout = null;
|
||||
}, 2000);
|
||||
(noteElement as any).__focusTimeout = timeout;
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reloadNotes: loadNotes,
|
||||
focusNote: focusNote,
|
||||
}));
|
||||
|
||||
const handleDelete = async (id: number | string) => {
|
||||
@ -167,6 +194,7 @@ export const NotesList = forwardRef<NotesListRef, NotesListProps>(
|
||||
onReload={loadNotes}
|
||||
isSelected={selectedNoteIds.includes(note.id)}
|
||||
onSelect={onNoteSelect}
|
||||
onFocusNote={focusNote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -63,10 +63,32 @@ const NotesPage: React.FC = () => {
|
||||
|
||||
const activeFilters = getActiveFilters();
|
||||
|
||||
const handleNoteSave = () => {
|
||||
const handleNoteSave = (noteId?: number | string) => {
|
||||
// Вызываем перезагрузку заметок после создания новой заметки
|
||||
if (notesListRef.current) {
|
||||
notesListRef.current.reloadNotes();
|
||||
// Устанавливаем фокус на сохраненную заметку после небольшой задержки,
|
||||
// чтобы дать время для перезагрузки и рендеринга заметок
|
||||
if (noteId) {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 15;
|
||||
const attemptFocus = () => {
|
||||
attempts++;
|
||||
if (notesListRef.current) {
|
||||
// Проверяем, есть ли элемент заметки в DOM
|
||||
const noteElement = document.querySelector(
|
||||
`[data-note-id="${noteId}"]`
|
||||
);
|
||||
if (noteElement || attempts >= maxAttempts) {
|
||||
notesListRef.current.focusNote(noteId);
|
||||
} else if (attempts < maxAttempts) {
|
||||
// Пытаемся еще раз, если заметка еще не появилась в DOM
|
||||
setTimeout(attemptFocus, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(attemptFocus, 200);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user