This commit is contained in:
351
panel/ui/HTMLEditor.tsx
Normal file
351
panel/ui/HTMLEditor.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* HTML редактор с подсветкой синтаксиса через contenteditable
|
||||
* @module HTMLEditor
|
||||
*/
|
||||
|
||||
import { createEffect, onMount, untrack, createSignal } from 'solid-js'
|
||||
import Prism from 'prismjs'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/themes/prism.css'
|
||||
import styles from '../styles/Form.module.css'
|
||||
|
||||
interface HTMLEditorProps {
|
||||
value: string
|
||||
onInput: (value: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент HTML редактора с contenteditable и подсветкой синтаксиса
|
||||
*/
|
||||
const HTMLEditor = (props: HTMLEditorProps) => {
|
||||
let editorElement: HTMLDivElement | undefined
|
||||
const [isUpdating, setIsUpdating] = createSignal(false)
|
||||
|
||||
// Функция для принудительного обновления подсветки
|
||||
const forceHighlight = (element?: Element) => {
|
||||
if (!element) return
|
||||
|
||||
// Многократная попытка подсветки для надежности
|
||||
const attemptHighlight = (attempts = 0) => {
|
||||
if (attempts > 3) return // Максимум 3 попытки
|
||||
|
||||
if (typeof window !== 'undefined' && window.Prism && element) {
|
||||
try {
|
||||
Prism.highlightElement(element)
|
||||
} catch (error) {
|
||||
console.warn('Prism highlight failed, retrying...', error)
|
||||
setTimeout(() => attemptHighlight(attempts + 1), 50)
|
||||
}
|
||||
} else {
|
||||
setTimeout(() => attemptHighlight(attempts + 1), 50)
|
||||
}
|
||||
}
|
||||
|
||||
attemptHighlight()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (editorElement) {
|
||||
// Устанавливаем начальное содержимое сразу
|
||||
updateContentWithoutCursor()
|
||||
|
||||
// Принудительно перезапускаем подсветку через короткий таймаут
|
||||
setTimeout(() => {
|
||||
if (editorElement) {
|
||||
const codeElement = editorElement.querySelector('code')
|
||||
if (codeElement) {
|
||||
forceHighlight(codeElement)
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
|
||||
// Устанавливаем фокус в конец если есть содержимое
|
||||
if (props.value) {
|
||||
setTimeout(() => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
const codeElement = editorElement?.querySelector('code')
|
||||
if (codeElement && codeElement.firstChild) {
|
||||
range.setStart(codeElement.firstChild, codeElement.firstChild.textContent?.length || 0)
|
||||
range.setEnd(codeElement.firstChild, codeElement.firstChild.textContent?.length || 0)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Обновляем содержимое при изменении props.value извне
|
||||
createEffect(() => {
|
||||
const newValue = props.value
|
||||
|
||||
untrack(() => {
|
||||
if (editorElement && !isUpdating()) {
|
||||
const currentText = getPlainText()
|
||||
|
||||
// Обновляем только если значение действительно изменилось извне
|
||||
// и элемент не в фокусе (чтобы не мешать вводу)
|
||||
if (newValue !== currentText && document.activeElement !== editorElement) {
|
||||
updateContentWithoutCursor()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const updateContent = () => {
|
||||
if (!editorElement || isUpdating()) return
|
||||
|
||||
const value = untrack(() => props.value) || ''
|
||||
|
||||
// Сохраняем позицию курсора более надежно
|
||||
const selection = window.getSelection()
|
||||
let savedRange: Range | null = null
|
||||
let cursorOffset = 0
|
||||
|
||||
if (selection && selection.rangeCount > 0 && document.activeElement === editorElement) {
|
||||
const range = selection.getRangeAt(0)
|
||||
savedRange = range.cloneRange()
|
||||
|
||||
// Вычисляем общий offset относительно всего текстового содержимого
|
||||
const walker = document.createTreeWalker(
|
||||
editorElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
)
|
||||
|
||||
let node
|
||||
let totalOffset = 0
|
||||
|
||||
while (node = walker.nextNode()) {
|
||||
if (node === range.startContainer) {
|
||||
cursorOffset = totalOffset + range.startOffset
|
||||
break
|
||||
}
|
||||
totalOffset += node.textContent?.length || 0
|
||||
}
|
||||
}
|
||||
|
||||
if (value.trim()) {
|
||||
// Экранируем HTML для безопасности
|
||||
const escapedValue = value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
editorElement.innerHTML = `<code class="language-html">${escapedValue}</code>`
|
||||
|
||||
// Применяем подсветку с дополнительной проверкой
|
||||
const codeElement = editorElement.querySelector('code')
|
||||
if (codeElement) {
|
||||
forceHighlight(codeElement)
|
||||
|
||||
// Восстанавливаем позицию курсора только если элемент в фокусе
|
||||
if (cursorOffset > 0 && document.activeElement === editorElement) {
|
||||
setTimeout(() => {
|
||||
const walker = document.createTreeWalker(
|
||||
codeElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
)
|
||||
let currentOffset = 0
|
||||
let node
|
||||
|
||||
while (node = walker.nextNode()) {
|
||||
const nodeLength = node.textContent?.length || 0
|
||||
if (currentOffset + nodeLength >= cursorOffset) {
|
||||
try {
|
||||
const range = document.createRange()
|
||||
const newSelection = window.getSelection()
|
||||
const targetOffset = Math.min(cursorOffset - currentOffset, nodeLength)
|
||||
|
||||
range.setStart(node, targetOffset)
|
||||
range.setEnd(node, targetOffset)
|
||||
newSelection?.removeAllRanges()
|
||||
newSelection?.addRange(range)
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки позиционирования курсора
|
||||
}
|
||||
break
|
||||
}
|
||||
currentOffset += nodeLength
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Для пустого содержимого просто очищаем
|
||||
editorElement.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
const updateContentWithoutCursor = () => {
|
||||
if (!editorElement) return
|
||||
|
||||
const value = props.value || ''
|
||||
|
||||
if (value.trim()) {
|
||||
// Экранируем HTML для безопасности
|
||||
const escapedValue = value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
editorElement.innerHTML = `<code class="language-html">${escapedValue}</code>`
|
||||
|
||||
// Применяем подсветку с дополнительной проверкой
|
||||
const codeElement = editorElement.querySelector('code')
|
||||
if (codeElement) {
|
||||
forceHighlight(codeElement)
|
||||
}
|
||||
} else {
|
||||
// Для пустого содержимого просто очищаем
|
||||
editorElement.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
const getPlainText = (): string => {
|
||||
if (!editorElement) return ''
|
||||
|
||||
// Получаем текстовое содержимое с правильной обработкой новых строк
|
||||
let text = ''
|
||||
|
||||
const processNode = (node: Node): void => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += node.textContent || ''
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element
|
||||
|
||||
// Обрабатываем элементы, которые должны создавать новые строки
|
||||
if (element.tagName === 'DIV' || element.tagName === 'P') {
|
||||
// Добавляем новую строку перед содержимым div/p (кроме первого)
|
||||
if (text && !text.endsWith('\n')) {
|
||||
text += '\n'
|
||||
}
|
||||
|
||||
// Обрабатываем дочерние элементы
|
||||
for (const child of Array.from(element.childNodes)) {
|
||||
processNode(child)
|
||||
}
|
||||
|
||||
// Добавляем новую строку после содержимого div/p
|
||||
if (!text.endsWith('\n')) {
|
||||
text += '\n'
|
||||
}
|
||||
} else if (element.tagName === 'BR') {
|
||||
text += '\n'
|
||||
} else {
|
||||
// Для других элементов просто обрабатываем содержимое
|
||||
for (const child of Array.from(element.childNodes)) {
|
||||
processNode(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
processNode(editorElement)
|
||||
} catch (e) {
|
||||
// В случае ошибки возвращаем базовый textContent
|
||||
return editorElement.textContent || ''
|
||||
}
|
||||
|
||||
// Убираем лишние новые строки в конце
|
||||
return text.replace(/\n+$/, '')
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
if (!editorElement || isUpdating()) return
|
||||
|
||||
setIsUpdating(true)
|
||||
|
||||
const text = untrack(() => getPlainText())
|
||||
|
||||
// Обновляем значение через props, используя untrack для избежания циклических обновлений
|
||||
untrack(() => props.onInput(text))
|
||||
|
||||
// Отложенное обновление подсветки без влияния на курсор
|
||||
setTimeout(() => {
|
||||
// Используем untrack для всех операций чтения состояния
|
||||
untrack(() => {
|
||||
if (document.activeElement === editorElement && !isUpdating()) {
|
||||
const currentText = getPlainText()
|
||||
if (currentText === text && editorElement) {
|
||||
updateContent()
|
||||
}
|
||||
}
|
||||
setIsUpdating(false)
|
||||
})
|
||||
}, 100) // Ещё меньше задержка
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Поддержка Tab для отступов
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.getRangeAt(0)
|
||||
|
||||
if (range) {
|
||||
const textNode = document.createTextNode(' ')
|
||||
range.insertNode(textNode)
|
||||
range.setStartAfter(textNode)
|
||||
range.setEndAfter(textNode)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
// Обновляем содержимое без задержки для Tab
|
||||
untrack(() => {
|
||||
const text = getPlainText()
|
||||
props.onInput(text)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Для Enter не делаем ничего - полностью полагаемся на handleInput
|
||||
if (e.key === 'Enter') {
|
||||
// Полностью доверяем handleInput обработать изменение
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData?.getData('text/plain') || ''
|
||||
document.execCommand('insertText', false, text)
|
||||
|
||||
// Обновляем значение после вставки
|
||||
setTimeout(() => {
|
||||
untrack(() => {
|
||||
const newText = getPlainText()
|
||||
props.onInput(newText)
|
||||
})
|
||||
}, 10)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editorElement}
|
||||
class={`${styles.htmlEditorContenteditable} ${props.class || ''}`}
|
||||
contenteditable={!props.disabled}
|
||||
data-placeholder={props.placeholder}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
style={{
|
||||
'min-height': `${(props.rows || 6) * 1.6}em`
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HTMLEditor
|
Reference in New Issue
Block a user