361 lines
10 KiB
TypeScript
361 lines
10 KiB
TypeScript
|
// 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('<html') || xml.trim().startsWith('<!DOCTYPE') ? xml : `<div>${xml}</div>`
|
|||
|
|
|||
|
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('<div>') ? 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<span class="html-tag">$2</span><span class="html-attr">$3</span>$4'
|
|||
|
)
|
|||
|
|
|||
|
// Подсвечиваем атрибуты
|
|||
|
highlighted = highlighted.replace(
|
|||
|
/(\s)([a-zA-Z-]+)(=)(".*?")/g,
|
|||
|
'$1<span class="html-attr-name">$2</span>$3<span class="html-attr-value">$4</span>'
|
|||
|
)
|
|||
|
|
|||
|
// Подсвечиваем сами теги
|
|||
|
highlighted = highlighted.replace(
|
|||
|
/(<\/?)([^&]*?)(>)/g,
|
|||
|
'<span class="html-bracket">$1</span>$2<span class="html-bracket">$3</span>'
|
|||
|
)
|
|||
|
|
|||
|
return highlighted
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Простая подсветка JSON
|
|||
|
*/
|
|||
|
function highlightJSON(json: string): string {
|
|||
|
let highlighted = escapeHtml(json)
|
|||
|
|
|||
|
// Подсвечиваем строки
|
|||
|
highlighted = highlighted.replace(/(".*?")(?=\s*:)/g, '<span class="json-key">$1</span>')
|
|||
|
highlighted = highlighted.replace(/:\s*(".*?")/g, ': <span class="json-string">$1</span>')
|
|||
|
|
|||
|
// Подсвечиваем числа
|
|||
|
highlighted = highlighted.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
|
|||
|
|
|||
|
// Подсвечиваем boolean и null
|
|||
|
highlighted = highlighted.replace(/:\s*(true|false|null)/g, ': <span class="json-boolean">$1</span>')
|
|||
|
|
|||
|
return highlighted
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Экранирует HTML символы
|
|||
|
*/
|
|||
|
function escapeHtml(unsafe: string): string {
|
|||
|
return unsafe
|
|||
|
.replace(/&/g, '&')
|
|||
|
.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
|