youssefreda9 commited on
Commit
036913a
·
1 Parent(s): 040f556

feat: Rich text formatting toolbar + formatting-preserving renderer - Bold, Italic, Underline, Strikethrough, Alignment, Undo/Redo - Font family and font size dropdowns - New DOM-level overlay renderer (preserves formatting during analysis) - Apply/dismiss corrections keep original formatting

Browse files
Files changed (5) hide show
  1. src/css/components.css +79 -0
  2. src/index.html +52 -6
  3. src/js/editor.js +50 -29
  4. src/js/format.js +132 -0
  5. src/js/renderer.js +148 -1
src/css/components.css CHANGED
@@ -283,6 +283,85 @@
283
  color: var(--color-text-inverse);
284
  }
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  .editor-surface {
287
  background: var(--color-editor);
288
  color: var(--color-text-primary);
 
283
  color: var(--color-text-inverse);
284
  }
285
 
286
+ /* ── Formatting Toolbar ── */
287
+ .format-toolbar {
288
+ display: flex;
289
+ flex-wrap: wrap;
290
+ align-items: center;
291
+ gap: 4px;
292
+ padding: 6px var(--spacing-md);
293
+ border-bottom: 1px solid var(--color-border);
294
+ background: var(--color-surface);
295
+ }
296
+
297
+ .fmt-group {
298
+ display: flex;
299
+ align-items: center;
300
+ gap: 2px;
301
+ }
302
+
303
+ .fmt-btn {
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: center;
307
+ width: 32px;
308
+ height: 32px;
309
+ border: none;
310
+ border-radius: var(--radius-sm, 4px);
311
+ background: transparent;
312
+ color: var(--color-text-secondary);
313
+ cursor: pointer;
314
+ font-family: 'Georgia', 'Times New Roman', serif;
315
+ font-size: 14px;
316
+ transition: all var(--transition-base);
317
+ }
318
+
319
+ .fmt-btn:hover {
320
+ background: var(--color-surface-elevated);
321
+ color: var(--color-text-primary);
322
+ }
323
+
324
+ .fmt-btn.fmt-active {
325
+ background: var(--color-primary);
326
+ color: var(--color-text-inverse);
327
+ border-radius: var(--radius-sm, 4px);
328
+ }
329
+
330
+ .fmt-btn svg {
331
+ flex-shrink: 0;
332
+ }
333
+
334
+ .fmt-divider {
335
+ width: 1px;
336
+ height: 24px;
337
+ background: var(--color-border);
338
+ margin: 0 4px;
339
+ }
340
+
341
+ .fmt-select {
342
+ padding: 4px 8px;
343
+ border: 1px solid var(--color-border);
344
+ border-radius: var(--radius-sm, 4px);
345
+ background: var(--color-surface);
346
+ color: var(--color-text-primary);
347
+ font-family: inherit;
348
+ font-size: var(--font-size-label, 12px);
349
+ cursor: pointer;
350
+ height: 32px;
351
+ min-width: 100px;
352
+ transition: border-color var(--transition-base);
353
+ }
354
+
355
+ .fmt-select:hover,
356
+ .fmt-select:focus {
357
+ border-color: var(--color-primary);
358
+ outline: none;
359
+ }
360
+
361
+ .fmt-select--size {
362
+ min-width: 80px;
363
+ }
364
+
365
  .editor-surface {
366
  background: var(--color-editor);
367
  color: var(--color-text-primary);
src/index.html CHANGED
@@ -33,6 +33,7 @@
33
  <script src="/js/ui.js"></script>
34
  <script src="/js/documents/doc-utils.js"></script>
35
  <script src="/js/editor.js"></script>
 
36
  <script src="/js/documents/import.js"></script>
37
  <script src="/js/documents/export.js?v=20260615d"></script>
38
  <script src="/js/documents/documents.js"></script>
@@ -489,6 +490,56 @@
489
  </button>
490
  </div>
491
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  <input type="file" id="doc-import-input" class="sr-only" accept=".txt,.docx,text/plain,application/vnd.openxmlformats-officedocument.wordprocessingml.document" aria-hidden="true">
493
  <div id="write-area">
494
  <div id="analysis-limit-banner" class="analysis-limit-banner is-hidden" role="status" aria-live="polite"></div>
@@ -996,6 +1047,7 @@
996
  initTheme();
997
  initUI();
998
  initEditor();
 
999
  initDocuments();
1000
  updateSuggestionsList([]);
1001
  // Phase 7 — Sync System
@@ -1006,12 +1058,6 @@
1006
  initDocumentsCloud();
1007
  initSummaries();
1008
 
1009
- const mobileImport = document.getElementById('doc-import-btn-mobile');
1010
- const importInput = document.getElementById('doc-import-input');
1011
- if (mobileImport && importInput) {
1012
- mobileImport.addEventListener('click', () => importInput.click());
1013
- }
1014
-
1015
  try {
1016
  const savedPage = sessionStorage.getItem('bayan_current_page');
1017
  if (savedPage) {
 
33
  <script src="/js/ui.js"></script>
34
  <script src="/js/documents/doc-utils.js"></script>
35
  <script src="/js/editor.js"></script>
36
+ <script src="/js/format.js"></script>
37
  <script src="/js/documents/import.js"></script>
38
  <script src="/js/documents/export.js?v=20260615d"></script>
39
  <script src="/js/documents/documents.js"></script>
 
490
  </button>
491
  </div>
492
  </div>
493
+ <!-- Formatting Toolbar -->
494
+ <div class="format-toolbar" id="format-toolbar">
495
+ <div class="fmt-group">
496
+ <button id="fmt-bold" class="fmt-btn" onclick="formatBold()" type="button" title="غامق (Ctrl+B)"><b>B</b></button>
497
+ <button id="fmt-italic" class="fmt-btn" onclick="formatItalic()" type="button" title="مائل (Ctrl+I)"><i>I</i></button>
498
+ <button id="fmt-underline" class="fmt-btn" onclick="formatUnderline()" type="button" title="تحته خط (Ctrl+U)"><u>U</u></button>
499
+ <button id="fmt-strikethrough" class="fmt-btn" onclick="formatStrikethrough()" type="button" title="يتوسطه خط"><s>S</s></button>
500
+ </div>
501
+ <div class="fmt-divider"></div>
502
+ <div class="fmt-group">
503
+ <button id="fmt-align-right" class="fmt-btn" onclick="formatAlignRight()" type="button" title="محاذاة لليمين">
504
+ <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24"><path d="M3 5h18v2H3V5zm4 4h14v2H7V9zm-4 4h18v2H3v-2zm4 4h14v2H7v-2z"/></svg>
505
+ </button>
506
+ <button id="fmt-align-center" class="fmt-btn" onclick="formatAlignCenter()" type="button" title="توسيط">
507
+ <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24"><path d="M3 5h18v2H3V5zm3 4h12v2H6V9zm-3 4h18v2H3v-2zm3 4h12v2H6v-2z"/></svg>
508
+ </button>
509
+ <button id="fmt-align-left" class="fmt-btn" onclick="formatAlignLeft()" type="button" title="محاذاة لليسار">
510
+ <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24"><path d="M3 5h18v2H3V5zm0 4h14v2H3V9zm0 4h18v2H3v-2zm0 4h14v2H3v-2z"/></svg>
511
+ </button>
512
+ </div>
513
+ <div class="fmt-divider"></div>
514
+ <div class="fmt-group">
515
+ <button class="fmt-btn" onclick="formatUndo()" type="button" title="تراجع (Ctrl+Z)">
516
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a5 5 0 015 5v2M3 10l4-4M3 10l4 4"/></svg>
517
+ </button>
518
+ <button class="fmt-btn" onclick="formatRedo()" type="button" title="إعادة (Ctrl+Y)">
519
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10H11a5 5 0 00-5 5v2M21 10l-4-4M21 10l-4 4"/></svg>
520
+ </button>
521
+ </div>
522
+ <div class="fmt-divider"></div>
523
+ <div class="fmt-group">
524
+ <select id="fmt-font-family" class="fmt-select" title="نوع الخط">
525
+ <option value="Cairo" selected>Cairo</option>
526
+ <option value="Arial">Arial</option>
527
+ <option value="Times New Roman">Times New Roman</option>
528
+ <option value="Courier New">Courier New</option>
529
+ <option value="Tahoma">Tahoma</option>
530
+ <option value="Simplified Arabic">Simplified Arabic</option>
531
+ <option value="Traditional Arabic">Traditional Arabic</option>
532
+ </select>
533
+ <select id="fmt-font-size" class="fmt-select fmt-select--size" title="حجم الخط">
534
+ <option value="12px">صغير</option>
535
+ <option value="16px" selected>عادي</option>
536
+ <option value="20px">فوق المتوسط</option>
537
+ <option value="24px">كبير</option>
538
+ <option value="32px">كبير جداً</option>
539
+ <option value="48px">الأقصى</option>
540
+ </select>
541
+ </div>
542
+ </div>
543
  <input type="file" id="doc-import-input" class="sr-only" accept=".txt,.docx,text/plain,application/vnd.openxmlformats-officedocument.wordprocessingml.document" aria-hidden="true">
544
  <div id="write-area">
545
  <div id="analysis-limit-banner" class="analysis-limit-banner is-hidden" role="status" aria-live="polite"></div>
 
1047
  initTheme();
1048
  initUI();
1049
  initEditor();
1050
+ if (typeof initFormatToolbar === 'function') initFormatToolbar();
1051
  initDocuments();
1052
  updateSuggestionsList([]);
1053
  // Phase 7 — Sync System
 
1058
  initDocumentsCloud();
1059
  initSummaries();
1060
 
 
 
 
 
 
 
1061
  try {
1062
  const savedPage = sessionStorage.getItem('bayan_current_page');
1063
  if (savedPage) {
src/js/editor.js CHANGED
@@ -137,7 +137,6 @@ async function analyzeText() {
137
 
138
  try {
139
  const savedSelection = saveSelection();
140
- const currentCaretOffset = getCaretOffset();
141
 
142
  const response = await fetch('/api/analyze', {
143
  method: 'POST',
@@ -161,17 +160,12 @@ async function analyzeText() {
161
 
162
  window.currentSuggestions = sortSuggestions(data.suggestions || []);
163
 
164
- const highlightedHtml = render({
165
- text: text,
166
- suggestions: window.currentSuggestions
167
- });
168
-
169
- setEditorHTML(highlightedHtml);
170
 
171
  if (savedSelection) {
172
  restoreSelection(savedSelection);
173
- } else {
174
- setCaretOffset(currentCaretOffset);
175
  }
176
 
177
  const spellingCount = window.currentSuggestions.filter((s) => s.type === 'spelling').length;
@@ -193,7 +187,8 @@ async function analyzeText() {
193
  function renderWithoutSuggestions(text) {
194
  const editor = getEditorElement();
195
  if (!editor) return;
196
- editor.textContent = text;
 
197
  updatePlaceholder();
198
  }
199
 
@@ -302,11 +297,23 @@ function hideTooltip() {
302
  }
303
 
304
  function applySuggestionAtOffsets(suggestion) {
305
- const text = getEditorText();
306
- const before = text.substring(0, suggestion.start);
307
- const after = text.substring(suggestion.end);
308
- const newText = before + suggestion.correction + after;
309
- setEditorHTML(escapeHtml(newText));
 
 
 
 
 
 
 
 
 
 
 
 
310
  hideTooltip();
311
  analyzeTextDelayed();
312
  }
@@ -317,29 +324,43 @@ function applyCorrection() {
317
  }
318
 
319
  function applyAlternativeCorrection(suggestion, correctionText) {
320
- const text = getEditorText();
321
- const before = text.substring(0, suggestion.start);
322
- const after = text.substring(suggestion.end);
323
- const newText = before + correctionText + after;
324
- setEditorHTML(escapeHtml(newText));
 
 
 
 
 
 
 
 
325
  hideTooltip();
326
  analyzeTextDelayed();
327
  }
328
 
329
  function dismissSuggestion(suggestion) {
330
- // Remove this suggestion from the list (keep the word as-is)
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  if (window.currentSuggestions) {
332
  window.currentSuggestions = window.currentSuggestions.filter(
333
  s => !(s.start === suggestion.start && s.end === suggestion.end)
334
  );
335
- // Re-render without this suggestion
336
- const text = getEditorText();
337
- const highlightedHtml = render({
338
- text: text,
339
- suggestions: window.currentSuggestions
340
- });
341
- setEditorHTML(highlightedHtml);
342
-
343
  const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
344
  const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
345
  const punctuationCount = window.currentSuggestions.filter(s => s.type === 'punctuation').length;
 
137
 
138
  try {
139
  const savedSelection = saveSelection();
 
140
 
141
  const response = await fetch('/api/analyze', {
142
  method: 'POST',
 
160
 
161
  window.currentSuggestions = sortSuggestions(data.suggestions || []);
162
 
163
+ // Use DOM overlay instead of innerHTML replacement to preserve formatting
164
+ const editor = getEditorElement();
165
+ overlaySuggestions(editor, window.currentSuggestions);
 
 
 
166
 
167
  if (savedSelection) {
168
  restoreSelection(savedSelection);
 
 
169
  }
170
 
171
  const spellingCount = window.currentSuggestions.filter((s) => s.type === 'spelling').length;
 
187
  function renderWithoutSuggestions(text) {
188
  const editor = getEditorElement();
189
  if (!editor) return;
190
+ // Just clear overlays, don't replace content (preserves formatting)
191
+ clearOverlays(editor);
192
  updatePlaceholder();
193
  }
194
 
 
297
  }
298
 
299
  function applySuggestionAtOffsets(suggestion) {
300
+ // Find the error span in the DOM and replace its text content
301
+ // This preserves formatting (bold, italic, etc.) around/inside the span
302
+ const idx = (window.currentSuggestions || []).indexOf(suggestion);
303
+ const errorSpan = idx >= 0 ? document.querySelector(`[data-suggestion-id="${idx}"]`) : null;
304
+
305
+ if (errorSpan) {
306
+ // Replace the error span with the corrected text node
307
+ const correctedNode = document.createTextNode(suggestion.correction);
308
+ errorSpan.replaceWith(correctedNode);
309
+ } else {
310
+ // Fallback: use offset-based replacement
311
+ const text = getEditorText();
312
+ const before = text.substring(0, suggestion.start);
313
+ const after = text.substring(suggestion.end);
314
+ const newText = before + suggestion.correction + after;
315
+ setEditorHTML(escapeHtml(newText));
316
+ }
317
  hideTooltip();
318
  analyzeTextDelayed();
319
  }
 
324
  }
325
 
326
  function applyAlternativeCorrection(suggestion, correctionText) {
327
+ const idx = (window.currentSuggestions || []).indexOf(suggestion);
328
+ const errorSpan = idx >= 0 ? document.querySelector(`[data-suggestion-id="${idx}"]`) : null;
329
+
330
+ if (errorSpan) {
331
+ const correctedNode = document.createTextNode(correctionText);
332
+ errorSpan.replaceWith(correctedNode);
333
+ } else {
334
+ const text = getEditorText();
335
+ const before = text.substring(0, suggestion.start);
336
+ const after = text.substring(suggestion.end);
337
+ const newText = before + correctionText + after;
338
+ setEditorHTML(escapeHtml(newText));
339
+ }
340
  hideTooltip();
341
  analyzeTextDelayed();
342
  }
343
 
344
  function dismissSuggestion(suggestion) {
345
+ // Remove the error highlight but keep the text as-is
346
+ const idx = (window.currentSuggestions || []).indexOf(suggestion);
347
+ const errorSpan = idx >= 0 ? document.querySelector(`[data-suggestion-id="${idx}"]`) : null;
348
+
349
+ if (errorSpan) {
350
+ // Unwrap: replace span with its text content
351
+ const parent = errorSpan.parentNode;
352
+ while (errorSpan.firstChild) {
353
+ parent.insertBefore(errorSpan.firstChild, errorSpan);
354
+ }
355
+ parent.removeChild(errorSpan);
356
+ parent.normalize();
357
+ }
358
+
359
  if (window.currentSuggestions) {
360
  window.currentSuggestions = window.currentSuggestions.filter(
361
  s => !(s.start === suggestion.start && s.end === suggestion.end)
362
  );
363
+
 
 
 
 
 
 
 
364
  const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
365
  const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
366
  const punctuationCount = window.currentSuggestions.filter(s => s.type === 'punctuation').length;
src/js/format.js ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // src/js/format.js
2
+ // Rich text formatting commands for the editor
3
+
4
+ /**
5
+ * Execute a formatting command on the current selection
6
+ */
7
+ function execFormat(command, value) {
8
+ document.execCommand(command, false, value || null);
9
+ const editor = getEditorElement();
10
+ if (editor) editor.focus();
11
+ updateFormatState();
12
+ }
13
+
14
+ /* ── Text style ── */
15
+ function formatBold() { execFormat('bold'); }
16
+ function formatItalic() { execFormat('italic'); }
17
+ function formatUnderline() { execFormat('underline'); }
18
+ function formatStrikethrough() { execFormat('strikethrough'); }
19
+
20
+ /* ── Undo / Redo ── */
21
+ function formatUndo() { execFormat('undo'); }
22
+ function formatRedo() { execFormat('redo'); }
23
+
24
+ /* ── Alignment ── */
25
+ function formatAlignRight() { execFormat('justifyRight'); }
26
+ function formatAlignCenter() { execFormat('justifyCenter'); }
27
+ function formatAlignLeft() { execFormat('justifyLeft'); }
28
+
29
+ /* ── Font family ── */
30
+ function formatFont(fontName) {
31
+ execFormat('fontName', fontName);
32
+ }
33
+
34
+ /* ── Font size ── */
35
+ // execCommand fontSize only supports 1-7, so we use CSS instead
36
+ function formatFontSize(size) {
37
+ const sel = window.getSelection();
38
+ if (!sel.rangeCount) return;
39
+
40
+ const range = sel.getRangeAt(0);
41
+ if (range.collapsed) return; // no selection
42
+
43
+ // Wrap selected text in a span with the font size
44
+ const span = document.createElement('span');
45
+ span.style.fontSize = size;
46
+ try {
47
+ range.surroundContents(span);
48
+ } catch (e) {
49
+ // If selection crosses elements, use execCommand as fallback
50
+ execFormat('fontSize', '4');
51
+ // Then find the font element and fix the size
52
+ const editor = getEditorElement();
53
+ if (editor) {
54
+ editor.querySelectorAll('font[size="4"]').forEach(f => {
55
+ const s = document.createElement('span');
56
+ s.style.fontSize = size;
57
+ s.innerHTML = f.innerHTML;
58
+ f.replaceWith(s);
59
+ });
60
+ }
61
+ }
62
+ updateFormatState();
63
+ }
64
+
65
+ /**
66
+ * Update toolbar button active states based on current selection
67
+ */
68
+ function updateFormatState() {
69
+ const btnMap = {
70
+ 'fmt-bold': 'bold',
71
+ 'fmt-italic': 'italic',
72
+ 'fmt-underline': 'underline',
73
+ 'fmt-strikethrough': 'strikeThrough',
74
+ 'fmt-align-right': 'justifyRight',
75
+ 'fmt-align-center': 'justifyCenter',
76
+ 'fmt-align-left': 'justifyLeft',
77
+ };
78
+
79
+ Object.entries(btnMap).forEach(([id, command]) => {
80
+ const btn = document.getElementById(id);
81
+ if (btn) {
82
+ const isActive = document.queryCommandState(command);
83
+ btn.classList.toggle('fmt-active', isActive);
84
+ }
85
+ });
86
+
87
+ // Font family
88
+ const fontSelect = document.getElementById('fmt-font-family');
89
+ if (fontSelect) {
90
+ const currentFont = document.queryCommandValue('fontName').replace(/['"]/g, '');
91
+ if (currentFont) {
92
+ // Try to match
93
+ for (let i = 0; i < fontSelect.options.length; i++) {
94
+ if (fontSelect.options[i].value === currentFont) {
95
+ fontSelect.selectedIndex = i;
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Initialize formatting toolbar events
105
+ */
106
+ function initFormatToolbar() {
107
+ const editor = getEditorElement();
108
+ if (!editor) return;
109
+
110
+ // Update button states on selection change
111
+ document.addEventListener('selectionchange', () => {
112
+ if (editor.contains(document.activeElement) || editor === document.activeElement) {
113
+ updateFormatState();
114
+ }
115
+ });
116
+
117
+ // Font family change
118
+ const fontSelect = document.getElementById('fmt-font-family');
119
+ if (fontSelect) {
120
+ fontSelect.addEventListener('change', () => {
121
+ formatFont(fontSelect.value);
122
+ });
123
+ }
124
+
125
+ // Font size change
126
+ const sizeSelect = document.getElementById('fmt-font-size');
127
+ if (sizeSelect) {
128
+ sizeSelect.addEventListener('change', () => {
129
+ formatFontSize(sizeSelect.value);
130
+ });
131
+ }
132
+ }
src/js/renderer.js CHANGED
@@ -200,6 +200,151 @@ function render(input) {
200
  return renderHighlightedText(text, suggestions);
201
  }
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  // Export for use in modules (if using ES6 modules)
204
  if (typeof module !== 'undefined' && module.exports) {
205
  module.exports = {
@@ -208,6 +353,8 @@ if (typeof module !== 'undefined' && module.exports) {
208
  escapeHtml,
209
  createSegments,
210
  sortSuggestions,
211
- getErrorClass
 
 
212
  };
213
  }
 
200
  return renderHighlightedText(text, suggestions);
201
  }
202
 
203
+ /**
204
+ * Walk all text nodes in a DOM subtree in document order
205
+ */
206
+ function walkTextNodes(root, callback) {
207
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
208
+ let node;
209
+ while ((node = walker.nextNode())) {
210
+ callback(node);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Remove existing error highlight spans without destroying content
216
+ * Unwraps the spans back to plain text nodes
217
+ */
218
+ function clearOverlays(editor) {
219
+ const errorSpans = editor.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
220
+ errorSpans.forEach(span => {
221
+ const parent = span.parentNode;
222
+ while (span.firstChild) {
223
+ parent.insertBefore(span.firstChild, span);
224
+ }
225
+ parent.removeChild(span);
226
+ });
227
+ editor.normalize(); // merge adjacent text nodes
228
+ }
229
+
230
+ /**
231
+ * Overlay suggestion highlights on the editor DOM without replacing innerHTML.
232
+ * This preserves all formatting (bold, italic, underline, font, etc.)
233
+ *
234
+ * @param {HTMLElement} editor - The editor element
235
+ * @param {Array} suggestions - Sorted array of suggestions with { start, end, original, correction, type }
236
+ */
237
+ function overlaySuggestions(editor, suggestions) {
238
+ // 1. Clear old overlays
239
+ clearOverlays(editor);
240
+
241
+ if (!suggestions || suggestions.length === 0) return;
242
+
243
+ // 2. Collect text nodes with their character offsets
244
+ const textNodes = [];
245
+ let offset = 0;
246
+ walkTextNodes(editor, (node) => {
247
+ textNodes.push({ node, start: offset, end: offset + node.length });
248
+ offset += node.length;
249
+ });
250
+
251
+ if (textNodes.length === 0) return;
252
+
253
+ // 3. Process suggestions in REVERSE order to avoid offset shifts
254
+ const sorted = [...suggestions].sort((a, b) => b.start - a.start);
255
+
256
+ sorted.forEach((suggestion, reverseIdx) => {
257
+ const realIdx = suggestions.length - 1 - reverseIdx;
258
+ const { start, end } = suggestion;
259
+ const errorClass = getErrorClass(suggestion.type);
260
+
261
+ // Find text nodes that overlap with this suggestion range
262
+ const overlapping = textNodes.filter(tn => tn.start < end && tn.end > start);
263
+ if (overlapping.length === 0) return;
264
+
265
+ // Create the wrapper span
266
+ const wrapper = document.createElement('span');
267
+ wrapper.className = errorClass;
268
+ wrapper.dataset.suggestionId = String(realIdx);
269
+ wrapper.dataset.original = suggestion.original || '';
270
+ wrapper.dataset.correction = suggestion.correction || '';
271
+ wrapper.dataset.type = suggestion.type || 'spelling';
272
+ wrapper.title = `${suggestion.type}: ${suggestion.correction}`;
273
+
274
+ if (overlapping.length === 1) {
275
+ // Simple case: suggestion falls within a single text node
276
+ const tn = overlapping[0];
277
+ const localStart = Math.max(0, start - tn.start);
278
+ const localEnd = Math.min(tn.node.length, end - tn.start);
279
+
280
+ // Split the text node
281
+ const textContent = tn.node.textContent;
282
+ const beforeText = textContent.slice(0, localStart);
283
+ const errorText = textContent.slice(localStart, localEnd);
284
+ const afterText = textContent.slice(localEnd);
285
+
286
+ const parent = tn.node.parentNode;
287
+ const errorTextNode = document.createTextNode(errorText);
288
+ wrapper.appendChild(errorTextNode);
289
+
290
+ // Replace the original text node
291
+ if (afterText) {
292
+ parent.insertBefore(document.createTextNode(afterText), tn.node.nextSibling);
293
+ }
294
+ parent.insertBefore(wrapper, tn.node.nextSibling || null);
295
+ if (beforeText) {
296
+ parent.insertBefore(document.createTextNode(beforeText), wrapper);
297
+ }
298
+ parent.removeChild(tn.node);
299
+
300
+ } else {
301
+ // Complex case: suggestion spans multiple text nodes
302
+ // We use a Range to extract and wrap the content
303
+ try {
304
+ const range = document.createRange();
305
+
306
+ const firstTN = overlapping[0];
307
+ const lastTN = overlapping[overlapping.length - 1];
308
+ const rangeStart = Math.max(0, start - firstTN.start);
309
+ const rangeEnd = Math.min(lastTN.node.length, end - lastTN.start);
310
+
311
+ range.setStart(firstTN.node, rangeStart);
312
+ range.setEnd(lastTN.node, rangeEnd);
313
+
314
+ range.surroundContents(wrapper);
315
+ } catch (e) {
316
+ // surroundContents can fail if the range crosses element boundaries
317
+ // In that case, just wrap the text of the first overlapping node
318
+ const tn = overlapping[0];
319
+ const localStart = Math.max(0, start - tn.start);
320
+ const localEnd = Math.min(tn.node.length, end - tn.start);
321
+
322
+ if (localEnd > localStart) {
323
+ const textContent = tn.node.textContent;
324
+ const beforeText = textContent.slice(0, localStart);
325
+ const errorText = textContent.slice(localStart, localEnd);
326
+ const afterText = textContent.slice(localEnd);
327
+
328
+ const parent = tn.node.parentNode;
329
+ wrapper.appendChild(document.createTextNode(errorText));
330
+ if (afterText) parent.insertBefore(document.createTextNode(afterText), tn.node.nextSibling);
331
+ parent.insertBefore(wrapper, tn.node.nextSibling || null);
332
+ if (beforeText) parent.insertBefore(document.createTextNode(beforeText), wrapper);
333
+ parent.removeChild(tn.node);
334
+ }
335
+ }
336
+ }
337
+
338
+ // Rebuild textNodes array after modification (for next iteration)
339
+ textNodes.length = 0;
340
+ offset = 0;
341
+ walkTextNodes(editor, (node) => {
342
+ textNodes.push({ node, start: offset, end: offset + node.length });
343
+ offset += node.length;
344
+ });
345
+ });
346
+ }
347
+
348
  // Export for use in modules (if using ES6 modules)
349
  if (typeof module !== 'undefined' && module.exports) {
350
  module.exports = {
 
353
  escapeHtml,
354
  createSegments,
355
  sortSuggestions,
356
+ getErrorClass,
357
+ overlaySuggestions,
358
+ clearOverlays
359
  };
360
  }