wa-dev-tools/public/regex.html

611 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regex Tester — WA Dev Tools</title>
<link href="/vendor/tailwind.min.css" rel="stylesheet">
<script src="/shared.js?v=3"></script>
<link href="/vendor/fonts.css" rel="stylesheet">
<link href="/shared.css" rel="stylesheet">
<script>window.WA_TOOL_ID = 'regex';</script>
<style>
/* ── Layout ── */
.regex-layout { display: flex; flex-direction: column; gap: 16px; }
/* ── Pattern row ── */
.pattern-row { display: flex; align-items: center; gap: 0; background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 10px; overflow: hidden; transition: border-color .2s; }
.pattern-row:focus-within { border-color: var(--accent); }
.pattern-row.error { border-color: #f44336; }
.delimiter { padding: 0 12px; font-size: 18px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; font-weight: 300; line-height: 44px; user-select: none; flex-shrink: 0; }
.pattern-input { flex: 1; padding: 10px 0; background: transparent; border: none; outline: none; font-size: 15px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary); min-width: 0; }
.pattern-input::placeholder { color: var(--text-muted); }
.flags-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.flag-pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; letter-spacing: .04em; cursor: pointer; user-select: none; transition: background .15s, color .15s, border-color .15s; border: 1px solid var(--surface-600); background: var(--surface-800); color: var(--text-muted); }
.flag-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.flag-pill:hover:not(.active) { border-color: var(--accent); color: var(--accent); }
.flag-desc { font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; margin-left: 4px; }
/* ── Match count badge ── */
.match-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; background: rgba(0,84,230,.12); color: var(--accent); border: 1px solid rgba(0,84,230,.2); }
.match-badge.no-match { background: rgba(128,128,128,.1); color: var(--text-muted); border-color: var(--surface-600); }
.match-badge.error-badge { background: rgba(244,67,54,.1); color: #f44336; border-color: rgba(244,67,54,.2); }
/* ── Test area with highlights ── */
.test-area-wrap { position: relative; }
.test-textarea { width: 100%; min-height: 180px; padding: 14px; background: var(--surface-900); border: 1px solid var(--surface-600); border-radius: 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary); resize: vertical; outline: none; transition: border-color .2s; line-height: 1.7; box-sizing: border-box; }
.test-textarea:focus { border-color: var(--accent); }
/* Overlay for highlights — sits on top of textarea */
.highlight-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 14px; font-size: 13px; font-family: 'JetBrains Mono', monospace; line-height: 1.7; pointer-events: none; white-space: pre-wrap; word-break: break-all; overflow: hidden; box-sizing: border-box; color: transparent; border-radius: 10px; }
/* Highlight colors cycling through groups */
.hl-0 { background: rgba(0,188,212,.28); border-radius: 2px; }
.hl-1 { background: rgba(156,39,176,.28); border-radius: 2px; }
.hl-2 { background: rgba(255,152,0,.28); border-radius: 2px; }
.hl-3 { background: rgba(76,175,80,.28); border-radius: 2px; }
.hl-4 { background: rgba(233,30,99,.28); border-radius: 2px; }
.hl-wrap { position: relative; overflow: hidden; border-radius: 10px; }
.hl-wrap textarea { position: relative; z-index: 1; background: transparent !important; caret-color: var(--text-primary); }
.hl-wrap .highlight-overlay { z-index: 0; background: var(--surface-900); }
/* ── Error message ── */
.regex-error { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: #f44336; margin-top: 6px; min-height: 16px; }
/* ── Match list ── */
.match-list { border: 1px solid var(--surface-600); border-radius: 10px; overflow: hidden; max-height: 320px; overflow-y: auto; }
.match-list::-webkit-scrollbar { width: 4px; }
.match-list::-webkit-scrollbar-thumb { background: var(--surface-600); border-radius: 2px; }
.match-item { padding: 10px 14px; border-bottom: 1px solid var(--surface-600); font-size: 12px; }
.match-item:last-child { border-bottom: none; }
.match-item:hover { background: var(--accent-bg); }
.match-num { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-muted); font-weight: 700; margin-bottom: 4px; }
.match-text { font-family: 'JetBrains Mono', monospace; font-size: 13px; color: var(--text-primary); background: rgba(0,84,230,.1); padding: 2px 6px; border-radius: 4px; display: inline-block; word-break: break-all; }
.match-groups { margin-top: 5px; display: flex; flex-wrap: wrap; gap: 4px; }
.group-tag { font-size: 10px; font-family: 'JetBrains Mono', monospace; padding: 1px 7px; border-radius: 10px; background: var(--surface-700); color: var(--text-muted); }
.group-val { font-weight: 600; color: var(--text-primary); }
.match-empty { padding: 24px; text-align: center; font-size: 13px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); }
/* ── Quick ref ── */
.quick-ref-toggle { display: flex; align-items: center; gap: 6px; background: none; border: none; cursor: pointer; font-size: 11px; font-weight: 700; font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted); padding: 0; transition: color .2s; }
.quick-ref-toggle:hover { color: var(--accent); }
.quick-ref-toggle svg { transition: transform .2s; }
.quick-ref-toggle.open svg { transform: rotate(90deg); }
.quick-ref-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; margin-top: 12px; }
.ref-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 10px; background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 6px; cursor: pointer; transition: border-color .15s; }
.ref-item:hover { border-color: var(--accent); }
.ref-token { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700; color: var(--accent); flex-shrink: 0; min-width: 52px; }
.ref-desc { font-size: 11px; color: var(--text-muted); line-height: 1.4; }
/* ── Section labels ── */
.section-title { font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-bottom: 8px; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
/* ── Animations ── */
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp .35s ease-out forwards; }
.fade-up-delay { animation: fadeUp .35s ease-out .1s forwards; opacity: 0; }
@media (max-width: 600px) {
.flag-desc { display: none; }
.quick-ref-grid { grid-template-columns: 1fr 1fr; }
}
/* AI Explain */
.ai-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--accent); color: var(--accent); background: transparent; font-size: 12px; font-weight: 700; font-family: 'JetBrains Mono', monospace; cursor: pointer; transition: all .2s; margin-left: 12px; }
.ai-btn:hover { background: var(--accent); color: #fff; }
.ai-btn:disabled { opacity: .5; cursor: wait; }
.ai-result { margin-top: 14px; padding: 16px; background: var(--surface-700); border: 1px solid var(--surface-600); border-radius: 10px; font-size: 13px; line-height: 1.7; color: var(--text-primary); }
.ai-result.hidden { display: none; }
.ai-result h1,.ai-result h2,.ai-result h3 { font-weight: 700; margin: .8em 0 .3em; }
.ai-result code { background: var(--surface-800); padding: 1px 5px; border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: .9em; }
.ai-result ul,.ai-result ol { padding-left: 1.5em; margin: .4em 0; }
.ai-result li { margin: .2em 0; }
.ai-result p { margin: .4em 0; }
</style>
<script src="/vendor/marked.min.js"></script>
</head>
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
<div class="main-content">
<div class="tool-container relative z-10 max-w-3xl mx-auto py-8 sm:py-12">
<!-- Header -->
<div class="text-center mb-8 fade-up">
<div class="page-header" style="text-align:center;">
<div class="inline-flex items-center gap-2 mb-3 px-3 py-1 rounded-full border dark:border-surface-600 border-gray-300 dark:bg-surface-800/60 bg-white/60 text-xs font-mono dark:text-gray-400 text-gray-500 tracking-wide">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
REGEX
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Regex <span class="text-accent">Tester</span>
</h1>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">Тестирование регулярных выражений · JavaScript · 100% offline</p>
</div>
</div>
<!-- Main card -->
<div class="dark:bg-surface-800/80 bg-white/85 border dark:border-surface-600 border-gray-200 rounded-2xl p-5 sm:p-7 backdrop-blur-sm fade-up-delay shadow-sm dark:shadow-none">
<div class="regex-layout">
<!-- Pattern input -->
<div>
<div class="section-title">Регулярное выражение</div>
<div id="patternRow" class="pattern-row">
<span class="delimiter">/</span>
<input id="patternInput" class="pattern-input" type="text" placeholder="введите паттерн" autocomplete="off" autocorrect="off" spellcheck="false" />
<span class="delimiter">/</span>
<span id="flagsDisplay" class="delimiter" style="padding-left:0;min-width:32px;color:var(--accent);font-size:14px;">g</span>
</div>
<div id="regexError" class="regex-error"></div>
<!-- Flags -->
<div class="flags-row">
<span style="font-size:11px;color:var(--text-muted);font-family:'JetBrains Mono',monospace;margin-right:2px;">Флаги:</span>
<button class="flag-pill active" data-flag="g" title="Глобальный поиск — все совпадения">g <span class="flag-desc">global</span></button>
<button class="flag-pill" data-flag="i" title="Регистронезависимый поиск">i <span class="flag-desc">ignore case</span></button>
<button class="flag-pill" data-flag="m" title="Многострочный режим — ^ и $ для каждой строки">m <span class="flag-desc">multiline</span></button>
<button class="flag-pill" data-flag="s" title="Точка совпадает с переносом строки">s <span class="flag-desc">dotall</span></button>
<button class="flag-pill" data-flag="u" title="Режим Unicode">u <span class="flag-desc">unicode</span></button>
</div>
</div>
<!-- Test string area -->
<div>
<div class="section-header">
<div class="section-title" style="margin-bottom:0">Тестовая строка</div>
<div id="matchBadge" class="match-badge no-match">нет совпадений</div>
</div>
<div class="hl-wrap">
<div id="highlightOverlay" class="highlight-overlay" aria-hidden="true"></div>
<textarea id="testInput" class="test-textarea" placeholder="Введите текст для тестирования..." spellcheck="false"></textarea>
</div>
</div>
<!-- Match results -->
<div id="matchesSection">
<div class="section-title">Совпадения</div>
<div id="matchList" class="match-list">
<div class="match-empty">Введите паттерн и текст для поиска</div>
</div>
</div>
<!-- Quick reference -->
<div>
<button id="quickRefToggle" class="quick-ref-toggle" type="button" onclick="toggleQuickRef()">
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
Справка по синтаксису
</button>
<button id="aiExplainBtn" class="ai-btn" type="button" onclick="aiExplain()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"/></svg>
AI объяснение
</button>
<div id="quickRefPanel" style="display:none">
<div class="quick-ref-grid" style="margin-top:12px">
<div class="ref-item" onclick="insertToken('\\\\d')"><span class="ref-token">\d</span><span class="ref-desc">Цифра [0-9]</span></div>
<div class="ref-item" onclick="insertToken('\\\\D')"><span class="ref-token">\D</span><span class="ref-desc">Не цифра</span></div>
<div class="ref-item" onclick="insertToken('\\\\w')"><span class="ref-token">\w</span><span class="ref-desc">Слово [a-zA-Z0-9_]</span></div>
<div class="ref-item" onclick="insertToken('\\\\W')"><span class="ref-token">\W</span><span class="ref-desc">Не слово</span></div>
<div class="ref-item" onclick="insertToken('\\\\s')"><span class="ref-token">\s</span><span class="ref-desc">Пробел / таб / перенос</span></div>
<div class="ref-item" onclick="insertToken('\\\\S')"><span class="ref-token">\S</span><span class="ref-desc">Не пробел</span></div>
<div class="ref-item" onclick="insertToken('.')"><span class="ref-token">.</span><span class="ref-desc">Любой символ (кроме \n)</span></div>
<div class="ref-item" onclick="insertToken('^')"><span class="ref-token">^</span><span class="ref-desc">Начало строки</span></div>
<div class="ref-item" onclick="insertToken('$')"><span class="ref-token">$</span><span class="ref-desc">Конец строки</span></div>
<div class="ref-item" onclick="insertToken('*')"><span class="ref-token">*</span><span class="ref-desc">0 или более повторений</span></div>
<div class="ref-item" onclick="insertToken('+')"><span class="ref-token">+</span><span class="ref-desc">1 или более повторений</span></div>
<div class="ref-item" onclick="insertToken('?')"><span class="ref-token">?</span><span class="ref-desc">0 или 1 вхождение</span></div>
<div class="ref-item" onclick="insertToken('{n,m}')"><span class="ref-token">{n,m}</span><span class="ref-desc">От n до m повторений</span></div>
<div class="ref-item" onclick="insertToken('[abc]')"><span class="ref-token">[abc]</span><span class="ref-desc">Набор символов</span></div>
<div class="ref-item" onclick="insertToken('[^abc]')"><span class="ref-token">[^abc]</span><span class="ref-desc">Отрицание набора</span></div>
<div class="ref-item" onclick="insertToken('()')"><span class="ref-token">(...)</span><span class="ref-desc">Группа с захватом</span></div>
<div class="ref-item" onclick="insertToken('(?:)')"><span class="ref-token">(?:...)</span><span class="ref-desc">Группа без захвата</span></div>
<div class="ref-item" onclick="insertToken('(?=)')"><span class="ref-token">(?=...)</span><span class="ref-desc">Lookahead</span></div>
<div class="ref-item" onclick="insertToken('(?!)')"><span class="ref-token">(?!...)</span><span class="ref-desc">Negative lookahead</span></div>
<div class="ref-item" onclick="insertToken('|')"><span class="ref-token">a|b</span><span class="ref-desc">Альтернатива (или)</span></div>
<div class="ref-item" onclick="insertToken('\\\\b')"><span class="ref-token">\b</span><span class="ref-desc">Граница слова</span></div>
<div class="ref-item" onclick="insertToken('\\\\n')"><span class="ref-token">\n</span><span class="ref-desc">Перенос строки</span></div>
</div>
</div>
</div>
<div id="aiResult" class="ai-result hidden"></div>
</div>
</div>
</div>
</div><!-- /main-content -->
<script>
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
pattern: '',
flags: new Set(['g']),
text: '',
regex: null,
matches: [],
error: null,
};
// ── DOM refs ──────────────────────────────────────────────────────────────────
const patternInput = document.getElementById('patternInput');
const patternRow = document.getElementById('patternRow');
const flagsDisplay = document.getElementById('flagsDisplay');
const regexError = document.getElementById('regexError');
const testInput = document.getElementById('testInput');
const highlightOverlay= document.getElementById('highlightOverlay');
const matchBadge = document.getElementById('matchBadge');
const matchList = document.getElementById('matchList');
// ── Flag toggles ──────────────────────────────────────────────────────────────
document.querySelectorAll('.flag-pill').forEach(btn => {
btn.addEventListener('click', () => {
const flag = btn.dataset.flag;
if (state.flags.has(flag)) {
state.flags.delete(flag);
btn.classList.remove('active');
} else {
state.flags.add(flag);
btn.classList.add('active');
}
updateFlagsDisplay();
scheduleUpdate();
});
});
function updateFlagsDisplay() {
const flagStr = [...state.flags].sort().join('');
flagsDisplay.textContent = flagStr || ' ';
}
// ── Input events with debounce ────────────────────────────────────────────────
let debounceTimer = null;
function scheduleUpdate() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(runRegex, 150);
}
patternInput.addEventListener('input', () => {
state.pattern = patternInput.value;
scheduleUpdate();
});
testInput.addEventListener('input', () => {
state.text = testInput.value;
syncScroll();
scheduleUpdate();
});
testInput.addEventListener('scroll', syncScroll);
function syncScroll() {
highlightOverlay.scrollTop = testInput.scrollTop;
highlightOverlay.scrollLeft = testInput.scrollLeft;
}
// ── Core regex engine ─────────────────────────────────────────────────────────
function runRegex() {
state.error = null;
state.matches = [];
state.regex = null;
const pattern = state.pattern;
const text = state.text;
if (!pattern) {
renderHighlights([]);
renderMatches([]);
renderBadge(null, null);
patternRow.classList.remove('error');
regexError.textContent = '';
return;
}
let regex;
try {
const flagStr = [...state.flags].sort().join('');
// Ensure 'd' flag not used (not universally supported), use what we have
regex = new RegExp(pattern, flagStr);
state.regex = regex;
patternRow.classList.remove('error');
regexError.textContent = '';
} catch (err) {
state.error = err.message;
patternRow.classList.add('error');
regexError.textContent = err.message;
renderHighlights([]);
renderMatches([]);
renderBadge(null, true);
return;
}
// Collect matches — handle non-global to avoid infinite loop
const matches = [];
if (state.flags.has('g') || state.flags.has('y')) {
// Reset lastIndex defensively
regex.lastIndex = 0;
let m;
let safety = 0;
while ((m = regex.exec(text)) !== null && safety < 1000) {
matches.push(m);
// Advance if zero-length match to prevent infinite loop
if (m[0].length === 0) {
regex.lastIndex++;
}
safety++;
}
} else {
const m = regex.exec(text);
if (m) matches.push(m);
}
state.matches = matches;
renderHighlights(matches);
renderMatches(matches);
renderBadge(matches.length, false);
}
// ── Highlight overlay ─────────────────────────────────────────────────────────
const HL_CLASSES = ['hl-0', 'hl-1', 'hl-2', 'hl-3', 'hl-4'];
function renderHighlights(matches) {
if (!matches.length || !state.text) {
highlightOverlay.innerHTML = escHtml(state.text);
return;
}
const text = state.text;
let html = '';
let cursor = 0;
for (let i = 0; i < matches.length; i++) {
const m = matches[i];
const start = m.index;
const end = start + m[0].length;
// Text before match
if (start > cursor) {
html += escHtml(text.slice(cursor, start));
}
// Highlighted match
const cls = HL_CLASSES[i % HL_CLASSES.length];
html += '<mark class="' + cls + '">' + escHtml(m[0] || '\u200b') + '</mark>';
cursor = end;
// Advance past zero-length matches to avoid overlapping
if (m[0].length === 0) cursor++;
}
// Remaining text
if (cursor < text.length) {
html += escHtml(text.slice(cursor));
}
highlightOverlay.innerHTML = html;
syncScroll();
}
// ── Match list ────────────────────────────────────────────────────────────────
function renderMatches(matches) {
if (!state.pattern) {
matchList.innerHTML = '<div class="match-empty">Введите паттерн и текст для поиска</div>';
return;
}
if (!matches.length) {
matchList.innerHTML = '<div class="match-empty">Совпадений не найдено</div>';
return;
}
const frag = document.createDocumentFragment();
matches.forEach((m, idx) => {
const item = document.createElement('div');
item.className = 'match-item';
const num = document.createElement('div');
num.className = 'match-num';
num.textContent = '#' + (idx + 1) + ' · индекс ' + m.index;
item.appendChild(num);
const txt = document.createElement('div');
txt.style.marginBottom = '4px';
const badge = document.createElement('span');
badge.className = 'match-text';
badge.textContent = m[0] !== '' ? m[0] : '(пустое совпадение)';
txt.appendChild(badge);
item.appendChild(txt);
// Groups
const groups = m.slice(1).filter((_, i) => i < 20);
if (groups.length) {
const groupsWrap = document.createElement('div');
groupsWrap.className = 'match-groups';
groups.forEach((g, gi) => {
const tag = document.createElement('span');
tag.className = 'group-tag';
tag.innerHTML = 'Группа ' + (gi + 1) + ': <span class="group-val">'
+ (g !== undefined ? escHtml(String(g)) : '<em>undefined</em>') + '</span>';
groupsWrap.appendChild(tag);
});
item.appendChild(groupsWrap);
}
frag.appendChild(item);
});
matchList.innerHTML = '';
matchList.appendChild(frag);
}
// ── Badge ─────────────────────────────────────────────────────────────────────
function renderBadge(count, isError) {
if (isError) {
matchBadge.textContent = 'ошибка';
matchBadge.className = 'match-badge error-badge';
return;
}
if (count === null || !state.pattern) {
matchBadge.textContent = 'нет совпадений';
matchBadge.className = 'match-badge no-match';
return;
}
if (count === 0) {
matchBadge.textContent = 'нет совпадений';
matchBadge.className = 'match-badge no-match';
return;
}
matchBadge.textContent = count + ' ' + pluralMatch(count);
matchBadge.className = 'match-badge';
}
function pluralMatch(n) {
const m = n % 10, m100 = n % 100;
if (m === 1 && m100 !== 11) return 'совпадение';
if (m >= 2 && m <= 4 && (m100 < 12 || m100 > 14)) return 'совпадения';
return 'совпадений';
}
// ── Utility ───────────────────────────────────────────────────────────────────
function escHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/ /g, '\u00a0'); // preserve spaces for overlay
}
// ── Quick reference ───────────────────────────────────────────────────────────
function toggleQuickRef() {
const panel = document.getElementById('quickRefPanel');
const toggle = document.getElementById('quickRefToggle');
const isOpen = panel.style.display !== 'none';
panel.style.display = isOpen ? 'none' : 'block';
toggle.classList.toggle('open', !isOpen);
}
function insertToken(token) {
const decoded = token
.replace(/\\\\d/g, '\\d')
.replace(/\\\\D/g, '\\D')
.replace(/\\\\w/g, '\\w')
.replace(/\\\\W/g, '\\W')
.replace(/\\\\s/g, '\\s')
.replace(/\\\\S/g, '\\S')
.replace(/\\\\b/g, '\\b')
.replace(/\\\\n/g, '\\n');
const input = patternInput;
const start = input.selectionStart;
const end = input.selectionEnd;
const val = input.value;
input.value = val.slice(0, start) + decoded + val.slice(end);
input.selectionStart = input.selectionEnd = start + decoded.length;
input.focus();
state.pattern = input.value;
scheduleUpdate();
}
// ── Sync textarea/overlay height ──────────────────────────────────────────────
// Keep overlay same height as textarea when resized
const resizeObserver = new ResizeObserver(() => {
highlightOverlay.style.height = testInput.offsetHeight + 'px';
highlightOverlay.style.width = testInput.offsetWidth + 'px';
});
resizeObserver.observe(testInput);
// Initial render
runRegex();
// ── AI Explain ────────────────────────────────────────────────────────────────
async function aiExplain() {
const patternInput = document.getElementById('patternInput');
const pattern = patternInput ? patternInput.value.trim() : '';
if (!pattern) return;
const flags = Array.from(document.querySelectorAll('.flag-btn.active')).map(b => b.dataset.flag).join('');
const btn = document.getElementById('aiExplainBtn');
const resultDiv = document.getElementById('aiResult');
btn.disabled = true;
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" style="animation:spin .7s linear infinite"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"/></svg> Думаю...';
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '';
let fullText = '';
try {
const res = await fetch('/api/ai/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern, flags }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка ' + res.status);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.error) throw new Error(parsed.error);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
fullText += delta;
let md = fullText.replace(/^```[a-z]*\n?/gm, '').replace(/```\s*$/gm, '').trim();
resultDiv.innerHTML = marked.parse(md);
}
} catch (e) {
if (e.message && !e.message.includes('JSON')) throw e;
}
}
}
if (!fullText.trim()) {
resultDiv.innerHTML = '<span style="color:var(--text-muted)">Пустой ответ от AI</span>';
}
} catch (err) {
resultDiv.innerHTML = '<span style="color:#f44336">' + (err.message || 'AI недоступен') + '</span>';
} finally {
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"/></svg> AI объяснение';
}
}
</script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</body>
</html>