| # Phase 1 Verification 6 - Cursor Preservation |
|
|
| **Test Scenario**: |
| 1. User places cursor in middle of text |
| 2. Trigger analysis |
| 3. Verify cursor remains in same location |
|
|
| --- |
|
|
| ## Code Analysis: Cursor Preservation |
|
|
| ### Initial Setup |
|
|
| ``` |
| Text: "الحمد لله على نعمه" |
| ↑ |
| Cursor here (offset 6, after "الحمد ") |
| ``` |
|
|
| ### STEP 1: User Places Cursor |
|
|
| **JavaScript calculates cursor offset** |
|
|
| From selection.js:getCaretOffset() |
| ```javascript |
| function getCaretOffset() { |
| const selection = window.getSelection(); |
| if (selection.rangeCount === 0) { |
| return 0; |
| } |
| |
| const range = selection.getRangeAt(0); |
| const editor = document.getElementById('editor-container'); |
| |
| try { |
| const preCaretRange = range.cloneRange(); |
| preCaretRange.selectNodeContents(editor); |
| preCaretRange.setEnd(range.endContainer, range.endOffset); |
| return preCaretRange.toString().length; |
| } catch (e) { |
| console.warn('getCaretOffset failed:', e); |
| return 0; |
| } |
| } |
| ``` |
|
|
| **Result**: `currentCaretOffset = 6` |
|
|
| ### STEP 2: User Triggers Analysis (types more text) |
|
|
| **From editor.js:analyzeTextDelayed()** |
| ```javascript |
| function analyzeTextDelayed() { |
| clearTimeout(analyzeTimeout); |
| analyzeTimeout = setTimeout(() => { |
| analyzeText(); |
| }, ANALYZE_DEBOUNCE_MS); // 500ms debounce |
| } |
| ``` |
|
|
| **Waits 500ms, then calls analyzeText()** |
|
|
| ### STEP 3: Save Current Position (CRITICAL STEP) |
|
|
| **From editor.js:analyzeText(), Lines 90-91** |
| ```javascript |
| // Save current selection |
| const savedSelection = saveSelection(); |
| const currentCaretOffset = getCaretOffset(); |
| ``` |
|
|
| **Executes**: saveSelection() from selection.js |
|
|
| ```javascript |
| function saveSelection() { |
| const selection = window.getSelection(); |
| if (selection.rangeCount === 0) { |
| return null; |
| } |
| |
| const range = selection.getRangeAt(0); |
| const editor = document.getElementById('editor-container'); |
| |
| try { |
| const preCaretRange = range.cloneRange(); |
| preCaretRange.selectNodeContents(editor); |
| preCaretRange.setEnd(range.endContainer, range.endOffset); |
| |
| const offset = preCaretRange.toString().length; |
| const isCollapsed = range.collapsed; |
| |
| let selectionStart = offset; |
| let selectionEnd = offset; |
| |
| if (!isCollapsed) { |
| const preCaretRangeStart = range.cloneRange(); |
| preCaretRangeStart.selectNodeContents(editor); |
| preCaretRangeStart.setEnd(range.startContainer, range.startOffset); |
| selectionStart = preCaretRangeStart.toString().length; |
| } |
| |
| return { |
| selectionStart, |
| selectionEnd, |
| isCollapsed |
| }; |
| } catch (e) { |
| console.warn('saveSelection failed:', e); |
| return null; |
| } |
| } |
| ``` |
|
|
| **Result**: |
| ```javascript |
| savedSelection = { |
| selectionStart: 6, |
| selectionEnd: 6, |
| isCollapsed: true // Just cursor, no selection |
| } |
| ``` |
|
|
| ### STEP 4: Call Backend API |
|
|
| **From editor.js:analyzeText(), Lines 94-99** |
| ```javascript |
| const response = await fetch('/api/analyze', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ text }) |
| }); |
| ``` |
|
|
| **API returns suggestions** |
|
|
| ### STEP 5: Render with New Highlights |
|
|
| **From editor.js:analyzeText(), Line 119** |
| ```javascript |
| const highlightedHtml = render({ |
| text: text, |
| suggestions: data.suggestions |
| }); |
| |
| // This generates NEW HTML with spans: |
| // <span>الحمد</span> <span>لله</span> على نعمه |
| ``` |
|
|
| **This NEW HTML is applied to the DOM:** |
|
|
| ```javascript |
| setEditorHTML(highlightedHtml); |
| ``` |
|
|
| **From selection.js:setEditorHTML()** |
| ```javascript |
| function setEditorHTML(html) { |
| const editor = document.getElementById('editor-container'); |
| if (!editor) return; |
| editor.innerHTML = html; // ← DOM changed here |
| } |
| ``` |
|
|
| **CRITICAL**: At this point, the DOM has NEW structure with span elements. The old selection/cursor is LOST because the text nodes changed. |
|
|
| ### STEP 6: RESTORE CURSOR POSITION (THE FIX) |
|
|
| **From editor.js:analyzeText(), Lines 122-126** |
| ```javascript |
| // Restore selection/caret position |
| if (savedSelection) { |
| restoreSelection(savedSelection); |
| } else { |
| setCaretOffset(currentCaretOffset); |
| } |
| ``` |
|
|
| **Condition**: `savedSelection` exists (true), so call `restoreSelection()` |
|
|
| **From selection.js:restoreSelection()** |
| ```javascript |
| function restoreSelection(savedSelection) { |
| if (!savedSelection) return; |
| |
| const editor = document.getElementById('editor-container'); |
| const selection = window.getSelection(); |
| |
| try { |
| let charCount = 0; |
| let nodeStack = [editor]; |
| let node, foundStart = false, foundEnd = false; |
| |
| while (!foundEnd && (node = nodeStack.pop())) { |
| if (node.nodeType === Node.TEXT_NODE) { |
| const nextCharCount = charCount + node.length; |
| |
| // Find the text node containing selectionStart |
| if ( |
| !foundStart && |
| savedSelection.selectionStart >= charCount && |
| savedSelection.selectionStart <= nextCharCount |
| ) { |
| const range = document.createRange(); |
| range.setStart(node, savedSelection.selectionStart - charCount); |
| foundStart = true; |
| |
| // If just cursor (collapsed), set it |
| if (savedSelection.isCollapsed) { |
| range.collapse(true); |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| return; // ← Done, cursor is restored |
| } |
| } |
| |
| // ... handle selection end if not collapsed ... |
| charCount = nextCharCount; |
| } else { |
| let i = node.childNodes.length; |
| while (i--) { |
| nodeStack.push(node.childNodes[i]); |
| } |
| } |
| } |
| } catch (e) { |
| console.warn('restoreSelection failed:', e); |
| } |
| } |
| ``` |
|
|
| **Execution Trace**: |
| ``` |
| 1. savedSelection = {selectionStart: 6, selectionEnd: 6, isCollapsed: true} |
| 2. Walk through DOM text nodes counting characters |
| 3. Find the text node where character offset 6 falls |
| 4. Create a range at position 6 in that text node |
| 5. Apply range to selection |
| 6. Return (cursor now at offset 6) |
| ``` |
|
|
| **Result**: Cursor repositioned to offset 6 in new DOM structure |
|
|
| --- |
|
|
| ## Expected Behavior Timeline |
|
|
| ``` |
| Time 0: |
| Text: "الحمد لله على نعمه" |
| Cursor: After "الحمد " (offset 6) |
| Visual: الحمد █ لله على نعمه |
| |
| Time 500ms (user stops typing, debounce triggers): |
| 1. Save cursor position (6) |
| 2. Call /api/analyze |
| 3. Get suggestions |
| 4. Render new HTML: |
| <span class="...">الحمد</span> <span class="...">لله</span> على نعمه |
| 5. Apply to DOM (cursor would be lost here without restoration) |
| 6. Restore cursor at offset 6 |
| 7. User sees highlights WITHOUT cursor moving |
| |
| Time 500+: |
| Text: [same with highlights visible] |
| Cursor: Still at offset 6 (after "الحمد ") |
| Visual: [الحمد] █ [لله] على نعمه |
| └─highlight─┘ └─highlight─┘ |
| ``` |
|
|
| --- |
|
|
| ## Code Verification Checklist |
|
|
| ### Checkpoint 1: getCaretOffset() ✅ |
| ```javascript |
| // Correctly counts characters to cursor position |
| preCaretRange.toString().length; |
| // Works with both plain text and spans |
| ``` |
|
|
| ### Checkpoint 2: saveSelection() ✅ |
| ```javascript |
| // Stores position as character offset (language-independent) |
| return { |
| selectionStart: 6, |
| selectionEnd: 6, |
| isCollapsed: true |
| }; |
| ``` |
|
|
| ### Checkpoint 3: restoreSelection() ✅ |
| ```javascript |
| // Walks new DOM to find same character offset |
| // Uses charCount to track position |
| // Places cursor at exact same character coordinate |
| ``` |
|
|
| ### Checkpoint 4: Flow Integration ✅ |
| ```javascript |
| // In analyzeText(): |
| const savedSelection = saveSelection(); // SAVE |
| const highlightedHtml = render(...); // RENDER |
| setEditorHTML(highlightedHtml); // DOM CHANGES |
| restoreSelection(savedSelection); // RESTORE |
| ``` |
|
|
| --- |
|
|
| ## Potential Issues & Mitigations |
|
|
| ### Issue 1: UTF-8 Multi-byte Characters |
| Arabic characters are multi-byte in UTF-8. However, JavaScript strings are UTF-16, so `.length` and `.substring()` work correctly. |
|
|
| ✅ **Mitigation**: Using JavaScript string operations, not byte operations |
|
|
| ### Issue 2: Complex DOM with Nested Spans |
| The rendered HTML has nested children. restoreSelection() walks the entire tree. |
|
|
| ✅ **Mitigation**: `nodeStack.pop()` traverses all nodes |
|
|
| ### Issue 3: Cursor in Span Text |
| If cursor is inside a `<span>`, the text node is the span's child. |
|
|
| ✅ **Mitigation**: charCount accumulates across all text nodes regardless of depth |
|
|
| --- |
|
|
| ## Test Case: Before/After |
|
|
| ### Before Fix (No Cursor Preservation) |
| ``` |
| Step 1: User types: "الحمد لله على نعمه" |
| Cursor: █ (blinking) |
| |
| Step 2: User positions cursor: الحمد █ لله على نعمه |
| |
| Step 3: Trigger analysis |
| DOM re-renders with <span> elements |
| |
| Step 4: Result WITHOUT restoration: |
| [الحمد] █ [لله] على نعمه |
| ↑ |
| Cursor jumped to start of new DOM |
| ❌ BUG: Cursor moved! |
| ``` |
|
|
| ### After Fix (With Cursor Preservation) |
| ``` |
| Step 1: User types: "الحمد لله على نعمه" |
| Cursor: █ (blinking) |
| |
| Step 2: User positions cursor: الحمد █ لله على نعمه |
| Offset saved: 6 |
| |
| Step 3: Trigger analysis |
| 1. Save cursor at offset 6 |
| 2. DOM re-renders with <span> elements |
| 3. Restore cursor at offset 6 in new DOM |
| |
| Step 4: Result WITH restoration: |
| [الحمد] █ [لله] على نعمه |
| ↑ |
| Cursor still at correct position |
| ✅ FIXED: Cursor preserved! |
| ``` |
|
|
| --- |
|
|
| ## Verification 6: Conclusion |
|
|
| ### Implementation |
| - ✅ getCaretOffset() - Captures position |
| - ✅ saveSelection() - Stores position |
| - ✅ Render pipeline - Updates DOM |
| - ✅ restoreSelection() - Restores position |
|
|
| ### Expected Result |
| ✅ **Cursor will remain at the same character offset after analysis re-renders** |
|
|
| ### Code Quality |
| - ✅ Error handling: try/catch wrapper |
| - ✅ Fallback: If saveSelection fails, uses getCaretOffset |
| - ✅ RTL support: Uses character counts (works for all directions) |
| - ✅ Multi-byte support: Uses JavaScript strings (not bytes) |
|
|
| --- |
|
|
| ## Summary |
|
|
| The cursor preservation system: |
| 1. **Saves** exact character position before re-render |
| 2. **Clears** DOM with new HTML |
| 3. **Restores** cursor at saved character position in new DOM |
|
|
| **Result**: User experience is seamless - cursor appears to stay in place while highlights appear around it. |
|
|