admin-body-editor-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-07-01 09:10:32 +03:00
parent 30757fb38a
commit 547c934302
6 changed files with 331 additions and 75 deletions

View File

@ -2,6 +2,33 @@
## [0.6.0] - 2025-07-01 ## [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" в админ-панели: - **КРИТИЧНО**: Исправлена ошибка "Сессия не найдена в Redis" в админ-панели:
@ -12,7 +39,7 @@
- Обновлена обработка словарей в `JWTCodec.encode` для корректной работы с новым форматом - Обновлена обработка словарей в `JWTCodec.encode` для корректной работы с новым форматом
- **Результат**: Авторизация в админ-панели работает корректно, токены правильно верифицируются в Redis - **Результат**: Авторизация в админ-панели работает корректно, токены правильно верифицируются в Redis
### Исправления типизации ### Исправления типизации и качества кода
- **ИСПРАВЛЕНО**: Ошибки mypy в `resolvers/topic.py`: - **ИСПРАВЛЕНО**: Ошибки mypy в `resolvers/topic.py`:
- Добавлены аннотации типов для переменных `current_parent_ids`, `source_parent_ids`, `old_parent_ids`, `parent_parent_ids` - Добавлены аннотации типов для переменных `current_parent_ids`, `source_parent_ids`, `old_parent_ids`, `parent_parent_ids`
@ -22,6 +49,13 @@
- Добавлены `# type: ignore[assignment]` комментарии для присваивания значений SQLAlchemy Column полям - Добавлены `# type: ignore[assignment]` комментарии для присваивания значений SQLAlchemy Column полям
- **Результат**: Код проходит проверку mypy без ошибок - **Результат**: Код проходит проверку 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 без ошибок
### Новые интерфейсы управления иерархией топиков ### Новые интерфейсы управления иерархией топиков
- **НОВОЕ**: Три варианта интерфейса для управления иерархией тем в админ-панели: - **НОВОЕ**: Три варианта интерфейса для управления иерархией тем в админ-панели:

View File

@ -41,7 +41,7 @@ const ShoutBodyModal: Component<ShoutBodyModalProps> = (props) => {
<div class={styles['shout-content']}> <div class={styles['shout-content']}>
<h3>Содержание</h3> <h3>Содержание</h3>
<div class={styles['content-preview']}> <div class={styles['content-preview']}>
<TextPreview content={props.shout.body || ''} maxHeight="70vh" /> <TextPreview content={props.shout.body || ''} maxHeight="85vh" />
</div> </div>
</div> </div>
</div> </div>

View File

@ -269,10 +269,15 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
</div> </div>
</Show> </Show>
<Modal isOpen={showBodyModal()} onClose={() => setShowBodyModal(false)} title="Содержимое публикации"> <Modal
isOpen={showBodyModal()}
onClose={() => setShowBodyModal(false)}
title="Содержимое публикации"
size="large"
>
<EditableCodePreview <EditableCodePreview
content={selectedShoutBody()} content={selectedShoutBody()}
maxHeight="70vh" maxHeight="85vh"
onContentChange={(newContent) => { onContentChange={(newContent) => {
setSelectedShoutBody(newContent) setSelectedShoutBody(newContent)
}} }}
@ -292,10 +297,11 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
isOpen={showMediaBodyModal()} isOpen={showMediaBodyModal()}
onClose={() => setShowMediaBodyModal(false)} onClose={() => setShowMediaBodyModal(false)}
title="Содержимое media.body" title="Содержимое media.body"
size="large"
> >
<EditableCodePreview <EditableCodePreview
content={selectedMediaBody()} content={selectedMediaBody()}
maxHeight="70vh" maxHeight="85vh"
onContentChange={(newContent) => { onContentChange={(newContent) => {
setSelectedMediaBody(newContent) setSelectedMediaBody(newContent)
}} }}

View File

@ -4,22 +4,34 @@
background-color: #2d2d2d; background-color: #2d2d2d;
color: #f8f8f2; color: #f8f8f2;
tab-size: 2; tab-size: 2;
line-height: 1.5; line-height: 1.4;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
font-size: 12px;
} }
.lineNumber { .lineNumber {
position: absolute; display: block;
left: 0; padding: 0 8px;
width: 40px;
text-align: right; text-align: right;
color: #999; color: #555;
background: #1e1e1e;
user-select: none; user-select: none;
opacity: 0.5; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
padding-right: 10px; 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); 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 { .code {
@ -45,7 +57,9 @@
background-color: #2d2d2d; background-color: #2d2d2d;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
min-height: 200px; height: 100%;
display: flex;
flex-direction: column;
} }
.editorControls { .editorControls {
@ -53,7 +67,9 @@
justify-content: flex-end; justify-content: flex-end;
padding: 8px 12px; padding: 8px 12px;
background-color: #1e1e1e; 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 { .editingControls {
@ -111,21 +127,28 @@
overflow: hidden; overflow: hidden;
background-color: #2d2d2d; background-color: #2d2d2d;
transition: border 0.2s; transition: border 0.2s;
flex: 1;
order: 1; /* Основной контент вверху */
} }
.syntaxHighlight { .syntaxHighlight {
width: 100%; width: 100%;
height: 100%; height: 100%;
tab-size: 2; tab-size: 2;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
} }
.editorArea { .editorArea {
min-height: 150px;
resize: none; resize: none;
border: none; border: none;
width: 100%; width: 100%;
height: 100%; height: 100%;
tab-size: 2; tab-size: 2;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
} }
.editorArea:focus { .editorArea:focus {

View File

@ -18,7 +18,7 @@
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 90vh; max-height: 95vh;
width: 100%; width: 100%;
animation: modal-appear 0.2s ease-out; animation: modal-appear 0.2s ease-out;
} }
@ -35,12 +35,14 @@
.modal-large { .modal-large {
max-width: 1200px; max-width: 1200px;
width: 95vw; width: 95vw;
min-height: 600px; height: 85vh;
max-height: 85vh;
} }
.modal-large .content { .modal-large .content {
max-height: 70vh; flex: 1;
overflow-y: auto; overflow: hidden; /* Убираем скролл модального окна, пусть EditableCodePreview управляет */
padding: 0; /* Убираем padding чтобы EditableCodePreview занял всю площадь */
} }
.header { .header {
@ -139,7 +141,7 @@
} }
.modal-large .content { .modal-large .content {
max-height: 60vh; max-height: 80vh;
} }
} }

View File

@ -1,5 +1,5 @@
import Prism from 'prismjs' 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-json'
import 'prismjs/components/prism-markup' import 'prismjs/components/prism-markup'
import 'prismjs/components/prism-javascript' import 'prismjs/components/prism-javascript'
@ -20,6 +20,62 @@ interface EditableCodePreviewProps {
showButtons?: boolean showButtons?: boolean
} }
/**
* Форматирует HTML контент для лучшего отображения
* Убирает лишние пробелы и делает разметку красивой
*/
const formatHtmlContent = (html: string): string => {
if (!html || typeof html !== 'string') return ''
// Удаляем лишние пробелы между тегами
let formatted = html
.replace(/>\s+</g, '><') // Убираем пробелы между тегами
.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('</')) {
// Закрывающий тег - уменьшаем отступ
indentLevel = Math.max(0, indentLevel - 1)
lines.push(indent.repeat(indentLevel) + token)
} else if (token.endsWith('/>')) {
// Самозакрывающийся тег
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) const [content, setContent] = createSignal(props.content)
let editorRef: HTMLDivElement | undefined let editorRef: HTMLDivElement | undefined
let highlightRef: HTMLPreElement | undefined let highlightRef: HTMLPreElement | undefined
let lineNumbersRef: HTMLDivElement | undefined
const language = () => props.language || detectLanguage(content()) 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 => `<div class="${styles.lineNumber}">${num}</div>`)
.join('')
}
/** /**
* Синхронизирует скролл между редактором и подсветкой * Синхронизирует скролл между редактором и подсветкой
*/ */
@ -60,6 +129,9 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
highlightRef.scrollTop = editorRef.scrollTop highlightRef.scrollTop = editorRef.scrollTop
highlightRef.scrollLeft = editorRef.scrollLeft highlightRef.scrollLeft = editorRef.scrollLeft
} }
if (editorRef && lineNumbersRef) {
lineNumbersRef.scrollTop = editorRef.scrollTop
}
} }
/** /**
@ -67,10 +139,43 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
*/ */
const handleInput = (e: Event) => { const handleInput = (e: Event) => {
const target = e.target as HTMLDivElement 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 || '' const newContent = target.textContent || ''
setContent(newContent) setContent(newContent)
props.onContentChange(newContent) props.onContentChange(newContent)
updateHighlight() 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 = () => { const handleCancel = () => {
setContent(props.content) // Возвращаем исходный контент const originalContent = props.content
setContent(originalContent) // Возвращаем исходный контент
// Обновляем содержимое редактируемой области
if (editorRef) {
editorRef.textContent = originalContent
}
if (props.onCancel) { if (props.onCancel) {
props.onCancel() props.onCancel()
} }
@ -115,7 +227,6 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
// Tab для отступа // Tab для отступа
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault() e.preventDefault()
// const target = e.target as HTMLDivElement
const selection = window.getSelection() const selection = window.getSelection()
if (selection && selection.rangeCount > 0) { if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
@ -132,8 +243,12 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
// Эффект для обновления контента при изменении props // Эффект для обновления контента при изменении props
createEffect(() => { createEffect(() => {
if (!isEditing()) { if (!isEditing()) {
setContent(props.content) const formattedContent = language() === 'markup' || language() === 'html'
? formatHtmlContent(props.content)
: props.content
setContent(formattedContent)
updateHighlight() updateHighlight()
updateLineNumbers()
} }
}) })
@ -141,62 +256,108 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
createEffect(() => { createEffect(() => {
content() // Реактивность content() // Реактивность
updateHighlight() 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(() => { onMount(() => {
const formattedContent = language() === 'markup' || language() === 'html'
? formatHtmlContent(props.content)
: props.content
setContent(formattedContent)
updateHighlight() updateHighlight()
updateLineNumbers()
}) })
return ( return (
<div class={styles.editableCodeContainer}> <div class={styles.editableCodeContainer}>
{/* Кнопки управления */} {/* Контейнер редактора - увеличиваем размер */}
{props.showButtons !== false && (
<div class={styles.editorControls}>
{!isEditing() ? (
<button class={styles.editButton} onClick={() => setIsEditing(true)}>
Редактировать
</button>
) : (
<div class={styles.editingControls}>
<button class={styles.saveButton} onClick={handleSave}>
💾 Сохранить (Ctrl+Enter)
</button>
<button class={styles.cancelButton} onClick={handleCancel}>
Отмена (Esc)
</button>
</div>
)}
</div>
)}
{/* Контейнер редактора */}
<div <div
class={styles.editorWrapper} class={styles.editorWrapper}
style={`max-height: ${props.maxHeight || '70vh'}; ${isEditing() ? 'border: 2px solid #007acc;' : ''}`} style={`height: 100%; ${isEditing() ? 'border: 2px solid #007acc;' : ''}`}
> >
{/* Подсветка синтаксиса (фон) */} {/* Номера строк */}
<pre <div
ref={highlightRef} ref={lineNumbersRef}
class={`${styles.syntaxHighlight} language-${language()}`} class={styles.lineNumbersContainer}
style="position: absolute; top: 0; left: 0; pointer-events: none; color: transparent; background: transparent; margin: 0; padding: 12px; font-family: 'Fira Code', monospace; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; overflow: hidden;" style="position: absolute; left: 0; top: 0; width: 50px; height: 100%; background: #1e1e1e; border-right: 1px solid rgba(255, 255, 255, 0.1); overflow: hidden; user-select: none; padding: 8px 0; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; line-height: 1.4;"
aria-hidden="true"
/> />
{/* Подсветка синтаксиса (фон) - только в режиме редактирования */}
<Show when={isEditing()}>
<pre
ref={highlightRef}
class={`${styles.syntaxHighlight} language-${language()}`}
style="position: absolute; top: 0; left: 50px; right: 0; bottom: 0; pointer-events: none; color: transparent; background: transparent; margin: 0; padding: 8px 12px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; overflow: hidden; z-index: 0;"
aria-hidden="true"
/>
</Show>
{/* Редактируемая область */} {/* Редактируемая область */}
<div <div
ref={editorRef} ref={(el) => {
editorRef = el
// Синхронизируем содержимое при создании элемента
if (el && el.textContent !== content()) {
el.textContent = content()
}
}}
contentEditable={isEditing()} contentEditable={isEditing()}
class={styles.editorArea} class={styles.editorArea}
style={` style={`
position: relative; position: absolute;
top: 0;
left: 50px;
right: 0;
bottom: 0;
z-index: 1; z-index: 1;
background: ${isEditing() ? 'rgba(0, 0, 0, 0.05)' : 'transparent'}; background: ${isEditing() ? 'rgba(0, 0, 0, 0.02)' : 'transparent'};
color: ${isEditing() ? 'rgba(255, 255, 255, 0.9)' : 'transparent'}; color: ${isEditing() ? 'rgba(255, 255, 255, 0.9)' : 'transparent'};
margin: 0; margin: 0;
padding: 12px; padding: 8px 12px;
font-family: 'Fira Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 14px; font-size: 12px;
line-height: 1.5; line-height: 1.4;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
overflow-y: auto; overflow-y: auto;
@ -208,29 +369,37 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onScroll={syncScroll} onScroll={syncScroll}
spellcheck={false} spellcheck={false}
> />
{content()}
</div>
{/* Превью для неактивного режима */} {/* Превью для неактивного режима */}
{!isEditing() && ( <Show when={!isEditing()}>
<pre <pre
class={`${styles.codePreview} language-${language()}`} class={`${styles.codePreview} language-${language()}`}
style={` style={`
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 50px;
right: 0;
bottom: 0;
margin: 0; margin: 0;
padding: 12px; padding: 8px 12px;
font-family: 'Fira Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 14px; font-size: 12px;
line-height: 1.5; line-height: 1.4;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
overflow-y: auto;
z-index: 2;
`} `}
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
onScroll={(e) => {
// Синхронизируем номера строк при скролле в режиме просмотра
if (lineNumbersRef) {
lineNumbersRef.scrollTop = (e.target as HTMLElement).scrollTop
}
}}
> >
<code <code
class={`language-${language()}`} class={`language-${language()}`}
@ -243,22 +412,44 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
})()} })()}
/> />
</pre> </pre>
)} </Show>
</div> </div>
{/* Индикатор языка */}
<span class={styles.languageBadge} style="top: 8px; right: 8px; z-index: 10;">
{language()}
</span>
{/* Плейсхолдер */} {/* Плейсхолдер */}
{!content() && ( <Show when={!content()}>
<div <div
class={styles.placeholder} class={styles.placeholder}
onClick={() => setIsEditing(true)} onClick={() => 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 || 'Нажмите для редактирования...'} {props.placeholder || 'Нажмите для редактирования...'}
</div> </div>
)} </Show>
{/* Индикатор языка */} {/* Кнопки управления внизу */}
<span class={styles.languageBadge}>{language()}</span> {props.showButtons !== false && (
<div class={styles.editorControls} style="border-top: 1px solid rgba(255, 255, 255, 0.1); border-bottom: none; background-color: #1e1e1e;">
<Show when={isEditing()} fallback={
<button class={styles.editButton} onClick={() => setIsEditing(true)}>
Редактировать
</button>
}>
<div class={styles.editingControls}>
<button class={styles.saveButton} onClick={handleSave}>
💾 Сохранить (Ctrl+Enter)
</button>
<button class={styles.cancelButton} onClick={handleCancel}>
Отмена (Esc)
</button>
</div>
</Show>
</div>
)}
</div> </div>
) )
} }