@andrewcyuan/focusgrid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/core/index.cjs +812 -0
- package/dist/core/index.d.cts +184 -0
- package/dist/core/index.d.ts +184 -0
- package/dist/core/index.js +807 -0
- package/dist/dom/index.cjs +318 -0
- package/dist/dom/index.d.cts +177 -0
- package/dist/dom/index.d.ts +177 -0
- package/dist/dom/index.js +315 -0
- package/dist/react/index.cjs +1336 -0
- package/dist/react/index.d.cts +294 -0
- package/dist/react/index.d.ts +294 -0
- package/dist/react/index.js +1328 -0
- package/dist/react/styles.css +42 -0
- package/package.json +66 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region core/src/utils/ids.ts
|
|
3
|
+
let fallbackId = 0;
|
|
4
|
+
function createId(prefix = "node") {
|
|
5
|
+
const cryptoLike = globalThis.crypto;
|
|
6
|
+
if (cryptoLike && "randomUUID" in cryptoLike) return `${prefix}-${cryptoLike.randomUUID()}`;
|
|
7
|
+
fallbackId += 1;
|
|
8
|
+
return `${prefix}-${fallbackId}`;
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region core/src/layout/geometry.ts
|
|
12
|
+
function computeLayout(state) {
|
|
13
|
+
const geometry = {
|
|
14
|
+
panes: [],
|
|
15
|
+
handles: [],
|
|
16
|
+
rectByNodeId: /* @__PURE__ */ new Map()
|
|
17
|
+
};
|
|
18
|
+
const root = computeNode(state.root, {
|
|
19
|
+
x: 0,
|
|
20
|
+
y: 0,
|
|
21
|
+
width: Math.max(0, state.container.width),
|
|
22
|
+
height: Math.max(0, state.container.height)
|
|
23
|
+
}, geometry, state.activePaneId);
|
|
24
|
+
return {
|
|
25
|
+
...geometry,
|
|
26
|
+
root
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function normalizeSplitSizes(sizes, expectedLength) {
|
|
30
|
+
const fallback = () => Array.from({ length: expectedLength }, () => 1 / expectedLength);
|
|
31
|
+
if (sizes.length !== expectedLength) return fallback();
|
|
32
|
+
const total = sizes.reduce((sum, size) => sum + Math.max(0, size), 0);
|
|
33
|
+
if (total <= 0) return fallback();
|
|
34
|
+
return sizes.map((size) => Math.max(0, size) / total);
|
|
35
|
+
}
|
|
36
|
+
function getMinimumSize(node, direction, minWidth = 0, minHeight = 0) {
|
|
37
|
+
if (node.kind === "pane") return direction === "horizontal" ? minWidth : minHeight;
|
|
38
|
+
if (node.orientation === direction) return node.children.reduce((sum, child) => sum + getMinimumSize(child, direction, minWidth, minHeight), 0) + Math.max(0, node.children.length - 1) * 3;
|
|
39
|
+
return Math.max(0, ...node.children.map((child) => getMinimumSize(child, direction, minWidth, minHeight)));
|
|
40
|
+
}
|
|
41
|
+
function computeNode(node, rect, geometry, activePaneId) {
|
|
42
|
+
geometry.rectByNodeId.set(node.id, rect);
|
|
43
|
+
if (node.kind === "pane") {
|
|
44
|
+
const pane = {
|
|
45
|
+
paneId: node.paneId,
|
|
46
|
+
nodeId: node.id,
|
|
47
|
+
rect,
|
|
48
|
+
active: node.paneId === activePaneId
|
|
49
|
+
};
|
|
50
|
+
geometry.panes.push(pane);
|
|
51
|
+
return {
|
|
52
|
+
kind: "pane",
|
|
53
|
+
pane
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const sizes = normalizeSplitSizes(node.sizes, node.children.length);
|
|
57
|
+
const horizontal = node.orientation === "horizontal";
|
|
58
|
+
const axisSize = horizontal ? rect.width : rect.height;
|
|
59
|
+
const axisStart = horizontal ? rect.x : rect.y;
|
|
60
|
+
const handleTotal = Math.max(0, node.children.length - 1) * 3;
|
|
61
|
+
const contentSize = Math.max(0, axisSize - handleTotal);
|
|
62
|
+
let cursor = axisStart;
|
|
63
|
+
const handles = [];
|
|
64
|
+
const children = node.children.map((child, index) => {
|
|
65
|
+
const isLast = index === node.children.length - 1;
|
|
66
|
+
const childSize = isLast ? axisStart + axisSize - cursor : Math.floor(contentSize * (sizes[index] ?? 0));
|
|
67
|
+
const computedChild = computeNode(child, createChildRect(node.orientation, rect, cursor, childSize), geometry, activePaneId);
|
|
68
|
+
cursor += childSize;
|
|
69
|
+
if (!isLast) {
|
|
70
|
+
const handle = {
|
|
71
|
+
id: `${node.id}:${index}`,
|
|
72
|
+
splitId: node.id,
|
|
73
|
+
index,
|
|
74
|
+
direction: node.orientation,
|
|
75
|
+
rect: createChildRect(node.orientation, rect, cursor, 3)
|
|
76
|
+
};
|
|
77
|
+
handles.push(handle);
|
|
78
|
+
geometry.handles.push(handle);
|
|
79
|
+
cursor += 3;
|
|
80
|
+
}
|
|
81
|
+
return computedChild;
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
kind: "split",
|
|
85
|
+
orientation: node.orientation,
|
|
86
|
+
children,
|
|
87
|
+
handles
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function createChildRect(direction, rect, cursor, size) {
|
|
91
|
+
return direction === "horizontal" ? {
|
|
92
|
+
x: cursor,
|
|
93
|
+
y: rect.y,
|
|
94
|
+
width: Math.max(0, size),
|
|
95
|
+
height: rect.height
|
|
96
|
+
} : {
|
|
97
|
+
x: rect.x,
|
|
98
|
+
y: cursor,
|
|
99
|
+
width: rect.width,
|
|
100
|
+
height: Math.max(0, size)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region core/src/layout/resize.ts
|
|
105
|
+
function resizeSplit(split, totalPx, index, deltaPx, snapshotSizes, minWidth = 0, minHeight = 0) {
|
|
106
|
+
if (totalPx <= 0 || index < 0 || index >= split.children.length - 1) return split;
|
|
107
|
+
const baseSizes = normalizeSplitSizes(snapshotSizes ?? split.sizes, split.children.length);
|
|
108
|
+
const deltaRatio = deltaPx / totalPx;
|
|
109
|
+
const nextSizes = [...baseSizes];
|
|
110
|
+
nextSizes[index] += deltaRatio;
|
|
111
|
+
nextSizes[index + 1] -= deltaRatio;
|
|
112
|
+
const minSizes = split.children.map((child) => getMinimumSize(child, split.orientation, minWidth, minHeight) / totalPx);
|
|
113
|
+
const currentSizes = normalizeSplitSizes(split.sizes, split.children.length);
|
|
114
|
+
const clamped = clampAdjacentPair(nextSizes, minSizes, index);
|
|
115
|
+
const fitted = fitNodeToAxisSize({
|
|
116
|
+
...split,
|
|
117
|
+
sizes: clamped
|
|
118
|
+
}, split.orientation, totalPx, minWidth, minHeight);
|
|
119
|
+
if (fitted.kind !== "split" || sizesEqual(fitted.sizes, currentSizes)) return split;
|
|
120
|
+
return fitted;
|
|
121
|
+
}
|
|
122
|
+
function sizesEqual(a, b) {
|
|
123
|
+
if (a.length !== b.length) return false;
|
|
124
|
+
return a.every((value, index) => value === b[index]);
|
|
125
|
+
}
|
|
126
|
+
function fitNodeToAxisSize(node, direction, axisSize, minWidth, minHeight) {
|
|
127
|
+
if (node.kind === "pane") return node;
|
|
128
|
+
if (node.orientation !== direction) {
|
|
129
|
+
let changed = false;
|
|
130
|
+
const children = node.children.map((child) => {
|
|
131
|
+
const nextChild = fitNodeToAxisSize(child, direction, axisSize, minWidth, minHeight);
|
|
132
|
+
changed ||= nextChild !== child;
|
|
133
|
+
return nextChild;
|
|
134
|
+
});
|
|
135
|
+
return changed ? {
|
|
136
|
+
...node,
|
|
137
|
+
children
|
|
138
|
+
} : node;
|
|
139
|
+
}
|
|
140
|
+
const sizes = normalizeSplitSizes(node.sizes, node.children.length);
|
|
141
|
+
const handleTotal = Math.max(0, node.children.length - 1) * 3;
|
|
142
|
+
const contentSize = Math.max(0, axisSize - handleTotal);
|
|
143
|
+
const fittedSizes = fitSizesToMinimums(sizes, node.children.map((child) => getMinimumSize(child, direction, minWidth, minHeight)), contentSize);
|
|
144
|
+
let changed = !sizesEqual(fittedSizes, sizes);
|
|
145
|
+
const children = node.children.map((child, index) => {
|
|
146
|
+
const nextChild = fitNodeToAxisSize(child, direction, contentSize * (fittedSizes[index] ?? 0), minWidth, minHeight);
|
|
147
|
+
changed ||= nextChild !== child;
|
|
148
|
+
return nextChild;
|
|
149
|
+
});
|
|
150
|
+
return changed ? {
|
|
151
|
+
...node,
|
|
152
|
+
children,
|
|
153
|
+
sizes: fittedSizes
|
|
154
|
+
} : node;
|
|
155
|
+
}
|
|
156
|
+
function fitSizesToMinimums(sizes, minSizes, contentSize) {
|
|
157
|
+
const normalized = normalizeSplitSizes(sizes, sizes.length);
|
|
158
|
+
if (contentSize <= 0) return normalized;
|
|
159
|
+
if (normalized.map((size) => size * contentSize).every((size, index) => size >= (minSizes[index] ?? 0))) return normalized;
|
|
160
|
+
const out = Array.from({ length: normalized.length }, () => 0);
|
|
161
|
+
const fixed = Array.from({ length: normalized.length }, () => false);
|
|
162
|
+
let remainingSize = contentSize;
|
|
163
|
+
let remainingWeight = normalized.reduce((sum, size) => sum + size, 0);
|
|
164
|
+
let changed = true;
|
|
165
|
+
while (changed) {
|
|
166
|
+
changed = false;
|
|
167
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
168
|
+
if (fixed[index]) continue;
|
|
169
|
+
const weight = remainingWeight > 0 ? normalized[index] / remainingWeight : 0;
|
|
170
|
+
const allocated = remainingSize * weight;
|
|
171
|
+
const minSize = minSizes[index] ?? 0;
|
|
172
|
+
if (allocated < minSize) {
|
|
173
|
+
out[index] = minSize;
|
|
174
|
+
fixed[index] = true;
|
|
175
|
+
remainingSize -= minSize;
|
|
176
|
+
remainingWeight -= normalized[index];
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
182
|
+
if (fixed[index]) continue;
|
|
183
|
+
const weight = remainingWeight > 0 ? normalized[index] / remainingWeight : 0;
|
|
184
|
+
out[index] = Math.max(0, remainingSize * weight);
|
|
185
|
+
}
|
|
186
|
+
const total = out.reduce((sum, size) => sum + size, 0);
|
|
187
|
+
if (total <= 0) return normalized;
|
|
188
|
+
return out.map((size) => size / total);
|
|
189
|
+
}
|
|
190
|
+
function clampAdjacentPair(sizes, minSizes, index) {
|
|
191
|
+
const out = [...sizes];
|
|
192
|
+
const pairTotal = out[index] + out[index + 1];
|
|
193
|
+
const leftMin = minSizes[index] ?? 0;
|
|
194
|
+
const rightMin = minSizes[index + 1] ?? 0;
|
|
195
|
+
const maxLeft = Math.max(leftMin, pairTotal - rightMin);
|
|
196
|
+
out[index] = Math.min(Math.max(out[index], leftMin), maxLeft);
|
|
197
|
+
out[index + 1] = pairTotal - out[index];
|
|
198
|
+
return normalizeSplitSizes(out, out.length);
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region core/src/layout/types.ts
|
|
202
|
+
const cardinalDirections = [
|
|
203
|
+
"left",
|
|
204
|
+
"right",
|
|
205
|
+
"up",
|
|
206
|
+
"down"
|
|
207
|
+
];
|
|
208
|
+
let PaneCommandCapability = /* @__PURE__ */ function(PaneCommandCapability) {
|
|
209
|
+
PaneCommandCapability["ResizeX"] = "canResizeX";
|
|
210
|
+
PaneCommandCapability["ResizeY"] = "canResizeY";
|
|
211
|
+
PaneCommandCapability["Remove"] = "canRemove";
|
|
212
|
+
PaneCommandCapability["SplitHorizontal"] = "canSplitHorizontal";
|
|
213
|
+
PaneCommandCapability["SplitVertical"] = "canSplitVertical";
|
|
214
|
+
PaneCommandCapability["SwapX"] = "canSwapX";
|
|
215
|
+
PaneCommandCapability["SwapY"] = "canSwapY";
|
|
216
|
+
PaneCommandCapability["Focus"] = "canFocus";
|
|
217
|
+
return PaneCommandCapability;
|
|
218
|
+
}({});
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region core/src/layout/tree.ts
|
|
221
|
+
function buildLayoutIndex(root) {
|
|
222
|
+
const index = {
|
|
223
|
+
nodeById: /* @__PURE__ */ new Map(),
|
|
224
|
+
paneNodeByPaneId: /* @__PURE__ */ new Map(),
|
|
225
|
+
parentByNodeId: /* @__PURE__ */ new Map()
|
|
226
|
+
};
|
|
227
|
+
visit(root, null);
|
|
228
|
+
return index;
|
|
229
|
+
function visit(node, parent) {
|
|
230
|
+
index.nodeById.set(node.id, node);
|
|
231
|
+
index.parentByNodeId.set(node.id, parent);
|
|
232
|
+
if (node.kind === "pane") {
|
|
233
|
+
index.paneNodeByPaneId.set(node.paneId, node);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
for (const child of node.children) visit(child, node);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function findPaneNode(root, paneId) {
|
|
240
|
+
if (!paneId) return null;
|
|
241
|
+
if (root.kind === "pane") return root.paneId === paneId ? root : null;
|
|
242
|
+
for (const child of root.children) {
|
|
243
|
+
const pane = findPaneNode(child, paneId);
|
|
244
|
+
if (pane) return pane;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
function findSplitNode(root, splitId) {
|
|
249
|
+
if (root.kind === "pane") return null;
|
|
250
|
+
if (root.id === splitId) return root;
|
|
251
|
+
for (const child of root.children) {
|
|
252
|
+
const split = findSplitNode(child, splitId);
|
|
253
|
+
if (split) return split;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
function collectPaneIds(root) {
|
|
258
|
+
if (root.kind === "pane") return [root.paneId];
|
|
259
|
+
return root.children.flatMap(collectPaneIds);
|
|
260
|
+
}
|
|
261
|
+
function transformLayout(node, transform) {
|
|
262
|
+
if (node.kind === "pane") return transform(node);
|
|
263
|
+
let childrenChanged = false;
|
|
264
|
+
const children = node.children.map((child) => {
|
|
265
|
+
const nextChild = transformLayout(child, transform);
|
|
266
|
+
childrenChanged ||= nextChild !== child;
|
|
267
|
+
return nextChild;
|
|
268
|
+
});
|
|
269
|
+
return transform(childrenChanged ? {
|
|
270
|
+
...node,
|
|
271
|
+
children
|
|
272
|
+
} : node);
|
|
273
|
+
}
|
|
274
|
+
function updatePane(root, paneId, update) {
|
|
275
|
+
return transformLayout(root, (node) => node.kind === "pane" && node.paneId === paneId ? update(node) : node);
|
|
276
|
+
}
|
|
277
|
+
function markFocusedPanePath(root, paneId) {
|
|
278
|
+
const index = buildLayoutIndex(root);
|
|
279
|
+
const pane = index.paneNodeByPaneId.get(paneId);
|
|
280
|
+
if (!pane) return root;
|
|
281
|
+
const path = /* @__PURE__ */ new Map();
|
|
282
|
+
let childId = pane.id;
|
|
283
|
+
let parent = index.parentByNodeId.get(childId);
|
|
284
|
+
while (parent) {
|
|
285
|
+
path.set(parent.id, childId);
|
|
286
|
+
childId = parent.id;
|
|
287
|
+
parent = index.parentByNodeId.get(childId);
|
|
288
|
+
}
|
|
289
|
+
return transformLayout(root, (node) => {
|
|
290
|
+
const focusedChildId = path.get(node.id);
|
|
291
|
+
return node.kind === "split" && focusedChildId !== void 0 && node.lastFocusedChildId !== focusedChildId ? {
|
|
292
|
+
...node,
|
|
293
|
+
lastFocusedChildId: focusedChildId
|
|
294
|
+
} : node;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function removePaneNode(node, paneId) {
|
|
298
|
+
if (node.kind === "pane") return node.paneId === paneId ? null : node;
|
|
299
|
+
const children = [];
|
|
300
|
+
const sizes = [];
|
|
301
|
+
for (let i = 0; i < node.children.length; i += 1) {
|
|
302
|
+
const child = removePaneNode(node.children[i], paneId);
|
|
303
|
+
if (child) {
|
|
304
|
+
children.push(child);
|
|
305
|
+
sizes.push(node.sizes[i] ?? 1);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (children.length === 0) return null;
|
|
309
|
+
if (children.length === 1) return children[0];
|
|
310
|
+
return {
|
|
311
|
+
...node,
|
|
312
|
+
children,
|
|
313
|
+
sizes: normalizeSplitSizes(sizes, children.length)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region core/src/layout/spatial.ts
|
|
318
|
+
function isHorizontalDirection(direction) {
|
|
319
|
+
return direction === "left" || direction === "right";
|
|
320
|
+
}
|
|
321
|
+
function isRectInDirection(rect, activeRect, direction) {
|
|
322
|
+
const epsilon = .001;
|
|
323
|
+
if (direction === "right") return rect.x >= activeRect.x + activeRect.width - epsilon;
|
|
324
|
+
if (direction === "left") return rect.x + rect.width <= activeRect.x + epsilon;
|
|
325
|
+
if (direction === "down") return rect.y >= activeRect.y + activeRect.height - epsilon;
|
|
326
|
+
return rect.y + rect.height <= activeRect.y + epsilon;
|
|
327
|
+
}
|
|
328
|
+
function getEnteringEdge(rect, direction) {
|
|
329
|
+
if (direction === "right") return rect.x;
|
|
330
|
+
if (direction === "left") return rect.x + rect.width;
|
|
331
|
+
if (direction === "down") return rect.y;
|
|
332
|
+
return rect.y + rect.height;
|
|
333
|
+
}
|
|
334
|
+
function compareEnteringEdge(first, second, direction) {
|
|
335
|
+
return direction === "left" || direction === "up" ? second - first : first - second;
|
|
336
|
+
}
|
|
337
|
+
function getPerpendicularCenter(rect, direction) {
|
|
338
|
+
const center = getRectCenter(rect);
|
|
339
|
+
return isHorizontalDirection(direction) ? center.y : center.x;
|
|
340
|
+
}
|
|
341
|
+
function getRectCenter(rect) {
|
|
342
|
+
return {
|
|
343
|
+
x: rect.x + rect.width / 2,
|
|
344
|
+
y: rect.y + rect.height / 2
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function getCenterDistance(rect, center) {
|
|
348
|
+
const rectCenter = getRectCenter(rect);
|
|
349
|
+
return Math.abs(rectCenter.x - center.x) + Math.abs(rectCenter.y - center.y);
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region core/src/layout/navigation.ts
|
|
353
|
+
function findPaneInDirection(root, layout, paneId, direction) {
|
|
354
|
+
const index = buildLayoutIndex(root);
|
|
355
|
+
const paneNode = index.paneNodeByPaneId.get(paneId);
|
|
356
|
+
if (!paneNode) return null;
|
|
357
|
+
const activePane = layout.panes.find((pane) => pane.paneId === paneId);
|
|
358
|
+
if (!activePane) return null;
|
|
359
|
+
let currentId = paneNode.id;
|
|
360
|
+
let parent = index.parentByNodeId.get(currentId) ?? null;
|
|
361
|
+
while (parent) {
|
|
362
|
+
const sibling = findDirectionalSibling(parent, currentId, direction);
|
|
363
|
+
if (sibling) return findTargetPaneInSubtree(sibling, layout.panes, activePane, direction);
|
|
364
|
+
currentId = parent.id;
|
|
365
|
+
parent = index.parentByNodeId.get(currentId) ?? null;
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
function findPaneForFocusCommand(root, layout, paneId, direction, overflow) {
|
|
370
|
+
const activePane = layout.panes.find((pane) => pane.paneId === paneId);
|
|
371
|
+
if (!activePane) return null;
|
|
372
|
+
const index = buildLayoutIndex(root);
|
|
373
|
+
const candidates = layout.panes.filter((pane) => pane.paneId !== paneId && index.paneNodeByPaneId.get(pane.paneId)?.["canFocus"] !== false);
|
|
374
|
+
const target = sortFocusCandidates(candidates.filter((pane) => isRectInDirection(pane.rect, activePane.rect, direction)), activePane, direction)[0]?.paneId ?? null;
|
|
375
|
+
if (target || !overflow) return target;
|
|
376
|
+
return sortOverflowCandidates(candidates, direction)[0]?.paneId ?? null;
|
|
377
|
+
}
|
|
378
|
+
function sortFocusCandidates(candidates, activePane, direction) {
|
|
379
|
+
const activeCenter = getRectCenter(activePane.rect);
|
|
380
|
+
return [...candidates].sort((first, second) => {
|
|
381
|
+
const edgeDelta = compareEnteringEdge(getEnteringEdge(first.rect, direction), getEnteringEdge(second.rect, direction), direction);
|
|
382
|
+
if (edgeDelta !== 0) return edgeDelta;
|
|
383
|
+
const firstPerpendicular = Math.abs(getPerpendicularCenter(first.rect, direction) - getPerpendicularCenter(activePane.rect, direction));
|
|
384
|
+
const secondPerpendicular = Math.abs(getPerpendicularCenter(second.rect, direction) - getPerpendicularCenter(activePane.rect, direction));
|
|
385
|
+
if (firstPerpendicular !== secondPerpendicular) return firstPerpendicular - secondPerpendicular;
|
|
386
|
+
return getCenterDistance(first.rect, activeCenter) - getCenterDistance(second.rect, activeCenter);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function sortOverflowCandidates(candidates, direction) {
|
|
390
|
+
return [...candidates].sort((first, second) => {
|
|
391
|
+
if (direction === "right") return first.rect.x - second.rect.x;
|
|
392
|
+
if (direction === "left") return second.rect.x + second.rect.width - (first.rect.x + first.rect.width);
|
|
393
|
+
if (direction === "down") return first.rect.y - second.rect.y;
|
|
394
|
+
return second.rect.y + second.rect.height - (first.rect.y + first.rect.height);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
function findDirectionalSibling(parent, childId, direction) {
|
|
398
|
+
const childIndex = parent.children.findIndex((child) => child.id === childId);
|
|
399
|
+
if (childIndex === -1) return null;
|
|
400
|
+
const horizontal = direction === "left" || direction === "right";
|
|
401
|
+
if (horizontal && parent.orientation === "horizontal") {
|
|
402
|
+
if (direction === "right") return parent.children[childIndex + 1] ?? null;
|
|
403
|
+
return parent.children[childIndex - 1] ?? null;
|
|
404
|
+
}
|
|
405
|
+
if (!horizontal && parent.orientation === "vertical") {
|
|
406
|
+
if (direction === "down") return parent.children[childIndex + 1] ?? null;
|
|
407
|
+
return parent.children[childIndex - 1] ?? null;
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
function findTargetPaneInSubtree(subtree, panes, activePane, direction) {
|
|
412
|
+
const subtreePaneIds = new Set(collectPaneIds(subtree));
|
|
413
|
+
const candidates = panes.filter((pane) => subtreePaneIds.has(pane.paneId));
|
|
414
|
+
if (candidates.length === 0) return null;
|
|
415
|
+
const activeCenter = getRectCenter(activePane.rect);
|
|
416
|
+
return candidates.map((pane) => ({
|
|
417
|
+
pane,
|
|
418
|
+
edge: getEnteringEdge(pane.rect, direction),
|
|
419
|
+
perpendicularDistance: Math.abs(getPerpendicularCenter(pane.rect, direction) - getPerpendicularCenter(activePane.rect, direction)),
|
|
420
|
+
focusMemoryRank: getFocusMemoryRank(subtree, pane.paneId),
|
|
421
|
+
centerDistance: getCenterDistance(pane.rect, activeCenter)
|
|
422
|
+
})).sort((first, second) => compareEnteringEdge(first.edge, second.edge, direction) || first.focusMemoryRank - second.focusMemoryRank || first.perpendicularDistance - second.perpendicularDistance || first.centerDistance - second.centerDistance)[0].pane.paneId;
|
|
423
|
+
}
|
|
424
|
+
function getFocusMemoryRank(subtree, paneId) {
|
|
425
|
+
if (subtree.kind === "pane") return Number.POSITIVE_INFINITY;
|
|
426
|
+
const rememberedChild = subtree.children.find((child) => child.id === subtree.lastFocusedChildId);
|
|
427
|
+
if (!rememberedChild || !collectPaneIds(rememberedChild).includes(paneId)) return Number.POSITIVE_INFINITY;
|
|
428
|
+
if (rememberedChild.kind === "pane") return 0;
|
|
429
|
+
const nestedRank = getFocusMemoryRank(rememberedChild, paneId);
|
|
430
|
+
return Number.isFinite(nestedRank) ? nestedRank : 1;
|
|
431
|
+
}
|
|
432
|
+
function resolvePaneResizeBoundary(index, nodeId, direction) {
|
|
433
|
+
let currentId = nodeId;
|
|
434
|
+
let parent = index.parentByNodeId.get(currentId) ?? null;
|
|
435
|
+
while (parent) {
|
|
436
|
+
const childIndex = parent.children.findIndex((child) => child.id === currentId);
|
|
437
|
+
if (childIndex === -1) return null;
|
|
438
|
+
if (isHorizontalDirection(direction) === (parent.orientation === "horizontal")) {
|
|
439
|
+
const boundaryIndex = childIndex > 0 ? childIndex - 1 : childIndex < parent.children.length - 1 ? childIndex : null;
|
|
440
|
+
if (boundaryIndex !== null) return {
|
|
441
|
+
splitId: parent.id,
|
|
442
|
+
index: boundaryIndex,
|
|
443
|
+
deltaPxSign: direction === "right" || direction === "down" ? 1 : -1
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
currentId = parent.id;
|
|
447
|
+
parent = index.parentByNodeId.get(currentId) ?? null;
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region core/src/validation.ts
|
|
453
|
+
var FocusGridStateValidationException = class extends Error {
|
|
454
|
+
errors;
|
|
455
|
+
constructor(errors) {
|
|
456
|
+
const details = errors.slice(0, 3).map((error) => `${error.path}: ${error.message}`).join("; ");
|
|
457
|
+
const suffix = errors.length > 3 ? `; ${errors.length - 3} more` : "";
|
|
458
|
+
super(details ? `Invalid FocusGrid controller state: ${details}${suffix}` : "Invalid FocusGrid controller state.");
|
|
459
|
+
this.errors = errors;
|
|
460
|
+
this.name = "FocusGridStateValidationException";
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
function assertValidFocusGridControllerState(input) {
|
|
464
|
+
const result = validateFocusGridControllerState(input);
|
|
465
|
+
if (!result.ok) throw new FocusGridStateValidationException(result.errors);
|
|
466
|
+
}
|
|
467
|
+
function validateFocusGridControllerState(input) {
|
|
468
|
+
const errors = [];
|
|
469
|
+
const nodeIds = /* @__PURE__ */ new Set();
|
|
470
|
+
const paneIds = /* @__PURE__ */ new Set();
|
|
471
|
+
function add(code, path, message, meta = {}) {
|
|
472
|
+
errors.push({
|
|
473
|
+
code,
|
|
474
|
+
path,
|
|
475
|
+
message,
|
|
476
|
+
...meta
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
function fields(value, path, allowed, meta = {}) {
|
|
480
|
+
for (const field of Object.keys(value)) if (!allowed.has(field)) add("unknown-field", `${path}.${field}`, `Unknown field "${field}".`, meta);
|
|
481
|
+
}
|
|
482
|
+
function size(value, path, label, meta = {}) {
|
|
483
|
+
if (!isSize(value)) add("invalid-number", path, `${label} must be a finite, non-negative number.`, meta);
|
|
484
|
+
}
|
|
485
|
+
function id(value, seen, kind, path, meta) {
|
|
486
|
+
const label = kind === "pane" ? "Pane" : "Node";
|
|
487
|
+
if (typeof value !== "string" || value.length === 0) add(`invalid-${kind}-id`, path, `${label} id must be a non-empty string.`, meta);
|
|
488
|
+
else if (seen.has(value)) add(`duplicate-${kind}-id`, path, `Duplicate ${kind} id "${value}".`, {
|
|
489
|
+
...meta,
|
|
490
|
+
[kind === "pane" ? "paneId" : "nodeId"]: value
|
|
491
|
+
});
|
|
492
|
+
else seen.add(value);
|
|
493
|
+
}
|
|
494
|
+
function node(value, path) {
|
|
495
|
+
if (!isRecord(value)) {
|
|
496
|
+
add("invalid-node", path, "Layout node must be an object.");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const meta = {
|
|
500
|
+
nodeId: typeof value.id === "string" ? value.id : void 0,
|
|
501
|
+
paneId: typeof value.paneId === "string" ? value.paneId : void 0
|
|
502
|
+
};
|
|
503
|
+
if (value.kind !== "pane" && value.kind !== "split") {
|
|
504
|
+
add("invalid-node-kind", `${path}.kind`, "Layout node kind must be \"pane\" or \"split\".", meta);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
fields(value, path, value.kind === "pane" ? PANE_FIELDS : SPLIT_FIELDS, meta);
|
|
508
|
+
id(value.id, nodeIds, "node", `${path}.id`, meta);
|
|
509
|
+
if (value.kind === "pane") {
|
|
510
|
+
id(value.paneId, paneIds, "pane", `${path}.paneId`, meta);
|
|
511
|
+
for (const key of Object.values(PaneCommandCapability)) if (key in value && typeof value[key] !== "boolean") add("invalid-capability", `${path}.${key}`, `${key} must be a boolean when provided.`, meta);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (value.orientation !== "horizontal" && value.orientation !== "vertical") add("invalid-direction", `${path}.orientation`, "Split orientation must be \"horizontal\" or \"vertical\".", meta);
|
|
515
|
+
if (!Array.isArray(value.children)) add("invalid-children", `${path}.children`, "Split children must be an array.", meta);
|
|
516
|
+
else {
|
|
517
|
+
if (value.children.length !== 2) add("non-binary-split", `${path}.children`, "Split nodes must have exactly two children.", meta);
|
|
518
|
+
value.children.forEach((child, index) => node(child, `${path}.children[${index}]`));
|
|
519
|
+
}
|
|
520
|
+
if (!Array.isArray(value.sizes)) add("invalid-sizes", `${path}.sizes`, "Split sizes must be an array.", meta);
|
|
521
|
+
else {
|
|
522
|
+
if (value.sizes.length !== 2) add("invalid-sizes", `${path}.sizes`, "Split sizes must contain exactly two values.", meta);
|
|
523
|
+
value.sizes.forEach((entry, index) => {
|
|
524
|
+
if (!isSize(entry)) add("invalid-size", `${path}.sizes[${index}]`, "Split sizes must be finite, non-negative numbers.", meta);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
if (value.lastFocusedChildId !== void 0 && typeof value.lastFocusedChildId !== "string") add("invalid-last-focused-child", `${path}.lastFocusedChildId`, "lastFocusedChildId must be a node id string when provided.", meta);
|
|
528
|
+
if (typeof value.lastFocusedChildId === "string" && Array.isArray(value.children) && !value.children.some((child) => isRecord(child) && child.id === value.lastFocusedChildId)) add("invalid-last-focused-child", `${path}.lastFocusedChildId`, "lastFocusedChildId must reference one of the split's direct children.", {
|
|
529
|
+
...meta,
|
|
530
|
+
nodeId: value.lastFocusedChildId
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
if (!isRecord(input)) add("invalid-state", "$", "State must be an object.");
|
|
534
|
+
else {
|
|
535
|
+
fields(input, "$", STATE_FIELDS);
|
|
536
|
+
for (const key of STATE_FIELDS) if (!(key in input)) add("missing-field", `$.${key}`, `${key} is required.`);
|
|
537
|
+
if ("root" in input) node(input.root, "$.root");
|
|
538
|
+
if ("activePaneId" in input) {
|
|
539
|
+
if (input.activePaneId !== null && typeof input.activePaneId !== "string") add("invalid-active-pane", "$.activePaneId", "State must use a pane id string or null for activePaneId.");
|
|
540
|
+
else if (typeof input.activePaneId === "string" && !paneIds.has(input.activePaneId)) add("unknown-active-pane", "$.activePaneId", `activePaneId "${input.activePaneId}" does not reference a pane in root.`, { paneId: input.activePaneId });
|
|
541
|
+
}
|
|
542
|
+
if ("container" in input) if (!isRecord(input.container)) add("invalid-container", "$.container", "Container must be an object.");
|
|
543
|
+
else {
|
|
544
|
+
fields(input.container, "$.container", CONTAINER_FIELDS);
|
|
545
|
+
for (const key of CONTAINER_FIELDS) if (!(key in input.container)) add("missing-field", `$.container.${key}`, `${key} is required.`);
|
|
546
|
+
else size(input.container[key], `$.container.${key}`, `container ${key}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return errors.length ? {
|
|
550
|
+
ok: false,
|
|
551
|
+
errors
|
|
552
|
+
} : {
|
|
553
|
+
ok: true,
|
|
554
|
+
state: input,
|
|
555
|
+
errors: []
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function isRecord(input) {
|
|
559
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
560
|
+
}
|
|
561
|
+
function isSize(input) {
|
|
562
|
+
return typeof input === "number" && Number.isFinite(input) && input >= 0;
|
|
563
|
+
}
|
|
564
|
+
const STATE_FIELDS = /* @__PURE__ */ new Set([
|
|
565
|
+
"root",
|
|
566
|
+
"activePaneId",
|
|
567
|
+
"container"
|
|
568
|
+
]);
|
|
569
|
+
const CONTAINER_FIELDS = /* @__PURE__ */ new Set(["width", "height"]);
|
|
570
|
+
const PANE_FIELDS = /* @__PURE__ */ new Set([
|
|
571
|
+
"kind",
|
|
572
|
+
"id",
|
|
573
|
+
"paneId",
|
|
574
|
+
...Object.values(PaneCommandCapability)
|
|
575
|
+
]);
|
|
576
|
+
const SPLIT_FIELDS = /* @__PURE__ */ new Set([
|
|
577
|
+
"kind",
|
|
578
|
+
"id",
|
|
579
|
+
"orientation",
|
|
580
|
+
"children",
|
|
581
|
+
"sizes",
|
|
582
|
+
"lastFocusedChildId"
|
|
583
|
+
]);
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region core/src/controller.ts
|
|
586
|
+
var FocusGridController = class {
|
|
587
|
+
domController;
|
|
588
|
+
minWidth;
|
|
589
|
+
minHeight;
|
|
590
|
+
directionalFocusOverflow;
|
|
591
|
+
currentState;
|
|
592
|
+
cachedLayout = null;
|
|
593
|
+
paneDefaults;
|
|
594
|
+
listeners = /* @__PURE__ */ new Set();
|
|
595
|
+
constructor({ paneDefaults = {}, minWidth = 0, minHeight = 0, directionalFocusOverflow = false, ...state }, domController) {
|
|
596
|
+
this.domController = domController;
|
|
597
|
+
assertValidFocusGridControllerState(state);
|
|
598
|
+
if (![minWidth, minHeight].every((value) => Number.isFinite(value) && value >= 0)) throw new RangeError("Minimum pane dimensions must be finite, non-negative numbers.");
|
|
599
|
+
this.minWidth = minWidth;
|
|
600
|
+
this.minHeight = minHeight;
|
|
601
|
+
this.paneDefaults = paneDefaults;
|
|
602
|
+
this.directionalFocusOverflow = directionalFocusOverflow;
|
|
603
|
+
const root = transformLayout(state.root, (node) => node.kind === "pane" ? withPaneDefaults(node, paneDefaults) : node);
|
|
604
|
+
this.currentState = root === state.root ? state : {
|
|
605
|
+
...state,
|
|
606
|
+
root
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/** Computed render data; stable until the next committed change. */
|
|
610
|
+
getLayout() {
|
|
611
|
+
return this.cachedLayout ??= computeLayout(this.currentState);
|
|
612
|
+
}
|
|
613
|
+
getPane(paneId) {
|
|
614
|
+
const pane = findPaneNode(this.currentState.root, paneId);
|
|
615
|
+
return pane ? { ...pane } : null;
|
|
616
|
+
}
|
|
617
|
+
getContainerSize() {
|
|
618
|
+
return { ...this.currentState.container };
|
|
619
|
+
}
|
|
620
|
+
getSplitSizes(splitId) {
|
|
621
|
+
const split = findSplitNode(this.currentState.root, splitId);
|
|
622
|
+
return split ? [...split.sizes] : null;
|
|
623
|
+
}
|
|
624
|
+
splitActive(side) {
|
|
625
|
+
const pane = findPaneNode(this.currentState.root, this.currentState.activePaneId);
|
|
626
|
+
const capability = isHorizontalDirection(side) ? "canSplitHorizontal" : "canSplitVertical";
|
|
627
|
+
return pane && pane[capability] !== false ? this.split(pane.id, { side }) : null;
|
|
628
|
+
}
|
|
629
|
+
removeActive() {
|
|
630
|
+
const pane = findPaneNode(this.currentState.root, this.currentState.activePaneId);
|
|
631
|
+
return pane && pane["canRemove"] !== false ? this.remove(pane.paneId) : false;
|
|
632
|
+
}
|
|
633
|
+
focusAdjacent(direction) {
|
|
634
|
+
const state = this.currentState;
|
|
635
|
+
if (!state.activePaneId) return false;
|
|
636
|
+
const target = findPaneForFocusCommand(state.root, this.getLayout(), state.activePaneId, direction, this.directionalFocusOverflow);
|
|
637
|
+
if (!target || !this.domController.focus(target)) return false;
|
|
638
|
+
this.focus(target);
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
swapAdjacent(direction) {
|
|
642
|
+
const state = this.currentState;
|
|
643
|
+
const active = findPaneNode(state.root, state.activePaneId);
|
|
644
|
+
if (!active) return false;
|
|
645
|
+
const targetId = findPaneInDirection(state.root, this.getLayout(), active.paneId, direction);
|
|
646
|
+
const target = findPaneNode(state.root, targetId);
|
|
647
|
+
const capability = isHorizontalDirection(direction) ? "canSwapX" : "canSwapY";
|
|
648
|
+
return target && active[capability] !== false && target[capability] !== false ? this.swap(active.paneId, target.paneId) : false;
|
|
649
|
+
}
|
|
650
|
+
resizeActive(direction, deltaPx) {
|
|
651
|
+
const pane = findPaneNode(this.currentState.root, this.currentState.activePaneId);
|
|
652
|
+
const capability = isHorizontalDirection(direction) ? "canResizeX" : "canResizeY";
|
|
653
|
+
return pane && pane[capability] !== false ? this.resize(pane.paneId, {
|
|
654
|
+
direction,
|
|
655
|
+
deltaPx
|
|
656
|
+
}) : false;
|
|
657
|
+
}
|
|
658
|
+
split(paneNodeId, props) {
|
|
659
|
+
const state = this.currentState;
|
|
660
|
+
const index = buildLayoutIndex(state.root);
|
|
661
|
+
const target = index.nodeById.get(paneNodeId);
|
|
662
|
+
if (target?.kind !== "pane") return null;
|
|
663
|
+
const paneId = props.newPaneId ?? createId("pane");
|
|
664
|
+
const id = props.newPaneNodeId ?? createId("node");
|
|
665
|
+
const splitId = props.splitId ?? createId("split");
|
|
666
|
+
if (index.paneNodeByPaneId.has(paneId) || index.nodeById.has(id) || index.nodeById.has(splitId) || id === splitId) return null;
|
|
667
|
+
const { side, newPaneId, newPaneNodeId, splitId: suppliedSplitId, preserveActivePane, ...paneProps } = props;
|
|
668
|
+
const pane = withPaneDefaults({
|
|
669
|
+
kind: "pane",
|
|
670
|
+
id,
|
|
671
|
+
paneId,
|
|
672
|
+
...paneProps
|
|
673
|
+
}, this.paneDefaults);
|
|
674
|
+
const root = updatePane(state.root, target.paneId, () => ({
|
|
675
|
+
kind: "split",
|
|
676
|
+
id: splitId,
|
|
677
|
+
orientation: isHorizontalDirection(side) ? "horizontal" : "vertical",
|
|
678
|
+
children: side === "left" || side === "up" ? [pane, target] : [target, pane],
|
|
679
|
+
sizes: [.5, .5]
|
|
680
|
+
}));
|
|
681
|
+
const activePaneId = preserveActivePane ? state.activePaneId : paneId;
|
|
682
|
+
this.commit({
|
|
683
|
+
root: activePaneId ? markFocusedPanePath(root, activePaneId) : root,
|
|
684
|
+
activePaneId
|
|
685
|
+
});
|
|
686
|
+
return paneId;
|
|
687
|
+
}
|
|
688
|
+
remove(paneId) {
|
|
689
|
+
const state = this.currentState;
|
|
690
|
+
const panes = collectPaneIds(state.root);
|
|
691
|
+
if (!panes.includes(paneId) || panes.length <= 1) return false;
|
|
692
|
+
const root = removePaneNode(state.root, paneId);
|
|
693
|
+
const activePaneId = state.activePaneId === paneId ? collectPaneIds(root)[0] : state.activePaneId;
|
|
694
|
+
return this.commit({
|
|
695
|
+
root: activePaneId ? markFocusedPanePath(root, activePaneId) : root,
|
|
696
|
+
activePaneId
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
swap(firstPaneId, secondPaneId) {
|
|
700
|
+
if (firstPaneId === secondPaneId) return false;
|
|
701
|
+
const state = this.currentState;
|
|
702
|
+
const index = buildLayoutIndex(state.root);
|
|
703
|
+
const first = index.paneNodeByPaneId.get(firstPaneId);
|
|
704
|
+
const second = index.paneNodeByPaneId.get(secondPaneId);
|
|
705
|
+
if (!first || !second) return false;
|
|
706
|
+
const root = transformLayout(state.root, (node) => {
|
|
707
|
+
if (node.id === first.id) return {
|
|
708
|
+
...second,
|
|
709
|
+
id: node.id
|
|
710
|
+
};
|
|
711
|
+
if (node.id === second.id) return {
|
|
712
|
+
...first,
|
|
713
|
+
id: node.id
|
|
714
|
+
};
|
|
715
|
+
return node;
|
|
716
|
+
});
|
|
717
|
+
return this.commit({ root: state.activePaneId ? markFocusedPanePath(root, state.activePaneId) : root });
|
|
718
|
+
}
|
|
719
|
+
resize(paneId, props) {
|
|
720
|
+
const index = buildLayoutIndex(this.currentState.root);
|
|
721
|
+
const pane = index.paneNodeByPaneId.get(paneId);
|
|
722
|
+
if (!pane) return false;
|
|
723
|
+
const boundary = resolvePaneResizeBoundary(index, pane.id, props.direction);
|
|
724
|
+
return boundary ? this.resizeHandle(boundary.splitId, {
|
|
725
|
+
index: boundary.index,
|
|
726
|
+
deltaPx: boundary.deltaPxSign * props.deltaPx
|
|
727
|
+
}) : false;
|
|
728
|
+
}
|
|
729
|
+
resizeHandle(splitId, props) {
|
|
730
|
+
const state = this.currentState;
|
|
731
|
+
const rect = this.getLayout().rectByNodeId.get(splitId);
|
|
732
|
+
if (!rect) return false;
|
|
733
|
+
const root = transformLayout(state.root, (node) => node.kind === "split" && node.id === splitId ? resizeSplit(node, node.orientation === "horizontal" ? rect.width : rect.height, props.index, props.deltaPx, props.snapshotSizes, this.minWidth, this.minHeight) : node);
|
|
734
|
+
return this.commit({ root });
|
|
735
|
+
}
|
|
736
|
+
focus(paneId) {
|
|
737
|
+
const state = this.currentState;
|
|
738
|
+
if (!findPaneNode(state.root, paneId)) return false;
|
|
739
|
+
const root = markFocusedPanePath(state.root, paneId);
|
|
740
|
+
return this.commit({
|
|
741
|
+
root,
|
|
742
|
+
activePaneId: paneId
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
updatePane(paneId, patch) {
|
|
746
|
+
const root = updatePane(this.currentState.root, paneId, (pane) => Object.entries(patch).every(([key, value]) => Object.is(pane[key], value)) ? pane : Object.fromEntries(Object.entries({
|
|
747
|
+
...pane,
|
|
748
|
+
...patch
|
|
749
|
+
}).filter(([, value]) => value !== void 0)));
|
|
750
|
+
return this.commit({ root });
|
|
751
|
+
}
|
|
752
|
+
setContainerSize(width, height) {
|
|
753
|
+
if (this.currentState.container.width === width && this.currentState.container.height === height) return false;
|
|
754
|
+
return this.commit({ container: {
|
|
755
|
+
width,
|
|
756
|
+
height
|
|
757
|
+
} });
|
|
758
|
+
}
|
|
759
|
+
commit(patch) {
|
|
760
|
+
const previous = this.currentState;
|
|
761
|
+
if (Object.entries(patch).every(([key, value]) => Object.is(previous[key], value))) return false;
|
|
762
|
+
const next = {
|
|
763
|
+
...previous,
|
|
764
|
+
...patch
|
|
765
|
+
};
|
|
766
|
+
this.currentState = next;
|
|
767
|
+
this.cachedLayout = null;
|
|
768
|
+
for (const listener of this.listeners) listener(next, previous);
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
subscribePaneEvents({ onPaneLayoutChange, onPaneClose }) {
|
|
772
|
+
if (!onPaneLayoutChange && !onPaneClose) return () => {};
|
|
773
|
+
return this.subscribe((next, previous) => {
|
|
774
|
+
const previousPanes = new Map(computeLayout(previous).panes.map((pane) => [pane.paneId, pane]));
|
|
775
|
+
const nextPanes = (next === this.currentState ? this.getLayout() : computeLayout(next)).panes;
|
|
776
|
+
for (const pane of nextPanes) {
|
|
777
|
+
const previousPane = previousPanes.get(pane.paneId);
|
|
778
|
+
previousPanes.delete(pane.paneId);
|
|
779
|
+
if (previousPane && (pane.rect.x !== previousPane.rect.x || pane.rect.y !== previousPane.rect.y || pane.rect.width !== previousPane.rect.width || pane.rect.height !== previousPane.rect.height)) onPaneLayoutChange?.({
|
|
780
|
+
pane,
|
|
781
|
+
previousPane,
|
|
782
|
+
controller: this
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
for (const previousPane of previousPanes.values()) onPaneClose?.({
|
|
786
|
+
paneId: previousPane.paneId,
|
|
787
|
+
previousPane,
|
|
788
|
+
controller: this
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
subscribe(listener) {
|
|
793
|
+
this.listeners.add(listener);
|
|
794
|
+
return () => {
|
|
795
|
+
this.listeners.delete(listener);
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
function withPaneDefaults(pane, defaults) {
|
|
800
|
+
const definedPane = Object.fromEntries(Object.entries(pane).filter(([, value]) => value !== void 0));
|
|
801
|
+
const next = Object.fromEntries(Object.entries({
|
|
802
|
+
...defaults,
|
|
803
|
+
...definedPane
|
|
804
|
+
}).filter(([, value]) => value !== void 0));
|
|
805
|
+
return Object.keys(next).length === Object.keys(pane).length && Object.entries(next).every(([key, value]) => Object.is(pane[key], value)) ? pane : next;
|
|
806
|
+
}
|
|
807
|
+
//#endregion
|
|
808
|
+
exports.FocusGridController = FocusGridController;
|
|
809
|
+
exports.FocusGridStateValidationException = FocusGridStateValidationException;
|
|
810
|
+
exports.PaneCommandCapability = PaneCommandCapability;
|
|
811
|
+
exports.cardinalDirections = cardinalDirections;
|
|
812
|
+
exports.validateFocusGridControllerState = validateFocusGridControllerState;
|