// Prism.js временно отключен для упрощения загрузки
/**
* Определяет язык контента (html, json, javascript, css или plaintext)
*/
export function detectLanguage(content: string): string {
if (!content?.trim()) return ''
try {
JSON.parse(content)
return 'json'
} catch {
// HTML/XML detection
if (/<[^>]*>/g.test(content)) {
return 'html'
}
// CSS detection
if (/\{[^}]*\}/.test(content) && /[#.]\w+|@\w+/.test(content)) {
return 'css'
}
// JavaScript detection
if (/\b(function|const|let|var|class|import|export)\b/.test(content)) {
return 'javascript'
}
}
return ''
}
/**
* Форматирует XML/HTML с отступами используя DOMParser
*/
export function formatXML(xml: string): string {
if (!xml?.trim()) return ''
try {
// Пытаемся распарсить как HTML
const parser = new DOMParser()
let doc: Document
// Оборачиваем в корневой элемент, если это фрагмент
const wrappedXml =
xml.trim().startsWith('${xml}`
doc = parser.parseFromString(wrappedXml, 'text/html')
// Проверяем на ошибки парсинга
const parserError = doc.querySelector('parsererror')
if (parserError) {
// Если HTML парсинг не удался, пытаемся как XML
doc = parser.parseFromString(wrappedXml, 'application/xml')
const xmlError = doc.querySelector('parsererror')
if (xmlError) {
// Если и XML не удался, возвращаем исходный код
return xml
}
}
// Извлекаем содержимое body или корневого элемента
const body = doc.body || doc.documentElement
const rootElement = xml.trim().startsWith('
') ? body.firstChild : body
if (!rootElement) return xml
// Форматируем рекурсивно
return formatNode(rootElement as Element, 0)
} catch (error) {
// В случае ошибки возвращаем исходный код
console.warn('XML formatting failed:', error)
return xml
}
}
/**
* Рекурсивно форматирует узел DOM
*/
function formatNode(node: Node, indentLevel: number): string {
const indentSize = 2
const indent = ' '.repeat(indentLevel * indentSize)
const childIndent = ' '.repeat((indentLevel + 1) * indentSize)
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent?.trim()
return text ? text : ''
}
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
const tagName = element.tagName.toLowerCase()
const attributes = Array.from(element.attributes)
.map((attr) => `${attr.name}="${attr.value}"`)
.join(' ')
const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`
const closeTag = `${tagName}>`
// Самозакрывающиеся теги
if (isSelfClosingTag(`<${tagName}>`)) {
return `${indent}${openTag.replace('>', ' />')}`
}
// Если нет дочерних элементов
if (element.childNodes.length === 0) {
return `${indent}${openTag}${closeTag}`
}
// Если только один текстовый узел
if (element.childNodes.length === 1 && element.firstChild?.nodeType === Node.TEXT_NODE) {
const text = element.firstChild.textContent?.trim()
if (text && text.length < 80) {
// Короткий текст на одной строке
return `${indent}${openTag}${text}${closeTag}`
}
}
// Многострочный элемент
let result = `${indent}${openTag}\n`
for (const child of Array.from(element.childNodes)) {
const childFormatted = formatNode(child, indentLevel + 1)
if (childFormatted) {
if (child.nodeType === Node.TEXT_NODE) {
result += `${childIndent}${childFormatted}\n`
} else {
result += `${childFormatted}\n`
}
}
}
result += `${indent}${closeTag}`
return result
}
return ''
}
/**
* Проверяет, является ли тег самозакрывающимся
*/
function isSelfClosingTag(line: string): boolean {
const selfClosingTags = [
'br',
'hr',
'img',
'input',
'meta',
'link',
'area',
'base',
'col',
'embed',
'source',
'track',
'wbr'
]
const tagMatch = line.match(/<(\w+)/)
if (tagMatch) {
const tagName = tagMatch[1].toLowerCase()
return selfClosingTags.includes(tagName)
}
return false
}
/**
* Форматирует JSON с отступами
*/
export function formatJSON(json: string): string {
try {
return JSON.stringify(JSON.parse(json), null, 2)
} catch {
return json
}
}
/**
* Форматирует код в зависимости от языка
*/
export function formatCode(content: string, language?: string): string {
if (!content?.trim()) return ''
const lang = language || detectLanguage(content)
switch (lang) {
case 'json':
return formatJSON(content)
case 'markup':
case 'html':
return formatXML(content)
default:
return content
}
}
/**
* Подсвечивает синтаксис кода с использованием простых правил CSS
*/
export function highlightCode(content: string, language?: string): string {
if (!content?.trim()) return ''
const lang = language || detectLanguage(content)
if (lang === 'html' || lang === 'markup') {
return highlightHTML(content)
}
if (lang === 'json') {
return highlightJSON(content)
}
// Для других языков возвращаем исходный код
return escapeHtml(content)
}
/**
* Простая подсветка HTML с использованием CSS классов
*/
function highlightHTML(html: string): string {
let highlighted = escapeHtml(html)
// Подсвечиваем теги
highlighted = highlighted.replace(
/(<\/?)([a-zA-Z][a-zA-Z0-9]*)(.*?)(>)/g,
'$1$2$3$4'
)
// Подсвечиваем атрибуты
highlighted = highlighted.replace(
/(\s)([a-zA-Z-]+)(=)(".*?")/g,
'$1$2$3$4'
)
// Подсвечиваем сами теги
highlighted = highlighted.replace(
/(<\/?)([^&]*?)(>)/g,
'$1$2$3'
)
return highlighted
}
/**
* Простая подсветка JSON
*/
function highlightJSON(json: string): string {
let highlighted = escapeHtml(json)
// Подсвечиваем строки
highlighted = highlighted.replace(/(".*?")(?=\s*:)/g, '$1')
highlighted = highlighted.replace(/:\s*(".*?")/g, ': $1')
// Подсвечиваем числа
highlighted = highlighted.replace(/:\s*(-?\d+\.?\d*)/g, ': $1')
// Подсвечиваем boolean и null
highlighted = highlighted.replace(/:\s*(true|false|null)/g, ': $1')
return highlighted
}
/**
* Экранирует HTML символы
*/
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
/**
* Обработчик Tab в редакторе - вставляет отступ вместо смены фокуса
*/
export function handleTabKey(event: KeyboardEvent): boolean {
if (event.key !== 'Tab') return false
event.preventDefault()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return true
const range = selection.getRangeAt(0)
const indent = event.shiftKey ? '' : ' ' // Shift+Tab для unindent (пока просто не добавляем)
if (!event.shiftKey) {
range.deleteContents()
range.insertNode(document.createTextNode(indent))
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
return true
}
/**
* Сохраняет и восстанавливает позицию курсора в contentEditable элементе
*/
export class CaretManager {
private element: HTMLElement
private offset = 0
constructor(element: HTMLElement) {
this.element = element
}
savePosition(): void {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(this.element)
preCaretRange.setEnd(range.endContainer, range.endOffset)
this.offset = preCaretRange.toString().length
}
restorePosition(): void {
const selection = window.getSelection()
if (!selection) return
try {
const textNode = this.element.firstChild
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange()
const safeOffset = Math.min(this.offset, textNode.textContent?.length || 0)
range.setStart(textNode, safeOffset)
range.setEnd(textNode, safeOffset)
selection.removeAllRanges()
selection.addRange(range)
}
} catch (error) {
console.warn('Could not restore caret position:', error)
}
}
}
/**
* Настройки по умолчанию для редактора кода
*/
export const DEFAULT_EDITOR_CONFIG = {
fontSize: 13,
lineHeight: 1.5,
tabSize: 2,
fontFamily:
'"JetBrains Mono", "Fira Code", "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Consolas", monospace',
theme: 'dark',
showLineNumbers: true,
autoFormat: true,
keyBindings: {
save: ['Ctrl+Enter', 'Cmd+Enter'],
cancel: ['Escape'],
tab: ['Tab'],
format: ['Ctrl+Shift+F', 'Cmd+Shift+F']
}
} as const