Spaces:
Running
Running
Download index.html from AnnLior/Text_Processing: direct link, hf CLI and curl.
- Browser
- Download file 26.5 kB
-
https://huggingface.co/spaces/AnnLior/Text_Processing/resolve/main/index.html
- Command line
-
hf download hf://spaces/AnnLior/Text_Processing/index.html
-
curl -L -o index.html https://huggingface.co/spaces/AnnLior/Text_Processing/resolve/main/index.html
26.5 kB
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Проверка русского текста (LanguageTool)</title> | |
| <style> | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; } | |
| body { | |
| font-family: -apple-system, Segoe UI, Roboto, sans-serif; | |
| margin: 0; padding: 12px; background: #f4f6f8; | |
| height: 100vh; height: 100dvh; | |
| display: flex; flex-direction: column; overflow: hidden; | |
| } | |
| h1 { font-size: 18px; margin: 0 0 8px; flex: none; } | |
| .toolbar { margin-bottom: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; flex: none; } | |
| button { padding: 8px 12px; font-size: 14px; cursor: pointer; border: none; border-radius: 6px; background: #2563eb; color: #fff; } | |
| button:disabled { background: #94a3b8; cursor: not-allowed; } | |
| button.pause { background: #f59e0b; } | |
| button.reset { background: #64748b; } | |
| /* ===== Три колонки на всю высоту экрана ===== */ | |
| .cols { | |
| flex: 1; min-height: 0; | |
| display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| grid-template-rows: minmax(0, 1fr); | |
| gap: 10px; | |
| } | |
| @media (max-width: 900px) { | |
| .cols { grid-template-columns: minmax(0, 1fr); grid-template-rows: repeat(3, minmax(0, 1fr)); } | |
| } | |
| .col { | |
| background: #fff; border-radius: 10px; padding: 10px; | |
| display: flex; flex-direction: column; min-height: 0; min-width: 0; | |
| box-shadow: 0 1px 3px rgba(0,0,0,.08); | |
| } | |
| .col h2 { flex: none; font-size: 13px; margin: 0 0 8px; color: #334155; text-transform: uppercase; letter-spacing: .5px; } | |
| /* ===== Единый стиль текста для 1-й и 3-й колонок ===== */ | |
| .txt { | |
| font-family: Georgia, "Times New Roman", serif; | |
| font-size: 16px; line-height: 1.6; color: #0f172a; | |
| font-weight: 400; font-style: normal; | |
| white-space: pre-wrap; word-wrap: break-word; overflow-wrap: anywhere; | |
| } | |
| .txt b, .txt strong { font-weight: 700; } | |
| .txt i, .txt em { font-style: italic; } | |
| .pane { | |
| flex: 1; min-height: 0; overflow-y: auto; | |
| padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fafafa; | |
| } | |
| #input { outline: none; background: #fff; } | |
| #input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,.15); } | |
| #input:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; } | |
| #input div { min-height: 1.6em; } | |
| /* Результаты проверки (2-я колонка) */ | |
| #results { font-size: 14px; line-height: 1.5; font-family: -apple-system, Segoe UI, Roboto, sans-serif; white-space: normal; } | |
| .chunk-buttons { flex: none; margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; max-height: 84px; overflow-y: auto; } | |
| .chunk-buttons button { background: #0ea5e9; font-size: 13px; padding: 5px 9px; } | |
| .chunk-buttons button.done { background: #16a34a; } | |
| .chunk-buttons button.err { background: #dc2626; } | |
| .chunk-info { flex: none; font-size: 12px; color: #64748b; margin-top: 6px; } | |
| .status { font-size: 13px; color: #475569; margin-left: 8px; } | |
| .err { background: #fee2e2; border-bottom: 2px solid #ef4444; cursor: help; } | |
| .fix { background: #dcfce7; color: #166534; font-weight: 600; font-family: -apple-system, Segoe UI, Roboto, sans-serif; font-size: 13px; } | |
| .block { margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px dashed #e2e8f0; } | |
| .block:last-child { border-bottom: none; } | |
| .block-title { font-family: -apple-system, Segoe UI, Roboto, sans-serif; font-size: 11px; color: #94a3b8; margin-bottom: 4px; } | |
| .issue { background: #fff; border-left: 4px solid #ef4444; padding: 6px 8px; margin-bottom: 6px; border-radius: 4px; font-size: 13px; } | |
| .issue.ok { border-color: #16a34a; } | |
| .issue[data-m] { cursor: pointer; transition: background .15s; } | |
| .issue[data-m]:hover { background: #f1f5f9; } | |
| .issue.active { background: #fef9c3; border-left-color: #f59e0b; } | |
| @keyframes flash { 0%,100% { box-shadow: 0 0 0 0 rgba(245,158,11,0); } 30% { box-shadow: 0 0 0 4px rgba(245,158,11,.7); background: #fde68a; } } | |
| .err.flash { animation: flash 1.6s ease-out; } | |
| .issue .msg { font-weight: 600; } | |
| .issue .sugg { color: #16a34a; } | |
| .issue .ctx { color: #64748b; font-size: 12px; margin-top: 3px; } | |
| .issue pre { white-space: pre-wrap; word-wrap: break-word; font-size: 12px; margin: 4px 0 0; color: #7f1d1d; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Проверка русского текста — LanguageTool</h1> | |
| <div class="toolbar"> | |
| <button id="btnSplit">Разбить на куски ≤ 20000 знаков</button> | |
| <button id="btnRunAll" disabled>Проверить все куски</button> | |
| <button id="btnPause" class="pause" disabled>Пауза</button> | |
| <button id="btnReset" class="reset">Сброс</button> | |
| <span class="status" id="status"></span> | |
| </div> | |
| <div class="cols"> | |
| <div class="col"> | |
| <h2>1. Ваш текст</h2> | |
| <div id="input" class="pane txt" contenteditable="true" spellcheck="false" | |
| data-placeholder="Вставьте сюда большой русский текст…"></div> | |
| <div class="chunk-info" id="chunkInfo"></div> | |
| <div class="chunk-buttons" id="chunkButtons"></div> | |
| </div> | |
| <div class="col"> | |
| <h2>2. Результаты проверки</h2> | |
| <div class="pane" id="results">Здесь появятся ошибки по каждому куску…</div> | |
| </div> | |
| <div class="col"> | |
| <h2>3. Текст с подсветкой</h2> | |
| <div class="pane txt" id="highlighted">Здесь будет сводный текст: красным — проблемные места, зелёным — предложенные исправления.</div> | |
| </div> | |
| </div> | |
| <script> | |
| const API = 'https://api.languagetool.org/v2/check'; | |
| const LIMIT = 20000; | |
| /* ================== СТОП-ЛИСТ СЛОВ ================== */ | |
| const STOP_WORDS = [ | |
| 'Иезавель', | |
| 'Иезавели', | |
| 'Иезавелью', | |
| 'Уззия', | |
| 'Уззии', | |
| 'Ругияш', | |
| 'Осия', | |
| 'Потифар', | |
| // добавьте свои — каждое с новой строки в кавычках и с запятой | |
| ]; | |
| const STOP_SET = new Set(STOP_WORDS.map(w => w.toLowerCase().trim())); | |
| /* ================== ИГНОР-ЛИСТ ПРАВИЛ ================== */ | |
| const IGNORED_RULES = new Set([ | |
| 'Many_PNN', // Загруженность предложения местоимениями | |
| ]); | |
| /* ================== ФИЛЬТРЫ ================== */ | |
| function isInsideStopWord(text, offset, length) { | |
| if (STOP_SET.size === 0) return false; | |
| const target = text.substring(offset, offset + length).toLowerCase().trim(); | |
| if (STOP_SET.has(target)) return true; | |
| const isLetter = c => /[а-яёa-z]/i.test(c); | |
| const before = text[offset - 1] || ' '; | |
| const after = text[offset + length] || ' '; | |
| if (isLetter(before) || isLetter(after)) { | |
| let s = offset, e = offset + length; | |
| while (s > 0 && isLetter(text[s - 1])) s--; | |
| while (e < text.length && isLetter(text[e])) e++; | |
| const wholeWord = text.substring(s, e).toLowerCase().trim(); | |
| if (STOP_SET.has(wholeWord)) return true; | |
| } | |
| return false; | |
| } | |
| function isSillySuggestion(orig, sugg) { | |
| if (!sugg) return false; | |
| const o = orig.toLowerCase(), s = sugg.toLowerCase(); | |
| if (o === s) return false; | |
| if (s.length > o.length * 2 || o.length > s.length * 2) return true; | |
| const common = [...new Set(o)].filter(c => s.includes(c)).length; | |
| const minLen = Math.min(o.length, s.length); | |
| if (minLen > 3 && common < minLen / 2) return true; | |
| return false; | |
| } | |
| function filterMatches(matches, text) { | |
| return matches | |
| .filter(m => !IGNORED_RULES.has(m.rule?.id)) | |
| .filter(m => !isInsideStopWord(text, m.offset, m.length)) | |
| .map(m => { | |
| const orig = text.substring(m.offset, m.offset + m.length); | |
| const good = m.replacements.filter(r => !isSillySuggestion(orig, r.value)); | |
| return { ...m, replacements: good }; | |
| }) | |
| .filter(m => { | |
| if (m.replacements.length > 0) return true; | |
| if (m.rule?.issueType === 'misspelling') return true; | |
| return false; | |
| }); | |
| } | |
| /* ==================================================== */ | |
| const input = document.getElementById('input'); | |
| const results = document.getElementById('results'); | |
| const highlighted = document.getElementById('highlighted'); | |
| const chunkButtons = document.getElementById('chunkButtons'); | |
| const chunkInfo = document.getElementById('chunkInfo'); | |
| const status = document.getElementById('status'); | |
| let chunks = []; | |
| let running = false; | |
| let paused = false; | |
| // Снимок текста и форматирования на момент разбивки | |
| let fullText = ''; | |
| let fullFmt = []; // для каждого символа: бит 1 = жирный, бит 2 = курсив | |
| /* ================== ВСТАВКА: чистим всё, кроме жирного и курсива ================== */ | |
| function esc(s) { | |
| return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| const BLOCK_TAGS = new Set(['P','DIV','LI','UL','OL','TR','BLOCKQUOTE','TABLE','SECTION','ARTICLE','PRE','HR','H1','H2','H3','H4','H5','H6']); | |
| const SKIP_TAGS = new Set(['SCRIPT','STYLE','HEAD','META','TITLE','LINK']); | |
| function sanitizeHtml(html) { | |
| const doc = new DOMParser().parseFromString(html, 'text/html'); | |
| const runs = []; | |
| const atLineStart = () => runs.length === 0 || runs[runs.length - 1].t === '\n'; | |
| const endLine = () => { if (!atLineStart()) runs.push({ t: '\n' }); }; | |
| function walk(n, b, i) { | |
| if (n.nodeType === 3) { | |
| let s = n.nodeValue.replace(/[\s\u00a0]+/g, ' '); | |
| if (atLineStart()) s = s.replace(/^ /, ''); | |
| if (!s) return; | |
| runs.push({ t: s, b, i }); | |
| return; | |
| } | |
| if (n.nodeType !== 1) return; | |
| const tag = n.tagName; | |
| if (SKIP_TAGS.has(tag)) return; | |
| if (tag === 'BR') { runs.push({ t: '\n' }); return; } | |
| let nb = b, ni = i; | |
| if (tag === 'B' || tag === 'STRONG' || /^H[1-6]$/.test(tag)) nb = true; | |
| if (tag === 'I' || tag === 'EM' || tag === 'CITE') ni = true; | |
| const st = n.style || {}; | |
| // inline-стиль главнее тега (Google Docs оборачивает всё в <b style="font-weight:normal">) | |
| if (st.fontWeight) nb = (st.fontWeight === 'bold' || st.fontWeight === 'bolder' || parseInt(st.fontWeight) >= 600); | |
| if (st.fontStyle) ni = (st.fontStyle === 'italic' || st.fontStyle === 'oblique'); | |
| const blk = BLOCK_TAGS.has(tag); | |
| if (blk) endLine(); | |
| n.childNodes.forEach(c => walk(c, nb, ni)); | |
| if (blk) endLine(); | |
| } | |
| walk(doc.body, false, false); | |
| // собираем строки | |
| const lines = [[]]; | |
| for (const r of runs) { | |
| if (r.t === '\n') lines.push([]); else lines[lines.length - 1].push(r); | |
| } | |
| while (lines.length && lines[lines.length - 1].length === 0) lines.pop(); | |
| return lines.map(line => { | |
| if (!line.length) return '<div><br></div>'; | |
| line[line.length - 1] = { ...line[line.length - 1], t: line[line.length - 1].t.replace(/ $/, '') }; | |
| return '<div>' + line.map(r => { | |
| let s = esc(r.t); | |
| if (r.b) s = '<b>' + s + '</b>'; | |
| if (r.i) s = '<i>' + s + '</i>'; | |
| return s; | |
| }).join('') + '</div>'; | |
| }).join(''); | |
| } | |
| function plainToHtml(text) { | |
| return text.replace(/\r/g, '').split('\n') | |
| .map(l => l ? '<div>' + esc(l) + '</div>' : '<div><br></div>').join(''); | |
| } | |
| // Вставка HTML в позицию курсора без execCommand (он в ряде браузеров молча ничего не делает) | |
| function insertHtmlAtCursor(html) { | |
| input.focus(); | |
| const sel = window.getSelection(); | |
| let range; | |
| if (sel.rangeCount && input.contains(sel.getRangeAt(0).commonAncestorContainer)) { | |
| range = sel.getRangeAt(0); | |
| } else { | |
| range = document.createRange(); | |
| range.selectNodeContents(input); | |
| range.collapse(false); | |
| } | |
| range.deleteContents(); | |
| const tpl = document.createElement('template'); | |
| tpl.innerHTML = html; | |
| const frag = tpl.content; | |
| const last = frag.lastChild; | |
| range.insertNode(frag); | |
| if (last) { | |
| const r = document.createRange(); | |
| r.setStartAfter(last); | |
| r.collapse(true); | |
| sel.removeAllRanges(); | |
| sel.addRange(r); | |
| } | |
| } | |
| function insertFromData(dt) { | |
| const html = dt.getData('text/html'); | |
| const text = dt.getData('text/plain'); | |
| let clean = ''; | |
| if (html) { | |
| try { clean = sanitizeHtml(html); } | |
| catch (err) { console.error('Ошибка очистки HTML, вставляю как простой текст', err); } | |
| } | |
| if (!clean) clean = plainToHtml(text); | |
| insertHtmlAtCursor(clean); | |
| } | |
| input.addEventListener('paste', e => { | |
| e.preventDefault(); | |
| try { | |
| insertFromData(e.clipboardData); | |
| } catch (err) { | |
| console.error('Ошибка вставки', err); | |
| try { insertHtmlAtCursor(plainToHtml(e.clipboardData.getData('text/plain'))); } catch (_) {} | |
| } | |
| }); | |
| input.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| try { insertFromData(e.dataTransfer); } catch (err) { console.error(err); } | |
| }); | |
| /* ================== Извлечение текста + форматирования из редактора ================== */ | |
| function extractFromEditor(root) { | |
| let text = ''; | |
| const fmt = []; | |
| const styleCache = new Map(); | |
| function flagsOf(el) { | |
| if (styleCache.has(el)) return styleCache.get(el); | |
| const cs = getComputedStyle(el); | |
| const f = (parseInt(cs.fontWeight) >= 600 ? 1 : 0) | ((cs.fontStyle === 'italic' || cs.fontStyle === 'oblique') ? 2 : 0); | |
| styleCache.set(el, f); | |
| return f; | |
| } | |
| function push(s, f) { | |
| text += s; | |
| for (let k = 0; k < s.length; k++) fmt.push(f); | |
| } | |
| const endsNL = () => text.length === 0 || text[text.length - 1] === '\n'; | |
| function walk(n) { | |
| if (n.nodeType === 3) { | |
| const s = n.nodeValue.replace(/\u00a0/g, ' '); | |
| if (s) push(s, flagsOf(n.parentElement)); | |
| return; | |
| } | |
| if (n.nodeType !== 1) return; | |
| if (n.tagName === 'BR') { push('\n', 0); return; } | |
| const blk = BLOCK_TAGS.has(n.tagName); | |
| if (blk && !endsNL()) push('\n', 0); | |
| n.childNodes.forEach(walk); | |
| if (blk && !endsNL()) push('\n', 0); | |
| } | |
| root.childNodes.forEach(walk); | |
| // убираем хвостовые переводы строк | |
| while (text.endsWith('\n')) { text = text.slice(0, -1); fmt.pop(); } | |
| return { text, fmt }; | |
| } | |
| /* Текст [from,to) из снимка, с жирным/курсивом, в HTML */ | |
| function fmtSpan(from, to) { | |
| let out = ''; | |
| let i = from; | |
| while (i < to) { | |
| const f = fullFmt[i]; | |
| let j = i + 1; | |
| while (j < to && fullFmt[j] === f) j++; | |
| let s = esc(fullText.substring(i, j)); | |
| if (f & 1) s = '<b>' + s + '</b>'; | |
| if (f & 2) s = '<i>' + s + '</i>'; | |
| out += s; | |
| i = j; | |
| } | |
| return out; | |
| } | |
| /* ================== СИНХРОННАЯ ПРОКРУТКА ================== */ | |
| const scrollables = [input, results, highlighted]; | |
| let syncing = false; | |
| function syncScroll(source) { | |
| if (syncing) return; | |
| syncing = true; | |
| const srcMax = source.scrollHeight - source.clientHeight; | |
| const ratio = srcMax > 0 ? source.scrollTop / srcMax : 0; | |
| scrollables.forEach(el => { | |
| if (el === source) return; | |
| const max = el.scrollHeight - el.clientHeight; | |
| if (max > 0) el.scrollTop = ratio * max; | |
| }); | |
| requestAnimationFrame(() => { syncing = false; }); | |
| } | |
| scrollables.forEach(el => { | |
| el.addEventListener('scroll', () => syncScroll(el), { passive: true }); | |
| }); | |
| /* ---------- Разбивка на куски ≤ LIMIT по абзацам ---------- */ | |
| function splitText(text, limit) { | |
| const parts = []; | |
| const paragraphs = text.split(/(\n)/); | |
| let buf = ''; | |
| let cursor = 0; | |
| let bufStart = 0; | |
| function flush() { | |
| if (buf.length > 0) { | |
| parts.push({ text: buf, start: bufStart, end: bufStart + buf.length, done: false, matches: [], error: null }); | |
| buf = ''; | |
| } | |
| } | |
| for (const p of paragraphs) { | |
| if (p.length > limit) { | |
| flush(); | |
| const pieces = hardSplit(p, limit); | |
| for (const piece of pieces) { | |
| parts.push({ text: piece, start: cursor, end: cursor + piece.length, done: false, matches: [], error: null }); | |
| cursor += piece.length; | |
| } | |
| bufStart = cursor; | |
| continue; | |
| } | |
| if ((buf + p).length > limit) { | |
| flush(); | |
| bufStart = cursor; | |
| } | |
| if (buf.length === 0) bufStart = cursor; | |
| buf += p; | |
| cursor += p.length; | |
| } | |
| flush(); | |
| return parts; | |
| } | |
| function hardSplit(s, limit) { | |
| const out = []; | |
| let i = 0; | |
| while (i < s.length) { | |
| let end = Math.min(i + limit, s.length); | |
| if (end < s.length) { | |
| const slice = s.substring(i, end); | |
| const lastDot = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('! '), slice.lastIndexOf('? '), slice.lastIndexOf('\n')); | |
| if (lastDot > limit * 0.5) end = i + lastDot + 1; | |
| } | |
| out.push(s.substring(i, end)); | |
| i = end; | |
| } | |
| return out; | |
| } | |
| document.getElementById('btnSplit').onclick = () => { | |
| const ex = extractFromEditor(input); | |
| if (!ex.text.trim()) { alert('Введите текст'); return; } | |
| fullText = ex.text; | |
| fullFmt = ex.fmt; | |
| chunks = splitText(fullText, LIMIT); | |
| chunks.forEach(ch => { ch.text = fullText.substring(ch.start, ch.end); }); // гарантия совпадения смещений | |
| chunkInfo.textContent = `Всего знаков: ${fullText.length}. Кусков: ${chunks.length} (по ≤ ${LIMIT}).`; | |
| chunkButtons.innerHTML = ''; | |
| chunks.forEach((ch, i) => { | |
| const b = document.createElement('button'); | |
| b.textContent = `Кусок ${i+1} (${ch.text.length} зн.)`; | |
| b.dataset.idx = i; | |
| b.onclick = () => runOne(i); | |
| chunkButtons.appendChild(b); | |
| }); | |
| document.getElementById('btnRunAll').disabled = false; | |
| results.textContent = ''; | |
| renderHighlighted(); | |
| status.textContent = ''; | |
| }; | |
| /* ---------- Запрос к LanguageTool ---------- */ | |
| async function checkChunk(text) { | |
| const body = new URLSearchParams({ text: text, language: 'ru-RU' }); | |
| let res; | |
| try { | |
| res = await fetch(API, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| body | |
| }); | |
| } catch (netErr) { | |
| throw new Error('Сеть/CORS: ' + netErr.message + | |
| ' — попробуйте открыть страницу через http://localhost, а не file://'); | |
| } | |
| if (!res.ok) { | |
| let detail = ''; | |
| try { detail = (await res.text()).substring(0, 300); } catch(_) {} | |
| let hint = ''; | |
| if (res.status === 429) hint = ' — превышен лимит 20 запросов/мин, подождите минуту и повторите'; | |
| if (res.status === 413) hint = ' — кусок слишком большой, уменьшите LIMIT в коде'; | |
| throw new Error('HTTP ' + res.status + hint + (detail ? ' | ' + detail : '')); | |
| } | |
| const data = await res.json(); | |
| return filterMatches(data.matches || [], text); | |
| } | |
| /* ---------- Проверка одного куска ---------- */ | |
| async function runOne(idx) { | |
| const ch = chunks[idx]; | |
| if (!ch || ch.done) return false; | |
| status.textContent = `Проверяем кусок ${idx+1}…`; | |
| try { | |
| ch.matches = await checkChunk(ch.text); | |
| ch.done = true; | |
| ch.error = null; | |
| markButton(idx, 'done'); | |
| renderChunkResult(idx); | |
| renderHighlighted(); | |
| status.textContent = `Кусок ${idx+1} готов (ошибок: ${ch.matches.length}).`; | |
| return true; | |
| } catch (e) { | |
| console.error('Ошибка на куске', idx, e); | |
| ch.error = e.message; | |
| ch.done = false; | |
| markButton(idx, 'err'); | |
| renderChunkError(idx, e.message); | |
| status.textContent = `Ошибка на куске ${idx+1}: ${e.message}`; | |
| return false; | |
| } | |
| } | |
| function markButton(idx, cls) { | |
| const b = chunkButtons.querySelector(`button[data-idx="${idx}"]`); | |
| if (!b) return; | |
| b.classList.remove('done', 'err'); | |
| if (cls) b.classList.add(cls); | |
| } | |
| function renderChunkResult(idx) { | |
| const ch = chunks[idx]; | |
| const div = document.createElement('div'); | |
| div.className = 'block'; | |
| let html = `<div class="block-title">Кусок ${idx+1} (знаки ${ch.start}–${ch.end})</div>`; | |
| if (ch.matches.length === 0) { | |
| html += `<div class="issue ok">✅ Ошибок не найдено</div>`; | |
| } else { | |
| ch.matches.forEach((m, i) => { | |
| const start = Math.max(0, m.offset - 25); | |
| const end = Math.min(ch.text.length, m.offset + m.length + 25); | |
| const ctx = ch.text.substring(start, end).replace(/\n/g, ' '); | |
| const sugg = m.replacements.slice(0, 5).map(r => r.value).join(', '); | |
| html += `<div class="issue" data-chunk="${idx}" data-m="${i}" title="Показать в тексте"> | |
| <div class="msg">${i+1}. ${escapeHtml(m.message)}</div> | |
| <div class="ctx">…${escapeHtml(ctx)}…</div> | |
| ${sugg ? `<div class="sugg">→ ${escapeHtml(sugg)}</div>` : ''} | |
| </div>`; | |
| }); | |
| } | |
| div.innerHTML = html; | |
| results.appendChild(div); | |
| results.scrollTop = results.scrollHeight; | |
| } | |
| function renderChunkError(idx, msg) { | |
| const div = document.createElement('div'); | |
| div.className = 'block'; | |
| div.innerHTML = `<div class="block-title">Кусок ${idx+1}</div> | |
| <div class="issue">❌ ${escapeHtml(msg)}<pre>Проверьте вкладку Console (F12) для деталей</pre></div>`; | |
| results.appendChild(div); | |
| results.scrollTop = results.scrollHeight; | |
| } | |
| /* ---------- Подсветка в 3-й колонке (с сохранением жирного/курсива) ---------- */ | |
| function renderHighlighted() { | |
| const keepScroll = highlighted.scrollTop; | |
| let html = ''; | |
| chunks.forEach((ch, idx) => { | |
| html += `<div class="block"><div class="block-title">Кусок ${idx+1}</div>`; | |
| if (ch.error) { | |
| html += `<div class="issue">❌ ${escapeHtml(ch.error)}</div>` + fmtSpan(ch.start, ch.end); | |
| } else if (!ch.done) { | |
| html += fmtSpan(ch.start, ch.end); | |
| } else { | |
| html += highlightChunk(ch, idx); | |
| } | |
| html += `</div>`; | |
| }); | |
| highlighted.innerHTML = html; | |
| highlighted.scrollTop = keepScroll; | |
| } | |
| function highlightChunk(ch, ci) { | |
| const text = ch.text; | |
| const g = ch.start; | |
| const matches = ch.matches.map((m, i) => ({ m, i })).sort((a, b) => a.m.offset - b.m.offset); | |
| let out = ''; | |
| let pos = 0; | |
| for (const { m, i: mi } of matches) { | |
| if (m.offset < pos) continue; | |
| out += fmtSpan(g + pos, g + m.offset); | |
| const orig = text.substring(m.offset, m.offset + m.length); | |
| const sugg = m.replacements.length ? m.replacements[0].value : ''; | |
| out += `<span class="err" data-mid="${ci}-${mi}" title="${escapeAttr(m.message)}${sugg ? ' → ' + escapeAttr(sugg) : ''}">${fmtSpan(g + m.offset, g + m.offset + m.length)}</span>`; | |
| if (sugg && sugg !== orig) { | |
| out += ` <span class="fix">[→ ${escapeHtml(sugg)}]</span> `; | |
| } | |
| pos = m.offset + m.length; | |
| } | |
| out += fmtSpan(g + pos, g + text.length); | |
| return out; | |
| } | |
| /* ---------- Клик по ошибке во 2-й колонке → переход к месту в тексте ---------- */ | |
| results.addEventListener('click', e => { | |
| const issue = e.target.closest('.issue[data-m]'); | |
| if (!issue) return; | |
| jumpToMatch(+issue.dataset.chunk, +issue.dataset.m, issue); | |
| }); | |
| function jumpToMatch(ci, mi, issueEl) { | |
| const ch = chunks[ci]; | |
| const m = ch && ch.matches[mi]; | |
| if (!m) return; | |
| results.querySelectorAll('.issue.active').forEach(x => x.classList.remove('active')); | |
| if (issueEl) issueEl.classList.add('active'); | |
| syncing = true; // не даём синхронной прокрутке сдвинуть остальные колонки | |
| // 3-я колонка: точно к нужному месту | |
| const el = highlighted.querySelector(`[data-mid="${ci}-${mi}"]`); | |
| if (el) { | |
| const hr = highlighted.getBoundingClientRect(); | |
| const er = el.getBoundingClientRect(); | |
| highlighted.scrollTop += (er.top - hr.top) - highlighted.clientHeight / 3; | |
| el.classList.remove('flash'); | |
| void el.offsetWidth; | |
| el.classList.add('flash'); | |
| } | |
| // 1-я колонка: по доле текста (приблизительно, но рядом с нужным местом) | |
| const g = ch.start + m.offset; | |
| const max = input.scrollHeight - input.clientHeight; | |
| if (max > 0 && fullText.length) { | |
| input.scrollTop = Math.max(0, (g / fullText.length) * input.scrollHeight - input.clientHeight / 3); | |
| } | |
| requestAnimationFrame(() => requestAnimationFrame(() => { syncing = false; })); | |
| } | |
| /* ---------- Прогон всех кусков ---------- */ | |
| document.getElementById('btnRunAll').onclick = async () => { | |
| if (running) return; | |
| running = true; | |
| paused = false; | |
| document.getElementById('btnPause').disabled = false; | |
| document.getElementById('btnRunAll').disabled = true; | |
| let okCount = 0, errCount = 0; | |
| for (let i = 0; i < chunks.length; i++) { | |
| if (paused) { | |
| status.textContent = `Пауза (готово: ${okCount}, ошибок: ${errCount}). Нажмите «Проверить все куски» для продолжения.`; | |
| running = false; | |
| document.getElementById('btnRunAll').disabled = false; | |
| return; | |
| } | |
| if (chunks[i].done) { okCount++; continue; } | |
| const ok = await runOne(i); | |
| if (ok) okCount++; else errCount++; | |
| if (i < chunks.length - 1) await sleep(1200); | |
| } | |
| if (errCount === 0) { | |
| status.textContent = `Все куски проверены ✅ (${okCount})`; | |
| } else { | |
| status.textContent = `Готово: успешно ${okCount}, с ошибкой ${errCount}. Исправьте причину и повторите.`; | |
| } | |
| running = false; | |
| document.getElementById('btnPause').disabled = true; | |
| document.getElementById('btnRunAll').disabled = false; | |
| }; | |
| document.getElementById('btnPause').onclick = () => { paused = true; }; | |
| document.getElementById('btnReset').onclick = () => { | |
| chunks = []; | |
| fullText = ''; fullFmt = []; | |
| running = false; paused = false; | |
| input.innerHTML = ''; | |
| results.textContent = ''; | |
| highlighted.textContent = ''; | |
| chunkButtons.innerHTML = ''; | |
| chunkInfo.textContent = ''; | |
| status.textContent = ''; | |
| document.getElementById('btnRunAll').disabled = true; | |
| document.getElementById('btnPause').disabled = true; | |
| }; | |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| function escapeHtml(s) { return esc(s); } | |
| function escapeAttr(s) { return esc(s).replace(/"/g,'"'); } | |
| </script> | |
| </body> | |
| </html> |