2025-07-02 19:30:21 +00:00
|
|
|
|
import { createEffect, createMemo, createSignal, Show } from 'solid-js'
|
2025-06-30 18:25:26 +00:00
|
|
|
|
import 'prismjs/themes/prism-tomorrow.css'
|
|
|
|
|
|
|
|
|
|
import styles from '../styles/CodePreview.module.css'
|
2025-07-02 19:30:21 +00:00
|
|
|
|
import {
|
|
|
|
|
DEFAULT_EDITOR_CONFIG,
|
|
|
|
|
detectLanguage,
|
|
|
|
|
formatCode,
|
|
|
|
|
handleTabKey,
|
|
|
|
|
highlightCode
|
|
|
|
|
} from '../utils/codeHelpers'
|
2025-06-30 18:25:26 +00:00
|
|
|
|
|
|
|
|
|
interface EditableCodePreviewProps {
|
|
|
|
|
content: string
|
|
|
|
|
onContentChange: (newContent: string) => void
|
|
|
|
|
onSave?: (content: string) => void
|
|
|
|
|
onCancel?: () => void
|
|
|
|
|
language?: string
|
|
|
|
|
maxHeight?: string
|
|
|
|
|
placeholder?: string
|
|
|
|
|
showButtons?: boolean
|
2025-07-02 19:30:21 +00:00
|
|
|
|
autoFormat?: boolean
|
|
|
|
|
readOnly?: boolean
|
|
|
|
|
theme?: 'dark' | 'light' | 'highContrast'
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-07-01 06:10:32 +00:00
|
|
|
|
/**
|
2025-07-02 19:30:21 +00:00
|
|
|
|
* Современный редактор кода с подсветкой синтаксиса и удобными возможностями редактирования
|
|
|
|
|
*
|
|
|
|
|
* Возможности:
|
|
|
|
|
* - Подсветка синтаксиса в реальном времени
|
|
|
|
|
* - Номера строк с синхронизацией скролла
|
|
|
|
|
* - Автоформатирование кода
|
|
|
|
|
* - Горячие клавиши (Ctrl+Enter для сохранения, Esc для отмены)
|
|
|
|
|
* - Обработка Tab для отступов
|
|
|
|
|
* - Сохранение позиции курсора
|
|
|
|
|
* - Адаптивный дизайн
|
|
|
|
|
* - Поддержка тем оформления
|
2025-06-30 18:25:26 +00:00
|
|
|
|
*/
|
|
|
|
|
const EditableCodePreview = (props: EditableCodePreviewProps) => {
|
2025-07-02 19:30:21 +00:00
|
|
|
|
// Состояние компонента
|
2025-06-30 18:25:26 +00:00
|
|
|
|
const [isEditing, setIsEditing] = createSignal(false)
|
|
|
|
|
const [content, setContent] = createSignal(props.content)
|
2025-07-02 19:30:21 +00:00
|
|
|
|
const [isSaving, setIsSaving] = createSignal(false)
|
|
|
|
|
const [hasChanges, setHasChanges] = createSignal(false)
|
|
|
|
|
|
|
|
|
|
// Ссылки на DOM элементы
|
|
|
|
|
let editorRef: HTMLTextAreaElement | undefined
|
2025-06-30 18:25:26 +00:00
|
|
|
|
let highlightRef: HTMLPreElement | undefined
|
2025-07-01 06:10:32 +00:00
|
|
|
|
let lineNumbersRef: HTMLDivElement | undefined
|
2025-06-30 18:25:26 +00:00
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
// Реактивные вычисления
|
|
|
|
|
const language = createMemo(() => props.language || detectLanguage(content()))
|
|
|
|
|
|
|
|
|
|
// Контент для отображения (отформатированный в режиме просмотра, исходный в режиме редактирования)
|
|
|
|
|
const displayContent = createMemo(() => {
|
|
|
|
|
if (isEditing()) {
|
|
|
|
|
return content() // В режиме редактирования показываем исходный код
|
|
|
|
|
}
|
|
|
|
|
return props.autoFormat ? formatCode(content(), language()) : content() // В режиме просмотра - форматированный
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const isEmpty = createMemo(() => !content()?.trim())
|
|
|
|
|
const status = createMemo(() => {
|
|
|
|
|
if (isSaving()) return 'saving'
|
|
|
|
|
if (isEditing()) return 'editing'
|
|
|
|
|
return 'idle'
|
|
|
|
|
})
|
2025-06-30 18:25:26 +00:00
|
|
|
|
|
|
|
|
|
/**
|
2025-07-02 19:30:21 +00:00
|
|
|
|
* Синхронизирует скролл подсветки синтаксиса с textarea
|
2025-06-30 18:25:26 +00:00
|
|
|
|
*/
|
2025-07-02 19:30:21 +00:00
|
|
|
|
const syncScroll = () => {
|
|
|
|
|
if (!editorRef) return
|
2025-06-30 18:25:26 +00:00
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
const scrollTop = editorRef.scrollTop
|
|
|
|
|
const scrollLeft = editorRef.scrollLeft
|
2025-06-30 18:25:26 +00:00
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
// Синхронизируем только подсветку синтаксиса в режиме редактирования
|
|
|
|
|
if (highlightRef && isEditing()) {
|
|
|
|
|
highlightRef.scrollTop = scrollTop
|
|
|
|
|
highlightRef.scrollLeft = scrollLeft
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-01 06:10:32 +00:00
|
|
|
|
/**
|
2025-07-02 19:30:21 +00:00
|
|
|
|
* Генерирует элементы номеров строк для CSS счетчика
|
|
|
|
|
*/
|
|
|
|
|
const generateLineElements = createMemo(() => {
|
|
|
|
|
const lines = displayContent().split('\n')
|
|
|
|
|
return lines.map((_, _index) => <div class={styles.lineNumberItem} />)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Обработчик изменения контента
|
2025-07-01 06:10:32 +00:00
|
|
|
|
*/
|
2025-07-02 19:30:21 +00:00
|
|
|
|
const handleInput = (e: Event) => {
|
|
|
|
|
const target = e.target as HTMLTextAreaElement
|
|
|
|
|
const newContent = target.value
|
2025-07-01 06:10:32 +00:00
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
setContent(newContent)
|
|
|
|
|
setHasChanges(newContent !== props.content)
|
|
|
|
|
props.onContentChange(newContent)
|
2025-07-01 06:10:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-30 18:25:26 +00:00
|
|
|
|
/**
|
2025-07-02 19:30:21 +00:00
|
|
|
|
* Обработчик горячих клавиш
|
2025-06-30 18:25:26 +00:00
|
|
|
|
*/
|
2025-07-02 19:30:21 +00:00
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
// Ctrl+Enter или Cmd+Enter для сохранения
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
void handleSave()
|
|
|
|
|
return
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
2025-07-02 19:30:21 +00:00
|
|
|
|
|
|
|
|
|
// Escape для отмены
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
handleCancel()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ctrl+Shift+F для форматирования
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
handleFormat()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tab для отступов
|
|
|
|
|
if (handleTabKey(e)) {
|
|
|
|
|
// Обновляем контент после вставки отступа
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const _target = e.target as HTMLTextAreaElement
|
|
|
|
|
handleInput(e)
|
|
|
|
|
}, 0)
|
2025-07-01 06:10:32 +00:00
|
|
|
|
}
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-07-02 19:30:21 +00:00
|
|
|
|
* Форматирование кода
|
2025-06-30 18:25:26 +00:00
|
|
|
|
*/
|
2025-07-02 19:30:21 +00:00
|
|
|
|
const handleFormat = () => {
|
|
|
|
|
if (!props.autoFormat) return
|
|
|
|
|
|
|
|
|
|
const formatted = formatCode(content(), language())
|
|
|
|
|
if (formatted !== content()) {
|
|
|
|
|
setContent(formatted)
|
|
|
|
|
setHasChanges(true)
|
|
|
|
|
props.onContentChange(formatted)
|
|
|
|
|
|
|
|
|
|
// Обновляем textarea
|
|
|
|
|
if (editorRef) {
|
|
|
|
|
editorRef.value = formatted
|
2025-07-01 06:10:32 +00:00
|
|
|
|
}
|
2025-07-02 19:30:21 +00:00
|
|
|
|
}
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-07-02 19:30:21 +00:00
|
|
|
|
* Сохранение изменений
|
2025-06-30 18:25:26 +00:00
|
|
|
|
*/
|
2025-07-02 19:30:21 +00:00
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
if (!props.onSave || isSaving()) return
|
|
|
|
|
|
|
|
|
|
setIsSaving(true)
|
|
|
|
|
try {
|
|
|
|
|
await props.onSave(content())
|
|
|
|
|
setHasChanges(false)
|
|
|
|
|
setIsEditing(false)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Ошибка при сохранении:', error)
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false)
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-07-02 19:30:21 +00:00
|
|
|
|
* Отмена изменений
|
2025-06-30 18:25:26 +00:00
|
|
|
|
*/
|
|
|
|
|
const handleCancel = () => {
|
2025-07-01 06:10:32 +00:00
|
|
|
|
const originalContent = props.content
|
2025-07-02 19:30:21 +00:00
|
|
|
|
setContent(originalContent)
|
|
|
|
|
setHasChanges(false)
|
2025-07-01 06:10:32 +00:00
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
// Обновляем textarea
|
2025-07-01 06:10:32 +00:00
|
|
|
|
if (editorRef) {
|
2025-07-02 19:30:21 +00:00
|
|
|
|
editorRef.value = originalContent
|
2025-07-01 06:10:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-30 18:25:26 +00:00
|
|
|
|
if (props.onCancel) {
|
|
|
|
|
props.onCancel()
|
|
|
|
|
}
|
|
|
|
|
setIsEditing(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-07-02 19:30:21 +00:00
|
|
|
|
* Переход в режим редактирования
|
2025-06-30 18:25:26 +00:00
|
|
|
|
*/
|
2025-07-02 19:30:21 +00:00
|
|
|
|
const startEditing = () => {
|
|
|
|
|
if (props.readOnly) return
|
|
|
|
|
|
|
|
|
|
// Форматируем контент при переходе в режим редактирования, если автоформатирование включено
|
|
|
|
|
if (props.autoFormat) {
|
|
|
|
|
const formatted = formatCode(content(), language())
|
|
|
|
|
if (formatted !== content()) {
|
|
|
|
|
setContent(formatted)
|
|
|
|
|
props.onContentChange(formatted)
|
|
|
|
|
}
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
setIsEditing(true)
|
2025-06-30 18:25:26 +00:00
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
// Фокус на editor после рендера
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (editorRef) {
|
|
|
|
|
editorRef.focus()
|
|
|
|
|
// Устанавливаем курсор в конец
|
|
|
|
|
editorRef.setSelectionRange(editorRef.value.length, editorRef.value.length)
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
2025-07-02 19:30:21 +00:00
|
|
|
|
}, 50)
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Эффект для обновления контента при изменении props
|
|
|
|
|
createEffect(() => {
|
|
|
|
|
if (!isEditing()) {
|
2025-07-02 19:30:21 +00:00
|
|
|
|
setContent(props.content)
|
|
|
|
|
setHasChanges(false)
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
// Эффект для синхронизации textarea с content
|
2025-07-01 06:10:32 +00:00
|
|
|
|
createEffect(() => {
|
2025-07-02 19:30:21 +00:00
|
|
|
|
if (editorRef && editorRef.value !== content()) {
|
|
|
|
|
editorRef.value = content()
|
2025-07-01 06:10:32 +00:00
|
|
|
|
}
|
2025-06-30 18:25:26 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return (
|
2025-07-02 19:30:21 +00:00
|
|
|
|
<div class={`${styles.editableCodeContainer} ${styles[props.theme || 'darkTheme']}`}>
|
|
|
|
|
{/* Основной контейнер редактора */}
|
|
|
|
|
<div class={`${styles.editorContainer} ${isEditing() ? styles.editing : ''}`}>
|
|
|
|
|
{/* Область кода */}
|
|
|
|
|
<div class={styles.codeArea}>
|
|
|
|
|
{/* Контейнер для кода со скроллом */}
|
|
|
|
|
<div class={styles.codeContentWrapper}>
|
|
|
|
|
{/* Контейнер для самого кода */}
|
|
|
|
|
<div class={styles.codeTextWrapper}>
|
|
|
|
|
{/* Нумерация строк внутри скроллящегося контейнера */}
|
|
|
|
|
<div ref={lineNumbersRef} class={styles.lineNumbers}>
|
|
|
|
|
{generateLineElements()}
|
|
|
|
|
</div>
|
|
|
|
|
{/* Подсветка синтаксиса в режиме редактирования */}
|
|
|
|
|
<Show when={isEditing()}>
|
|
|
|
|
<pre
|
|
|
|
|
ref={highlightRef}
|
|
|
|
|
class={styles.syntaxHighlight}
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
innerHTML={highlightCode(displayContent(), language())}
|
|
|
|
|
/>
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
{/* Режим просмотра или редактирования */}
|
|
|
|
|
<Show
|
|
|
|
|
when={isEditing()}
|
|
|
|
|
fallback={
|
|
|
|
|
<Show
|
|
|
|
|
when={!isEmpty()}
|
|
|
|
|
fallback={
|
|
|
|
|
<div class={styles.placeholderClickable} onClick={startEditing}>
|
|
|
|
|
{props.placeholder || 'Нажмите для редактирования...'}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<pre
|
|
|
|
|
class={styles.codePreviewContent}
|
|
|
|
|
onClick={startEditing}
|
|
|
|
|
innerHTML={highlightCode(displayContent(), language())}
|
|
|
|
|
/>
|
|
|
|
|
</Show>
|
2025-06-30 18:25:26 +00:00
|
|
|
|
}
|
2025-07-02 19:30:21 +00:00
|
|
|
|
>
|
|
|
|
|
<textarea
|
|
|
|
|
ref={editorRef}
|
|
|
|
|
class={styles.editorTextarea}
|
|
|
|
|
value={content()}
|
|
|
|
|
onInput={handleInput}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
onScroll={syncScroll}
|
|
|
|
|
placeholder={props.placeholder || 'Введите код...'}
|
|
|
|
|
spellcheck={false}
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
autocorrect="off"
|
|
|
|
|
autocapitalize="off"
|
|
|
|
|
wrap="off"
|
|
|
|
|
style={`
|
|
|
|
|
font-family: ${DEFAULT_EDITOR_CONFIG.fontFamily};
|
|
|
|
|
font-size: ${DEFAULT_EDITOR_CONFIG.fontSize}px;
|
|
|
|
|
line-height: ${DEFAULT_EDITOR_CONFIG.lineHeight};
|
|
|
|
|
tab-size: ${DEFAULT_EDITOR_CONFIG.tabSize};
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: transparent;
|
|
|
|
|
caret-color: var(--code-text);
|
|
|
|
|
`}
|
|
|
|
|
/>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-06-30 18:25:26 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
2025-07-02 19:30:21 +00:00
|
|
|
|
{/* Панель управления */}
|
|
|
|
|
<div class={styles.controls}>
|
|
|
|
|
{/* Левая часть - информация */}
|
|
|
|
|
<div class={styles.controlsLeft}>
|
|
|
|
|
<span class={styles.languageBadge}>{language()}</span>
|
|
|
|
|
|
|
|
|
|
<div class={styles.statusIndicator}>
|
|
|
|
|
<div class={`${styles.statusDot} ${styles[status()]}`} />
|
|
|
|
|
<span>
|
|
|
|
|
{status() === 'saving' && 'Сохранение...'}
|
|
|
|
|
{status() === 'editing' && 'Редактирование'}
|
|
|
|
|
{status() === 'idle' && (hasChanges() ? 'Есть изменения' : 'Сохранено')}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Show when={hasChanges()}>
|
|
|
|
|
<span style="color: var(--code-warning); font-size: 11px;">●</span>
|
2025-07-01 06:10:32 +00:00
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
2025-07-02 19:30:21 +00:00
|
|
|
|
|
|
|
|
|
{/* Правая часть - кнопки */}
|
|
|
|
|
<Show when={props.showButtons !== false}>
|
|
|
|
|
<div class={styles.controlsRight}>
|
|
|
|
|
<Show
|
|
|
|
|
when={!isEditing()}
|
|
|
|
|
fallback={
|
|
|
|
|
<div class={`${styles.editingControls} ${styles.fadeIn}`}>
|
|
|
|
|
<Show when={props.autoFormat}>
|
|
|
|
|
<button
|
|
|
|
|
class={styles.formatButton}
|
|
|
|
|
onClick={handleFormat}
|
|
|
|
|
disabled={isSaving()}
|
|
|
|
|
title="Форматировать код (Ctrl+Shift+F)"
|
|
|
|
|
>
|
|
|
|
|
🎨 Форматировать
|
|
|
|
|
</button>
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
class={styles.saveButton}
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={isSaving() || !hasChanges()}
|
|
|
|
|
title="Сохранить изменения (Ctrl+Enter)"
|
|
|
|
|
>
|
|
|
|
|
{isSaving() ? '⏳ Сохранение...' : '💾 Сохранить'}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
class={styles.cancelButton}
|
|
|
|
|
onClick={handleCancel}
|
|
|
|
|
disabled={isSaving()}
|
|
|
|
|
title="Отменить изменения (Esc)"
|
|
|
|
|
>
|
|
|
|
|
❌ Отмена
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Show when={!props.readOnly}>
|
|
|
|
|
<button class={styles.editButton} onClick={startEditing} title="Редактировать код">
|
|
|
|
|
✏️ Редактировать
|
|
|
|
|
</button>
|
|
|
|
|
</Show>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
2025-06-30 18:25:26 +00:00
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default EditableCodePreview
|