Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix: suggestion grouping and toolbar organization' (#34) from fix/suggestions-and-toolbar into main

scott 68925472 e5dcc502

+509 -58
+2 -2
src/css/app.css
··· 1920 1920 top: 100%; 1921 1921 left: 0; 1922 1922 z-index: 50; 1923 - min-width: 9rem; 1923 + min-width: 10rem; 1924 1924 padding: var(--space-xs) 0; 1925 1925 margin-top: 2px; 1926 1926 background: var(--color-bg); ··· 2010 2010 top: 100%; 2011 2011 right: 0; 2012 2012 z-index: 50; 2013 - min-width: 12rem; 2013 + min-width: 13rem; 2014 2014 padding: var(--space-xs) 0; 2015 2015 margin-top: 2px; 2016 2016 background: var(--color-bg);
+23 -18
src/docs/index.html
··· 58 58 59 59 <!-- Formatting toolbar (Google Docs-style, flat single-row) --> 60 60 <div class="toolbar gdocs-toolbar" id="toolbar" role="toolbar" aria-label="Formatting toolbar"> 61 - <!-- Undo, Redo --> 61 + <!-- Group: History & Print --> 62 62 <button class="tb-btn toolbar-mobile-hide" id="tb-undo" title="Undo (Cmd+Z)" aria-label="Undo"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M3 7.5h8a3 3 0 0 1 0 6H9"/><path d="M5.5 5L3 7.5 5.5 10"/></svg></button> 63 63 <button class="tb-btn toolbar-mobile-hide" id="tb-redo" title="Redo (Cmd+Shift+Z)" aria-label="Redo"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M13 7.5H5a3 3 0 0 0 0 6h2"/><path d="M10.5 5l2.5 2.5-2.5 2.5"/></svg></button> 64 - <span class="toolbar-sep toolbar-mobile-hide"></span> 65 - <!-- Print --> 66 64 <button class="tb-btn toolbar-mobile-hide" id="tb-print" title="Print (Cmd+P)" aria-label="Print"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 5V2h8v3"/><rect x="2" y="5" width="12" height="6" rx="1"/><path d="M4 11v3h8v-3"/><line x1="6" y1="12.5" x2="10" y2="12.5"/></svg></button> 67 65 <span class="toolbar-sep toolbar-mobile-hide"></span> 68 - <!-- Heading select --> 66 + 67 + <!-- Group: Text style selects --> 69 68 <select class="tb-select toolbar-mobile-hide" id="tb-heading" title="Heading level" aria-label="Heading level"> 70 69 <option value="paragraph">Normal</option> 71 70 <option value="1">Heading 1</option> 72 71 <option value="2">Heading 2</option> 73 72 <option value="3">Heading 3</option> 74 73 </select> 75 - <!-- Font size select --> 76 74 <select class="tb-select tb-select-narrow toolbar-mobile-hide" id="tb-font-size" title="Font size" aria-label="Font size"> 77 75 <option value="">&#8211;</option> 78 76 <option value="8px">8</option> ··· 92 90 <option value="72px">72</option> 93 91 </select> 94 92 <span class="toolbar-sep toolbar-mobile-hide"></span> 95 - <!-- Bold, Italic, Underline, Strikethrough --> 93 + 94 + <!-- Group: Character formatting --> 96 95 <button class="tb-btn" id="tb-bold" title="Bold (Cmd+B)" aria-label="Bold"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4.5 2.5h4.5a2.5 2.5 0 0 1 0 5h-4.5zM4.5 7.5h5a3 3 0 0 1 0 6h-5z" stroke-width="2"/></svg></button> 97 96 <button class="tb-btn" id="tb-italic" title="Italic (Cmd+I)" aria-label="Italic"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="10" y1="2.5" x2="6" y2="13.5"/><line x1="7" y1="2.5" x2="11" y2="2.5"/><line x1="5" y1="13.5" x2="9" y2="13.5"/></svg></button> 98 97 <button class="tb-btn toolbar-mobile-hide" id="tb-underline" title="Underline (Cmd+U)" aria-label="Underline"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4.5 2.5v5a3.5 3.5 0 0 0 7 0v-5"/><line x1="3.5" y1="14" x2="12.5" y2="14"/></svg></button> 99 98 <button class="tb-btn toolbar-mobile-hide" id="tb-strike" title="Strikethrough" aria-label="Strikethrough"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M10.5 4.5c-.5-1-1.8-2-3.5-2s-3.5 1.2-3.5 3c0 1 .5 1.7 1.2 2"/><path d="M5.5 11.5c.5 1 1.8 2 3.5 2s3.5-1.2 3.5-3c0-.6-.2-1.1-.5-1.5"/><line x1="2" y1="8" x2="14" y2="8"/></svg></button> 100 - <!-- Text color --> 101 99 <div class="tb-color-wrap toolbar-mobile-hide"> 102 100 <input type="color" class="tb-color" id="tb-text-color" value="#1a1815" title="Text color" aria-label="Text color"> 103 101 <span class="tb-color-label">A</span> 104 102 <span class="tb-color-swatch" id="tb-text-color-swatch"></span> 105 103 </div> 106 - <!-- Highlight color --> 107 104 <div class="tb-color-wrap toolbar-mobile-hide"> 108 105 <input type="color" class="tb-color" id="tb-highlight" value="#fff3c4" title="Highlight color" aria-label="Highlight color"> 109 106 <span class="tb-color-label"><svg class="tb-icon" viewBox="0 0 16 16" style="width:12px;height:12px"><path d="M10 2L3.5 8.5 7.5 12.5 14 6z"/><path d="M3.5 8.5L2 14l5.5-1.5"/></svg></span> 110 107 <span class="tb-color-swatch tb-color-swatch-highlight" id="tb-highlight-swatch"></span> 111 108 </div> 112 109 <span class="toolbar-sep toolbar-mobile-hide"></span> 113 - <!-- Link, Image, Comment --> 110 + 111 + <!-- Group: Insert objects --> 114 112 <button class="tb-btn toolbar-mobile-hide" id="tb-link" title="Insert link (Cmd+K)" aria-label="Insert link"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M6.5 9.5l3-3"/><path d="M9 4.5l1.5-1.5a2.12 2.12 0 0 1 3 3L12 7.5"/><path d="M7 11.5L5.5 13a2.12 2.12 0 0 1-3-3L4 8.5"/></svg></button> 115 113 <button class="tb-btn toolbar-mobile-hide" id="tb-image" title="Insert image" aria-label="Insert image"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><circle cx="5.5" cy="6" r="1.25"/><path d="M1.5 11l3.5-3.5 2.5 2.5 2.5-3L14.5 11"/></svg></button> 116 114 <button class="tb-btn toolbar-mobile-hide" id="tb-comment" title="Insert comment" aria-label="Insert comment"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2.5 2.5h11a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1h-3l-2.5 2.5v-2.5h-5.5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z"/></svg></button> 117 115 <span class="toolbar-sep toolbar-mobile-hide"></span> 118 - <!-- Alignment dropdown --> 116 + 117 + <!-- Group: Paragraph formatting --> 119 118 <div class="toolbar-dropdown toolbar-mobile-hide" id="dd-align"> 120 119 <button class="toolbar-dropdown-toggle" id="tb-align-toggle" title="Text alignment" aria-label="Text alignment" aria-expanded="false" aria-haspopup="true"> 121 120 <span class="dd-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="2" y1="6.5" x2="10" y2="6.5"/><line x1="2" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="10" y2="13.5"/></svg></span><span class="caret">&#9662;</span> ··· 132 131 </button> 133 132 </div> 134 133 </div> 135 - <!-- Line Spacing dropdown --> 136 134 <div class="toolbar-dropdown toolbar-mobile-hide" id="dd-line-spacing"> 137 135 <button class="toolbar-dropdown-toggle" id="tb-line-spacing-toggle" title="Line spacing" aria-label="Line spacing" aria-expanded="false" aria-haspopup="true"> 138 136 <span class="dd-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="5" y1="3" x2="14" y2="3"/><line x1="5" y1="8" x2="14" y2="8"/><line x1="5" y1="13" x2="14" y2="13"/><path d="M2.5 4.5l-1-2h2z"/><path d="M2.5 11.5l-1 2h2z"/><line x1="2.5" y1="4.5" x2="2.5" y2="11.5"/></svg></span><span class="caret">&#9662;</span> ··· 162 160 </div> 163 161 </div> 164 162 <span class="toolbar-sep"></span> 165 - <!-- Bullet list, Numbered list, Task list (individual buttons) --> 163 + 164 + <!-- Group: Lists & indentation --> 166 165 <button class="tb-btn" id="tb-bullet-list" title="Bullet list" aria-label="Bullet list"><svg class="tb-icon" viewBox="0 0 16 16"><circle cx="3" cy="4" r="1.25" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r="1.25" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1.25" fill="currentColor" stroke="none"/><line x1="6.5" y1="4" x2="14" y2="4"/><line x1="6.5" y1="8" x2="14" y2="8"/><line x1="6.5" y1="12" x2="14" y2="12"/></svg></button> 167 166 <button class="tb-btn" id="tb-ordered-list" title="Numbered list" aria-label="Numbered list"><svg class="tb-icon" viewBox="0 0 16 16"><text x="2" y="5.5" font-size="4.5" fill="currentColor" stroke="none" font-family="sans-serif">1</text><text x="2" y="9.5" font-size="4.5" fill="currentColor" stroke="none" font-family="sans-serif">2</text><text x="2" y="13.5" font-size="4.5" fill="currentColor" stroke="none" font-family="sans-serif">3</text><line x1="6.5" y1="4" x2="14" y2="4"/><line x1="6.5" y1="8" x2="14" y2="8"/><line x1="6.5" y1="12" x2="14" y2="12"/></svg></button> 168 167 <button class="tb-btn toolbar-mobile-hide" id="tb-task-list" title="Task list" aria-label="Task list"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="1.5" y="1.5" width="5" height="5" rx="1"/><path d="M2.5 4l1.5 1.5 3-3"/><rect x="1.5" y="9.5" width="5" height="5" rx="1"/><line x1="8.5" y1="4" x2="14.5" y2="4"/><line x1="8.5" y1="12" x2="14.5" y2="12"/></svg></button> 169 - <span class="toolbar-sep toolbar-mobile-hide"></span> 170 - <!-- Indent, Outdent --> 171 168 <button class="tb-btn toolbar-mobile-hide" id="tb-indent" title="Increase indent" aria-label="Increase indent"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="7" y1="6.5" x2="14" y2="6.5"/><line x1="7" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="14" y2="13.5"/><path d="M2 6l3 2.25-3 2.25z" fill="currentColor" stroke="none"/></svg></button> 172 169 <button class="tb-btn toolbar-mobile-hide" id="tb-outdent" title="Decrease indent" aria-label="Decrease indent"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="7" y1="6.5" x2="14" y2="6.5"/><line x1="7" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="14" y2="13.5"/><path d="M5 6l-3 2.25 3 2.25z" fill="currentColor" stroke="none"/></svg></button> 173 170 <span class="toolbar-sep toolbar-mobile-hide"></span> 171 + 174 172 <!-- Mobile more button (visible only on mobile) --> 175 173 <button class="tb-btn toolbar-mobile-more" id="tb-mobile-more" title="More formatting" aria-label="More formatting" aria-expanded="false" aria-haspopup="true"><svg class="tb-icon" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.25" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1.25" fill="currentColor" stroke="none"/><circle cx="13" cy="8" r="1.25" fill="currentColor" stroke="none"/></svg></button> 176 - <!-- More overflow (less-used items) --> 174 + 175 + <!-- More overflow menu --> 177 176 <div class="toolbar-overflow" id="overflow-menu"> 178 177 <button class="toolbar-overflow-toggle" id="overflow-toggle" title="More options" aria-label="More options" aria-expanded="false" aria-haspopup="true"> 179 178 <svg class="tb-icon" viewBox="0 0 16 16"><circle cx="8" cy="3" r="1.25" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1.25" fill="currentColor" stroke="none"/><circle cx="8" cy="13" r="1.25" fill="currentColor" stroke="none"/></svg> 180 179 </button> 181 180 <div class="toolbar-overflow-menu" role="menu"> 181 + <!-- Section: Block elements --> 182 182 <button class="toolbar-dropdown-item" id="tb-blockquote" title="Blockquote" role="menuitem"> 183 183 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M3 3v10"/><line x1="6" y1="5" x2="14" y2="5"/><line x1="6" y1="8" x2="12" y2="8"/><line x1="6" y1="11" x2="14" y2="11"/></svg></span><span class="item-label">Blockquote</span> 184 184 </button> ··· 194 194 <button class="toolbar-dropdown-item" id="tb-hr" title="Horizontal rule" role="menuitem"> 195 195 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="8" x2="14" y2="8"/></svg></span><span class="item-label">Horizontal rule</span> 196 196 </button> 197 + <div class="toolbar-dropdown-divider"></div> 198 + 199 + <!-- Section: Advanced formatting --> 197 200 <button class="toolbar-dropdown-item" id="tb-subscript" title="Subscript" role="menuitem"> 198 201 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 3l5 5-5 5"/><text x="10" y="14" font-size="6" fill="currentColor" stroke="none" font-family="sans-serif">2</text></svg></span><span class="item-label">Subscript</span> 199 202 </button> ··· 203 206 <button class="toolbar-dropdown-item" id="tb-page-break" title="Page break (Cmd+Enter)" role="menuitem"> 204 207 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="2" y1="13" x2="14" y2="13"/><line x1="2" y1="8" x2="5" y2="8"/><line x1="7" y1="8" x2="9" y2="8"/><line x1="11" y1="8" x2="14" y2="8"/></svg></span><span class="item-label">Page break</span><span class="item-shortcut">&#8984;&#9166;</span> 205 208 </button> 206 - <!-- Paragraph Spacing (moved from toolbar) --> 207 209 <div class="toolbar-dropdown-divider"></div> 210 + 211 + <!-- Section: Spacing --> 208 212 <button class="toolbar-dropdown-item" id="tb-para-spacing-toggle" title="Paragraph spacing" role="menuitem"> 209 213 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="5" y1="2" x2="14" y2="2"/><line x1="5" y1="6" x2="14" y2="6"/><line x1="5" y1="10" x2="14" y2="10"/><line x1="5" y1="14" x2="14" y2="14"/><path d="M2 3.5v2"/><path d="M2 7.5v2"/></svg></span><span class="item-label">Paragraph spacing...</span> 210 214 </button> 215 + <div class="toolbar-dropdown-divider"></div> 216 + 217 + <!-- Section: Document --> 211 218 <button class="toolbar-dropdown-item" id="tb-spellcheck" title="Toggle spell check" role="menuitem"> 212 219 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 8l3 4 7-8"/></svg></span><span class="item-label">Spell check</span> 213 220 </button> 214 - <div class="toolbar-dropdown-divider"></div> 215 221 <button class="toolbar-dropdown-item" id="tb-export-html" title="Export as HTML (Cmd+Shift+S)" role="menuitem"> 216 222 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M5 4L2 8l3 4"/><path d="M11 4l3 4-3 4"/></svg></span><span class="item-label">Export HTML</span><span class="item-shortcut">&#8984;&#8679;S</span> 217 223 </button> ··· 224 230 <button class="toolbar-dropdown-item" id="tb-export-pdf" title="Export as PDF (Cmd+Shift+P)" role="menuitem"> 225 231 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><text x="5.5" y="12" font-size="5" fill="currentColor" stroke="none" font-family="sans-serif" font-weight="bold">PDF</text></svg></span><span class="item-label">Export PDF</span><span class="item-shortcut">&#8984;&#8679;P</span> 226 232 </button> 227 - <div class="toolbar-dropdown-divider"></div> 228 233 <button class="toolbar-dropdown-item" id="tb-import" title="Import file" role="menuitem"> 229 234 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M8 10V2"/><path d="M5 5l3-3 3 3"/><path d="M2 10v3h12v-3"/></svg></span><span class="item-label">Import file</span> 230 235 </button>
+10 -2
src/docs/main.js
··· 1345 1345 const suggestTr = editor.view.state.tr; 1346 1346 suggestTr.setMeta('suggestion', true); 1347 1347 1348 - const attrs = createSuggestionAttrs({ type: 'insert', author: userName }); 1348 + // Use session-aware attrs so consecutive keystrokes share one suggestion ID 1349 + const cursorPos = editor.state.selection.from; 1350 + const attrs = suggestionMgr.getSessionAttrs({ type: 'insert', author: userName, cursorPos }); 1349 1351 const insertMark = editor.schema.marks.suggestionInsert.create(attrs); 1350 - const deleteAttrs = createSuggestionAttrs({ type: 'delete', author: userName }); 1352 + const deleteAttrs = suggestionMgr.getSessionAttrs({ type: 'delete', author: userName, cursorPos }); 1351 1353 const deleteMark = editor.schema.marks.suggestionDelete.create(deleteAttrs); 1352 1354 1353 1355 let hasSuggestions = false; ··· 1373 1375 1374 1376 if (hasSuggestions) { 1375 1377 originalDispatch(suggestTr); 1378 + // Update session cursor to end of the deletion range 1379 + suggestionMgr.updateSessionCursor(cursorPos); 1376 1380 } else { 1377 1381 // For insertions, apply original and then mark the inserted range 1378 1382 originalDispatch(tr); 1379 1383 // Mark newly inserted content 1380 1384 const newTr = editor.view.state.tr; 1381 1385 let marked = false; 1386 + let lastNewEnd = cursorPos; 1382 1387 tr.steps.forEach((step, i) => { 1383 1388 const map = tr.mapping.maps[i]; 1384 1389 if (!map) return; ··· 1386 1391 if (newEnd > newStart && newEnd <= editor.view.state.doc.content.size) { 1387 1392 newTr.addMark(newStart, newEnd, insertMark); 1388 1393 marked = true; 1394 + lastNewEnd = newEnd; 1389 1395 } 1390 1396 }); 1391 1397 }); 1392 1398 if (marked) { 1393 1399 newTr.setMeta('suggestion', true); 1394 1400 originalDispatch(newTr); 1401 + // Update session cursor to end of inserted content 1402 + suggestionMgr.updateSessionCursor(lastNewEnd); 1395 1403 } 1396 1404 } 1397 1405 };
+191 -12
src/lib/suggesting.js
··· 4 4 * Manages suggestion marks (insert/delete) for track-changes functionality. 5 5 * Each suggestion has: id, author, timestamp, type (insert/delete). 6 6 * Accept/reject logic determines whether text is kept or removed. 7 + * 8 + * Session tracking groups consecutive keystrokes into a single suggestion, 9 + * similar to how Google Docs groups characters into word/phrase-level suggestions. 7 10 */ 8 11 9 12 export const SUGGESTION_TYPES = { ··· 11 14 DELETE: 'suggestion-delete', 12 15 }; 13 16 17 + /** Default timeout (ms) after which a new suggestion session starts. */ 18 + export const SESSION_TIMEOUT_MS = 2000; 19 + 20 + /** 21 + * Generate a fresh suggestion ID. 22 + * @returns {string} 23 + */ 24 + function generateId() { 25 + return typeof crypto !== 'undefined' && crypto.randomUUID 26 + ? crypto.randomUUID() 27 + : `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; 28 + } 29 + 14 30 /** 15 31 * Create suggestion mark attributes. 16 32 * @param {object} opts ··· 20 36 * @returns {object} Mark attributes 21 37 */ 22 38 export function createSuggestionAttrs(opts) { 23 - const id = typeof crypto !== 'undefined' && crypto.randomUUID 24 - ? crypto.randomUUID() 25 - : `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; 26 - 27 39 return { 28 - suggestionId: id, 40 + suggestionId: generateId(), 29 41 author: opts.author, 30 42 type: opts.type, 31 43 timestamp: opts.timestamp || new Date().toISOString(), ··· 33 45 } 34 46 35 47 /** 36 - * Manages suggestions — tracking, accept/reject logic. 48 + * Tracks a "suggestion session" — a sequence of consecutive edits by the same 49 + * author, of the same type, at an adjacent cursor position. All edits within 50 + * a session share a single suggestion ID so they render as one cohesive mark. 51 + * 52 + * A new session starts when: 53 + * - The author changes 54 + * - The suggestion type changes (insert vs delete) 55 + * - The cursor jumps to a non-adjacent position 56 + * - More than SESSION_TIMEOUT_MS have elapsed since the last edit 57 + * - `resetSession()` is called explicitly 58 + */ 59 + export class SuggestionSession { 60 + /** 61 + * @param {object} [opts] 62 + * @param {number} [opts.timeoutMs=SESSION_TIMEOUT_MS] - inactivity timeout 63 + * @param {() => number} [opts.now] - clock function (for testing) 64 + */ 65 + constructor(opts = {}) { 66 + this._timeoutMs = opts.timeoutMs ?? SESSION_TIMEOUT_MS; 67 + this._now = opts.now ?? (() => Date.now()); 68 + 69 + /** @type {string|null} Current session suggestion ID */ 70 + this._id = null; 71 + /** @type {string|null} Author of the current session */ 72 + this._author = null; 73 + /** @type {'insert'|'delete'|null} Type of the current session */ 74 + this._type = null; 75 + /** @type {number|null} Last cursor position (end of last edit) */ 76 + this._cursorPos = null; 77 + /** @type {number} Timestamp of last edit */ 78 + this._lastTime = 0; 79 + } 80 + 81 + /** 82 + * Get-or-create suggestion attributes for the current session. 83 + * 84 + * If the incoming edit is compatible with the active session (same author, 85 + * same type, adjacent cursor, within timeout), the existing session ID is 86 + * reused. Otherwise a new session is started. 87 + * 88 + * @param {object} opts 89 + * @param {'insert'|'delete'} opts.type 90 + * @param {string} opts.author 91 + * @param {number} opts.cursorPos - cursor position *before* this edit 92 + * @param {string} [opts.timestamp] - ISO string, defaults to now 93 + * @returns {object} Mark attributes (always contains `suggestionId`) 94 + */ 95 + getAttrs(opts) { 96 + const now = this._now(); 97 + const needsNew = this._needsNewSession(opts.author, opts.type, opts.cursorPos, now); 98 + 99 + if (needsNew) { 100 + this._id = generateId(); 101 + this._author = opts.author; 102 + this._type = opts.type; 103 + } 104 + 105 + this._cursorPos = opts.cursorPos; 106 + this._lastTime = now; 107 + 108 + return { 109 + suggestionId: this._id, 110 + author: opts.author, 111 + type: opts.type, 112 + timestamp: opts.timestamp || new Date().toISOString(), 113 + }; 114 + } 115 + 116 + /** 117 + * Update the cursor position after an edit completes (e.g. set to end of 118 + * inserted text so the next consecutive keystroke is recognised as adjacent). 119 + * @param {number} pos 120 + */ 121 + updateCursor(pos) { 122 + this._cursorPos = pos; 123 + } 124 + 125 + /** 126 + * Forcefully end the current session. The next `getAttrs` call will start 127 + * a fresh session. 128 + */ 129 + resetSession() { 130 + this._id = null; 131 + this._author = null; 132 + this._type = null; 133 + this._cursorPos = null; 134 + this._lastTime = 0; 135 + } 136 + 137 + /** 138 + * Return the current session ID (or null if no session is active). 139 + * @returns {string|null} 140 + */ 141 + get currentId() { 142 + return this._id; 143 + } 144 + 145 + // --- internals --- 146 + 147 + /** 148 + * Determine whether we need to start a new session. 149 + * @private 150 + */ 151 + _needsNewSession(author, type, cursorPos, now) { 152 + // No active session 153 + if (!this._id) return true; 154 + // Different author 155 + if (author !== this._author) return true; 156 + // Different type (insert vs delete) 157 + if (type !== this._type) return true; 158 + // Timeout 159 + if (now - this._lastTime > this._timeoutMs) return true; 160 + // Cursor jump (not adjacent to previous edit position) 161 + if (this._cursorPos !== null && cursorPos !== null && Math.abs(cursorPos - this._cursorPos) > 1) return true; 162 + 163 + return false; 164 + } 165 + } 166 + 167 + /** 168 + * Manages suggestions — tracking, accept/reject logic, and session grouping. 37 169 * This is the pure-logic layer; TipTap mark management is handled separately. 38 170 */ 39 171 export class SuggestionManager { 40 - constructor() { 172 + /** 173 + * @param {object} [opts] 174 + * @param {number} [opts.sessionTimeoutMs] - inactivity timeout for session grouping 175 + * @param {() => number} [opts.now] - clock function (for testing) 176 + */ 177 + constructor(opts = {}) { 41 178 /** @type {'editing'|'suggesting'} */ 42 179 this._mode = 'editing'; 43 180 44 181 /** @type {Map<string, object>} suggestionId -> attrs */ 45 182 this._suggestions = new Map(); 183 + 184 + /** Session tracker for grouping consecutive edits */ 185 + this._session = new SuggestionSession({ 186 + timeoutMs: opts.sessionTimeoutMs, 187 + now: opts.now, 188 + }); 46 189 } 47 190 48 191 /** ··· 59 202 */ 60 203 setMode(mode) { 61 204 this._mode = mode; 205 + if (mode === 'editing') { 206 + this._session.resetSession(); 207 + } 62 208 } 63 209 64 210 /** ··· 66 212 */ 67 213 toggleMode() { 68 214 this._mode = this._mode === 'editing' ? 'suggesting' : 'editing'; 215 + if (this._mode === 'editing') { 216 + this._session.resetSession(); 217 + } 218 + } 219 + 220 + /** 221 + * Get session-aware suggestion attributes. Consecutive edits by the same 222 + * author/type at adjacent positions share the same suggestion ID. 223 + * 224 + * @param {object} opts 225 + * @param {'insert'|'delete'} opts.type 226 + * @param {string} opts.author 227 + * @param {number} opts.cursorPos - cursor position before this edit 228 + * @param {string} [opts.timestamp] 229 + * @returns {object} Mark attributes 230 + */ 231 + getSessionAttrs(opts) { 232 + return this._session.getAttrs(opts); 233 + } 234 + 235 + /** 236 + * Update the session cursor position after an edit. 237 + * @param {number} pos 238 + */ 239 + updateSessionCursor(pos) { 240 + this._session.updateCursor(pos); 241 + } 242 + 243 + /** 244 + * Reset the current suggestion session (e.g. on explicit cursor move). 245 + */ 246 + resetSession() { 247 + this._session.resetSession(); 69 248 } 70 249 71 250 /** 72 251 * Track a new suggestion. 73 - * @param {object} attrs - From createSuggestionAttrs 252 + * @param {object} attrs - From createSuggestionAttrs or getSessionAttrs 74 253 */ 75 254 addSuggestion(attrs) { 76 255 this._suggestions.set(attrs.suggestionId, { ...attrs }); ··· 105 284 /** 106 285 * Accept a suggestion. Returns an action descriptor for the editor. 107 286 * 108 - * - Accept INSERT: keep the text, remove the suggestion mark → { action: 'remove-mark', type: 'insert' } 109 - * - Accept DELETE: actually delete the text → { action: 'delete-text', type: 'delete' } 287 + * - Accept INSERT: keep the text, remove the suggestion mark -> { action: 'remove-mark', type: 'insert' } 288 + * - Accept DELETE: actually delete the text -> { action: 'delete-text', type: 'delete' } 110 289 * 111 290 * @param {string} id 112 291 * @returns {object|null} Action descriptor or null if not found ··· 127 306 /** 128 307 * Reject a suggestion. Returns an action descriptor for the editor. 129 308 * 130 - * - Reject INSERT: remove the inserted text → { action: 'delete-text', type: 'insert' } 131 - * - Reject DELETE: keep the text, remove the mark → { action: 'remove-mark', type: 'delete' } 309 + * - Reject INSERT: remove the inserted text -> { action: 'delete-text', type: 'insert' } 310 + * - Reject DELETE: keep the text, remove the mark -> { action: 'remove-mark', type: 'delete' } 132 311 * 133 312 * @param {string} id 134 313 * @returns {object|null} Action descriptor or null if not found
+25 -24
src/sheets/index.html
··· 45 45 46 46 <!-- Formatting toolbar (Google Sheets-style, flat single-row) --> 47 47 <div class="toolbar gdocs-toolbar" id="toolbar" role="toolbar" aria-label="Formatting toolbar"> 48 - <!-- Undo, Redo --> 48 + <!-- Group: History & Print --> 49 49 <button class="tb-btn toolbar-mobile-hide" id="tb-undo" title="Undo (Cmd+Z)" aria-label="Undo"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M3 7.5h8a3 3 0 0 1 0 6H9"/><path d="M5.5 5L3 7.5 5.5 10"/></svg></button> 50 50 <button class="tb-btn toolbar-mobile-hide" id="tb-redo" title="Redo (Cmd+Shift+Z)" aria-label="Redo"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M13 7.5H5a3 3 0 0 0 0 6h2"/><path d="M10.5 5l2.5 2.5-2.5 2.5"/></svg></button> 51 - <!-- Print --> 52 51 <button class="tb-btn toolbar-mobile-hide" id="tb-print" title="Print spreadsheet (Cmd+P)" aria-label="Print spreadsheet"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 5V2h8v3"/><rect x="2" y="5" width="12" height="6" rx="1"/><path d="M4 11v3h8v-3"/><line x1="6" y1="12.5" x2="10" y2="12.5"/></svg></button> 53 52 <span class="toolbar-sep toolbar-mobile-hide"></span> 54 - <!-- Number format select --> 53 + 54 + <!-- Group: Number format --> 55 55 <select class="tb-select toolbar-mobile-hide" id="tb-format" title="Cell number format" aria-label="Cell number format"> 56 56 <option value="auto">Auto</option> 57 57 <option value="number">123</option> ··· 61 61 <option value="text">Text</option> 62 62 </select> 63 63 <span class="toolbar-sep toolbar-mobile-hide"></span> 64 - <!-- Bold, Italic --> 64 + 65 + <!-- Group: Cell formatting --> 65 66 <button class="tb-btn" id="tb-bold" title="Bold (Cmd+B)" aria-label="Bold"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4.5 2.5h4.5a2.5 2.5 0 0 1 0 5h-4.5zM4.5 7.5h5a3 3 0 0 1 0 6h-5z" stroke-width="2"/></svg></button> 66 67 <button class="tb-btn" id="tb-italic" title="Italic (Cmd+I)" aria-label="Italic"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="10" y1="2.5" x2="6" y2="13.5"/><line x1="7" y1="2.5" x2="11" y2="2.5"/><line x1="5" y1="13.5" x2="9" y2="13.5"/></svg></button> 67 - <!-- Text color --> 68 68 <div class="tb-color-wrap toolbar-mobile-hide"> 69 69 <input type="color" class="tb-color" id="tb-text-color" value="#1a1815" title="Text color" aria-label="Text color"> 70 70 <span class="tb-color-label">A</span> 71 71 <span class="tb-color-swatch" id="tb-text-color-swatch"></span> 72 72 </div> 73 - <!-- BG color --> 74 73 <div class="tb-color-wrap toolbar-mobile-hide"> 75 74 <input type="color" class="tb-color" id="tb-bg-color" value="#ffffff" title="Cell background" aria-label="Cell background"> 76 75 <span class="tb-color-label"><svg class="tb-icon" viewBox="0 0 16 16" style="width:12px;height:12px"><path d="M10 2L3.5 8.5 7.5 12.5 14 6z"/><path d="M3.5 8.5L2 14l5.5-1.5"/></svg></span> 77 76 <span class="tb-color-swatch tb-color-swatch-highlight" id="tb-bg-color-swatch"></span> 78 77 </div> 79 78 <span class="toolbar-sep toolbar-mobile-hide"></span> 80 - <!-- Alignment dropdown --> 79 + 80 + <!-- Group: Cell layout --> 81 81 <div class="toolbar-dropdown toolbar-mobile-hide" id="dd-align"> 82 82 <button class="toolbar-dropdown-toggle" id="tb-align-toggle" title="Cell alignment" aria-label="Cell alignment" aria-expanded="false" aria-haspopup="true"> 83 83 <span class="dd-icon"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="2" y1="6.5" x2="10" y2="6.5"/><line x1="2" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="10" y2="13.5"/></svg></span><span class="caret">&#9662;</span> ··· 94 94 </button> 95 95 </div> 96 96 </div> 97 - <span class="toolbar-sep toolbar-mobile-hide"></span> 98 - <!-- Borders dropdown --> 99 97 <div class="toolbar-dropdown toolbar-mobile-hide" id="dd-borders"> 100 98 <button class="toolbar-dropdown-toggle" id="tb-borders-toggle" title="Cell borders" aria-label="Cell borders" aria-expanded="false" aria-haspopup="true"> 101 99 <span class="dd-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" fill="none"/></svg></span><span class="caret">&#9662;</span> ··· 124 122 </button> 125 123 </div> 126 124 </div> 127 - <!-- Wrap text --> 128 125 <button class="tb-btn toolbar-mobile-hide" id="tb-wrap" title="Wrap text" aria-label="Wrap text"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 3h12M2 7h9a2 2 0 0 1 0 4H9"/><path d="M10 9.5l-1.5 1.5L10 12.5"/><line x1="2" y1="13" x2="7" y2="13"/></svg></button> 129 - <span class="toolbar-sep toolbar-mobile-hide"></span> 130 - <!-- Merge --> 131 126 <button class="tb-btn toolbar-mobile-hide" id="tb-merge" title="Merge/Unmerge cells" aria-label="Merge cells"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="1"/><path d="M5 8h6"/><path d="M6.5 6L5 8l1.5 2"/><path d="M9.5 6l1.5 2-1.5 2"/></svg></button> 132 127 <span class="toolbar-sep toolbar-mobile-hide"></span> 133 - <!-- Sort, Filter, Chart --> 134 - <button class="tb-btn toolbar-mobile-hide" id="tb-sort-asc" title="Sort A to Z" aria-label="Sort ascending"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 11V3"/><path d="M2 5l2-2 2 2"/><line x1="8" y1="4" x2="14" y2="4"/><line x1="8" y1="8" x2="12" y2="8"/><line x1="8" y1="12" x2="10" y2="12"/></svg></button> 135 - <button class="tb-btn toolbar-mobile-hide" id="tb-sort-desc" title="Sort Z to A" aria-label="Sort descending"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 5v8"/><path d="M2 11l2 2 2-2"/><line x1="8" y1="4" x2="10" y2="4"/><line x1="8" y1="8" x2="12" y2="8"/><line x1="8" y1="12" x2="14" y2="12"/></svg></button> 136 - <button class="tb-btn toolbar-mobile-hide" id="tb-filter" title="Toggle column filters" aria-label="Toggle filters"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 3h12l-4 5v4l-4 2V8z"/></svg></button> 128 + 129 + <!-- Group: Sort, Filter, Chart --> 130 + <button class="tb-btn toolbar-mobile-hide" id="tb-sort-asc" title="Sort column A to Z" aria-label="Sort ascending"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 11V3"/><path d="M2 5l2-2 2 2"/><line x1="8" y1="4" x2="14" y2="4"/><line x1="8" y1="8" x2="12" y2="8"/><line x1="8" y1="12" x2="10" y2="12"/></svg></button> 131 + <button class="tb-btn toolbar-mobile-hide" id="tb-sort-desc" title="Sort column Z to A" aria-label="Sort descending"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 5v8"/><path d="M2 11l2 2 2-2"/><line x1="8" y1="4" x2="10" y2="4"/><line x1="8" y1="8" x2="12" y2="8"/><line x1="8" y1="12" x2="14" y2="12"/></svg></button> 132 + <button class="tb-btn toolbar-mobile-hide" id="tb-filter" title="Toggle column filters" aria-label="Toggle column filters"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 3h12l-4 5v4l-4 2V8z"/></svg></button> 137 133 <button class="tb-btn toolbar-mobile-hide" id="tb-chart" title="Insert chart" aria-label="Insert chart"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="8" width="3" height="6" rx="0.5"/><rect x="6.5" y="4" width="3" height="10" rx="0.5"/><rect x="11" y="6" width="3" height="8" rx="0.5"/></svg></button> 138 134 <span class="toolbar-sep toolbar-mobile-hide"></span> 135 + 139 136 <!-- Mobile more button (visible only on mobile) --> 140 137 <button class="tb-btn toolbar-mobile-more" id="tb-mobile-more" title="More formatting" aria-label="More formatting" aria-expanded="false" aria-haspopup="true"><svg class="tb-icon" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.25" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1.25" fill="currentColor" stroke="none"/><circle cx="13" cy="8" r="1.25" fill="currentColor" stroke="none"/></svg></button> 141 - <!-- More overflow --> 138 + 139 + <!-- More overflow menu --> 142 140 <div class="toolbar-overflow" id="overflow-menu"> 143 141 <button class="toolbar-overflow-toggle" id="overflow-toggle" title="More options" aria-label="More options" aria-expanded="false" aria-haspopup="true"> 144 142 <svg class="tb-icon" viewBox="0 0 16 16"><circle cx="8" cy="3" r="1.25" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1.25" fill="currentColor" stroke="none"/><circle cx="8" cy="13" r="1.25" fill="currentColor" stroke="none"/></svg> 145 143 </button> 146 144 <div class="toolbar-overflow-menu" role="menu"> 147 - <button class="toolbar-dropdown-item" id="tb-sort-multi" title="Multi-column sort" role="menuitem"> 148 - <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 11V3"/><path d="M2 5l2-2 2 2"/><path d="M12 5v8"/><path d="M10 11l2 2 2-2"/></svg></span><span class="item-label">Multi-column sort</span> 149 - </button> 150 - <div class="toolbar-dropdown-divider"></div> 145 + <!-- Section: Structure (row/col operations, freeze) --> 151 146 <button class="toolbar-dropdown-item" id="tb-add-row" title="Insert row below" role="menuitem"> 152 147 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="1"/><path d="M8 5v6"/><path d="M5 8h6"/></svg></span><span class="item-label">Insert row</span> 153 148 </button> ··· 160 155 <button class="toolbar-dropdown-item" id="tb-del-col" title="Delete last column" role="menuitem"> 161 156 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="1"/><path d="M5 8h6"/></svg></span><span class="item-label">Delete column</span> 162 157 </button> 163 - <div class="toolbar-dropdown-divider"></div> 164 158 <button class="toolbar-dropdown-item" id="tb-freeze-rows" title="Freeze rows above cursor" role="menuitem"> 165 159 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 5h12"/><path d="M2 5v-2h12v2" fill="currentColor" opacity="0.15" stroke="none"/><path d="M6 5v8"/><path d="M10 5v8"/></svg></span><span class="item-label">Freeze rows</span> 166 160 </button> ··· 171 165 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M3 3l10 10"/><path d="M13 3L3 13"/></svg></span><span class="item-label">Unfreeze all</span> 172 166 </button> 173 167 <div class="toolbar-dropdown-divider"></div> 174 - <button class="toolbar-dropdown-item" id="tb-striped" title="Toggle striped rows" role="menuitem"> 175 - <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="3" fill="currentColor" opacity="0.15" stroke="none"/><rect x="2" y="8" width="12" height="3" fill="currentColor" opacity="0.15" stroke="none"/><rect x="2" y="2" width="12" height="12" fill="none"/></svg></span><span class="item-label">Striped rows</span> 168 + 169 + <!-- Section: Data (conditional formatting, validation, sorting, appearance) --> 170 + <button class="toolbar-dropdown-item" id="tb-sort-multi" title="Multi-column sort" role="menuitem"> 171 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 11V3"/><path d="M2 5l2-2 2 2"/><path d="M12 5v8"/><path d="M10 11l2 2 2-2"/></svg></span><span class="item-label">Multi-column sort</span> 176 172 </button> 177 173 <button class="toolbar-dropdown-item" id="tb-cf" title="Conditional formatting" role="menuitem"> 178 174 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="1"/><path d="M5 8l2 2 4-4"/></svg></span><span class="item-label">Conditional formatting</span> ··· 180 176 <button class="toolbar-dropdown-item" id="tb-validation" title="Data validation" role="menuitem"> 181 177 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M3 8l3 3 7-7"/></svg></span><span class="item-label">Data validation</span> 182 178 </button> 179 + <button class="toolbar-dropdown-item" id="tb-striped" title="Toggle striped rows" role="menuitem"> 180 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="3" fill="currentColor" opacity="0.15" stroke="none"/><rect x="2" y="8" width="12" height="3" fill="currentColor" opacity="0.15" stroke="none"/><rect x="2" y="2" width="12" height="12" fill="none"/></svg></span><span class="item-label">Striped rows</span> 181 + </button> 183 182 <div class="toolbar-dropdown-divider"></div> 183 + 184 + <!-- Section: Document (export, import) --> 184 185 <button class="toolbar-dropdown-item" id="tb-export-csv" title="Export as CSV" role="menuitem"> 185 186 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="11" y2="10"/></svg></span><span class="item-label">Export CSV</span> 186 187 </button>
+258
tests/suggesting-mode.test.js
··· 8 8 * - SuggestionManager: accept/reject logic 9 9 * - Multi-user suggestion handling 10 10 * - Suggestion metadata 11 + * - Session-based suggestion grouping (consecutive keystrokes share one ID) 11 12 */ 12 13 13 14 import { 14 15 SuggestionManager, 16 + SuggestionSession, 15 17 createSuggestionAttrs, 16 18 SUGGESTION_TYPES, 19 + SESSION_TIMEOUT_MS, 17 20 } from '../src/lib/suggesting.js'; 18 21 19 22 describe('SUGGESTION_TYPES', () => { ··· 113 116 }); 114 117 }); 115 118 119 + describe('SuggestionSession', () => { 120 + let clock; 121 + let session; 122 + 123 + beforeEach(() => { 124 + clock = { value: 1000 }; 125 + session = new SuggestionSession({ 126 + timeoutMs: 2000, 127 + now: () => clock.value, 128 + }); 129 + }); 130 + 131 + describe('basic session creation', () => { 132 + it('starts with no active session', () => { 133 + expect(session.currentId).toBeNull(); 134 + }); 135 + 136 + it('creates a session on first getAttrs call', () => { 137 + const attrs = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 138 + expect(attrs.suggestionId).toBeTruthy(); 139 + expect(attrs.author).toBe('alice'); 140 + expect(attrs.type).toBe('insert'); 141 + expect(session.currentId).toBe(attrs.suggestionId); 142 + }); 143 + }); 144 + 145 + describe('consecutive keystroke grouping', () => { 146 + it('reuses the same ID for consecutive inserts at adjacent positions', () => { 147 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 148 + session.updateCursor(6); // after inserting one char at pos 5 149 + const b = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 6 }); 150 + session.updateCursor(7); 151 + const c = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 7 }); 152 + 153 + expect(a.suggestionId).toBe(b.suggestionId); 154 + expect(b.suggestionId).toBe(c.suggestionId); 155 + }); 156 + 157 + it('reuses the same ID for consecutive deletes at adjacent positions', () => { 158 + const a = session.getAttrs({ type: 'delete', author: 'alice', cursorPos: 10 }); 159 + session.updateCursor(9); 160 + const b = session.getAttrs({ type: 'delete', author: 'alice', cursorPos: 9 }); 161 + 162 + expect(a.suggestionId).toBe(b.suggestionId); 163 + }); 164 + }); 165 + 166 + describe('new session triggers', () => { 167 + it('starts new session when author changes', () => { 168 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 169 + session.updateCursor(6); 170 + const b = session.getAttrs({ type: 'insert', author: 'bob', cursorPos: 6 }); 171 + 172 + expect(a.suggestionId).not.toBe(b.suggestionId); 173 + }); 174 + 175 + it('starts new session when type changes (insert to delete)', () => { 176 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 177 + session.updateCursor(6); 178 + const b = session.getAttrs({ type: 'delete', author: 'alice', cursorPos: 6 }); 179 + 180 + expect(a.suggestionId).not.toBe(b.suggestionId); 181 + }); 182 + 183 + it('starts new session when cursor jumps to non-adjacent position', () => { 184 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 185 + session.updateCursor(6); 186 + // cursor jumps to position 20 (non-adjacent) 187 + const b = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 20 }); 188 + 189 + expect(a.suggestionId).not.toBe(b.suggestionId); 190 + }); 191 + 192 + it('starts new session after timeout', () => { 193 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 194 + session.updateCursor(6); 195 + 196 + // Advance clock past timeout 197 + clock.value += 3000; 198 + 199 + const b = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 6 }); 200 + expect(a.suggestionId).not.toBe(b.suggestionId); 201 + }); 202 + 203 + it('does NOT start new session if within timeout', () => { 204 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 205 + session.updateCursor(6); 206 + 207 + // Advance clock but stay within timeout 208 + clock.value += 1500; 209 + 210 + const b = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 6 }); 211 + expect(a.suggestionId).toBe(b.suggestionId); 212 + }); 213 + 214 + it('starts new session after explicit reset', () => { 215 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 216 + session.resetSession(); 217 + const b = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 6 }); 218 + 219 + expect(a.suggestionId).not.toBe(b.suggestionId); 220 + }); 221 + }); 222 + 223 + describe('edge cases', () => { 224 + it('treats position difference of exactly 1 as adjacent (same session)', () => { 225 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 226 + session.updateCursor(6); 227 + // position 7 is 1 away from cursor 6 -- still adjacent 228 + const b = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 7 }); 229 + 230 + expect(a.suggestionId).toBe(b.suggestionId); 231 + }); 232 + 233 + it('treats position difference of 2+ as a cursor jump (new session)', () => { 234 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 235 + session.updateCursor(6); 236 + // position 8 is 2 away from cursor 6 -- NOT adjacent 237 + const b = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: 8 }); 238 + 239 + expect(a.suggestionId).not.toBe(b.suggestionId); 240 + }); 241 + 242 + it('tolerates null cursor position on first call', () => { 243 + const a = session.getAttrs({ type: 'insert', author: 'alice', cursorPos: null }); 244 + expect(a.suggestionId).toBeTruthy(); 245 + }); 246 + }); 247 + }); 248 + 249 + describe('SESSION_TIMEOUT_MS', () => { 250 + it('exports default timeout of 2000ms', () => { 251 + expect(SESSION_TIMEOUT_MS).toBe(2000); 252 + }); 253 + }); 254 + 116 255 describe('SuggestionManager', () => { 117 256 let manager; 118 257 ··· 141 280 expect(manager.isSuggesting()).toBe(true); 142 281 manager.toggleMode(); 143 282 expect(manager.isSuggesting()).toBe(false); 283 + }); 284 + 285 + it('resets session when switching to editing mode', () => { 286 + manager.setMode('suggesting'); 287 + const a = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 288 + manager.setMode('editing'); 289 + manager.setMode('suggesting'); 290 + const b = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 6 }); 291 + 292 + expect(a.suggestionId).not.toBe(b.suggestionId); 293 + }); 294 + }); 295 + 296 + describe('session-aware attrs via manager', () => { 297 + it('groups consecutive inserts', () => { 298 + manager.setMode('suggesting'); 299 + const a = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 300 + manager.updateSessionCursor(6); 301 + const b = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 6 }); 302 + 303 + expect(a.suggestionId).toBe(b.suggestionId); 304 + }); 305 + 306 + it('resetSession forces new ID', () => { 307 + manager.setMode('suggesting'); 308 + const a = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 5 }); 309 + manager.resetSession(); 310 + const b = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 6 }); 311 + 312 + expect(a.suggestionId).not.toBe(b.suggestionId); 144 313 }); 145 314 }); 146 315 ··· 269 438 }); 270 439 }); 271 440 }); 441 + 442 + describe('SuggestionManager + SuggestionSession integration', () => { 443 + let clock; 444 + let manager; 445 + 446 + beforeEach(() => { 447 + clock = { value: 1000 }; 448 + manager = new SuggestionManager({ 449 + sessionTimeoutMs: 2000, 450 + now: () => clock.value, 451 + }); 452 + manager.setMode('suggesting'); 453 + }); 454 + 455 + it('simulates typing "hello" as a single grouped suggestion', () => { 456 + const ids = []; 457 + for (let i = 0; i < 5; i++) { 458 + const attrs = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 10 + i }); 459 + ids.push(attrs.suggestionId); 460 + manager.updateSessionCursor(10 + i + 1); 461 + clock.value += 100; // 100ms between keystrokes 462 + } 463 + 464 + // All 5 characters should share the same suggestion ID 465 + const uniqueIds = [...new Set(ids)]; 466 + expect(uniqueIds).toHaveLength(1); 467 + }); 468 + 469 + it('creates separate suggestions for typing, pausing, then typing again', () => { 470 + // Type "he" 471 + const a1 = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 10 }); 472 + manager.updateSessionCursor(11); 473 + clock.value += 100; 474 + const a2 = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 11 }); 475 + manager.updateSessionCursor(12); 476 + 477 + // Pause 3 seconds 478 + clock.value += 3000; 479 + 480 + // Type "llo" 481 + const b1 = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 12 }); 482 + manager.updateSessionCursor(13); 483 + 484 + expect(a1.suggestionId).toBe(a2.suggestionId); 485 + expect(a2.suggestionId).not.toBe(b1.suggestionId); 486 + }); 487 + 488 + it('creates separate suggestions when cursor jumps mid-word', () => { 489 + // Type at position 10 490 + const a = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 10 }); 491 + manager.updateSessionCursor(11); 492 + 493 + // Jump cursor to position 50 494 + const b = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 50 }); 495 + manager.updateSessionCursor(51); 496 + 497 + expect(a.suggestionId).not.toBe(b.suggestionId); 498 + }); 499 + 500 + it('creates separate suggestions when switching between insert and delete', () => { 501 + const a = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 10 }); 502 + manager.updateSessionCursor(11); 503 + 504 + const b = manager.getSessionAttrs({ type: 'delete', author: 'alice', cursorPos: 11 }); 505 + 506 + expect(a.suggestionId).not.toBe(b.suggestionId); 507 + }); 508 + 509 + it('creates separate suggestions for different authors', () => { 510 + const a = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 10 }); 511 + manager.updateSessionCursor(11); 512 + 513 + const b = manager.getSessionAttrs({ type: 'insert', author: 'bob', cursorPos: 11 }); 514 + 515 + expect(a.suggestionId).not.toBe(b.suggestionId); 516 + }); 517 + 518 + it('toggling mode resets the session', () => { 519 + const a = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 10 }); 520 + manager.updateSessionCursor(11); 521 + 522 + manager.toggleMode(); // to editing 523 + manager.toggleMode(); // back to suggesting 524 + 525 + const b = manager.getSessionAttrs({ type: 'insert', author: 'alice', cursorPos: 11 }); 526 + 527 + expect(a.suggestionId).not.toBe(b.suggestionId); 528 + }); 529 + });