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
|
252
panel/ui/TopicPillsCloud.tsx
Normal file
252
panel/ui/TopicPillsCloud.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Компонент облака топиков для выбора родительских тем
|
||||
* @module TopicPillsCloud
|
||||
*/
|
||||
|
||||
import { createSignal, createMemo, For, Show } from 'solid-js'
|
||||
import styles from '../styles/Form.module.css'
|
||||
|
||||
/**
|
||||
* Интерфейс для топика
|
||||
*/
|
||||
export interface TopicPill {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
community: string
|
||||
parent_ids?: string[]
|
||||
depth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Пропсы компонента TopicPillsCloud
|
||||
*/
|
||||
interface TopicPillsCloudProps {
|
||||
/** Доступные топики для выбора */
|
||||
topics: TopicPill[]
|
||||
/** Выбранные топики */
|
||||
selectedTopics: string[]
|
||||
/** Колбек при изменении выбора */
|
||||
onSelectionChange: (selectedIds: string[]) => void
|
||||
/** Фильтр по сообществу */
|
||||
communityFilter?: string
|
||||
/** Исключить топики (например, текущий редактируемый) */
|
||||
excludeTopics?: string[]
|
||||
/** Максимальное количество выбранных топиков */
|
||||
maxSelection?: number
|
||||
/** Заголовок компонента */
|
||||
title?: string
|
||||
/** Показать поиск */
|
||||
showSearch?: boolean
|
||||
/** Плейсхолдер для поиска */
|
||||
searchPlaceholder?: string
|
||||
/** Класс для стилизации */
|
||||
class?: string
|
||||
/** Скрыть выбранные элементы в заголовке (показывать только в основном списке) */
|
||||
hideSelectedInHeader?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент облака топиков для выбора
|
||||
*/
|
||||
const TopicPillsCloud = (props: TopicPillsCloudProps) => {
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
/**
|
||||
* Фильтрованные и отсортированные топики
|
||||
*/
|
||||
const filteredTopics = createMemo(() => {
|
||||
let topics = props.topics
|
||||
|
||||
// Исключаем запрещенные топики
|
||||
if (props.excludeTopics?.length) {
|
||||
topics = topics.filter(topic => !props.excludeTopics!.includes(topic.id))
|
||||
}
|
||||
|
||||
// Фильтруем по поисковому запросу
|
||||
const query = searchQuery().toLowerCase().trim()
|
||||
if (query) {
|
||||
topics = topics.filter(topic =>
|
||||
topic.title.toLowerCase().includes(query) ||
|
||||
topic.slug.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Умная сортировка: выбранные → релевантные → остальные
|
||||
return topics.sort((a, b) => {
|
||||
const aSelected = props.selectedTopics.includes(a.id)
|
||||
const bSelected = props.selectedTopics.includes(b.id)
|
||||
|
||||
// Сначала выбранные топики
|
||||
if (aSelected && !bSelected) return -1
|
||||
if (!aSelected && bSelected) return 1
|
||||
|
||||
// Для не выбранных: приоритет топикам из того же сообщества
|
||||
if (props.communityFilter) {
|
||||
const aSameCommunity = a.community === props.communityFilter
|
||||
const bSameCommunity = b.community === props.communityFilter
|
||||
|
||||
if (aSameCommunity && !bSameCommunity) return -1
|
||||
if (!aSameCommunity && bSameCommunity) return 1
|
||||
}
|
||||
|
||||
// Потом по алфавиту
|
||||
return a.title.localeCompare(b.title, 'ru')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Обработчик клика по топику
|
||||
*/
|
||||
const handleTopicClick = (topicId: string) => {
|
||||
const currentSelection = [...props.selectedTopics]
|
||||
const index = currentSelection.indexOf(topicId)
|
||||
|
||||
if (index >= 0) {
|
||||
// Убираем из выбора
|
||||
currentSelection.splice(index, 1)
|
||||
} else {
|
||||
// Добавляем в выбор (если не превышен лимит)
|
||||
if (!props.maxSelection || currentSelection.length < props.maxSelection) {
|
||||
currentSelection.push(topicId)
|
||||
}
|
||||
}
|
||||
|
||||
props.onSelectionChange(currentSelection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, выбран ли топик
|
||||
*/
|
||||
const isSelected = (topicId: string) => props.selectedTopics.includes(topicId)
|
||||
|
||||
/**
|
||||
* Проверяет, можно ли выбрать еще топики
|
||||
*/
|
||||
const canSelectMore = () => {
|
||||
return !props.maxSelection || props.selectedTopics.length < props.maxSelection
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает уровень вложенности топика
|
||||
*/
|
||||
const getTopicDepth = (topic: TopicPill): number => {
|
||||
if (topic.depth !== undefined) return topic.depth
|
||||
return topic.parent_ids?.length || 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Получить выбранные топики как объекты
|
||||
*/
|
||||
const selectedTopicObjects = createMemo(() => {
|
||||
return props.topics.filter(topic => props.selectedTopics.includes(topic.id))
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={`${styles.topicPillsCloud} ${props.class || ''}`}>
|
||||
{/* Поиск в самом верху */}
|
||||
<Show when={props.showSearch}>
|
||||
<div class={styles.pillsSearchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
class={`${styles.input} ${styles.pillsSearchInput}`}
|
||||
placeholder={props.searchPlaceholder || 'Поиск...'}
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Show when={searchQuery().trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.clearSearchBtn}
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Очистить поиск"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Заголовок и выбранные топики */}
|
||||
<Show when={props.title || (props.selectedTopics.length > 0 && !props.hideSelectedInHeader)}>
|
||||
<div class={styles.pillsCloudHeader}>
|
||||
<div class={styles.headerSection}>
|
||||
<Show when={props.title}>
|
||||
<h4 class={styles.pillsCloudTitle}>{props.title}</h4>
|
||||
</Show>
|
||||
<Show when={props.selectedTopics.length > 0 && !props.hideSelectedInHeader}>
|
||||
<div class={styles.selectedTopicsDisplay}>
|
||||
<span class={styles.selectedLabel}>Выбрано ({props.selectedTopics.length}):</span>
|
||||
<div class={styles.selectedTopicsContainer}>
|
||||
<For each={selectedTopicObjects()}>
|
||||
{(topic) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.topicPill} ${styles.pillSelected} ${styles.pillCompact}`}
|
||||
onClick={() => handleTopicClick(topic.id)}
|
||||
title={`Убрать ${topic.title}`}
|
||||
>
|
||||
<span class={styles.pillTitle}>{topic.title}</span>
|
||||
<span class={styles.pillRemoveIcon}>×</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.pillsContainer}>
|
||||
<For each={filteredTopics()}>
|
||||
{(topic) => {
|
||||
const selected = isSelected(topic.id)
|
||||
const disabled = !selected && !canSelectMore()
|
||||
const depth = getTopicDepth(topic)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.topicPill} ${
|
||||
selected ? styles.pillSelected : ''
|
||||
} ${disabled ? styles.pillDisabled : ''} ${
|
||||
depth > 0 ? styles.pillNested : ''
|
||||
}`}
|
||||
onClick={() => !disabled && handleTopicClick(topic.id)}
|
||||
disabled={disabled}
|
||||
title={`${topic.title} (${topic.slug})`}
|
||||
data-depth={depth}
|
||||
>
|
||||
<Show when={depth > 0}>
|
||||
<span class={styles.pillDepthIndicator}>
|
||||
{' '.repeat(depth)}└
|
||||
</span>
|
||||
</Show>
|
||||
<span class={styles.pillTitle}>{topic.title}</span>
|
||||
<Show when={selected}>
|
||||
<span class={styles.pillRemoveIcon}>×</span>
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={filteredTopics().length === 0}>
|
||||
<div class={styles.emptyState}>
|
||||
<Show
|
||||
when={searchQuery().trim()}
|
||||
fallback={<span>Нет доступных топиков</span>}
|
||||
>
|
||||
<span>Ничего не найдено по запросу "{searchQuery()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicPillsCloud
|
Reference in New Issue
Block a user