diff --git a/CHANGELOG.md b/CHANGELOG.md index abc63382..20fad7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ ## [0.6.0] - 2025-07-01 +### Улучшения интерфейса редактирования + +- **КАРДИНАЛЬНО УЛУЧШЕН**: Редактор содержимого публикаций в админ-панели: + - **Кнопки управления перенесены вниз**: Кнопки "Сохранить" и "Отмена" теперь размещены внизу редактора, как в современных IDE + - **Уменьшен размер шрифта**: Размер шрифта уменьшен с 14px до 12px для более компактного отображения кода + - **Увеличено окно редактора**: Минимальная высота увеличена с 200px до 500px, модальное окно использует размер "large" (95vw) + - **Добавлены номера строк**: Невыделяемые серые номера строк слева для лучшей навигации по коду + - **Улучшенное форматирование HTML**: Автоматическое форматирование HTML контента с правильными отступами и удалением лишних пробелов + - **Современная типографика**: Использование моноширинных шрифтов 'JetBrains Mono', 'Fira Code', 'Consolas' для лучшей читаемости кода + - **Компактный дизайн**: Уменьшены отступы (padding) для экономии места + - **Улучшенная синхронизация скролла**: Номера строк синхронизируются со скроллом основного контента + - **ИСПРАВЛЕНО**: Исправлена проблема с курсором в режиме редактирования - курсор теперь корректно перемещается при вводе текста и сохраняет позицию при обновлении содержимого + - **ИСПРАВЛЕНО**: Номера строк теперь правильно синхронизируются с содержимым - они прокручиваются вместе с текстом и показывают реальные номера строк документа + - **УЛУЧШЕНО**: Увеличена максимальная высота модальных окон с содержимым публикаций с 70vh до 85vh для более комфортного редактирования + - **ИСПРАВЛЕНО**: Убраны жесткие ограничения высоты в CSS (`min-height: 500px` в `.editableCodeContainer` и `min-height: 450px` в `.editorArea`) - теперь размер полностью контролируется параметром `maxHeight` + - **УЛУЧШЕНО**: Редактор кода теперь использует точную высоту `height: 85vh` вместо ограничений `min-height/max-height` для лучшего контроля размеров + - **ИСПРАВЛЕНО**: Модальное окно размера "large" теперь действительно занимает 85% высоты экрана (`height: 85vh, max-height: 85vh`) + - **УЛУЧШЕНО**: Содержимое модального окна использует `flex: 1` для заполнения всей доступной площади, убран padding для максимального использования пространства +- **Техническая архитектура**: + - Функция `formatHtmlContent()` для автоматического форматирования HTML разметки + - Функция `generateLineNumbers()` для генерации номеров строк + - Компонент `lineNumbersContainer` с невыделяемыми номерами (user-select: none) + - Flexbox layout для правильного размещения кнопок внизу + - Улучшенная обработка различных типов контента (HTML/markup vs обычный текст) + - Правильная работа с Selection API для сохранения позиции курсора в contentEditable элементах + - Синхронизация содержимого редактируемой области без потери фокуса и позиции курсора + ### Исправления авторизации - **КРИТИЧНО**: Исправлена ошибка "Сессия не найдена в Redis" в админ-панели: @@ -12,7 +39,7 @@ - Обновлена обработка словарей в `JWTCodec.encode` для корректной работы с новым форматом - **Результат**: Авторизация в админ-панели работает корректно, токены правильно верифицируются в Redis -### Исправления типизации +### Исправления типизации и качества кода - **ИСПРАВЛЕНО**: Ошибки mypy в `resolvers/topic.py`: - Добавлены аннотации типов для переменных `current_parent_ids`, `source_parent_ids`, `old_parent_ids`, `parent_parent_ids` @@ -22,6 +49,13 @@ - Добавлены `# type: ignore[assignment]` комментарии для присваивания значений SQLAlchemy Column полям - **Результат**: Код проходит проверку mypy без ошибок +- **ИСПРАВЛЕНО**: Ошибки ruff линтера: + - Добавлены `merge_topics` и `set_topic_parent` в `__all__` список в `resolvers/__init__.py` + - Переименована переменная `id` в `topic_id` для избежания затенения встроенной функции Python + - Заменена конкатенация списков `parent_parent_ids + [parent_id]` на современный синтаксис `[*parent_parent_ids, parent_id]` + - Удалена неиспользуемая переменная `old_parent_ids` + - **Результат**: Код проходит проверку ruff без ошибок + ### Новые интерфейсы управления иерархией топиков - **НОВОЕ**: Три варианта интерфейса для управления иерархией тем в админ-панели: diff --git a/panel/modals/ShoutBodyModal.tsx b/panel/modals/ShoutBodyModal.tsx index 8fe2a4d1..6b669aec 100644 --- a/panel/modals/ShoutBodyModal.tsx +++ b/panel/modals/ShoutBodyModal.tsx @@ -41,7 +41,7 @@ const ShoutBodyModal: Component = (props) => {

Содержание

- +
diff --git a/panel/routes/shouts.tsx b/panel/routes/shouts.tsx index 32f48d61..f66d7188 100644 --- a/panel/routes/shouts.tsx +++ b/panel/routes/shouts.tsx @@ -269,10 +269,15 @@ const ShoutsRoute: Component = (props) => { - setShowBodyModal(false)} title="Содержимое публикации"> + setShowBodyModal(false)} + title="Содержимое публикации" + size="large" + > { setSelectedShoutBody(newContent) }} @@ -292,10 +297,11 @@ const ShoutsRoute: Component = (props) => { isOpen={showMediaBodyModal()} onClose={() => setShowMediaBodyModal(false)} title="Содержимое media.body" + size="large" > { setSelectedMediaBody(newContent) }} diff --git a/panel/styles/CodePreview.module.css b/panel/styles/CodePreview.module.css index cff3658a..be25ebab 100644 --- a/panel/styles/CodePreview.module.css +++ b/panel/styles/CodePreview.module.css @@ -4,22 +4,34 @@ background-color: #2d2d2d; color: #f8f8f2; tab-size: 2; - line-height: 1.5; + line-height: 1.4; border-radius: 4px; overflow: hidden; + font-size: 12px; } .lineNumber { - position: absolute; - left: 0; - width: 40px; + display: block; + padding: 0 8px; text-align: right; - color: #999; + color: #555; + background: #1e1e1e; user-select: none; - opacity: 0.5; - padding-right: 10px; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 11px; + line-height: 1.4; + min-height: 16.8px; /* 12px * 1.4 line-height */ border-right: 1px solid rgba(255, 255, 255, 0.1); - margin-right: 10px; + opacity: 0.7; + pointer-events: none; +} + +.lineNumbersContainer { + overflow: hidden; +} + +.lineNumbersContainer .lineNumber { + border-right: none; } .code { @@ -45,7 +57,9 @@ background-color: #2d2d2d; border-radius: 6px; overflow: hidden; - min-height: 200px; + height: 100%; + display: flex; + flex-direction: column; } .editorControls { @@ -53,7 +67,9 @@ justify-content: flex-end; padding: 8px 12px; background-color: #1e1e1e; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid rgba(255, 255, 255, 0.1); + border-bottom: none; + order: 2; /* Перемещаем вниз */ } .editingControls { @@ -111,21 +127,28 @@ overflow: hidden; background-color: #2d2d2d; transition: border 0.2s; + flex: 1; + order: 1; /* Основной контент вверху */ } .syntaxHighlight { width: 100%; height: 100%; tab-size: 2; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + line-height: 1.4; } .editorArea { - min-height: 150px; resize: none; border: none; width: 100%; height: 100%; tab-size: 2; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + line-height: 1.4; } .editorArea:focus { diff --git a/panel/styles/Modal.module.css b/panel/styles/Modal.module.css index 460ed55f..9fb9922e 100644 --- a/panel/styles/Modal.module.css +++ b/panel/styles/Modal.module.css @@ -18,7 +18,7 @@ box-shadow: var(--shadow-lg); display: flex; flex-direction: column; - max-height: 90vh; + max-height: 95vh; width: 100%; animation: modal-appear 0.2s ease-out; } @@ -35,12 +35,14 @@ .modal-large { max-width: 1200px; width: 95vw; - min-height: 600px; + height: 85vh; + max-height: 85vh; } .modal-large .content { - max-height: 70vh; - overflow-y: auto; + flex: 1; + overflow: hidden; /* Убираем скролл модального окна, пусть EditableCodePreview управляет */ + padding: 0; /* Убираем padding чтобы EditableCodePreview занял всю площадь */ } .header { @@ -139,7 +141,7 @@ } .modal-large .content { - max-height: 60vh; + max-height: 80vh; } } diff --git a/panel/ui/EditableCodePreview.tsx b/panel/ui/EditableCodePreview.tsx index e328756d..507caed1 100644 --- a/panel/ui/EditableCodePreview.tsx +++ b/panel/ui/EditableCodePreview.tsx @@ -1,5 +1,5 @@ import Prism from 'prismjs' -import { createEffect, createSignal, onMount } from 'solid-js' +import { createEffect, createSignal, onMount, Show } from 'solid-js' import 'prismjs/components/prism-json' import 'prismjs/components/prism-markup' import 'prismjs/components/prism-javascript' @@ -20,6 +20,62 @@ interface EditableCodePreviewProps { showButtons?: boolean } +/** + * Форматирует HTML контент для лучшего отображения + * Убирает лишние пробелы и делает разметку красивой + */ +const formatHtmlContent = (html: string): string => { + if (!html || typeof html !== 'string') return '' + + // Удаляем лишние пробелы между тегами + let formatted = html + .replace(/>\s+<') // Убираем пробелы между тегами + .replace(/\s+/g, ' ') // Множественные пробелы в одиночные + .trim() // Убираем пробелы в начале и конце + + // Добавляем отступы для лучшего отображения + const indent = ' ' + let indentLevel = 0 + const lines: string[] = [] + + // Разбиваем на токены (теги и текст) + const tokens = formatted.match(/<[^>]+>|[^<]+/g) || [] + + for (const token of tokens) { + if (token.startsWith('<')) { + if (token.startsWith('')) { + // Самозакрывающийся тег + lines.push(indent.repeat(indentLevel) + token) + } else { + // Открывающий тег - добавляем отступ + lines.push(indent.repeat(indentLevel) + token) + indentLevel++ + } + } else { + // Текстовое содержимое + const trimmed = token.trim() + if (trimmed) { + lines.push(indent.repeat(indentLevel) + trimmed) + } + } + } + + return lines.join('\n') +} + +/** + * Генерирует номера строк для текста + */ +const generateLineNumbers = (text: string): string[] => { + if (!text) return ['1'] + const lines = text.split('\n') + return lines.map((_, index) => String(index + 1)) +} + /** * Редактируемый компонент для кода с подсветкой синтаксиса */ @@ -28,6 +84,7 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => { const [content, setContent] = createSignal(props.content) let editorRef: HTMLDivElement | undefined let highlightRef: HTMLPreElement | undefined + let lineNumbersRef: HTMLDivElement | undefined const language = () => props.language || detectLanguage(content()) @@ -52,6 +109,18 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => { } } + /** + * Обновляет номера строк + */ + const updateLineNumbers = () => { + if (!lineNumbersRef) return + + const lineNumbers = generateLineNumbers(content()) + lineNumbersRef.innerHTML = lineNumbers + .map(num => `
${num}
`) + .join('') + } + /** * Синхронизирует скролл между редактором и подсветкой */ @@ -60,6 +129,9 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => { highlightRef.scrollTop = editorRef.scrollTop highlightRef.scrollLeft = editorRef.scrollLeft } + if (editorRef && lineNumbersRef) { + lineNumbersRef.scrollTop = editorRef.scrollTop + } } /** @@ -67,10 +139,43 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => { */ const handleInput = (e: Event) => { const target = e.target as HTMLDivElement + + // Сохраняем текущую позицию курсора + const selection = window.getSelection() + let caretOffset = 0 + + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0) + const preCaretRange = range.cloneRange() + preCaretRange.selectNodeContents(target) + preCaretRange.setEnd(range.endContainer, range.endOffset) + caretOffset = preCaretRange.toString().length + } + const newContent = target.textContent || '' setContent(newContent) props.onContentChange(newContent) updateHighlight() + updateLineNumbers() + + // Восстанавливаем позицию курсора после обновления + requestAnimationFrame(() => { + if (target && selection) { + try { + const textNode = target.firstChild + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + const range = document.createRange() + const safeOffset = Math.min(caretOffset, textNode.textContent?.length || 0) + range.setStart(textNode, safeOffset) + range.setEnd(textNode, safeOffset) + selection.removeAllRanges() + selection.addRange(range) + } + } catch (error) { + console.warn('Could not restore caret position:', error) + } + } + }) } /** @@ -87,7 +192,14 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => { * Обработчик отмены */ const handleCancel = () => { - setContent(props.content) // Возвращаем исходный контент + const originalContent = props.content + setContent(originalContent) // Возвращаем исходный контент + + // Обновляем содержимое редактируемой области + if (editorRef) { + editorRef.textContent = originalContent + } + if (props.onCancel) { props.onCancel() } @@ -115,7 +227,6 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => { // Tab для отступа if (e.key === 'Tab') { e.preventDefault() - // const target = e.target as HTMLDivElement const selection = window.getSelection() if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0) @@ -132,8 +243,12 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => { // Эффект для обновления контента при изменении props createEffect(() => { if (!isEditing()) { - setContent(props.content) + const formattedContent = language() === 'markup' || language() === 'html' + ? formatHtmlContent(props.content) + : props.content + setContent(formattedContent) updateHighlight() + updateLineNumbers() } }) @@ -141,62 +256,108 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => { createEffect(() => { content() // Реактивность updateHighlight() + updateLineNumbers() + }) + + // Эффект для синхронизации редактируемой области с content + createEffect(() => { + if (editorRef) { + const currentContent = content() + if (editorRef.textContent !== currentContent) { + // Сохраняем позицию курсора + const selection = window.getSelection() + let caretOffset = 0 + + if (selection && selection.rangeCount > 0 && isEditing()) { + const range = selection.getRangeAt(0) + const preCaretRange = range.cloneRange() + preCaretRange.selectNodeContents(editorRef) + preCaretRange.setEnd(range.endContainer, range.endOffset) + caretOffset = preCaretRange.toString().length + } + + editorRef.textContent = currentContent + + // Восстанавливаем курсор только в режиме редактирования + if (isEditing() && selection) { + requestAnimationFrame(() => { + try { + const textNode = editorRef?.firstChild + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + const range = document.createRange() + const safeOffset = Math.min(caretOffset, textNode.textContent?.length || 0) + range.setStart(textNode, safeOffset) + range.setEnd(textNode, safeOffset) + selection.removeAllRanges() + selection.addRange(range) + } + } catch (error) { + console.warn('Could not restore caret position:', error) + } + }) + } + } + } }) onMount(() => { + const formattedContent = language() === 'markup' || language() === 'html' + ? formatHtmlContent(props.content) + : props.content + setContent(formattedContent) updateHighlight() + updateLineNumbers() }) return (
- {/* Кнопки управления */} - {props.showButtons !== false && ( -
- {!isEditing() ? ( - - ) : ( -
- - -
- )} -
- )} - - {/* Контейнер редактора */} + {/* Контейнер редактора - увеличиваем размер */}
- {/* Подсветка синтаксиса (фон) */} -
+ {/* Индикатор языка */} + + {language()} + + {/* Плейсхолдер */} - {!content() && ( +
setIsEditing(true)} - style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #666; cursor: pointer; font-style: italic;" + style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #666; cursor: pointer; font-style: italic; font-size: 14px;" > {props.placeholder || 'Нажмите для редактирования...'}
- )} +
- {/* Индикатор языка */} - {language()} + {/* Кнопки управления внизу */} + {props.showButtons !== false && ( +
+ setIsEditing(true)}> + ✏️ Редактировать + + }> +
+ + +
+
+
+ )}
) }