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- src/css/components.css +79 -0
- src/index.html +52 -6
- src/js/editor.js +50 -29
- src/js/format.js +132 -0
- 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 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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 |
-
|
|
|
|
| 197 |
updatePlaceholder();
|
| 198 |
}
|
| 199 |
|
|
@@ -302,11 +297,23 @@ function hideTooltip() {
|
|
| 302 |
}
|
| 303 |
|
| 304 |
function applySuggestionAtOffsets(suggestion) {
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
const
|
| 308 |
-
const
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
hideTooltip();
|
| 311 |
analyzeTextDelayed();
|
| 312 |
}
|
|
@@ -317,29 +324,43 @@ function applyCorrection() {
|
|
| 317 |
}
|
| 318 |
|
| 319 |
function applyAlternativeCorrection(suggestion, correctionText) {
|
| 320 |
-
const
|
| 321 |
-
const
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
hideTooltip();
|
| 326 |
analyzeTextDelayed();
|
| 327 |
}
|
| 328 |
|
| 329 |
function dismissSuggestion(suggestion) {
|
| 330 |
-
// Remove
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
if (window.currentSuggestions) {
|
| 332 |
window.currentSuggestions = window.currentSuggestions.filter(
|
| 333 |
s => !(s.start === suggestion.start && s.end === suggestion.end)
|
| 334 |
);
|
| 335 |
-
|
| 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 |
}
|