youssefreda9 commited on
Commit
3f8001b
·
1 Parent(s): d1f18ad

feat: Show 3 spelling alternatives + keep-as-is option - Backend: Generate alternatives via EditDistanceCorrector - Tooltip: Multiple clickable correction buttons - Sidebar cards: Alt chips for each suggestion - Dismiss button removes error highlight (keep original word)

Browse files
Files changed (5) hide show
  1. src/app.py +27 -0
  2. src/css/components.css +104 -12
  3. src/index.html +3 -3
  4. src/js/editor.js +66 -3
  5. src/js/ui.js +30 -3
src/app.py CHANGED
@@ -692,6 +692,29 @@ def analyze_text():
692
  curr_end = mapper.map_offset(curr_end)
693
  return curr_start, curr_end
694
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695
  # 1. Spelling (with conservative post-filtering to avoid over-editing)
696
  has_spelling = True # Always available via lazy-loaded araspell_service
697
  if has_spelling:
@@ -737,6 +760,7 @@ def analyze_text():
737
  'original': o_word,
738
  'correction': c_word,
739
  'type': 'spelling',
 
740
  })
741
  else:
742
  new_words.append(current_text[start_idx:end_idx])
@@ -752,6 +776,7 @@ def analyze_text():
752
  'original': o_word,
753
  'correction': corr_str,
754
  'type': 'spelling',
 
755
  })
756
  else:
757
  new_words.append(current_text[start_idx:end_idx])
@@ -776,6 +801,7 @@ def analyze_text():
776
  'original': o_word,
777
  'correction': c_word,
778
  'type': 'spelling',
 
779
  })
780
  ci += 1
781
  # Check if this is a 1→N word split
@@ -800,6 +826,7 @@ def analyze_text():
800
  'original': o_word,
801
  'correction': corr_str,
802
  'type': 'spelling',
 
803
  })
804
  ci = temp_ci
805
  else:
 
692
  curr_end = mapper.map_offset(curr_end)
693
  return curr_start, curr_end
694
 
695
+ def _get_spelling_alternatives(original_word, best_correction, spell_checker, max_alts=3):
696
+ """Generate alternative spelling suggestions for a word."""
697
+ alts = []
698
+ seen = {best_correction, original_word}
699
+
700
+ # 1. Try edit distance 1 candidates from the spell checker's vocabulary
701
+ try:
702
+ clean_w = re.sub(r'[^\w]', '', original_word)
703
+ edit_cands = spell_checker.edit_corrector.known(spell_checker.edit_corrector.edits1(clean_w))
704
+ if edit_cands:
705
+ ranked = sorted(list(edit_cands), key=lambda x: spell_checker.vocab_manager.get_frequency_rank(x))
706
+ for c in ranked:
707
+ if c not in seen and len(alts) < max_alts - 1:
708
+ alts.append(c)
709
+ seen.add(c)
710
+ except Exception:
711
+ pass
712
+
713
+ # 2. Always include 'keep as-is' as the last alternative
714
+ # Return: [best_correction, alt1, alt2, ..., original_word(keep)]
715
+ result = [best_correction] + alts + [original_word]
716
+ return result[:max_alts + 1] # cap at max_alts + keep-as-is
717
+
718
  # 1. Spelling (with conservative post-filtering to avoid over-editing)
719
  has_spelling = True # Always available via lazy-loaded araspell_service
720
  if has_spelling:
 
760
  'original': o_word,
761
  'correction': c_word,
762
  'type': 'spelling',
763
+ 'alternatives': _get_spelling_alternatives(o_word, c_word, spell_checker),
764
  })
765
  else:
766
  new_words.append(current_text[start_idx:end_idx])
 
776
  'original': o_word,
777
  'correction': corr_str,
778
  'type': 'spelling',
779
+ 'alternatives': [corr_str, o_word],
780
  })
781
  else:
782
  new_words.append(current_text[start_idx:end_idx])
 
801
  'original': o_word,
802
  'correction': c_word,
803
  'type': 'spelling',
804
+ 'alternatives': _get_spelling_alternatives(o_word, c_word, spell_checker),
805
  })
806
  ci += 1
807
  # Check if this is a 1→N word split
 
826
  'original': o_word,
827
  'correction': corr_str,
828
  'type': 'spelling',
829
+ 'alternatives': [corr_str, o_word],
830
  })
831
  ci = temp_ci
832
  else:
src/css/components.css CHANGED
@@ -486,30 +486,74 @@
486
  .popover-type--grammar { color: var(--color-warning); }
487
  .popover-type--punctuation { color: var(--color-success); }
488
 
489
- .popover-correction {
490
- font-size: var(--font-size-h3);
 
 
 
 
 
 
491
  font-weight: var(--font-weight-bold);
 
 
 
 
 
 
 
 
 
 
 
492
  margin-bottom: var(--spacing-sm);
493
- text-align: center;
494
- color: var(--color-text-primary);
495
  }
496
 
497
- .popover-apply {
498
  width: 100%;
499
- padding: var(--spacing-sm);
500
- border: none;
501
  border-radius: var(--radius-md);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
503
  color: var(--color-text-inverse);
 
504
  font-weight: var(--font-weight-bold);
505
- cursor: pointer;
506
- font-family: inherit;
507
- min-height: 44px;
508
- transition: opacity var(--transition-base);
509
  }
510
 
511
- .popover-apply:hover {
512
  opacity: 0.92;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  }
514
 
515
  .popover-hint {
@@ -519,6 +563,54 @@
519
  margin-top: var(--spacing-sm);
520
  }
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  /* ── Suggestion cards ── */
523
  .suggestion-card {
524
  padding: var(--spacing-md);
 
486
  .popover-type--grammar { color: var(--color-warning); }
487
  .popover-type--punctuation { color: var(--color-success); }
488
 
489
+ .popover-original-word {
490
+ font-size: var(--font-size-sm);
491
+ color: var(--color-text-secondary);
492
+ margin-bottom: var(--spacing-sm);
493
+ text-align: right;
494
+ }
495
+
496
+ .popover-original-word .popover-label {
497
  font-weight: var(--font-weight-bold);
498
+ }
499
+
500
+ #tooltip-original {
501
+ text-decoration: line-through;
502
+ color: var(--color-error);
503
+ }
504
+
505
+ .popover-alternatives {
506
+ display: flex;
507
+ flex-direction: column;
508
+ gap: 6px;
509
  margin-bottom: var(--spacing-sm);
 
 
510
  }
511
 
512
+ .popover-alt-btn {
513
  width: 100%;
514
+ padding: 8px 12px;
515
+ border: 1px solid var(--color-border);
516
  border-radius: var(--radius-md);
517
+ background: var(--color-surface);
518
+ color: var(--color-text-primary);
519
+ font-weight: var(--font-weight-semibold);
520
+ cursor: pointer;
521
+ font-family: inherit;
522
+ font-size: var(--font-size-base);
523
+ text-align: right;
524
+ transition: all var(--transition-base);
525
+ min-height: 40px;
526
+ }
527
+
528
+ .popover-alt-btn:hover {
529
+ background: var(--color-surface-elevated);
530
+ border-color: var(--color-primary);
531
+ transform: translateX(-2px);
532
+ }
533
+
534
+ .popover-alt-main {
535
  background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
536
  color: var(--color-text-inverse);
537
+ border-color: transparent;
538
  font-weight: var(--font-weight-bold);
 
 
 
 
539
  }
540
 
541
+ .popover-alt-main:hover {
542
  opacity: 0.92;
543
+ transform: translateX(-2px);
544
+ border-color: transparent;
545
+ }
546
+
547
+ .popover-alt-keep {
548
+ background: transparent;
549
+ border-style: dashed;
550
+ color: var(--color-text-muted);
551
+ font-size: var(--font-size-sm);
552
+ }
553
+
554
+ .popover-alt-keep:hover {
555
+ color: var(--color-text-primary);
556
+ border-color: var(--color-text-secondary);
557
  }
558
 
559
  .popover-hint {
 
563
  margin-top: var(--spacing-sm);
564
  }
565
 
566
+ /* ── Alt chips in suggestion cards ── */
567
+ .suggestion-card-alts {
568
+ display: flex;
569
+ flex-wrap: wrap;
570
+ gap: 6px;
571
+ margin-top: 8px;
572
+ }
573
+
574
+ .alt-chip {
575
+ padding: 4px 10px;
576
+ border-radius: 100px;
577
+ border: 1px solid var(--color-border);
578
+ background: var(--color-surface);
579
+ color: var(--color-text-secondary);
580
+ font-size: var(--font-size-label);
581
+ cursor: pointer;
582
+ font-family: inherit;
583
+ transition: all var(--transition-base);
584
+ }
585
+
586
+ .alt-chip:hover {
587
+ border-color: var(--color-primary);
588
+ color: var(--color-text-primary);
589
+ background: var(--color-surface-elevated);
590
+ }
591
+
592
+ .alt-chip--main {
593
+ background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
594
+ color: var(--color-text-inverse);
595
+ border-color: transparent;
596
+ font-weight: var(--font-weight-bold);
597
+ }
598
+
599
+ .alt-chip--main:hover {
600
+ opacity: 0.9;
601
+ border-color: transparent;
602
+ }
603
+
604
+ .alt-chip--keep {
605
+ border-style: dashed;
606
+ color: var(--color-text-muted);
607
+ font-size: 0.7rem;
608
+ }
609
+
610
+ .alt-chip--keep:hover {
611
+ color: var(--color-text-primary);
612
+ }
613
+
614
  /* ── Suggestion cards ── */
615
  .suggestion-card {
616
  padding: var(--spacing-md);
src/index.html CHANGED
@@ -490,9 +490,9 @@
490
  <p id="editor-hint" class="sr-only">اكتب نصاً عربياً. ستظهر الاقتراحات تلقائياً.</p>
491
  <div id="editor-tooltip" class="suggestion-popover" role="dialog" aria-label="اقتراح تصحيح">
492
  <div id="tooltip-type" class="popover-type">خطأ</div>
493
- <div id="tooltip-suggestion" class="popover-correction"></div>
494
- <button class="popover-apply" onclick="applyCorrection()" type="button">تطبيق التصحيح</button>
495
- <p class="popover-hint">Enter للتطبيق · Escape للإغلاق</p>
496
  </div>
497
  </div>
498
  <div id="summarize-area" class="summarize-panel is-hidden">
 
490
  <p id="editor-hint" class="sr-only">اكتب نصاً عربياً. ستظهر الاقتراحات تلقائياً.</p>
491
  <div id="editor-tooltip" class="suggestion-popover" role="dialog" aria-label="اقتراح تصحيح">
492
  <div id="tooltip-type" class="popover-type">خطأ</div>
493
+ <div class="popover-original-word"><span class="popover-label">الكلمة:</span> <span id="tooltip-original"></span></div>
494
+ <div id="tooltip-alternatives" class="popover-alternatives"></div>
495
+ <p class="popover-hint">اختر التصحيح المناسب · Escape للإغلاق</p>
496
  </div>
497
  </div>
498
  <div id="summarize-area" class="summarize-panel is-hidden">
src/js/editor.js CHANGED
@@ -214,7 +214,8 @@ function showTooltip(element) {
214
  if (!tooltip) return;
215
 
216
  const typeEl = document.getElementById('tooltip-type');
217
- const suggestionEl = document.getElementById('tooltip-suggestion');
 
218
 
219
  const typeMap = {
220
  spelling: 'خطأ إملائي',
@@ -227,8 +228,36 @@ function showTooltip(element) {
227
  typeEl.className = `popover-type popover-type--${suggestion.type}`;
228
  }
229
 
230
- if (suggestionEl) {
231
- suggestionEl.textContent = suggestion.correction;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  }
233
 
234
  const rect = element.getBoundingClientRect();
@@ -275,6 +304,40 @@ function applyCorrection() {
275
  applySuggestionAtOffsets(window.currentApplySuggestion);
276
  }
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  function applySuggestionByIndex(index) {
279
  const suggestions = window.currentSuggestions || [];
280
  const suggestion = suggestions[index];
 
214
  if (!tooltip) return;
215
 
216
  const typeEl = document.getElementById('tooltip-type');
217
+ const originalEl = document.getElementById('tooltip-original');
218
+ const alternativesEl = document.getElementById('tooltip-alternatives');
219
 
220
  const typeMap = {
221
  spelling: 'خطأ إملائي',
 
228
  typeEl.className = `popover-type popover-type--${suggestion.type}`;
229
  }
230
 
231
+ if (originalEl) {
232
+ originalEl.textContent = suggestion.original;
233
+ }
234
+
235
+ // Render alternatives
236
+ if (alternativesEl) {
237
+ const alts = suggestion.alternatives || [suggestion.correction, suggestion.original];
238
+ let html = '';
239
+ alts.forEach((alt, i) => {
240
+ const isKeep = alt === suggestion.original;
241
+ const isMain = i === 0;
242
+ const btnClass = isKeep ? 'popover-alt-btn popover-alt-keep' : (isMain ? 'popover-alt-btn popover-alt-main' : 'popover-alt-btn');
243
+ const label = isKeep ? `${escapeHtml(alt)} ✓ إبقاء` : escapeHtml(alt);
244
+ html += `<button class="${btnClass}" data-alt-correction="${escapeHtml(alt)}" type="button">${label}</button>`;
245
+ });
246
+ alternativesEl.innerHTML = html;
247
+
248
+ // Bind click events
249
+ alternativesEl.querySelectorAll('.popover-alt-btn').forEach(btn => {
250
+ btn.addEventListener('click', () => {
251
+ const correctionText = btn.dataset.altCorrection;
252
+ if (correctionText === suggestion.original) {
253
+ // "Keep as-is" — just dismiss the suggestion
254
+ dismissSuggestion(suggestion);
255
+ } else {
256
+ // Apply this alternative correction
257
+ applyAlternativeCorrection(suggestion, correctionText);
258
+ }
259
+ });
260
+ });
261
  }
262
 
263
  const rect = element.getBoundingClientRect();
 
304
  applySuggestionAtOffsets(window.currentApplySuggestion);
305
  }
306
 
307
+ function applyAlternativeCorrection(suggestion, correctionText) {
308
+ const text = getEditorText();
309
+ const before = text.substring(0, suggestion.start);
310
+ const after = text.substring(suggestion.end);
311
+ const newText = before + correctionText + after;
312
+ setEditorHTML(escapeHtml(newText));
313
+ hideTooltip();
314
+ analyzeTextDelayed();
315
+ }
316
+
317
+ function dismissSuggestion(suggestion) {
318
+ // Remove this suggestion from the list (keep the word as-is)
319
+ if (window.currentSuggestions) {
320
+ window.currentSuggestions = window.currentSuggestions.filter(
321
+ s => !(s.start === suggestion.start && s.end === suggestion.end)
322
+ );
323
+ // Re-render without this suggestion
324
+ const text = getEditorText();
325
+ const highlightedHtml = render({
326
+ text: text,
327
+ suggestions: window.currentSuggestions
328
+ });
329
+ setEditorHTML(highlightedHtml);
330
+
331
+ const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
332
+ const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
333
+ const punctuationCount = window.currentSuggestions.filter(s => s.type === 'punctuation').length;
334
+ updateSuggestionCounts(spellingCount, grammarCount, punctuationCount);
335
+ updateWritingScore(spellingCount, grammarCount, punctuationCount);
336
+ updateSuggestionsList(window.currentSuggestions);
337
+ }
338
+ hideTooltip();
339
+ }
340
+
341
  function applySuggestionByIndex(index) {
342
  const suggestions = window.currentSuggestions || [];
343
  const suggestion = suggestions[index];
src/js/ui.js CHANGED
@@ -62,18 +62,27 @@ function updateWritingScore(spelling, grammar, punctuation) {
62
  function buildSuggestionCardHTML(suggestion, index) {
63
  const badgeClass = `badge-${suggestion.type}`;
64
  const label = TYPE_LABELS[suggestion.type] || suggestion.type;
 
 
 
 
 
 
 
 
 
 
65
 
66
  return `
67
  <div class="suggestion-card" role="listitem" tabindex="0"
68
  data-suggestion-index="${index}"
69
  aria-label="${label}: ${suggestion.original} إلى ${suggestion.correction}">
70
- <button class="suggestion-card-apply" data-apply-index="${index}" aria-label="تطبيق التصحيح">✓</button>
71
  <span class="suggestion-card-badge ${badgeClass}">${label}</span>
72
  <div class="suggestion-card-change">
73
  <span class="suggestion-card-original">${escapeHtml(suggestion.original)}</span>
74
  <span class="suggestion-card-arrow">←</span>
75
- <span class="suggestion-card-fix">${escapeHtml(suggestion.correction)}</span>
76
  </div>
 
77
  </div>`;
78
  }
79
 
@@ -121,7 +130,7 @@ function updateSuggestionsList(suggestions) {
121
  function bindSuggestionCardEvents(container) {
122
  container.querySelectorAll('.suggestion-card').forEach((card) => {
123
  card.addEventListener('click', (e) => {
124
- if (e.target.closest('.suggestion-card-apply')) return;
125
  const idx = parseInt(card.dataset.suggestionIndex, 10);
126
  scrollToSuggestion(idx);
127
  focusSuggestionInEditor(idx);
@@ -135,6 +144,24 @@ function bindSuggestionCardEvents(container) {
135
  });
136
  });
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  container.querySelectorAll('.suggestion-card-apply').forEach((btn) => {
139
  btn.addEventListener('click', (e) => {
140
  e.stopPropagation();
 
62
  function buildSuggestionCardHTML(suggestion, index) {
63
  const badgeClass = `badge-${suggestion.type}`;
64
  const label = TYPE_LABELS[suggestion.type] || suggestion.type;
65
+ const alts = suggestion.alternatives || [suggestion.correction, suggestion.original];
66
+
67
+ let altsHTML = '';
68
+ alts.forEach((alt, i) => {
69
+ const isKeep = alt === suggestion.original;
70
+ const isMain = i === 0;
71
+ const cls = isKeep ? 'alt-chip alt-chip--keep' : (isMain ? 'alt-chip alt-chip--main' : 'alt-chip');
72
+ const chipLabel = isKeep ? `${escapeHtml(alt)} ✓` : escapeHtml(alt);
73
+ altsHTML += `<button class="${cls}" data-card-alt="${escapeHtml(alt)}" data-card-index="${index}" type="button">${chipLabel}</button>`;
74
+ });
75
 
76
  return `
77
  <div class="suggestion-card" role="listitem" tabindex="0"
78
  data-suggestion-index="${index}"
79
  aria-label="${label}: ${suggestion.original} إلى ${suggestion.correction}">
 
80
  <span class="suggestion-card-badge ${badgeClass}">${label}</span>
81
  <div class="suggestion-card-change">
82
  <span class="suggestion-card-original">${escapeHtml(suggestion.original)}</span>
83
  <span class="suggestion-card-arrow">←</span>
 
84
  </div>
85
+ <div class="suggestion-card-alts">${altsHTML}</div>
86
  </div>`;
87
  }
88
 
 
130
  function bindSuggestionCardEvents(container) {
131
  container.querySelectorAll('.suggestion-card').forEach((card) => {
132
  card.addEventListener('click', (e) => {
133
+ if (e.target.closest('.alt-chip') || e.target.closest('.suggestion-card-apply')) return;
134
  const idx = parseInt(card.dataset.suggestionIndex, 10);
135
  scrollToSuggestion(idx);
136
  focusSuggestionInEditor(idx);
 
144
  });
145
  });
146
 
147
+ // Alt chip clicks
148
+ container.querySelectorAll('.alt-chip').forEach((chip) => {
149
+ chip.addEventListener('click', (e) => {
150
+ e.stopPropagation();
151
+ const idx = parseInt(chip.dataset.cardIndex, 10);
152
+ const altText = chip.dataset.cardAlt;
153
+ const suggestions = window.currentSuggestions || [];
154
+ const suggestion = suggestions[idx];
155
+ if (!suggestion) return;
156
+
157
+ if (altText === suggestion.original) {
158
+ dismissSuggestion(suggestion);
159
+ } else {
160
+ applyAlternativeCorrection(suggestion, altText);
161
+ }
162
+ });
163
+ });
164
+
165
  container.querySelectorAll('.suggestion-card-apply').forEach((btn) => {
166
  btn.addEventListener('click', (e) => {
167
  e.stopPropagation();