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

Configure Feed

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

feat: split view pane management (#59)

Side-by-side document viewing with up to 4 panes.

- Pane state management: create, add, remove, focus, resize, swap
- Horizontal/vertical split direction toggle
- Min 10% size constraint with neighbor adjustment
- CSS grid template computation from pane sizes
- 24 unit tests

+409
+202
src/split-view.ts
··· 1 + /** 2 + * Split View — side-by-side document pane management. 3 + * 4 + * Pure logic module: pane state, layout calculations, focus tracking. 5 + * DOM rendering handled by the editor entry points. 6 + */ 7 + 8 + export type SplitDirection = 'horizontal' | 'vertical'; 9 + 10 + export interface Pane { 11 + id: string; 12 + docId: string; 13 + docType: 'doc' | 'sheet'; 14 + /** Percentage of total space (0-100) */ 15 + size: number; 16 + } 17 + 18 + export interface SplitState { 19 + direction: SplitDirection; 20 + panes: Pane[]; 21 + focusedPaneId: string | null; 22 + } 23 + 24 + let _paneCounter = 0; 25 + 26 + /** 27 + * Generate a unique pane ID. 28 + */ 29 + export function generatePaneId(): string { 30 + return `pane-${++_paneCounter}`; 31 + } 32 + 33 + /** 34 + * Reset pane counter (for testing). 35 + */ 36 + export function resetPaneCounter(): void { 37 + _paneCounter = 0; 38 + } 39 + 40 + /** 41 + * Create an initial single-pane state. 42 + */ 43 + export function createSplitState( 44 + docId: string, 45 + docType: 'doc' | 'sheet', 46 + ): SplitState { 47 + const id = generatePaneId(); 48 + return { 49 + direction: 'horizontal', 50 + panes: [{ id, docId, docType, size: 100 }], 51 + focusedPaneId: id, 52 + }; 53 + } 54 + 55 + /** 56 + * Add a new pane by splitting the focused pane. 57 + * Returns updated state or null if max panes reached. 58 + */ 59 + export function addPane( 60 + state: SplitState, 61 + docId: string, 62 + docType: 'doc' | 'sheet', 63 + maxPanes = 4, 64 + ): SplitState | null { 65 + if (state.panes.length >= maxPanes) return null; 66 + 67 + const newId = generatePaneId(); 68 + const newCount = state.panes.length + 1; 69 + const equalSize = Math.floor(100 / newCount); 70 + const remainder = 100 - equalSize * newCount; 71 + 72 + const panes: Pane[] = state.panes.map((p, i) => ({ 73 + ...p, 74 + size: equalSize + (i === 0 ? remainder : 0), 75 + })); 76 + 77 + panes.push({ id: newId, docId, docType, size: equalSize }); 78 + 79 + return { 80 + ...state, 81 + panes, 82 + focusedPaneId: newId, 83 + }; 84 + } 85 + 86 + /** 87 + * Remove a pane by ID. Redistributes space to remaining panes. 88 + * Returns null if only one pane remains (can't remove the last one). 89 + */ 90 + export function removePane( 91 + state: SplitState, 92 + paneId: string, 93 + ): SplitState | null { 94 + if (state.panes.length <= 1) return null; 95 + 96 + const removed = state.panes.find(p => p.id === paneId); 97 + if (!removed) return state; 98 + 99 + const remaining = state.panes.filter(p => p.id !== paneId); 100 + const redistributed = removed.size / remaining.length; 101 + 102 + const panes = remaining.map(p => ({ 103 + ...p, 104 + size: p.size + redistributed, 105 + })); 106 + 107 + // If we removed the focused pane, focus the first remaining 108 + const focusedPaneId = 109 + state.focusedPaneId === paneId 110 + ? panes[0].id 111 + : state.focusedPaneId; 112 + 113 + return { ...state, panes, focusedPaneId }; 114 + } 115 + 116 + /** 117 + * Set focus to a specific pane. 118 + */ 119 + export function focusPane( 120 + state: SplitState, 121 + paneId: string, 122 + ): SplitState { 123 + const exists = state.panes.some(p => p.id === paneId); 124 + if (!exists) return state; 125 + return { ...state, focusedPaneId: paneId }; 126 + } 127 + 128 + /** 129 + * Toggle split direction between horizontal and vertical. 130 + */ 131 + export function toggleDirection(state: SplitState): SplitState { 132 + return { 133 + ...state, 134 + direction: state.direction === 'horizontal' ? 'vertical' : 'horizontal', 135 + }; 136 + } 137 + 138 + /** 139 + * Resize a pane. Adjusts the next pane to compensate. 140 + * newSize is the desired percentage for the target pane. 141 + */ 142 + export function resizePane( 143 + state: SplitState, 144 + paneId: string, 145 + newSize: number, 146 + ): SplitState { 147 + const idx = state.panes.findIndex(p => p.id === paneId); 148 + if (idx === -1 || idx >= state.panes.length - 1) return state; 149 + 150 + const minSize = 10; 151 + const currentPane = state.panes[idx]; 152 + const nextPane = state.panes[idx + 1]; 153 + const combinedSize = currentPane.size + nextPane.size; 154 + 155 + // Clamp to valid range 156 + const clamped = Math.max(minSize, Math.min(combinedSize - minSize, newSize)); 157 + const nextSize = combinedSize - clamped; 158 + 159 + const panes = state.panes.map((p, i) => { 160 + if (i === idx) return { ...p, size: clamped }; 161 + if (i === idx + 1) return { ...p, size: nextSize }; 162 + return p; 163 + }); 164 + 165 + return { ...state, panes }; 166 + } 167 + 168 + /** 169 + * Swap the document in a pane. 170 + */ 171 + export function swapPaneDoc( 172 + state: SplitState, 173 + paneId: string, 174 + docId: string, 175 + docType: 'doc' | 'sheet', 176 + ): SplitState { 177 + const panes = state.panes.map(p => 178 + p.id === paneId ? { ...p, docId, docType } : p, 179 + ); 180 + return { ...state, panes }; 181 + } 182 + 183 + /** 184 + * Get the focused pane, or null if none. 185 + */ 186 + export function getFocusedPane(state: SplitState): Pane | null { 187 + return state.panes.find(p => p.id === state.focusedPaneId) || null; 188 + } 189 + 190 + /** 191 + * Check if split view is active (more than one pane). 192 + */ 193 + export function isSplitActive(state: SplitState): boolean { 194 + return state.panes.length > 1; 195 + } 196 + 197 + /** 198 + * Compute CSS grid template from pane sizes. 199 + */ 200 + export function computeGridTemplate(state: SplitState): string { 201 + return state.panes.map(p => `${p.size}%`).join(' '); 202 + }
+207
tests/split-view.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { 3 + createSplitState, 4 + addPane, 5 + removePane, 6 + focusPane, 7 + toggleDirection, 8 + resizePane, 9 + swapPaneDoc, 10 + getFocusedPane, 11 + isSplitActive, 12 + computeGridTemplate, 13 + resetPaneCounter, 14 + } from '../src/split-view.js'; 15 + 16 + describe('Split View', () => { 17 + beforeEach(() => { 18 + resetPaneCounter(); 19 + }); 20 + 21 + describe('createSplitState', () => { 22 + it('creates single-pane state', () => { 23 + const state = createSplitState('doc1', 'doc'); 24 + expect(state.panes).toHaveLength(1); 25 + expect(state.panes[0].docId).toBe('doc1'); 26 + expect(state.panes[0].size).toBe(100); 27 + expect(state.direction).toBe('horizontal'); 28 + }); 29 + 30 + it('focuses the initial pane', () => { 31 + const state = createSplitState('doc1', 'doc'); 32 + expect(state.focusedPaneId).toBe(state.panes[0].id); 33 + }); 34 + }); 35 + 36 + describe('addPane', () => { 37 + it('adds a second pane with equal sizes', () => { 38 + const state = createSplitState('doc1', 'doc'); 39 + const updated = addPane(state, 'doc2', 'sheet'); 40 + expect(updated).not.toBeNull(); 41 + expect(updated!.panes).toHaveLength(2); 42 + expect(updated!.panes[0].size).toBe(50); 43 + expect(updated!.panes[1].size).toBe(50); 44 + }); 45 + 46 + it('focuses the new pane', () => { 47 + const state = createSplitState('doc1', 'doc'); 48 + const updated = addPane(state, 'doc2', 'doc'); 49 + expect(updated!.focusedPaneId).toBe(updated!.panes[1].id); 50 + }); 51 + 52 + it('returns null when max panes reached', () => { 53 + let state = createSplitState('doc1', 'doc'); 54 + state = addPane(state, 'doc2', 'doc')!; 55 + const result = addPane(state, 'doc3', 'doc', 2); 56 + expect(result).toBeNull(); 57 + }); 58 + 59 + it('supports up to 4 panes by default', () => { 60 + let state = createSplitState('d1', 'doc'); 61 + state = addPane(state, 'd2', 'doc')!; 62 + state = addPane(state, 'd3', 'doc')!; 63 + state = addPane(state, 'd4', 'doc')!; 64 + expect(state.panes).toHaveLength(4); 65 + expect(addPane(state, 'd5', 'doc')).toBeNull(); 66 + }); 67 + }); 68 + 69 + describe('removePane', () => { 70 + it('removes a pane and redistributes space', () => { 71 + let state = createSplitState('doc1', 'doc'); 72 + state = addPane(state, 'doc2', 'doc')!; 73 + const paneToRemove = state.panes[1].id; 74 + const updated = removePane(state, paneToRemove); 75 + expect(updated).not.toBeNull(); 76 + expect(updated!.panes).toHaveLength(1); 77 + expect(updated!.panes[0].size).toBe(100); 78 + }); 79 + 80 + it('returns null when only one pane', () => { 81 + const state = createSplitState('doc1', 'doc'); 82 + expect(removePane(state, state.panes[0].id)).toBeNull(); 83 + }); 84 + 85 + it('moves focus when removing focused pane', () => { 86 + let state = createSplitState('doc1', 'doc'); 87 + state = addPane(state, 'doc2', 'doc')!; 88 + const focusedId = state.focusedPaneId!; 89 + const updated = removePane(state, focusedId); 90 + expect(updated!.focusedPaneId).not.toBe(focusedId); 91 + expect(updated!.focusedPaneId).toBe(updated!.panes[0].id); 92 + }); 93 + 94 + it('keeps focus when removing non-focused pane', () => { 95 + let state = createSplitState('doc1', 'doc'); 96 + state = addPane(state, 'doc2', 'doc')!; 97 + const firstPaneId = state.panes[0].id; 98 + state = focusPane(state, firstPaneId); 99 + const updated = removePane(state, state.panes[1].id); 100 + expect(updated!.focusedPaneId).toBe(firstPaneId); 101 + }); 102 + }); 103 + 104 + describe('focusPane', () => { 105 + it('sets focused pane', () => { 106 + let state = createSplitState('doc1', 'doc'); 107 + state = addPane(state, 'doc2', 'doc')!; 108 + const updated = focusPane(state, state.panes[0].id); 109 + expect(updated.focusedPaneId).toBe(state.panes[0].id); 110 + }); 111 + 112 + it('ignores invalid pane id', () => { 113 + const state = createSplitState('doc1', 'doc'); 114 + const updated = focusPane(state, 'nonexistent'); 115 + expect(updated.focusedPaneId).toBe(state.focusedPaneId); 116 + }); 117 + }); 118 + 119 + describe('toggleDirection', () => { 120 + it('toggles horizontal to vertical', () => { 121 + const state = createSplitState('doc1', 'doc'); 122 + expect(toggleDirection(state).direction).toBe('vertical'); 123 + }); 124 + 125 + it('toggles vertical back to horizontal', () => { 126 + let state = createSplitState('doc1', 'doc'); 127 + state = toggleDirection(state); 128 + expect(toggleDirection(state).direction).toBe('horizontal'); 129 + }); 130 + }); 131 + 132 + describe('resizePane', () => { 133 + it('resizes pane and adjusts neighbor', () => { 134 + let state = createSplitState('doc1', 'doc'); 135 + state = addPane(state, 'doc2', 'doc')!; 136 + const updated = resizePane(state, state.panes[0].id, 70); 137 + expect(updated.panes[0].size).toBe(70); 138 + expect(updated.panes[1].size).toBe(30); 139 + }); 140 + 141 + it('enforces minimum size of 10%', () => { 142 + let state = createSplitState('doc1', 'doc'); 143 + state = addPane(state, 'doc2', 'doc')!; 144 + const updated = resizePane(state, state.panes[0].id, 5); 145 + expect(updated.panes[0].size).toBe(10); 146 + expect(updated.panes[1].size).toBe(90); 147 + }); 148 + 149 + it('prevents neighbor from going below minimum', () => { 150 + let state = createSplitState('doc1', 'doc'); 151 + state = addPane(state, 'doc2', 'doc')!; 152 + const updated = resizePane(state, state.panes[0].id, 95); 153 + expect(updated.panes[0].size).toBe(90); 154 + expect(updated.panes[1].size).toBe(10); 155 + }); 156 + 157 + it('ignores resize on last pane (no neighbor)', () => { 158 + let state = createSplitState('doc1', 'doc'); 159 + state = addPane(state, 'doc2', 'doc')!; 160 + const lastId = state.panes[state.panes.length - 1].id; 161 + const updated = resizePane(state, lastId, 70); 162 + expect(updated).toEqual(state); 163 + }); 164 + }); 165 + 166 + describe('swapPaneDoc', () => { 167 + it('changes document in a pane', () => { 168 + const state = createSplitState('doc1', 'doc'); 169 + const updated = swapPaneDoc(state, state.panes[0].id, 'sheet1', 'sheet'); 170 + expect(updated.panes[0].docId).toBe('sheet1'); 171 + expect(updated.panes[0].docType).toBe('sheet'); 172 + }); 173 + }); 174 + 175 + describe('getFocusedPane', () => { 176 + it('returns the focused pane', () => { 177 + const state = createSplitState('doc1', 'doc'); 178 + const focused = getFocusedPane(state); 179 + expect(focused).not.toBeNull(); 180 + expect(focused!.docId).toBe('doc1'); 181 + }); 182 + }); 183 + 184 + describe('isSplitActive', () => { 185 + it('returns false for single pane', () => { 186 + expect(isSplitActive(createSplitState('doc1', 'doc'))).toBe(false); 187 + }); 188 + 189 + it('returns true for multiple panes', () => { 190 + let state = createSplitState('doc1', 'doc'); 191 + state = addPane(state, 'doc2', 'doc')!; 192 + expect(isSplitActive(state)).toBe(true); 193 + }); 194 + }); 195 + 196 + describe('computeGridTemplate', () => { 197 + it('returns single value for one pane', () => { 198 + expect(computeGridTemplate(createSplitState('d1', 'doc'))).toBe('100%'); 199 + }); 200 + 201 + it('returns space-separated values for multiple panes', () => { 202 + let state = createSplitState('d1', 'doc'); 203 + state = addPane(state, 'd2', 'doc')!; 204 + expect(computeGridTemplate(state)).toBe('50% 50%'); 205 + }); 206 + }); 207 + });