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: diagram routing bug + unified icon system' (#227) from fix/codebase-polish into main

scott d54131ac 9ff67194

+152 -149
+11 -11
src/diagrams/index.html
··· 39 39 <!-- Toolbar --> 40 40 <div class="diagrams-toolbar" id="diagrams-toolbar"> 41 41 <!-- Primary tools --> 42 - <button class="btn-icon diagrams-tool active" id="tool-select" title="Select (V)" data-tool="select">&#9995;</button> 43 - <button class="btn-icon diagrams-tool" id="tool-hand" title="Hand (H)" data-tool="hand">&#9997;</button> 42 + <button class="btn-icon diagrams-tool active" id="tool-select" title="Select (V)" data-tool="select">&#8598;</button> 43 + <button class="btn-icon diagrams-tool" id="tool-hand" title="Hand (H)" data-tool="hand">&#9678;</button> 44 44 <span class="toolbar-divider"></span> 45 45 46 46 <!-- Shape tools --> ··· 50 50 <button class="btn-icon diagrams-tool" id="tool-triangle" title="Triangle" data-tool="triangle">&#9651;</button> 51 51 <button class="btn-icon diagrams-tool" id="tool-star" title="Star" data-tool="star">&#9733;</button> 52 52 <button class="btn-icon diagrams-tool" id="tool-hexagon" title="Hexagon" data-tool="hexagon">&#11043;</button> 53 - <button class="btn-icon diagrams-tool" id="tool-cylinder" title="Cylinder" data-tool="cylinder">&#9778;</button> 53 + <button class="btn-icon diagrams-tool" id="tool-cylinder" title="Cylinder" data-tool="cylinder">&#8960;</button> 54 54 <button class="btn-icon diagrams-tool" id="tool-parallelogram" title="Parallelogram" data-tool="parallelogram">&#9645;</button> 55 55 <button class="btn-icon diagrams-tool" id="tool-cloud" title="Cloud" data-tool="cloud">&#9729;</button> 56 - <button class="btn-icon diagrams-tool" id="tool-note" title="Sticky Note (N)" data-tool="note">&#128466;</button> 56 + <button class="btn-icon diagrams-tool" id="tool-note" title="Sticky Note (N)" data-tool="note">&#9633;</button> 57 57 <span class="toolbar-divider"></span> 58 58 59 59 <!-- Drawing tools --> 60 60 <button class="btn-icon diagrams-tool" id="tool-text" title="Text (T)" data-tool="text">T</button> 61 - <button class="btn-icon diagrams-tool" id="tool-freehand" title="Freehand (P)" data-tool="freehand">&#9997;</button> 62 - <button class="btn-icon diagrams-tool" id="tool-highlighter" title="Highlighter" data-tool="highlighter">&#128396;</button> 61 + <button class="btn-icon diagrams-tool" id="tool-freehand" title="Freehand (P)" data-tool="freehand">&#10000;</button> 62 + <button class="btn-icon diagrams-tool" id="tool-highlighter" title="Highlighter" data-tool="highlighter">&#9618;</button> 63 63 <button class="btn-icon diagrams-tool" id="tool-line" title="Line (L)" data-tool="line">&#9585;</button> 64 64 <button class="btn-icon diagrams-tool" id="tool-arrow" title="Arrow (A)" data-tool="arrow">&#8594;</button> 65 - <button class="btn-icon diagrams-tool" id="tool-eraser" title="Eraser (X)" data-tool="eraser">&#128465;</button> 65 + <button class="btn-icon diagrams-tool" id="tool-eraser" title="Eraser (X)" data-tool="eraser">&#9003;</button> 66 66 <span class="toolbar-divider"></span> 67 67 68 68 <!-- Canvas controls --> ··· 80 80 81 81 <!-- Group --> 82 82 <button class="btn-icon" id="btn-group" title="Group (Cmd+G)">&#9744;</button> 83 - <button class="btn-icon" id="btn-ungroup" title="Ungroup (Cmd+Shift+G)">&#9744;</button> 83 + <button class="btn-icon" id="btn-ungroup" title="Ungroup (Cmd+Shift+G)">&#9746;</button> 84 84 <span class="toolbar-divider"></span> 85 85 86 86 <!-- Flip --> ··· 95 95 <button class="btn-icon btn-sm" data-align="top" title="Align top">&#8673;</button> 96 96 <button class="btn-icon btn-sm" data-align="center-v" title="Align middle">&#8597;</button> 97 97 <button class="btn-icon btn-sm" data-align="bottom" title="Align bottom">&#8675;</button> 98 - <button class="btn-icon btn-sm" data-distribute="horizontal" title="Distribute horizontally">&#9776;</button> 99 - <button class="btn-icon btn-sm" data-distribute="vertical" title="Distribute vertically">&#9776;</button> 98 + <button class="btn-icon btn-sm" data-distribute="horizontal" title="Distribute horizontally">&#8214;</button> 99 + <button class="btn-icon btn-sm" data-distribute="vertical" title="Distribute vertically">&#8801;</button> 100 100 <span class="toolbar-divider"></span> 101 101 102 102 <!-- Actions --> 103 - <button class="btn-icon" id="btn-delete" title="Delete selected">&#128465;</button> 103 + <button class="btn-icon" id="btn-delete" title="Delete selected">&#10005;</button> 104 104 </div> 105 105 106 106 <!-- Canvas -->
+6 -6
src/docs/table-toolbar.ts
··· 14 14 export const TABLE_COMMANDS: TableCommandMap = { 15 15 addRowBefore: { 16 16 label: 'Add row above', 17 - icon: '&#x2B06;', 17 + icon: '&#x2191;', 18 18 command: 'addRowBefore', 19 19 }, 20 20 addRowAfter: { 21 21 label: 'Add row below', 22 - icon: '&#x2B07;', 22 + icon: '&#x2193;', 23 23 command: 'addRowAfter', 24 24 }, 25 25 addColumnBefore: { 26 26 label: 'Add column left', 27 - icon: '&#x2B05;', 27 + icon: '&#x2190;', 28 28 command: 'addColumnBefore', 29 29 }, 30 30 addColumnAfter: { 31 31 label: 'Add column right', 32 - icon: '&#x27A1;', 32 + icon: '&#x2192;', 33 33 command: 'addColumnAfter', 34 34 }, 35 35 deleteRow: { 36 36 label: 'Delete row', 37 - icon: '&#x1F5D1;', 37 + icon: '&#x2715;', 38 38 command: 'deleteRow', 39 39 }, 40 40 deleteColumn: { 41 41 label: 'Delete column', 42 - icon: '&#x1F5D1;', 42 + icon: '&#x2715;', 43 43 command: 'deleteColumn', 44 44 }, 45 45 mergeCells: {
+5 -5
src/index.html
··· 54 54 <span class="create-card-desc">Formulas, formatting, multiple sheets, and real-time collaboration</span> 55 55 </a> 56 56 <a class="create-card" id="new-form" href="#"> 57 - <span class="create-card-icon">&#9744;</span> 57 + <span class="create-card-icon">&#9783;</span> 58 58 <span class="create-card-title">New Form</span> 59 59 <span class="create-card-desc">E2EE form builder with responses pipeline to sheets</span> 60 60 </a> 61 61 <a class="create-card" id="new-slide" href="#"> 62 - <span class="create-card-icon">&#9654;</span> 62 + <span class="create-card-icon">&#9707;</span> 63 63 <span class="create-card-title">New Presentation</span> 64 64 <span class="create-card-desc">Slide decks with themes, transitions, and presenter mode</span> 65 65 </a> 66 66 <a class="create-card" id="new-diagram" href="#"> 67 - <span class="create-card-icon">&#9998;</span> 67 + <span class="create-card-icon">&#9683;</span> 68 68 <span class="create-card-title">New Diagram</span> 69 69 <span class="create-card-desc">Freeform whiteboard with shapes, arrows, and freehand drawing</span> 70 70 </a> 71 71 <a class="create-card create-card-accent" id="daily-note" href="#"> 72 - <span class="create-card-icon">&#128197;</span> 72 + <span class="create-card-icon">&#9830;</span> 73 73 <span class="create-card-title">Today's Note</span> 74 74 <span class="create-card-desc">Open or create today's journal entry</span> 75 75 </a> ··· 175 175 <!-- Drag-and-drop import overlay --> 176 176 <div class="drop-overlay" id="drop-overlay" style="display:none;"> 177 177 <div class="drop-overlay-content"> 178 - <span class="drop-overlay-icon">&#128196;</span> 178 + <span class="drop-overlay-icon">&#8681;</span> 179 179 <span class="drop-overlay-text">Drop to import</span> 180 180 <span class="drop-overlay-hint">.docx, .xlsx, .csv, .md</span> 181 181 </div>
+2 -1
src/landing-dragdrop.ts
··· 51 51 * Build the editor URL path for a given document type and ID. 52 52 */ 53 53 export function buildEditorUrl(type: DocType, docId: string, keyStr: string): string { 54 - const base = type === 'doc' ? '/docs' : '/sheets'; 54 + const pathMap: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams' }; 55 + const base = pathMap[type] || '/docs'; 55 56 return `${base}/${docId}#${keyStr}`; 56 57 } 57 58
+9 -9
src/landing.ts
··· 680 680 const isGrid = viewMode === 'grid'; 681 681 let html = `<div class="doc-list${isGrid ? ' grid-view' : ''}">`; 682 682 for (const doc of sorted) { 683 - const path = doc.type === 'doc' ? '/docs' : '/sheets'; 684 - const icon = doc.type === 'doc' ? '&#9998;' : '&#9638;'; 683 + const path = docPath(doc.type); 684 + const icon = docIcon(doc.type); 685 685 const keyStr = doc._keyStr; 686 686 const name = doc._decryptedName || 'Encrypted Document'; 687 687 const isStarred = starSet.has(doc.id); ··· 707 707 <span class="doc-item-date">${date}</span> 708 708 </div> 709 709 <div class="doc-grid-card-actions"> 710 - <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">&#127991;</button> 711 - <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#128193;</button> 710 + <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">#</button> 711 + <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#9647;</button> 712 712 <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 713 713 <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> 714 714 </div> ··· 723 723 ${(doc as any).owner_name ? `<span class="doc-item-owner">${escapeHtml((doc as any).owner_name)}</span>` : ''} 724 724 <span class="doc-item-type">${doc.type}</span> 725 725 <span class="doc-item-date">${date}</span> 726 - <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">&#127991;</button> 727 - <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#128193;</button> 726 + <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">#</button> 727 + <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#9647;</button> 728 728 <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 729 729 <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> 730 730 </a>`; ··· 905 905 const docCount = activeDocs.filter(d => folderAssignments[d.id] === folder.id).length; 906 906 html += ` 907 907 <div class="folder-card" data-folder-id="${folder.id}"> 908 - <span class="folder-card-icon">&#128193;</span> 908 + <span class="folder-card-icon">&#9647;</span> 909 909 <span class="folder-card-name">${escapeHtml(folder.name)}</span> 910 910 <span class="folder-card-count">${docCount} doc${docCount !== 1 ? 's' : ''}</span> 911 911 <div class="folder-card-actions"> ··· 1050 1050 let html = ''; 1051 1051 // Option to move to root 1052 1052 html += `<button class="move-option ${currentFolder === null ? 'active' : ''}" data-folder-id=""> 1053 - &#127968; Root (no folder) 1053 + &#8962; Root (no folder) 1054 1054 </button>`; 1055 1055 for (const folder of folders) { 1056 1056 const active = currentFolder === folder.id ? 'active' : ''; 1057 1057 html += `<button class="move-option ${active}" data-folder-id="${folder.id}"> 1058 - &#128193; ${escapeHtml(folder.name)} 1058 + &#9647; ${escapeHtml(folder.name)} 1059 1059 </button>`; 1060 1060 } 1061 1061
+5 -5
src/slides/index.html
··· 27 27 <input class="doc-title-input" id="deck-title" type="text" value="Untitled Presentation" spellcheck="false"> 28 28 <span class="topbar-spacer"></span> 29 29 <span class="save-status" id="save-status"></span> 30 - <button class="btn-secondary" id="btn-present" title="Present (F5)">&#9654; Present</button> 30 + <button class="btn-secondary" id="btn-present" title="Present (F5)">&#9655; Present</button> 31 31 <button class="btn-secondary" id="btn-export" title="Export">Export</button> 32 32 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 33 33 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> ··· 50 50 <span class="toolbar-divider"></span> 51 51 <button class="btn-icon" id="btn-add-text" title="Add text">T</button> 52 52 <button class="btn-icon" id="btn-add-shape" title="Add shape">&#9632;</button> 53 - <button class="btn-icon" id="btn-add-image" title="Add image">&#128247;</button> 54 - <button class="btn-icon" id="btn-delete-element" title="Delete selected">&#128465;</button> 53 + <button class="btn-icon" id="btn-add-image" title="Add image">&#9635;</button> 54 + <button class="btn-icon" id="btn-delete-element" title="Delete selected">&#10005;</button> 55 55 </div> 56 56 <div class="slides-canvas-wrapper"> 57 57 <div class="slides-canvas" id="slide-canvas"></div> ··· 77 77 <div class="presenter-controls"> 78 78 <span class="presenter-timer" id="presenter-timer">00:00</span> 79 79 <span class="presenter-progress" id="presenter-progress">1 / 1</span> 80 - <button class="btn-icon" id="btn-presenter-prev" title="Previous">&#9664;</button> 81 - <button class="btn-icon" id="btn-presenter-next" title="Next">&#9654;</button> 80 + <button class="btn-icon" id="btn-presenter-prev" title="Previous">&#9665;</button> 81 + <button class="btn-icon" id="btn-presenter-next" title="Next">&#9655;</button> 82 82 <button class="btn-icon" id="btn-presenter-exit" title="Exit">&#10005;</button> 83 83 </div> 84 84 </div>
+20 -20
tests/canvas-engine-integrity.test.ts
··· 30 30 31 31 it('adding a slide at index 0 pushes the original slide to index 1', () => { 32 32 let deck = createDeck(); 33 - const originalId = deck.slides[0].id; 33 + const originalId = deck.slides[0]!.id; 34 34 35 35 deck = addSlide(deck, 0); 36 - expect(deck.slides[1].id).toBe(originalId); 36 + expect(deck.slides[1]!.id).toBe(originalId); 37 37 expect(slideCount(deck)).toBe(2); 38 38 }); 39 39 ··· 97 97 deck = addElement(deck, 'text', 10, 20, 200, 100, 'Hello'); 98 98 expect(elementCount(deck)).toBe(1); 99 99 100 - const el = currentSlide(deck).elements[0]; 100 + const el = currentSlide(deck).elements[0]!; 101 101 expect(el.type).toBe('text'); 102 102 expect(el.content).toBe('Hello'); 103 103 }); ··· 109 109 // Current slide is 0 110 110 deck = addElement(deck, 'text', 0, 0, 100, 50, 'On slide 0'); 111 111 112 - expect(deck.slides[0].elements).toHaveLength(1); 113 - expect(deck.slides[1].elements).toHaveLength(0); 112 + expect(deck.slides[0]!.elements).toHaveLength(1); 113 + expect(deck.slides[1]!.elements).toHaveLength(0); 114 114 }); 115 115 116 116 it('adding element to second slide only affects that slide', () => { ··· 120 120 121 121 deck = addElement(deck, 'image', 0, 0, 300, 200, 'image.png'); 122 122 123 - expect(deck.slides[0].elements).toHaveLength(0); 124 - expect(deck.slides[1].elements).toHaveLength(1); 123 + expect(deck.slides[0]!.elements).toHaveLength(0); 124 + expect(deck.slides[1]!.elements).toHaveLength(1); 125 125 }); 126 126 }); 127 127 ··· 138 138 deck = addElement(deck, 'image', 30, 40, 200, 150, 'image.png'); 139 139 140 140 // Slide 0 has text, Slide 1 has image 141 - expect(deck.slides[0].elements[0].content).toBe('Slide 0 text'); 142 - expect(deck.slides[1].elements[0].content).toBe('image.png'); 141 + expect(deck.slides[0]!.elements[0]!.content).toBe('Slide 0 text'); 142 + expect(deck.slides[1]!.elements[0]!.content).toBe('image.png'); 143 143 144 144 // Move slide 0 to position 1 145 145 deck = moveSlide(deck, 0, 1); 146 146 147 147 // Now slide at index 0 was the old slide 1 (image), and slide at index 1 is old slide 0 (text) 148 - expect(deck.slides[0].elements[0].content).toBe('image.png'); 149 - expect(deck.slides[1].elements[0].content).toBe('Slide 0 text'); 148 + expect(deck.slides[0]!.elements[0]!.content).toBe('image.png'); 149 + expect(deck.slides[1]!.elements[0]!.content).toBe('Slide 0 text'); 150 150 }); 151 151 152 152 it('reordering slides does not lose any elements', () => { ··· 185 185 186 186 deck = duplicateSlide(deck, 0); 187 187 expect(slideCount(deck)).toBe(2); 188 - expect(deck.slides[1].elements).toHaveLength(3); 188 + expect(deck.slides[1]!.elements).toHaveLength(3); 189 189 }); 190 190 191 191 it('duplicated elements have different IDs from originals', () => { ··· 195 195 196 196 deck = duplicateSlide(deck, 0); 197 197 198 - const originalIds = deck.slides[0].elements.map(e => e.id); 199 - const duplicateIds = deck.slides[1].elements.map(e => e.id); 198 + const originalIds = deck.slides[0]!.elements.map(e => e.id); 199 + const duplicateIds = deck.slides[1]!.elements.map(e => e.id); 200 200 201 201 for (const oid of originalIds) { 202 202 expect(duplicateIds).not.toContain(oid); ··· 209 209 210 210 deck = duplicateSlide(deck, 0); 211 211 212 - const original = deck.slides[0].elements[0]; 213 - const copy = deck.slides[1].elements[0]; 212 + const original = deck.slides[0]!.elements[0]!; 213 + const copy = deck.slides[1]!.elements[0]!; 214 214 215 215 expect(copy.content).toBe(original.content); 216 216 expect(copy.type).toBe(original.type); ··· 229 229 }; 230 230 231 231 deck = duplicateSlide(deck, 0); 232 - expect(deck.slides[1].notes).toBe('Speaker notes here'); 232 + expect(deck.slides[1]!.notes).toBe('Speaker notes here'); 233 233 }); 234 234 235 235 it('duplicated slide preserves background color', () => { ··· 237 237 deck = addSlide(deck, undefined, '#ff0000'); 238 238 239 239 deck = duplicateSlide(deck, 1); 240 - expect(deck.slides[2].background).toBe('#ff0000'); 240 + expect(deck.slides[2]!.background).toBe('#ff0000'); 241 241 }); 242 242 }); 243 243 ··· 271 271 deck = addElement(deck, 'text', i * 10, i * 5, 50, 30, `Element ${i}`); 272 272 } 273 273 274 - const firstId = currentSlide(deck).elements[0].id; 274 + const firstId = currentSlide(deck).elements[0]!.id; 275 275 deck = removeElement(deck, firstId); 276 276 expect(elementCount(deck)).toBe(99); 277 277 ··· 288 288 289 289 const elements = currentSlide(deck).elements; 290 290 for (let i = 0; i < elements.length; i++) { 291 - expect(elements[i].zIndex).toBe(i); 291 + expect(elements[i]!.zIndex).toBe(i); 292 292 } 293 293 }); 294 294 });
+2 -2
tests/crypto-edge-cases.test.ts
··· 161 161 // Flip a byte in the ciphertext body (past the 12-byte IV) 162 162 const tampered = new Uint8Array(ciphertext); 163 163 const midpoint = Math.floor((12 + ciphertext.length) / 2); 164 - tampered[midpoint] ^= 0x01; 164 + tampered[midpoint] = tampered[midpoint]! ^ 0x01; 165 165 166 166 await expect(decrypt(tampered, key)).rejects.toThrow(); 167 167 }); ··· 172 172 const ciphertext = await encrypt(plaintext, key); 173 173 174 174 const tampered = new Uint8Array(ciphertext); 175 - tampered[0] ^= 0xff; // flip first byte of IV 175 + tampered[0] = tampered[0]! ^ 0xff; // flip first byte of IV 176 176 177 177 await expect(decrypt(tampered, key)).rejects.toThrow(); 178 178 });
+52 -52
tests/form-builder-integrity.test.ts
··· 27 27 form = addQuestion(form, 'short_text', 'Name'); 28 28 expect(questionCount(form)).toBe(1); 29 29 30 - const qId = form.questions[0].id; 30 + const qId = form.questions[0]!.id; 31 31 form = removeQuestion(form, qId); 32 32 33 33 expect(questionCount(form)).toBe(0); ··· 49 49 const addedAt = form.updatedAt; 50 50 51 51 // Small delay to ensure timestamp difference 52 - form = removeQuestion(form, form.questions[0].id); 52 + form = removeQuestion(form, form.questions[0]!.id); 53 53 expect(form.updatedAt).toBeGreaterThanOrEqual(addedAt); 54 54 }); 55 55 }); ··· 65 65 form = addQuestion(form, 'short_text', 'B'); 66 66 form = addQuestion(form, 'short_text', 'C'); 67 67 68 - const cId = form.questions[2].id; 68 + const cId = form.questions[2]!.id; 69 69 form = moveQuestion(form, cId, 0); 70 70 71 71 expect(form.questions.map(q => q.label)).toEqual(['C', 'A', 'B']); ··· 77 77 form = addQuestion(form, 'short_text', 'B'); 78 78 form = addQuestion(form, 'short_text', 'C'); 79 79 80 - const aId = form.questions[0].id; 80 + const aId = form.questions[0]!.id; 81 81 form = moveQuestion(form, aId, 2); 82 82 83 83 expect(form.questions.map(q => q.label)).toEqual(['B', 'C', 'A']); ··· 89 89 form = addQuestion(form, 'short_text', 'B'); 90 90 form = addQuestion(form, 'short_text', 'C'); 91 91 92 - const bId = form.questions[1].id; 92 + const bId = form.questions[1]!.id; 93 93 form = moveQuestion(form, bId, 1); 94 94 95 95 expect(form.questions.map(q => q.label)).toEqual(['A', 'B', 'C']); ··· 100 100 form = addQuestion(form, 'short_text', 'A'); 101 101 form = addQuestion(form, 'short_text', 'B'); 102 102 103 - const aId = form.questions[0].id; 103 + const aId = form.questions[0]!.id; 104 104 form = moveQuestion(form, aId, 999); 105 105 106 106 // Should move A to the end 107 - expect(form.questions[1].label).toBe('A'); 107 + expect(form.questions[1]!.label).toBe('A'); 108 108 }); 109 109 110 110 it('moveQuestion with negative index clamps to 0', () => { ··· 112 112 form = addQuestion(form, 'short_text', 'A'); 113 113 form = addQuestion(form, 'short_text', 'B'); 114 114 115 - const bId = form.questions[1].id; 115 + const bId = form.questions[1]!.id; 116 116 form = moveQuestion(form, bId, -5); 117 117 118 118 // Should move B to the beginning 119 - expect(form.questions[0].label).toBe('B'); 119 + expect(form.questions[0]!.label).toBe('B'); 120 120 }); 121 121 }); 122 122 ··· 131 131 required: true, 132 132 description: 'Enter your email', 133 133 }); 134 - const qId = form.questions[0].id; 134 + const qId = form.questions[0]!.id; 135 135 136 136 form = updateQuestion(form, qId, { type: 'email' }); 137 137 138 - const q = form.questions[0]; 138 + const q = form.questions[0]!; 139 139 expect(q.type).toBe('email'); 140 140 expect(q.label).toBe('Email Address'); 141 141 expect(q.required).toBe(true); ··· 145 145 it('changing type preserves options on choice question', () => { 146 146 let form = createForm('Test'); 147 147 form = addQuestion(form, 'single_choice', 'Pick one'); 148 - const qId = form.questions[0].id; 148 + const qId = form.questions[0]!.id; 149 149 form = addOption(form, qId, 'Option A'); 150 150 form = addOption(form, qId, 'Option B'); 151 151 152 152 // Change type to dropdown — options should still be there 153 153 form = updateQuestion(form, qId, { type: 'dropdown' }); 154 154 155 - expect(form.questions[0].type).toBe('dropdown'); 156 - expect(form.questions[0].options).toHaveLength(2); 157 - expect(form.questions[0].options[0].label).toBe('Option A'); 155 + expect(form.questions[0]!.type).toBe('dropdown'); 156 + expect(form.questions[0]!.options).toHaveLength(2); 157 + expect(form.questions[0]!.options[0]!.label).toBe('Option A'); 158 158 }); 159 159 160 160 it('updating label does not change type or required', () => { 161 161 let form = createForm('Test'); 162 162 form = addQuestion(form, 'number', 'Age', { required: true }); 163 - const qId = form.questions[0].id; 163 + const qId = form.questions[0]!.id; 164 164 165 165 form = updateQuestion(form, qId, { label: 'Your Age' }); 166 166 167 - expect(form.questions[0].label).toBe('Your Age'); 168 - expect(form.questions[0].type).toBe('number'); 169 - expect(form.questions[0].required).toBe(true); 167 + expect(form.questions[0]!.label).toBe('Your Age'); 168 + expect(form.questions[0]!.type).toBe('number'); 169 + expect(form.questions[0]!.required).toBe(true); 170 170 }); 171 171 }); 172 172 ··· 178 178 it('addOption appends option to question', () => { 179 179 let form = createForm('Test'); 180 180 form = addQuestion(form, 'single_choice', 'Color'); 181 - const qId = form.questions[0].id; 181 + const qId = form.questions[0]!.id; 182 182 183 183 form = addOption(form, qId, 'Red'); 184 184 form = addOption(form, qId, 'Green'); 185 185 form = addOption(form, qId, 'Blue'); 186 186 187 - expect(form.questions[0].options).toHaveLength(3); 188 - expect(form.questions[0].options.map(o => o.label)).toEqual(['Red', 'Green', 'Blue']); 187 + expect(form.questions[0]!.options).toHaveLength(3); 188 + expect(form.questions[0]!.options.map(o => o.label)).toEqual(['Red', 'Green', 'Blue']); 189 189 }); 190 190 191 191 it('options have unique IDs', () => { 192 192 let form = createForm('Test'); 193 193 form = addQuestion(form, 'single_choice', 'Pick'); 194 - const qId = form.questions[0].id; 194 + const qId = form.questions[0]!.id; 195 195 196 196 form = addOption(form, qId, 'A'); 197 197 form = addOption(form, qId, 'B'); 198 198 form = addOption(form, qId, 'C'); 199 199 200 - const ids = form.questions[0].options.map(o => o.id); 200 + const ids = form.questions[0]!.options.map(o => o.id); 201 201 const uniqueIds = new Set(ids); 202 202 expect(uniqueIds.size).toBe(3); 203 203 }); ··· 205 205 it('updateQuestion can modify options array', () => { 206 206 let form = createForm('Test'); 207 207 form = addQuestion(form, 'single_choice', 'Pick'); 208 - const qId = form.questions[0].id; 208 + const qId = form.questions[0]!.id; 209 209 form = addOption(form, qId, 'A'); 210 210 form = addOption(form, qId, 'B'); 211 211 ··· 213 213 const newOptions = [createOption('X'), createOption('Y'), createOption('Z')]; 214 214 form = updateQuestion(form, qId, { options: newOptions }); 215 215 216 - expect(form.questions[0].options).toHaveLength(3); 217 - expect(form.questions[0].options.map(o => o.label)).toEqual(['X', 'Y', 'Z']); 216 + expect(form.questions[0]!.options).toHaveLength(3); 217 + expect(form.questions[0]!.options.map(o => o.label)).toEqual(['X', 'Y', 'Z']); 218 218 }); 219 219 220 220 it('removing question also removes its options', () => { 221 221 let form = createForm('Test'); 222 222 form = addQuestion(form, 'single_choice', 'Pick'); 223 - const qId = form.questions[0].id; 223 + const qId = form.questions[0]!.id; 224 224 form = addOption(form, qId, 'A'); 225 225 form = addOption(form, qId, 'B'); 226 226 ··· 244 244 expect(questionCount(form)).toBe(50); 245 245 246 246 for (let i = 0; i < 50; i++) { 247 - expect(form.questions[i].label).toBe(`Question ${i}`); 247 + expect(form.questions[i]!.label).toBe(`Question ${i}`); 248 248 } 249 249 }); 250 250 ··· 266 266 } 267 267 268 268 // Remove Q25 (middle) 269 - const q25Id = form.questions[25].id; 269 + const q25Id = form.questions[25]!.id; 270 270 form = removeQuestion(form, q25Id); 271 271 272 272 expect(questionCount(form)).toBe(49); 273 273 // Q24 should still be before Q26 274 - expect(form.questions[24].label).toBe('Q24'); 275 - expect(form.questions[25].label).toBe('Q26'); 274 + expect(form.questions[24]!.label).toBe('Q24'); 275 + expect(form.questions[25]!.label).toBe('Q26'); 276 276 }); 277 277 278 278 it('validation works on form with 50 required questions', () => { ··· 301 301 required: false, 302 302 description: 'Your email address', 303 303 }); 304 - const qId = form.questions[0].id; 304 + const qId = form.questions[0]!.id; 305 305 306 306 // Toggle to required 307 307 form = updateQuestion(form, qId, { required: true }); 308 - expect(form.questions[0].required).toBe(true); 309 - expect(form.questions[0].label).toBe('Email'); 310 - expect(form.questions[0].type).toBe('email'); 311 - expect(form.questions[0].description).toBe('Your email address'); 308 + expect(form.questions[0]!.required).toBe(true); 309 + expect(form.questions[0]!.label).toBe('Email'); 310 + expect(form.questions[0]!.type).toBe('email'); 311 + expect(form.questions[0]!.description).toBe('Your email address'); 312 312 313 313 // Toggle back to not required 314 314 form = updateQuestion(form, qId, { required: false }); 315 - expect(form.questions[0].required).toBe(false); 316 - expect(form.questions[0].label).toBe('Email'); 317 - expect(form.questions[0].type).toBe('email'); 318 - expect(form.questions[0].description).toBe('Your email address'); 315 + expect(form.questions[0]!.required).toBe(false); 316 + expect(form.questions[0]!.label).toBe('Email'); 317 + expect(form.questions[0]!.type).toBe('email'); 318 + expect(form.questions[0]!.description).toBe('Your email address'); 319 319 }); 320 320 321 321 it('toggling required does not affect options on choice question', () => { 322 322 let form = createForm('Test'); 323 323 form = addQuestion(form, 'single_choice', 'Pick', { required: false }); 324 - const qId = form.questions[0].id; 324 + const qId = form.questions[0]!.id; 325 325 form = addOption(form, qId, 'A'); 326 326 form = addOption(form, qId, 'B'); 327 327 328 328 form = updateQuestion(form, qId, { required: true }); 329 329 330 - expect(form.questions[0].required).toBe(true); 331 - expect(form.questions[0].options).toHaveLength(2); 332 - expect(form.questions[0].options[0].label).toBe('A'); 333 - expect(form.questions[0].options[1].label).toBe('B'); 330 + expect(form.questions[0]!.required).toBe(true); 331 + expect(form.questions[0]!.options).toHaveLength(2); 332 + expect(form.questions[0]!.options[0]!.label).toBe('A'); 333 + expect(form.questions[0]!.options[1]!.label).toBe('B'); 334 334 }); 335 335 336 336 it('requiredCount updates correctly after toggle', () => { ··· 340 340 341 341 expect(requiredCount(form)).toBe(1); 342 342 343 - form = updateQuestion(form, form.questions[0].id, { required: true }); 343 + form = updateQuestion(form, form.questions[0]!.id, { required: true }); 344 344 expect(requiredCount(form)).toBe(2); 345 345 346 - form = updateQuestion(form, form.questions[1].id, { required: false }); 346 + form = updateQuestion(form, form.questions[1]!.id, { required: false }); 347 347 expect(requiredCount(form)).toBe(1); 348 348 }); 349 349 }); ··· 458 458 459 459 const copy = duplicateForm(form); 460 460 expect(copy.questions).toHaveLength(2); 461 - expect(copy.questions[0].label).toBe('Q1'); 462 - expect(copy.questions[0].required).toBe(true); 463 - expect(copy.questions[1].label).toBe('Q2'); 464 - expect(copy.questions[1].description).toBe('desc'); 461 + expect(copy.questions[0]!.label).toBe('Q1'); 462 + expect(copy.questions[0]!.required).toBe(true); 463 + expect(copy.questions[1]!.label).toBe('Q2'); 464 + expect(copy.questions[1]!.description).toBe('desc'); 465 465 }); 466 466 467 467 it('modifying copy does not affect original', () => {
+4 -3
tests/formulas-security.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 2 import { evaluate, extractRefs } from '../src/sheets/formulas.js'; 3 + import type { CellValue } from '../src/sheets/types.js'; 3 4 4 5 // Helper: evaluate with a simple cell map 5 - function evalWith(formula: string, cells: Record<string, unknown> = {}) { 6 + function evalWith(formula: string, cells: Record<string, CellValue | ''> = {}) { 6 7 return evaluate(formula, (ref) => cells[ref] ?? ''); 7 8 } 8 9 9 10 // Helper: evaluate with cross-sheet support 10 11 function evalCrossSheet( 11 12 formula: string, 12 - sheetsData: Record<string, Record<string, unknown>> = {}, 13 + sheetsData: Record<string, Record<string, CellValue | ''>> = {}, 13 14 currentSheet = 'Sheet1', 14 15 ) { 15 16 const resolver = { ··· 137 138 138 139 describe('Formula — CONCATENATE with many arguments', () => { 139 140 it('CONCATENATE with 100 arguments works correctly', () => { 140 - const cells: Record<string, unknown> = {}; 141 + const cells: Record<string, CellValue | ''> = {}; 141 142 const refs: string[] = []; 142 143 for (let i = 1; i <= 100; i++) { 143 144 cells[`A${i}`] = `x`;
+10 -9
tests/recalc-edge-cases.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 2 import { RecalcEngine, isVolatile } from '../src/sheets/recalc.js'; 3 3 import { colToLetter } from '../src/sheets/formulas.js'; 4 + import type { CellStore, RecalcCellData, CellValue } from '../src/sheets/types.js'; 4 5 5 6 // --- Helpers --- 6 7 7 - function makeCellStore(data: Record<string, { v: unknown; f: string }>) { 8 - const store = new Map<string, { v: unknown; f: string }>(); 8 + function makeCellStore(data: Record<string, { v: CellValue | ''; f: string }>): CellStore { 9 + const store = new Map<string, RecalcCellData>(); 9 10 for (const [id, cell] of Object.entries(data)) { 10 11 store.set(id, { ...cell }); 11 12 } 12 13 return { 13 14 get(id: string) { return store.get(id) || null; }, 14 - set(id: string, cell: { v: unknown; f: string }) { store.set(id, { ...cell }); }, 15 + set(id: string, cell: RecalcCellData) { store.set(id, { ...cell }); }, 15 16 has(id: string) { return store.has(id); }, 16 17 entries() { return store.entries(); }, 17 18 getAllFormulaCells() { 18 - const result: Array<[string, { v: unknown; f: string }]> = []; 19 + const result: Array<[string, RecalcCellData]> = []; 19 20 for (const [id, cell] of store.entries()) { 20 21 if (cell.f) result.push([id, cell]); 21 22 } ··· 30 31 31 32 describe('RecalcEngine — deep dependency chain (100 cells)', () => { 32 33 it('handles A1->A2->...->A100 chain without stack overflow', () => { 33 - const data: Record<string, { v: unknown; f: string }> = { 34 + const data: Record<string, { v: CellValue | ''; f: string }> = { 34 35 A1: { v: 1, f: '' }, 35 36 }; 36 37 for (let i = 2; i <= 100; i++) { ··· 49 50 50 51 it('evaluation order is strictly upstream-first across 100 cells', () => { 51 52 const evalOrder: string[] = []; 52 - const data: Record<string, { v: unknown; f: string }> = { 53 + const data: Record<string, { v: CellValue | ''; f: string }> = { 53 54 A1: { v: 1, f: '' }, 54 55 }; 55 56 for (let i = 2; i <= 100; i++) { ··· 66 67 67 68 // Every cell must be evaluated before its successor 68 69 for (let i = 0; i < evalOrder.length - 1; i++) { 69 - const currentRow = parseInt(evalOrder[i].replace('A', '')); 70 - const nextRow = parseInt(evalOrder[i + 1].replace('A', '')); 70 + const currentRow = parseInt(evalOrder[i]!.replace('A', '')); 71 + const nextRow = parseInt(evalOrder[i + 1]!.replace('A', '')); 71 72 expect(currentRow).toBeLessThan(nextRow); 72 73 } 73 74 expect(evalOrder).toHaveLength(99); // A2 through A100 74 75 }); 75 76 76 77 it('all 99 formula cells are marked as changed after root edit', () => { 77 - const data: Record<string, { v: unknown; f: string }> = { 78 + const data: Record<string, { v: CellValue | ''; f: string }> = { 78 79 A1: { v: 1, f: '' }, 79 80 }; 80 81 for (let i = 2; i <= 100; i++) {
+26 -26
tests/whiteboard-integrity.test.ts
··· 46 46 expect(shapes).toHaveLength(100); 47 47 48 48 for (let i = 0; i < 100; i++) { 49 - expect(shapes[i].kind).toBe('ellipse'); 50 - expect(shapes[i].x).toBe(i * 5); 51 - expect(shapes[i].y).toBe(i * 3); 49 + expect(shapes[i]!.kind).toBe('ellipse'); 50 + expect(shapes[i]!.x).toBe(i * 5); 51 + expect(shapes[i]!.y).toBe(i * 3); 52 52 } 53 53 }); 54 54 }); ··· 62 62 let wb = noSnapWhiteboard(); 63 63 wb = addShape(wb, 'rectangle', 0, 0); 64 64 wb = addShape(wb, 'rectangle', 200, 0); 65 - const [id1, id2] = [...wb.shapes.keys()]; 65 + const [id1, id2] = [...wb.shapes.keys()] as [string, string]; 66 66 67 67 wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 68 68 expect(wb.arrows.size).toBe(1); ··· 76 76 let wb = noSnapWhiteboard(); 77 77 wb = addShape(wb, 'rectangle', 0, 0); 78 78 wb = addShape(wb, 'rectangle', 200, 0); 79 - const [id1, id2] = [...wb.shapes.keys()]; 79 + const [id1, id2] = [...wb.shapes.keys()] as [string, string]; 80 80 81 81 wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 82 82 wb = removeShape(wb, id2); ··· 89 89 wb = addShape(wb, 'rectangle', 0, 0); 90 90 wb = addShape(wb, 'rectangle', 200, 0); 91 91 wb = addShape(wb, 'rectangle', 400, 0); 92 - const [id1, id2, id3] = [...wb.shapes.keys()]; 92 + const [id1, id2, id3] = [...wb.shapes.keys()] as [string, string, string]; 93 93 94 94 // id2 connects to both id1 and id3 95 95 wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); ··· 105 105 let wb = noSnapWhiteboard(); 106 106 wb = addShape(wb, 'rectangle', 0, 0); 107 107 wb = addShape(wb, 'rectangle', 200, 0); 108 - const [id1, id2] = [...wb.shapes.keys()]; 108 + const [id1, id2] = [...wb.shapes.keys()] as [string, string]; 109 109 110 110 // Arrow from shape to free point 111 111 wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { x: 500, y: 500 }); ··· 126 126 it('moveShape preserves kind, width, height, rotation, label, style, opacity', () => { 127 127 let wb = noSnapWhiteboard(); 128 128 wb = addShape(wb, 'diamond', 0, 0, 200, 150, 'Test Label'); 129 - const id = [...wb.shapes.keys()][0]; 129 + const id = [...wb.shapes.keys()][0]!; 130 130 131 131 // Set some style 132 132 wb = setShapeStyle(wb, [id], { fill: 'red', stroke: 'blue' }); ··· 156 156 it('resizeShape clamps width to minimum 10', () => { 157 157 let wb = noSnapWhiteboard(); 158 158 wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 159 - const id = [...wb.shapes.keys()][0]; 159 + const id = [...wb.shapes.keys()][0]!; 160 160 161 161 wb = resizeShape(wb, id, -50, 100); 162 162 expect(wb.shapes.get(id)!.width).toBe(10); ··· 165 165 it('resizeShape clamps height to minimum 10', () => { 166 166 let wb = noSnapWhiteboard(); 167 167 wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 168 - const id = [...wb.shapes.keys()][0]; 168 + const id = [...wb.shapes.keys()][0]!; 169 169 170 170 wb = resizeShape(wb, id, 100, -50); 171 171 expect(wb.shapes.get(id)!.height).toBe(10); ··· 174 174 it('resizeShape clamps both dimensions to minimum 10', () => { 175 175 let wb = noSnapWhiteboard(); 176 176 wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 177 - const id = [...wb.shapes.keys()][0]; 177 + const id = [...wb.shapes.keys()][0]!; 178 178 179 179 wb = resizeShape(wb, id, -100, -200); 180 180 expect(wb.shapes.get(id)!.width).toBe(10); ··· 184 184 it('resizeShape to exactly 0 clamps to 10', () => { 185 185 let wb = noSnapWhiteboard(); 186 186 wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 187 - const id = [...wb.shapes.keys()][0]; 187 + const id = [...wb.shapes.keys()][0]!; 188 188 189 189 wb = resizeShape(wb, id, 0, 0); 190 190 expect(wb.shapes.get(id)!.width).toBe(10); ··· 201 201 let wb = noSnapWhiteboard(); 202 202 wb = addShape(wb, 'rectangle', 0, 0); 203 203 wb = addShape(wb, 'rectangle', 200, 0); 204 - const [id1, id2] = [...wb.shapes.keys()]; 204 + const [id1, id2] = [...wb.shapes.keys()] as [string, string]; 205 205 206 206 wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 207 207 wb = addArrow(wb, { shapeId: id1, anchor: 'bottom' }, { shapeId: id2, anchor: 'top' }); ··· 219 219 let wb = noSnapWhiteboard(); 220 220 wb = addShape(wb, 'rectangle', 0, 0); 221 221 wb = addShape(wb, 'rectangle', 200, 0); 222 - const [id1, id2] = [...wb.shapes.keys()]; 222 + const [id1, id2] = [...wb.shapes.keys()] as [string, string]; 223 223 224 224 wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 225 225 wb = addArrow(wb, { shapeId: id1, anchor: 'bottom' }, { shapeId: id2, anchor: 'top' }); 226 226 227 227 const arrowIds = [...wb.arrows.keys()]; 228 - wb = removeArrow(wb, arrowIds[0]); 228 + wb = removeArrow(wb, arrowIds[0]!); 229 229 230 230 expect(wb.arrows.size).toBe(1); 231 231 expect(arrowsForShape(wb, id1)).toHaveLength(1); ··· 236 236 let wb = noSnapWhiteboard(); 237 237 wb = addShape(wb, 'rectangle', 0, 0); 238 238 wb = addShape(wb, 'rectangle', 200, 0); 239 - const [id1, id2] = [...wb.shapes.keys()]; 239 + const [id1, id2] = [...wb.shapes.keys()] as [string, string]; 240 240 241 241 wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 242 242 wb = addArrow(wb, { shapeId: id1, anchor: 'top' }, { shapeId: id2, anchor: 'bottom' }); ··· 257 257 it('setShapeStyle merges new properties without removing existing ones', () => { 258 258 let wb = noSnapWhiteboard(); 259 259 wb = addShape(wb, 'rectangle', 0, 0); 260 - const id = [...wb.shapes.keys()][0]; 260 + const id = [...wb.shapes.keys()][0]!; 261 261 262 262 // First set: add fill and stroke 263 263 wb = setShapeStyle(wb, [id], { fill: 'red', stroke: 'blue' }); ··· 274 274 it('setShapeStyle can override an existing property', () => { 275 275 let wb = noSnapWhiteboard(); 276 276 wb = addShape(wb, 'rectangle', 0, 0); 277 - const id = [...wb.shapes.keys()][0]; 277 + const id = [...wb.shapes.keys()][0]!; 278 278 279 279 wb = setShapeStyle(wb, [id], { fill: 'red' }); 280 280 wb = setShapeStyle(wb, [id], { fill: 'green' }); ··· 286 286 let wb = noSnapWhiteboard(); 287 287 wb = addShape(wb, 'rectangle', 0, 0); 288 288 wb = addShape(wb, 'ellipse', 100, 0); 289 - const ids = [...wb.shapes.keys()]; 289 + const ids = [...wb.shapes.keys()] as string[]; 290 290 291 291 wb = setShapeStyle(wb, ids, { fill: 'yellow' }); 292 292 ··· 304 304 it('shape created without label has empty string label', () => { 305 305 let wb = noSnapWhiteboard(); 306 306 wb = addShape(wb, 'rectangle', 0, 0); 307 - const shape = [...wb.shapes.values()][0]; 307 + const shape = [...wb.shapes.values()][0]!; 308 308 expect(shape.label).toBe(''); 309 309 }); 310 310 311 311 it('shape created with empty string label has empty string', () => { 312 312 let wb = noSnapWhiteboard(); 313 313 wb = addShape(wb, 'rectangle', 0, 0, 120, 80, ''); 314 - const shape = [...wb.shapes.values()][0]; 314 + const shape = [...wb.shapes.values()][0]!; 315 315 expect(shape.label).toBe(''); 316 316 }); 317 317 318 318 it('setShapeLabel to empty string clears the label', () => { 319 319 let wb = noSnapWhiteboard(); 320 320 wb = addShape(wb, 'rectangle', 0, 0, 120, 80, 'Initial'); 321 - const id = [...wb.shapes.keys()][0]; 321 + const id = [...wb.shapes.keys()][0]!; 322 322 323 323 wb = setShapeLabel(wb, id, ''); 324 324 expect(wb.shapes.get(id)!.label).toBe(''); ··· 327 327 it('setShapeLabel preserves other shape properties', () => { 328 328 let wb = noSnapWhiteboard(); 329 329 wb = addShape(wb, 'diamond', 50, 60, 200, 150); 330 - const id = [...wb.shapes.keys()][0]; 330 + const id = [...wb.shapes.keys()][0]!; 331 331 const before = wb.shapes.get(id)!; 332 332 333 333 wb = setShapeLabel(wb, id, 'New Label'); ··· 373 373 374 374 // Add arrows between consecutive shapes 375 375 for (let i = 0; i < 49; i++) { 376 - wb = addArrow(wb, { shapeId: ids[i], anchor: 'right' }, { shapeId: ids[i + 1], anchor: 'left' }); 376 + wb = addArrow(wb, { shapeId: ids[i]!, anchor: 'right' }, { shapeId: ids[i + 1]!, anchor: 'left' }); 377 377 } 378 378 expect(wb.arrows.size).toBe(49); 379 379 ··· 398 398 // Remove 5 399 399 const ids = [...wb.shapes.keys()]; 400 400 for (let i = 0; i < 5; i++) { 401 - wb = removeShape(wb, ids[i]); 401 + wb = removeShape(wb, ids[i]!); 402 402 } 403 403 expect(wb.shapes.size).toBe(5); 404 404 ··· 422 422 let wb = noSnapWhiteboard(); 423 423 wb = addShape(wb, 'rectangle', 0, 0); 424 424 wb = addShape(wb, 'rectangle', 200, 0); 425 - const [id1, id2] = [...wb.shapes.keys()]; 425 + const [id1, id2] = [...wb.shapes.keys()] as [string, string]; 426 426 wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 427 427 428 428 wb = removeShape(wb, id1);