Full document, spreadsheet, slideshow, and diagram tooling
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
8export type SplitDirection = 'horizontal' | 'vertical';
9
10export interface Pane {
11 id: string;
12 docId: string;
13 docType: 'doc' | 'sheet';
14 /** Percentage of total space (0-100) */
15 size: number;
16}
17
18export interface SplitState {
19 direction: SplitDirection;
20 panes: Pane[];
21 focusedPaneId: string | null;
22}
23
24let _paneCounter = 0;
25
26/**
27 * Generate a unique pane ID.
28 */
29export function generatePaneId(): string {
30 return `pane-${++_paneCounter}`;
31}
32
33/**
34 * Reset pane counter (for testing).
35 */
36export function resetPaneCounter(): void {
37 _paneCounter = 0;
38}
39
40/**
41 * Create an initial single-pane state.
42 */
43export 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 */
59export 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 */
90export 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 */
119export 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 */
131export 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 */
142export 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 */
171export 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 */
186export 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 */
193export function isSplitActive(state: SplitState): boolean {
194 return state.panes.length > 1;
195}
196
197/**
198 * Compute CSS grid template from pane sizes.
199 */
200export function computeGridTemplate(state: SplitState): string {
201 return state.panes.map(p => `${p.size}%`).join(' ');
202}