This commit is contained in:
parent
30757fb38a
commit
547c934302
36
CHANGELOG.md
36
CHANGELOG.md
|
@ -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 без ошибок
|
||||||
|
|
||||||
### Новые интерфейсы управления иерархией топиков
|
### Новые интерфейсы управления иерархией топиков
|
||||||
|
|
||||||
- **НОВОЕ**: Три варианта интерфейса для управления иерархией тем в админ-панели:
|
- **НОВОЕ**: Три варианта интерфейса для управления иерархией тем в админ-панели:
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user