352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
/**
|
||
* 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
|