611 lines
34 KiB
HTML
611 lines
34 KiB
HTML
<!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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.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>
|