AnnLior commited on
Commit
8e6f06f
·
verified ·
1 Parent(s): 40eccb1

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +225 -49
index.html CHANGED
@@ -6,32 +6,71 @@
6
  <title>Проверка русского текста (LanguageTool)</title>
7
  <style>
8
  * { box-sizing: border-box; }
9
- body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 12px; background: #f4f6f8; }
10
- h1 { font-size: 18px; margin: 0 0 10px; }
11
- .toolbar { margin-bottom: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
 
 
 
 
 
 
12
  button { padding: 8px 12px; font-size: 14px; cursor: pointer; border: none; border-radius: 6px; background: #2563eb; color: #fff; }
13
  button:disabled { background: #94a3b8; cursor: not-allowed; }
14
  button.pause { background: #f59e0b; }
15
  button.reset { background: #64748b; }
16
- .cols { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
 
 
 
 
 
 
 
17
  @media (max-width: 900px) {
18
- .cols { grid-template-columns: 1fr; }
19
- textarea, .output { height: 40vh; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
- .col { background: #fff; border-radius: 8px; padding: 10px; display: flex; flex-direction: column; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
22
- .col h2 { font-size: 13px; margin: 0 0 8px; color: #334155; text-transform: uppercase; letter-spacing: .5px; }
23
- textarea { width: 100%; height: 55vh; resize: vertical; font-size: 14px; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; font-family: inherit; }
24
- .output { height: 55vh; overflow-y: auto; font-size: 14px; line-height: 1.5; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; background: #fafafa; white-space: pre-wrap; word-wrap: break-word; }
25
- .chunk-buttons { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
26
- .chunk-buttons button { background: #0ea5e9; font-size: 13px; padding: 6px 10px; }
 
 
 
 
27
  .chunk-buttons button.done { background: #16a34a; }
28
  .chunk-buttons button.err { background: #dc2626; }
29
- .chunk-info { font-size: 12px; color: #64748b; margin-top: 6px; }
30
  .status { font-size: 13px; color: #475569; margin-left: 8px; }
 
31
  .err { background: #fee2e2; border-bottom: 2px solid #ef4444; cursor: help; }
32
- .fix { background: #dcfce7; color: #166534; font-weight: 600; }
33
  .block { margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px dashed #e2e8f0; }
34
- .block-title { font-size: 11px; color: #94a3b8; margin-bottom: 4px; }
 
35
  .issue { background: #fff; border-left: 4px solid #ef4444; padding: 6px 8px; margin-bottom: 6px; border-radius: 4px; font-size: 13px; }
36
  .issue.ok { border-color: #16a34a; }
37
  .issue .msg { font-weight: 600; }
@@ -55,19 +94,20 @@
55
  <div class="cols">
56
  <div class="col">
57
  <h2>1. Ваш текст</h2>
58
- <textarea id="input" placeholder="Вставьте сюда большой русский текст..."></textarea>
 
59
  <div class="chunk-info" id="chunkInfo"></div>
60
  <div class="chunk-buttons" id="chunkButtons"></div>
61
  </div>
62
 
63
  <div class="col">
64
  <h2>2. Результаты проверки</h2>
65
- <div class="output" id="results">Здесь появятся ошибки по каждому куску…</div>
66
  </div>
67
 
68
  <div class="col">
69
  <h2>3. Текст с подсветкой</h2>
70
- <div class="output" id="highlighted">Здесь будет сводный текст: красным — проблемные места, зелёным — предложенные исправления.</div>
71
  </div>
72
  </div>
73
 
@@ -76,8 +116,6 @@ const API = 'https://api.languagetool.org/v2/check';
76
  const LIMIT = 20000;
77
 
78
  /* ================== СТОП-ЛИСТ СЛОВ ================== */
79
- // Слова, которые LanguageTool не должен проверять и предлагать замены.
80
- // Регистр не важен. Можно с пробелами и дефисами.
81
  const STOP_WORDS = [
82
  'Иезавель',
83
  'Иезавели',
@@ -86,17 +124,14 @@ const STOP_WORDS = [
86
  'Ругияш',
87
  'Осия',
88
  'Потифар',
89
-
90
  // добавьте свои — каждое с новой строки в кавычках и с запятой
91
  ];
92
  const STOP_SET = new Set(STOP_WORDS.map(w => w.toLowerCase().trim()));
93
 
94
  /* ================== ИГНОР-ЛИСТ ПРАВИЛ ================== */
95
- // ID правил LanguageTool, которые нужно полностью игнорировать.
96
- // Список ID можно смотреть на https://community.languagetool.org/rule/list?lang=ru
97
  const IGNORED_RULES = new Set([
98
  'Many_PNN', // Загруженность предложения местоимениями
99
- // добавьте сюда другие ID, если найдёте ненужные правила
100
  ]);
101
 
102
  /* ================== ФИЛЬТРЫ ================== */
@@ -105,7 +140,6 @@ function isInsideStopWord(text, offset, length) {
105
  const target = text.substring(offset, offset + length).toLowerCase().trim();
106
  if (STOP_SET.has(target)) return true;
107
 
108
- // если LT выделил часть слова — проверим границы целого слова
109
  const isLetter = c => /[а-яёa-z]/i.test(c);
110
  const before = text[offset - 1] || ' ';
111
  const after = text[offset + length] || ' ';
@@ -140,11 +174,8 @@ function filterMatches(matches, text) {
140
  return { ...m, replacements: good };
141
  })
142
  .filter(m => {
143
- // оставляем, если есть хоть одна замена
144
  if (m.replacements.length > 0) return true;
145
- // или если это орфографическая ошибка (её стоит показать даже без замен)
146
  if (m.rule?.issueType === 'misspelling') return true;
147
- // иначе — скрываем
148
  return false;
149
  });
150
  }
@@ -161,6 +192,149 @@ let chunks = [];
161
  let running = false;
162
  let paused = false;
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  /* ================== СИНХРОННАЯ ПРОКРУТКА ================== */
165
  const scrollables = [input, results, highlighted];
166
  let syncing = false;
@@ -180,12 +354,11 @@ function syncScroll(source) {
180
  scrollables.forEach(el => {
181
  el.addEventListener('scroll', () => syncScroll(el), { passive: true });
182
  });
183
- /* =========================================================== */
184
 
185
  /* ---------- Разбивка на куски ≤ LIMIT по абзацам ---------- */
186
  function splitText(text, limit) {
187
  const parts = [];
188
- const paragraphs = text.split(/(\n\s*\n)/);
189
  let buf = '';
190
  let cursor = 0;
191
  let bufStart = 0;
@@ -237,10 +410,14 @@ function hardSplit(s, limit) {
237
  }
238
 
239
  document.getElementById('btnSplit').onclick = () => {
240
- const text = input.value;
241
- if (!text.trim()) { alert('Введите текст'); return; }
242
- chunks = splitText(text, LIMIT);
243
- chunkInfo.textContent = `Всего знаков: ${text.length}. Кусков: ${chunks.length} (по ≤ ${LIMIT}).`;
 
 
 
 
244
 
245
  chunkButtons.innerHTML = '';
246
  chunks.forEach((ch, i) => {
@@ -253,7 +430,7 @@ document.getElementById('btnSplit').onclick = () => {
253
 
254
  document.getElementById('btnRunAll').disabled = false;
255
  results.textContent = '';
256
- highlighted.textContent = 'Нажмите «Проверить все куски» или отдельный кусок.';
257
  status.textContent = '';
258
  };
259
 
@@ -280,7 +457,6 @@ async function checkChunk(text) {
280
  throw new Error('HTTP ' + res.status + hint + (detail ? ' | ' + detail : ''));
281
  }
282
  const data = await res.json();
283
- // Пропускаем через стоп-лист, игнор правил и фильтр нелепых замен
284
  return filterMatches(data.matches || [], text);
285
  }
286
 
@@ -350,40 +526,43 @@ function renderChunkError(idx, msg) {
350
  results.scrollTop = results.scrollHeight;
351
  }
352
 
353
- /* ---------- Подсветка в 3-й колонке ---------- */
354
  function renderHighlighted() {
 
355
  let html = '';
356
  chunks.forEach((ch, idx) => {
357
  html += `<div class="block"><div class="block-title">Кусок ${idx+1}</div>`;
358
  if (ch.error) {
359
- html += `<div class="issue">❌ ${escapeHtml(ch.error)}</div>` + escapeHtml(ch.text);
360
  } else if (!ch.done) {
361
- html += escapeHtml(ch.text);
362
  } else {
363
  html += highlightChunk(ch);
364
  }
365
  html += `</div>`;
366
  });
367
  highlighted.innerHTML = html;
 
368
  }
369
 
370
  function highlightChunk(ch) {
371
  const text = ch.text;
 
372
  const matches = [...ch.matches].sort((a,b) => a.offset - b.offset);
373
  let out = '';
374
  let pos = 0;
375
  for (const m of matches) {
376
  if (m.offset < pos) continue;
377
- out += escapeHtml(text.substring(pos, m.offset));
378
  const orig = text.substring(m.offset, m.offset + m.length);
379
  const sugg = m.replacements.length ? m.replacements[0].value : '';
380
- out += `<span class="err" title="${escapeAttr(m.message)}${sugg ? ' → ' + escapeAttr(sugg) : ''}">${escapeHtml(orig)}</span>`;
381
  if (sugg && sugg !== orig) {
382
  out += ` <span class="fix">[→ ${escapeHtml(sugg)}]</span> `;
383
  }
384
  pos = m.offset + m.length;
385
  }
386
- out += escapeHtml(text.substring(pos));
387
  return out;
388
  }
389
 
@@ -424,8 +603,9 @@ document.getElementById('btnPause').onclick = () => { paused = true; };
424
 
425
  document.getElementById('btnReset').onclick = () => {
426
  chunks = [];
 
427
  running = false; paused = false;
428
- input.value = '';
429
  results.textContent = '';
430
  highlighted.textContent = '';
431
  chunkButtons.innerHTML = '';
@@ -436,12 +616,8 @@ document.getElementById('btnReset').onclick = () => {
436
  };
437
 
438
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
439
- function escapeHtml(s) {
440
- return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
441
- }
442
- function escapeAttr(s) {
443
- return escapeHtml(s).replace(/"/g,'&quot;');
444
- }
445
  </script>
446
  </body>
447
  </html>
 
6
  <title>Проверка русского текста (LanguageTool)</title>
7
  <style>
8
  * { box-sizing: border-box; }
9
+ html, body { height: 100%; }
10
+ body {
11
+ font-family: -apple-system, Segoe UI, Roboto, sans-serif;
12
+ margin: 0; padding: 12px; background: #f4f6f8;
13
+ height: 100vh; height: 100dvh;
14
+ display: flex; flex-direction: column; overflow: hidden;
15
+ }
16
+ h1 { font-size: 18px; margin: 0 0 8px; flex: none; }
17
+ .toolbar { margin-bottom: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; flex: none; }
18
  button { padding: 8px 12px; font-size: 14px; cursor: pointer; border: none; border-radius: 6px; background: #2563eb; color: #fff; }
19
  button:disabled { background: #94a3b8; cursor: not-allowed; }
20
  button.pause { background: #f59e0b; }
21
  button.reset { background: #64748b; }
22
+
23
+ /* ===== Три колонки на всю высоту экрана ===== */
24
+ .cols {
25
+ flex: 1; min-height: 0;
26
+ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
27
+ grid-template-rows: minmax(0, 1fr);
28
+ gap: 10px;
29
+ }
30
  @media (max-width: 900px) {
31
+ .cols { grid-template-columns: minmax(0, 1fr); grid-template-rows: repeat(3, minmax(0, 1fr)); }
32
+ }
33
+ .col {
34
+ background: #fff; border-radius: 10px; padding: 10px;
35
+ display: flex; flex-direction: column; min-height: 0; min-width: 0;
36
+ box-shadow: 0 1px 3px rgba(0,0,0,.08);
37
+ }
38
+ .col h2 { flex: none; font-size: 13px; margin: 0 0 8px; color: #334155; text-transform: uppercase; letter-spacing: .5px; }
39
+
40
+ /* ===== Единый стиль текста для 1-й и 3-й колонок ===== */
41
+ .txt {
42
+ font-family: Georgia, "Times New Roman", serif;
43
+ font-size: 16px; line-height: 1.6; color: #0f172a;
44
+ font-weight: 400; font-style: normal;
45
+ white-space: pre-wrap; word-wrap: break-word; overflow-wrap: anywhere;
46
+ }
47
+ .txt b, .txt strong { font-weight: 700; }
48
+ .txt i, .txt em { font-style: italic; }
49
+
50
+ .pane {
51
+ flex: 1; min-height: 0; overflow-y: auto;
52
+ padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fafafa;
53
  }
54
+ #input { outline: none; background: #fff; }
55
+ #input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,.15); }
56
+ #input:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; }
57
+ #input div { min-height: 1.6em; }
58
+
59
+ /* Результаты проверки (2-я колонка) */
60
+ #results { font-size: 14px; line-height: 1.5; font-family: -apple-system, Segoe UI, Roboto, sans-serif; white-space: normal; }
61
+
62
+ .chunk-buttons { flex: none; margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; max-height: 84px; overflow-y: auto; }
63
+ .chunk-buttons button { background: #0ea5e9; font-size: 13px; padding: 5px 9px; }
64
  .chunk-buttons button.done { background: #16a34a; }
65
  .chunk-buttons button.err { background: #dc2626; }
66
+ .chunk-info { flex: none; font-size: 12px; color: #64748b; margin-top: 6px; }
67
  .status { font-size: 13px; color: #475569; margin-left: 8px; }
68
+
69
  .err { background: #fee2e2; border-bottom: 2px solid #ef4444; cursor: help; }
70
+ .fix { background: #dcfce7; color: #166534; font-weight: 600; font-family: -apple-system, Segoe UI, Roboto, sans-serif; font-size: 13px; }
71
  .block { margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px dashed #e2e8f0; }
72
+ .block:last-child { border-bottom: none; }
73
+ .block-title { font-family: -apple-system, Segoe UI, Roboto, sans-serif; font-size: 11px; color: #94a3b8; margin-bottom: 4px; }
74
  .issue { background: #fff; border-left: 4px solid #ef4444; padding: 6px 8px; margin-bottom: 6px; border-radius: 4px; font-size: 13px; }
75
  .issue.ok { border-color: #16a34a; }
76
  .issue .msg { font-weight: 600; }
 
94
  <div class="cols">
95
  <div class="col">
96
  <h2>1. Ваш текст</h2>
97
+ <div id="input" class="pane txt" contenteditable="true" spellcheck="false"
98
+ data-placeholder="Вставьте сюда большой русский текст…"></div>
99
  <div class="chunk-info" id="chunkInfo"></div>
100
  <div class="chunk-buttons" id="chunkButtons"></div>
101
  </div>
102
 
103
  <div class="col">
104
  <h2>2. Результаты проверки</h2>
105
+ <div class="pane" id="results">Здесь появятся ошибки по каждому куску…</div>
106
  </div>
107
 
108
  <div class="col">
109
  <h2>3. Текст с подсветкой</h2>
110
+ <div class="pane txt" id="highlighted">Здесь будет сводный текст: красным — проблемные места, зелёным — предложенные исправления.</div>
111
  </div>
112
  </div>
113
 
 
116
  const LIMIT = 20000;
117
 
118
  /* ================== СТОП-ЛИСТ СЛОВ ================== */
 
 
119
  const STOP_WORDS = [
120
  'Иезавель',
121
  'Иезавели',
 
124
  'Ругияш',
125
  'Осия',
126
  'Потифар',
127
+
128
  // добавьте свои — каждое с новой строки в кавычках и с запятой
129
  ];
130
  const STOP_SET = new Set(STOP_WORDS.map(w => w.toLowerCase().trim()));
131
 
132
  /* ================== ИГНОР-ЛИСТ ПРАВИЛ ================== */
 
 
133
  const IGNORED_RULES = new Set([
134
  'Many_PNN', // Загруженность предложения местоимениями
 
135
  ]);
136
 
137
  /* ================== ФИЛЬТРЫ ================== */
 
140
  const target = text.substring(offset, offset + length).toLowerCase().trim();
141
  if (STOP_SET.has(target)) return true;
142
 
 
143
  const isLetter = c => /[а-яёa-z]/i.test(c);
144
  const before = text[offset - 1] || ' ';
145
  const after = text[offset + length] || ' ';
 
174
  return { ...m, replacements: good };
175
  })
176
  .filter(m => {
 
177
  if (m.replacements.length > 0) return true;
 
178
  if (m.rule?.issueType === 'misspelling') return true;
 
179
  return false;
180
  });
181
  }
 
192
  let running = false;
193
  let paused = false;
194
 
195
+ // Снимок текста и форматирования на момент разбивки
196
+ let fullText = '';
197
+ let fullFmt = []; // для каждого символа: бит 1 = жирный, бит 2 = курсив
198
+
199
+ /* ================== ВСТАВКА: чистим всё, кроме жирного и курсива ================== */
200
+ function esc(s) {
201
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
202
+ }
203
+
204
+ const BLOCK_TAGS = new Set(['P','DIV','LI','UL','OL','TR','BLOCKQUOTE','TABLE','SECTION','ARTICLE','PRE','HR','H1','H2','H3','H4','H5','H6']);
205
+ const SKIP_TAGS = new Set(['SCRIPT','STYLE','HEAD','META','TITLE','LINK']);
206
+
207
+ function sanitizeHtml(html) {
208
+ const doc = new DOMParser().parseFromString(html, 'text/html');
209
+ const runs = [];
210
+ const atLineStart = () => runs.length === 0 || runs[runs.length - 1].t === '\n';
211
+ const endLine = () => { if (!atLineStart()) runs.push({ t: '\n' }); };
212
+
213
+ function walk(n, b, i) {
214
+ if (n.nodeType === 3) {
215
+ let s = n.nodeValue.replace(/[\s\u00a0]+/g, ' ');
216
+ if (atLineStart()) s = s.replace(/^ /, '');
217
+ if (!s) return;
218
+ runs.push({ t: s, b, i });
219
+ return;
220
+ }
221
+ if (n.nodeType !== 1) return;
222
+ const tag = n.tagName;
223
+ if (SKIP_TAGS.has(tag)) return;
224
+ if (tag === 'BR') { runs.push({ t: '\n' }); return; }
225
+
226
+ let nb = b, ni = i;
227
+ if (tag === 'B' || tag === 'STRONG' || /^H[1-6]$/.test(tag)) nb = true;
228
+ if (tag === 'I' || tag === 'EM' || tag === 'CITE') ni = true;
229
+ const st = n.style || {};
230
+ // inline-стиль главнее тега (Google Docs оборачивает всё в <b style="font-weight:normal">)
231
+ if (st.fontWeight) nb = (st.fontWeight === 'bold' || st.fontWeight === 'bolder' || parseInt(st.fontWeight) >= 600);
232
+ if (st.fontStyle) ni = (st.fontStyle === 'italic' || st.fontStyle === 'oblique');
233
+
234
+ const blk = BLOCK_TAGS.has(tag);
235
+ if (blk) endLine();
236
+ n.childNodes.forEach(c => walk(c, nb, ni));
237
+ if (blk) endLine();
238
+ }
239
+ walk(doc.body, false, false);
240
+
241
+ // собираем строки
242
+ const lines = [[]];
243
+ for (const r of runs) {
244
+ if (r.t === '\n') lines.push([]); else lines[lines.length - 1].push(r);
245
+ }
246
+ while (lines.length && lines[lines.length - 1].length === 0) lines.pop();
247
+
248
+ return lines.map(line => {
249
+ if (!line.length) return '<div><br></div>';
250
+ line[line.length - 1] = { ...line[line.length - 1], t: line[line.length - 1].t.replace(/ $/, '') };
251
+ return '<div>' + line.map(r => {
252
+ let s = esc(r.t);
253
+ if (r.b) s = '<b>' + s + '</b>';
254
+ if (r.i) s = '<i>' + s + '</i>';
255
+ return s;
256
+ }).join('') + '</div>';
257
+ }).join('');
258
+ }
259
+
260
+ function plainToHtml(text) {
261
+ return text.replace(/\r/g, '').split('\n')
262
+ .map(l => l ? '<div>' + esc(l) + '</div>' : '<div><br></div>').join('');
263
+ }
264
+
265
+ function insertFromData(dt) {
266
+ const html = dt.getData('text/html');
267
+ const text = dt.getData('text/plain');
268
+ const clean = html ? sanitizeHtml(html) : plainToHtml(text);
269
+ document.execCommand('insertHTML', false, clean || plainToHtml(text));
270
+ }
271
+
272
+ input.addEventListener('paste', e => {
273
+ e.preventDefault();
274
+ insertFromData(e.clipboardData);
275
+ });
276
+ input.addEventListener('drop', e => {
277
+ e.preventDefault();
278
+ input.focus();
279
+ insertFromData(e.dataTransfer);
280
+ });
281
+
282
+ /* ================== Извлечение текста + форматирования из редактора ================== */
283
+ function extractFromEditor(root) {
284
+ let text = '';
285
+ const fmt = [];
286
+ const styleCache = new Map();
287
+
288
+ function flagsOf(el) {
289
+ if (styleCache.has(el)) return styleCache.get(el);
290
+ const cs = getComputedStyle(el);
291
+ const f = (parseInt(cs.fontWeight) >= 600 ? 1 : 0) | ((cs.fontStyle === 'italic' || cs.fontStyle === 'oblique') ? 2 : 0);
292
+ styleCache.set(el, f);
293
+ return f;
294
+ }
295
+ function push(s, f) {
296
+ text += s;
297
+ for (let k = 0; k < s.length; k++) fmt.push(f);
298
+ }
299
+ const endsNL = () => text.length === 0 || text[text.length - 1] === '\n';
300
+
301
+ function walk(n) {
302
+ if (n.nodeType === 3) {
303
+ const s = n.nodeValue.replace(/\u00a0/g, ' ');
304
+ if (s) push(s, flagsOf(n.parentElement));
305
+ return;
306
+ }
307
+ if (n.nodeType !== 1) return;
308
+ if (n.tagName === 'BR') { push('\n', 0); return; }
309
+ const blk = BLOCK_TAGS.has(n.tagName);
310
+ if (blk && !endsNL()) push('\n', 0);
311
+ n.childNodes.forEach(walk);
312
+ if (blk && !endsNL()) push('\n', 0);
313
+ }
314
+ root.childNodes.forEach(walk);
315
+
316
+ // убираем хвостовые переводы строк
317
+ while (text.endsWith('\n')) { text = text.slice(0, -1); fmt.pop(); }
318
+ return { text, fmt };
319
+ }
320
+
321
+ /* Текст [from,to) из снимка, с жирным/курсивом, в HTML */
322
+ function fmtSpan(from, to) {
323
+ let out = '';
324
+ let i = from;
325
+ while (i < to) {
326
+ const f = fullFmt[i];
327
+ let j = i + 1;
328
+ while (j < to && fullFmt[j] === f) j++;
329
+ let s = esc(fullText.substring(i, j));
330
+ if (f & 1) s = '<b>' + s + '</b>';
331
+ if (f & 2) s = '<i>' + s + '</i>';
332
+ out += s;
333
+ i = j;
334
+ }
335
+ return out;
336
+ }
337
+
338
  /* ================== СИНХРОННАЯ ПРОКРУТКА ================== */
339
  const scrollables = [input, results, highlighted];
340
  let syncing = false;
 
354
  scrollables.forEach(el => {
355
  el.addEventListener('scroll', () => syncScroll(el), { passive: true });
356
  });
 
357
 
358
  /* ---------- Разбивка на куски ≤ LIMIT по абзацам ---------- */
359
  function splitText(text, limit) {
360
  const parts = [];
361
+ const paragraphs = text.split(/(\n)/);
362
  let buf = '';
363
  let cursor = 0;
364
  let bufStart = 0;
 
410
  }
411
 
412
  document.getElementById('btnSplit').onclick = () => {
413
+ const ex = extractFromEditor(input);
414
+ if (!ex.text.trim()) { alert('Введите текст'); return; }
415
+ fullText = ex.text;
416
+ fullFmt = ex.fmt;
417
+
418
+ chunks = splitText(fullText, LIMIT);
419
+ chunks.forEach(ch => { ch.text = fullText.substring(ch.start, ch.end); }); // гарантия совпадения смещений
420
+ chunkInfo.textContent = `Всего знаков: ${fullText.length}. Кусков: ${chunks.length} (по ≤ ${LIMIT}).`;
421
 
422
  chunkButtons.innerHTML = '';
423
  chunks.forEach((ch, i) => {
 
430
 
431
  document.getElementById('btnRunAll').disabled = false;
432
  results.textContent = '';
433
+ renderHighlighted();
434
  status.textContent = '';
435
  };
436
 
 
457
  throw new Error('HTTP ' + res.status + hint + (detail ? ' | ' + detail : ''));
458
  }
459
  const data = await res.json();
 
460
  return filterMatches(data.matches || [], text);
461
  }
462
 
 
526
  results.scrollTop = results.scrollHeight;
527
  }
528
 
529
+ /* ---------- Подсветка в 3-й колонке (с сохранением жирного/курсива) ---------- */
530
  function renderHighlighted() {
531
+ const keepScroll = highlighted.scrollTop;
532
  let html = '';
533
  chunks.forEach((ch, idx) => {
534
  html += `<div class="block"><div class="block-title">Кусок ${idx+1}</div>`;
535
  if (ch.error) {
536
+ html += `<div class="issue">❌ ${escapeHtml(ch.error)}</div>` + fmtSpan(ch.start, ch.end);
537
  } else if (!ch.done) {
538
+ html += fmtSpan(ch.start, ch.end);
539
  } else {
540
  html += highlightChunk(ch);
541
  }
542
  html += `</div>`;
543
  });
544
  highlighted.innerHTML = html;
545
+ highlighted.scrollTop = keepScroll;
546
  }
547
 
548
  function highlightChunk(ch) {
549
  const text = ch.text;
550
+ const g = ch.start;
551
  const matches = [...ch.matches].sort((a,b) => a.offset - b.offset);
552
  let out = '';
553
  let pos = 0;
554
  for (const m of matches) {
555
  if (m.offset < pos) continue;
556
+ out += fmtSpan(g + pos, g + m.offset);
557
  const orig = text.substring(m.offset, m.offset + m.length);
558
  const sugg = m.replacements.length ? m.replacements[0].value : '';
559
+ out += `<span class="err" title="${escapeAttr(m.message)}${sugg ? ' → ' + escapeAttr(sugg) : ''}">${fmtSpan(g + m.offset, g + m.offset + m.length)}</span>`;
560
  if (sugg && sugg !== orig) {
561
  out += ` <span class="fix">[→ ${escapeHtml(sugg)}]</span> `;
562
  }
563
  pos = m.offset + m.length;
564
  }
565
+ out += fmtSpan(g + pos, g + text.length);
566
  return out;
567
  }
568
 
 
603
 
604
  document.getElementById('btnReset').onclick = () => {
605
  chunks = [];
606
+ fullText = ''; fullFmt = [];
607
  running = false; paused = false;
608
+ input.innerHTML = '';
609
  results.textContent = '';
610
  highlighted.textContent = '';
611
  chunkButtons.innerHTML = '';
 
616
  };
617
 
618
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
619
+ function escapeHtml(s) { return esc(s); }
620
+ function escapeAttr(s) { return esc(s).replace(/"/g,'&quot;'); }
 
 
 
 
621
  </script>
622
  </body>
623
  </html>