0.7.7-topics-editing
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
2025-07-03 12:15:10 +03:00
parent 441cca8045
commit eb2140bcc6
27 changed files with 3097 additions and 805 deletions

351
panel/ui/HTMLEditor.tsx Normal file
View 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
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

View 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