@jbrowse/app-core 5.0.0-beta.11 → 5.0.0-beta.14
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/esm/AppSession/AppSessionMixin.d.ts +110 -92
- package/esm/Assemblies/AssembliesMixin.d.ts +30 -0
- package/esm/HistoryManagement/index.d.ts +2 -0
- package/esm/HistoryManagement/index.js +2 -0
- package/esm/JBrowseConfig/RootConfiguration.d.ts +4 -10
- package/esm/JBrowseConfig/RootConfiguration.js +5 -8
- package/esm/JBrowseConfig/index.d.ts +3 -11
- package/esm/JBrowseConfig/index.js +2 -3
- package/esm/JBrowseModel/index.d.ts +10 -9
- package/esm/JBrowseModel/index.js +8 -7
- package/esm/JbApi/jbApi.d.ts +2 -2
- package/esm/JbApi/jbApi.js +16 -11
- package/esm/RootMenu/index.d.ts +2 -0
- package/esm/RootMenu/index.js +2 -0
- package/esm/SessionSpec/index.d.ts +2 -2
- package/esm/SessionSpec/index.js +1 -1
- package/esm/SessionSpec/loadSessionSpec.js +2 -12
- package/esm/SessionSpec/parseSessionSpecUrl.d.ts +19 -0
- package/esm/SessionSpec/parseSessionSpecUrl.js +29 -0
- package/esm/WorkspaceLayout/LayoutRenderer.d.ts +0 -10
- package/esm/WorkspaceLayout/LayoutRenderer.js +34 -101
- package/esm/WorkspaceLayout/PanelView.js +19 -45
- package/esm/WorkspaceLayout/TabStrip.d.ts +2 -2
- package/esm/WorkspaceLayout/TabStrip.js +36 -104
- package/esm/WorkspaceLayout/WorkspaceContainer.d.ts +0 -9
- package/esm/WorkspaceLayout/WorkspaceContainer.js +9 -42
- package/esm/WorkspaceLayout/WorkspacePanelActions.d.ts +1 -16
- package/esm/WorkspaceLayout/WorkspacePanelActions.js +46 -51
- package/esm/WorkspaceLayout/WorkspaceTab.d.ts +3 -19
- package/esm/WorkspaceLayout/WorkspaceTab.js +12 -48
- package/esm/WorkspaceLayout/dropZone.d.ts +7 -37
- package/esm/WorkspaceLayout/dropZone.js +7 -44
- package/esm/WorkspaceLayout/model.d.ts +45 -120
- package/esm/WorkspaceLayout/model.js +145 -303
- package/esm/WorkspaceLayout/panelChrome.d.ts +4 -7
- package/esm/WorkspaceLayout/panelChrome.js +0 -2
- package/esm/WorkspaceLayout/spec.d.ts +10 -51
- package/esm/WorkspaceLayout/spec.js +12 -80
- package/esm/WorkspaceLayout/splitter.d.ts +3 -19
- package/esm/WorkspaceLayout/splitter.js +5 -21
- package/esm/WorkspaceLayout/tabName.d.ts +8 -0
- package/esm/WorkspaceLayout/tabName.js +14 -0
- package/esm/WorkspaceLayout/tree.d.ts +19 -79
- package/esm/WorkspaceLayout/tree.js +34 -131
- package/esm/WorkspaceLayout/useLayoutDrag.d.ts +4 -22
- package/esm/WorkspaceLayout/useLayoutDrag.js +55 -177
- package/esm/WorkspaceLayout/usePointerGesture.d.ts +24 -0
- package/esm/WorkspaceLayout/usePointerGesture.js +83 -0
- package/esm/WorkspaceLayout/workspaceTheme.d.ts +27 -0
- package/esm/WorkspaceLayout/workspaceTheme.js +37 -0
- package/esm/index.d.ts +5 -3
- package/esm/index.js +3 -2
- package/esm/queryParams.d.ts +2 -0
- package/esm/queryParams.js +16 -5
- package/esm/sessionSummary.d.ts +16 -0
- package/esm/sessionSummary.js +57 -0
- package/esm/ui/App/App.js +3 -0
- package/esm/ui/App/DrawerHeaderHelpDialog.d.ts +3 -2
- package/esm/ui/App/DrawerHeaderHelpDialog.js +4 -2
- package/esm/ui/App/JBrowseTabMenu.d.ts +3 -2
- package/esm/ui/App/JBrowseTabMenu.js +5 -3
- package/esm/ui/App/ThemeModeButton.d.ts +4 -5
- package/esm/ui/App/ThemeModeButton.js +22 -14
- package/esm/ui/App/ViewContainer.js +7 -1
- package/esm/ui/App/ViewMenu.js +69 -42
- package/esm/ui/App/ViewWrapper.js +2 -2
- package/esm/ui/App/ViewsContainer.js +3 -4
- package/esm/ui/App/lazyParts.d.ts +0 -5
- package/esm/ui/App/lazyParts.js +0 -1
- package/esm/ui/App/preloadAppShell.js +3 -6
- package/esm/ui/App/types.d.ts +3 -2
- package/esm/ui/App/viewTitle.d.ts +6 -1
- package/esm/ui/App/viewTitle.js +7 -2
- package/package.json +7 -7
- package/esm/WorkspaceLayout/dockviewTheme.d.ts +0 -69
- package/esm/WorkspaceLayout/dockviewTheme.js +0 -82
- package/esm/ui/App/ClassicViewsContainer.d.ts +0 -6
- package/esm/ui/App/ClassicViewsContainer.js +0 -22
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The layout tree
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* deliberately not MST: no nodes, no parents, no lifecycle, nothing to be alive
|
|
6
|
-
* or dead. Every operation is `tree in -> tree out`, which means the invariants
|
|
7
|
-
* below can be tested exhaustively instead of observed to hold.
|
|
8
|
-
*
|
|
9
|
-
* The trade being made is worth naming, because it is not "complexity for no
|
|
10
|
-
* complexity". Reconciling with dockview is replaced by **normalisation**: a
|
|
11
|
-
* tree that has just had a panel removed, or a branch split, is usually not in
|
|
12
|
-
* canonical form, and every operation has to put it back. That is real work and
|
|
13
|
-
* it is where this design's bugs would live. What it is not is timing: there is
|
|
14
|
-
* no event, no re-entrancy, no window during which the tree is half-updated,
|
|
15
|
-
* and no second owner to disagree with.
|
|
2
|
+
* The layout tree as pure functions over plain snapshots, deliberately not MST
|
|
3
|
+
* so its invariants can be tested exhaustively. Every operation returns a tree
|
|
4
|
+
* in canonical form (`normalize`).
|
|
16
5
|
*/
|
|
17
6
|
export function isBranch(node) {
|
|
18
7
|
return 'children' in node;
|
|
@@ -42,6 +31,10 @@ export function findTab(node, tabId) {
|
|
|
42
31
|
export function activeTabIn(panel) {
|
|
43
32
|
return panel.tabs.find(t => t.id === panel.activeTabId) ?? panel.tabs[0];
|
|
44
33
|
}
|
|
34
|
+
export function activeTabOf(root, panelId) {
|
|
35
|
+
const panel = panels(root).find(p => p.id === panelId);
|
|
36
|
+
return panel && activeTabIn(panel);
|
|
37
|
+
}
|
|
45
38
|
export function tabContainingView(node, viewId) {
|
|
46
39
|
return homeOf(node, t => t.viewIds.includes(viewId));
|
|
47
40
|
}
|
|
@@ -58,14 +51,8 @@ export function panelContainingView(node, viewId) {
|
|
|
58
51
|
* it, its children's sizes scaled to preserve their share of the whole
|
|
59
52
|
* 4. sizes are renormalised so a node's siblings always sum to 1
|
|
60
53
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* silently reparented. Here it is representable AND canonicalised, which is
|
|
64
|
-
* what makes `size` work at any depth rather than only on the top-level split.
|
|
65
|
-
*
|
|
66
|
-
* Empty panels are NOT dropped, and neither are empty tabs. A tab with no views
|
|
67
|
-
* is exactly what "new empty tab" creates — it shows the view launcher — so
|
|
68
|
-
* pruning empties wholesale would delete the thing the user just asked for.
|
|
54
|
+
* Empty panels and empty tabs stay: "new empty tab" creates one on purpose, to
|
|
55
|
+
* show the view launcher.
|
|
69
56
|
*/
|
|
70
57
|
export function normalize(node) {
|
|
71
58
|
if (!isBranch(node)) {
|
|
@@ -83,30 +70,13 @@ export function normalize(node) {
|
|
|
83
70
|
return { ...node, children: scaleSizes(children) };
|
|
84
71
|
}
|
|
85
72
|
/**
|
|
86
|
-
* Siblings
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* on every action: seven equal panes renormalise to `0.14285714285714285`,
|
|
91
|
-
* which sums to `0.9999999999999998`, which renormalises to
|
|
92
|
-
* `0.14285714285714288`, which sums to `1.0000000000000002`, which renormalises
|
|
93
|
-
* back. So `normalize` had no fixed point — every action rewrote every size,
|
|
94
|
-
* a settled layout emitted a snapshot for each one, and the undo history filled
|
|
95
|
-
* with entries in which nothing observable changed. Exactly the failure
|
|
96
|
-
* dockview's echoing layout event caused, arrived at from the other side.
|
|
97
|
-
*
|
|
98
|
-
* The tolerance is what makes it terminate; the drift it admits does not
|
|
99
|
-
* accumulate, because the sizes stop moving as soon as they are inside it.
|
|
73
|
+
* Siblings already summing to the target within this tolerance are left alone.
|
|
74
|
+
* Rescaling is not the identity in floating point (seven equal panes oscillate
|
|
75
|
+
* between sums of 0.9999999999999998 and 1.0000000000000002), so without it
|
|
76
|
+
* `normalize` has no fixed point and every action fills undo with a no-op.
|
|
100
77
|
*/
|
|
101
78
|
const SIZE_EPSILON = 1e-9;
|
|
102
|
-
/**
|
|
103
|
-
* Rescale a set of siblings so their sizes sum to `total`.
|
|
104
|
-
*
|
|
105
|
-
* Rule 4 renormalises to 1; rule 3 rescales a flattened branch's children to
|
|
106
|
-
* the share that branch held, so a child at half of a branch that was a third
|
|
107
|
-
* of its parent ends up at a sixth. Siblings summing to nothing take equal
|
|
108
|
-
* shares — the alternative is dividing by zero.
|
|
109
|
-
*/
|
|
79
|
+
/** Rescale siblings to sum to `total`; siblings summing to zero share equally. */
|
|
110
80
|
function scaleSizes(children, total = 1) {
|
|
111
81
|
const sum = children.reduce((acc, c) => acc + c.size, 0);
|
|
112
82
|
if (sum <= 0) {
|
|
@@ -133,16 +103,11 @@ function mapNode(node, targetId, replacer) {
|
|
|
133
103
|
};
|
|
134
104
|
}
|
|
135
105
|
/**
|
|
136
|
-
* Split `panelId`, putting `newPanel` beside it in `direction`.
|
|
137
|
-
*
|
|
138
|
-
* Always builds a branch around the panel and lets `normalize` flatten it into
|
|
139
|
-
* the parent when the directions agree. Trying to decide up front whether to
|
|
140
|
-
* nest or to insert as a sibling is the same decision rule twice, and the two
|
|
141
|
-
* copies drift.
|
|
106
|
+
* Split `panelId`, putting `newPanel` beside it in `direction`. Always wraps
|
|
107
|
+
* the panel in a branch and lets `normalize` flatten it when directions agree.
|
|
142
108
|
*/
|
|
143
109
|
export function splitPanel(root, panelId, direction, newPanel, before = false) {
|
|
144
|
-
// derived rather than minted:
|
|
145
|
-
// is already unique in the tree, so a branch named after it is too
|
|
110
|
+
// derived rather than minted: the new panel's id is unique, so this is too
|
|
146
111
|
const branchId = `branch-${newPanel.id}`;
|
|
147
112
|
const split = mapNode(root, panelId, found => {
|
|
148
113
|
const pair = before ? [newPanel, found] : [found, newPanel];
|
|
@@ -158,11 +123,8 @@ export function splitPanel(root, panelId, direction, newPanel, before = false) {
|
|
|
158
123
|
/** Drop a panel. Its space goes back to its siblings via renormalisation. */
|
|
159
124
|
export function removePanel(root, panelId) {
|
|
160
125
|
const removed = mapNode(root, panelId, () => undefined);
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// `activeTabId` has to go with the tabs — it named one of them, and a panel
|
|
164
|
-
// pointing at a tab it no longer has is the dangling state every other
|
|
165
|
-
// operation here is careful not to leave (see integrity.test.ts).
|
|
126
|
+
// The only panel empties instead, and `activeTabId` goes with its tabs so it
|
|
127
|
+
// cannot dangle
|
|
166
128
|
return removed
|
|
167
129
|
? normalize(removed)
|
|
168
130
|
: { ...root, tabs: [], activeTabId: undefined };
|
|
@@ -191,10 +153,6 @@ function mapTab(root, tabId, replacer) {
|
|
|
191
153
|
: root;
|
|
192
154
|
}
|
|
193
155
|
// --- tabs ------------------------------------------------------------------
|
|
194
|
-
//
|
|
195
|
-
// A tab is the unit the user drags, closes and renames; a view is what lives
|
|
196
|
-
// inside one. Keeping them separate is what dockview models as group/panel, and
|
|
197
|
-
// it is the level the first version of this tree left out entirely.
|
|
198
156
|
export function addTab(root, panelId, tab) {
|
|
199
157
|
return mapPanel(root, panelId, panel => ({
|
|
200
158
|
...panel,
|
|
@@ -218,43 +176,22 @@ export function removeTab(root, tabId) {
|
|
|
218
176
|
...panel,
|
|
219
177
|
tabs: remaining,
|
|
220
178
|
activeTabId: panel.activeTabId === tabId
|
|
221
|
-
? //
|
|
222
|
-
// the one that slid into its place. A deliberate divergence from
|
|
223
|
-
// dockview, which opened `mostRecentlyUsed[0]`: that needs a
|
|
224
|
-
// per-panel MRU list in the tree, which is state to persist, keep
|
|
225
|
-
// pruned and hold as an invariant, for a difference nobody has
|
|
226
|
-
// asked about.
|
|
179
|
+
? // the left neighbour, or for the leftmost tab the one that slid in
|
|
227
180
|
remaining[Math.max(at - 1, 0)]?.id
|
|
228
181
|
: panel.activeTabId,
|
|
229
182
|
};
|
|
230
183
|
});
|
|
231
184
|
}
|
|
232
185
|
/**
|
|
233
|
-
* Move a tab into another panel, at `index` if given.
|
|
234
|
-
*
|
|
235
|
-
* One function returning one tree, so there is no instant at which the tab is
|
|
236
|
-
* in both panels or neither — the state the imperative bridge had to wrap an
|
|
237
|
-
* explicit `runInAction` around to hide from its own reconcile autorun.
|
|
186
|
+
* Move a tab into another panel, at `index` if given, or append.
|
|
238
187
|
*
|
|
239
|
-
*
|
|
240
|
-
* the
|
|
241
|
-
*
|
|
242
|
-
* tab is moving within its own panel and is currently to the LEFT of the gap it
|
|
243
|
-
* was dropped in. Dragging A to the gap between B and C in `[A, B, C]` is index
|
|
244
|
-
* 2 on screen; taking A out first makes that gap index 1, and inserting at 2
|
|
245
|
-
* lands it after C instead. Adjusting here rather than in the caller keeps the
|
|
246
|
-
* whole "remove then insert" mechanic inside the one function that does it.
|
|
247
|
-
*
|
|
248
|
-
* dockview reaches the same rule from the other end and it is worth knowing
|
|
249
|
-
* they agree, since this is the fiddliest arithmetic in the file: its tabs
|
|
250
|
-
* container computes `insertionIndex - (sourceIndex !== -1 && sourceIndex <
|
|
251
|
-
* insertionIndex ? 1 : 0)`, which is the condition below with the same bounds.
|
|
188
|
+
* `index` counts the strip the user sees, before the move. Within one panel a
|
|
189
|
+
* tab left of the gap shifts it by one once removed: dragging A between B and C
|
|
190
|
+
* in `[A, B, C]` is index 2 on screen but 1 after A comes out.
|
|
252
191
|
*/
|
|
253
192
|
export function moveTabToPanel(root, tabId, targetPanelId, index) {
|
|
254
193
|
const home = findTab(root, tabId);
|
|
255
|
-
//
|
|
256
|
-
// takes the tab out and puts it back, so a target that cannot be found and is
|
|
257
|
-
// not rejected here leaves the tab — and every view in it — nowhere at all.
|
|
194
|
+
// checked before the removal, or a missing target would delete the tab
|
|
258
195
|
if (!home || !findPanel(root, targetPanelId)) {
|
|
259
196
|
return root;
|
|
260
197
|
}
|
|
@@ -263,10 +200,6 @@ export function moveTabToPanel(root, tabId, targetPanelId, index) {
|
|
|
263
200
|
? home.panel.tabs.findIndex(t => t.id === tabId)
|
|
264
201
|
: -1;
|
|
265
202
|
return mapPanel(removeTab(root, tabId), targetPanelId, panel => {
|
|
266
|
-
// No index means append, and it has to keep meaning that within one panel.
|
|
267
|
-
// The shift below is about reading a STATED index against the strip on
|
|
268
|
-
// screen; a caller that states nothing is not describing a gap, so shifting
|
|
269
|
-
// its position lands the tab one place short of the end.
|
|
270
203
|
const at = index === undefined
|
|
271
204
|
? panel.tabs.length
|
|
272
205
|
: Math.min(Math.max(from >= 0 && from < index ? index - 1 : index, 0), panel.tabs.length);
|
|
@@ -302,14 +235,8 @@ export function removeView(root, viewId) {
|
|
|
302
235
|
: root;
|
|
303
236
|
}
|
|
304
237
|
/**
|
|
305
|
-
* Make the tree's membership agree with
|
|
306
|
-
*
|
|
307
|
-
* The only reconciliation left, and one-directional: `session.views` owns which
|
|
308
|
-
* views exist, and nothing reads back.
|
|
309
|
-
*
|
|
310
|
-
* **`viewIds` is the session's WHOLE set.** A shorter list does not leave the
|
|
311
|
-
* rest alone — it unhomes them, and the next homing pass sweeps them into one
|
|
312
|
-
* tab.
|
|
238
|
+
* Make the tree's membership agree with `session.views`, which owns which views
|
|
239
|
+
* exist. `viewIds` is the WHOLE set: a shorter list unhomes the rest.
|
|
313
240
|
*/
|
|
314
241
|
export function homeViews(root, viewIds, activePanelId, nextTabId) {
|
|
315
242
|
const all = panels(root);
|
|
@@ -320,9 +247,7 @@ export function homeViews(root, viewIds, activePanelId, nextTabId) {
|
|
|
320
247
|
let next = root;
|
|
321
248
|
let homeTabId = activeTabIn(target)?.id;
|
|
322
249
|
if (!homeTabId) {
|
|
323
|
-
//
|
|
324
|
-
// this mints one rather than declining, which also means a blank cell built
|
|
325
|
-
// by `closePanel` on the last panel is repaired by the next homing pass.
|
|
250
|
+
// a tabless panel is legal but cannot hold a view, so give it a tab
|
|
326
251
|
homeTabId = nextTabId();
|
|
327
252
|
next = addTab(next, target.id, { id: homeTabId, viewIds: [] });
|
|
328
253
|
}
|
|
@@ -331,8 +256,6 @@ export function homeViews(root, viewIds, activePanelId, nextTabId) {
|
|
|
331
256
|
next = addViewToTab(next, homeTabId, viewId);
|
|
332
257
|
}
|
|
333
258
|
}
|
|
334
|
-
// read the memberships once, then drop: `removeView` returns a new tree each
|
|
335
|
-
// call, so a list taken from a tree it has already replaced goes stale
|
|
336
259
|
const owned = new Set(viewIds);
|
|
337
260
|
const departed = tabs(next)
|
|
338
261
|
.flatMap(t => t.viewIds)
|
|
@@ -343,13 +266,8 @@ export function homeViews(root, viewIds, activePanelId, nextTabId) {
|
|
|
343
266
|
return next;
|
|
344
267
|
}
|
|
345
268
|
/**
|
|
346
|
-
* Drop a panel
|
|
347
|
-
*
|
|
348
|
-
* Deliberately NOT a normalisation rule. A panel with no tabs is reachable on
|
|
349
|
-
* purpose, so pruning empties wholesale would delete a space the user asked
|
|
350
|
-
* for. It is a step the *drag* gesture takes about its own source panel,
|
|
351
|
-
* because dragging the last tab out of a split and leaving a blank half is the
|
|
352
|
-
* one place an empty panel is clearly not what was meant.
|
|
269
|
+
* Drop a panel left with no tabs, unless it is the only one. Not a
|
|
270
|
+
* normalisation rule: only the gesture that emptied the panel calls it.
|
|
353
271
|
*/
|
|
354
272
|
export function pruneEmptyPanel(root, panelId) {
|
|
355
273
|
const found = findPanel(root, panelId);
|
|
@@ -357,23 +275,8 @@ export function pruneEmptyPanel(root, panelId) {
|
|
|
357
275
|
? removePanel(root, panelId)
|
|
358
276
|
: root;
|
|
359
277
|
}
|
|
360
|
-
/**
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
* legitimate (it shows the view launcher), so this only ever runs on the tab a
|
|
365
|
-
* move just emptied, never as a rule over the tree.
|
|
366
|
-
*
|
|
367
|
-
* Which is why closing a tab's last VIEW leaves the tab standing where a move
|
|
368
|
-
* out of it does not: the move acted somewhere else and left this behind, and
|
|
369
|
-
* the close acted here, where an empty tab is a usable place to open the next
|
|
370
|
-
* thing. A cell's last tab prunes because a blank cell is not usable.
|
|
371
|
-
*/
|
|
372
|
-
export function pruneEmptyTabIn(root, panelId, tabId) {
|
|
373
|
-
const found = findPanel(root, panelId);
|
|
374
|
-
if (!found || found.tabs.length <= 1) {
|
|
375
|
-
return root;
|
|
376
|
-
}
|
|
377
|
-
const tab = found.tabs.find(t => t.id === tabId);
|
|
378
|
-
return tab?.viewIds.length === 0 ? removeTab(root, tabId) : root;
|
|
278
|
+
/** Remove a tab that holds no views; a tab with views stays. */
|
|
279
|
+
export function pruneEmptyTab(root, tabId) {
|
|
280
|
+
const found = findTab(root, tabId);
|
|
281
|
+
return found?.tab.viewIds.length === 0 ? removeTab(root, tabId) : root;
|
|
379
282
|
}
|
|
@@ -4,31 +4,13 @@ export interface DragState extends DropTarget {
|
|
|
4
4
|
tabId: string;
|
|
5
5
|
panelId: string;
|
|
6
6
|
}
|
|
7
|
-
export interface TabDragHandlers {
|
|
8
|
-
onTabPointerDown: (tabId: string, event: React.PointerEvent<HTMLElement>) => void;
|
|
9
|
-
onTabPointerMove: (event: React.PointerEvent<HTMLElement>) => void;
|
|
10
|
-
onTabPointerUp: (event: React.PointerEvent<HTMLElement>) => void;
|
|
11
|
-
onTabPointerCancel: (event: React.PointerEvent<HTMLElement>) => void;
|
|
12
|
-
}
|
|
13
7
|
/**
|
|
14
|
-
* Dragging a tab
|
|
15
|
-
*
|
|
16
|
-
* HTML5 DnD would hand us a drag image we don't want, a `dragover` we can't
|
|
17
|
-
* read coordinates from reliably across browsers, and no pointer capture — so
|
|
18
|
-
* releasing outside the window leaves the drag stuck on. Pointer events give
|
|
19
|
-
* capture for free, which is what makes "let go anywhere" behave.
|
|
20
|
-
*
|
|
21
|
-
* The in-flight drag is React state, deliberately NOT in MST. It is transient
|
|
22
|
-
* UI, and putting it in the session would put every intermediate hover into the
|
|
23
|
-
* undo history — which is the mistake `dockviewLayout` made in the other
|
|
24
|
-
* direction, persisting something that was really a view of live state.
|
|
8
|
+
* Dragging a tab. Pointer events rather than HTML5 dnd, for capture; the
|
|
9
|
+
* in-flight drag is React state rather than MST, so hovers stay out of undo.
|
|
25
10
|
*/
|
|
26
11
|
export declare function useLayoutDrag(layout: WorkspaceLayout): {
|
|
27
12
|
drag: DragState | undefined;
|
|
28
|
-
handlers: {
|
|
29
|
-
|
|
30
|
-
onTabPointerMove: (event: React.PointerEvent<HTMLElement>) => void;
|
|
31
|
-
onTabPointerUp: (event: React.PointerEvent<HTMLElement>) => void;
|
|
32
|
-
onTabPointerCancel: (event: React.PointerEvent<HTMLElement>) => void;
|
|
13
|
+
handlers: import("./usePointerGesture.ts").PointerGestureHandlers & {
|
|
14
|
+
cancel: () => void;
|
|
33
15
|
};
|
|
34
16
|
};
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import { useCallback,
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
2
|
import { dropZoneAt, splitForZone, stripDropAt } from './dropZone.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* The indicator is a function of the cell and the zone — or, on a strip, the gap
|
|
7
|
-
* — and a pointer produces events far faster than it crosses between any of
|
|
8
|
-
* them. Publishing a fresh object per move re-rendered the cell under the
|
|
9
|
-
* pointer at pointer-event rate, and a cell's render rebuilds its `ViewStack`;
|
|
10
|
-
* `renderTabContent` hands back a new `views` array each time, so nothing
|
|
11
|
-
* downstream memoises it away. That is the same cost the memoised `handlers`
|
|
12
|
-
* removed for every OTHER cell, still being paid by the one being dragged over.
|
|
13
|
-
*/
|
|
3
|
+
import { usePointerGesture } from './usePointerGesture.js';
|
|
4
|
+
// the indicator is a function of the cell and the zone or strip gap, so a move
|
|
5
|
+
// that paints the same one publishes nothing
|
|
14
6
|
function samePlace(a, b) {
|
|
15
7
|
return (a?.tabId === b?.tabId &&
|
|
16
8
|
a?.panelId === b?.panelId &&
|
|
@@ -18,46 +10,25 @@ function samePlace(a, b) {
|
|
|
18
10
|
a?.strip?.index === b?.strip?.index &&
|
|
19
11
|
a?.strip?.left === b?.strip?.left);
|
|
20
12
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* the centre wash promises "be a tab of this cell" to a tab already in it, held
|
|
24
|
-
* for the whole hover and then dropped on release.
|
|
25
|
-
*
|
|
26
|
-
* Declining here rather than at the release makes it one decision: nothing is
|
|
27
|
-
* published, so `onTabPointerUp` finds an empty `dragRef` and needs no case.
|
|
28
|
-
* `pendingRef` stays armed, so the drag resumes on crossing back out.
|
|
29
|
-
*/
|
|
13
|
+
// a drop `dropTabInPanel` / `dropTabInNewSplit` would leave as it was, which
|
|
14
|
+
// paints nothing either
|
|
30
15
|
function declines(layout, drag) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
16
|
+
const home = layout.findTab(drag.tabId);
|
|
17
|
+
if (home?.panel.id !== drag.panelId) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (drag.strip) {
|
|
21
|
+
const from = home.panel.tabs.findIndex(t => t.id === drag.tabId);
|
|
22
|
+
return drag.strip.index === from || drag.strip.index === from + 1;
|
|
23
|
+
}
|
|
24
|
+
return drag.zone === 'center' || home.panel.tabs.length === 1;
|
|
34
25
|
}
|
|
35
26
|
/**
|
|
36
|
-
* Dragging a tab
|
|
37
|
-
*
|
|
38
|
-
* HTML5 DnD would hand us a drag image we don't want, a `dragover` we can't
|
|
39
|
-
* read coordinates from reliably across browsers, and no pointer capture — so
|
|
40
|
-
* releasing outside the window leaves the drag stuck on. Pointer events give
|
|
41
|
-
* capture for free, which is what makes "let go anywhere" behave.
|
|
42
|
-
*
|
|
43
|
-
* The in-flight drag is React state, deliberately NOT in MST. It is transient
|
|
44
|
-
* UI, and putting it in the session would put every intermediate hover into the
|
|
45
|
-
* undo history — which is the mistake `dockviewLayout` made in the other
|
|
46
|
-
* direction, persisting something that was really a view of live state.
|
|
27
|
+
* Dragging a tab. Pointer events rather than HTML5 dnd, for capture; the
|
|
28
|
+
* in-flight drag is React state rather than MST, so hovers stay out of undo.
|
|
47
29
|
*/
|
|
48
30
|
export function useLayoutDrag(layout) {
|
|
49
31
|
const [drag, setDrag] = useState(undefined);
|
|
50
|
-
// the pointer has gone down on a tab but may still turn out to be a click.
|
|
51
|
-
// `pointerId` is what makes every handler below about ONE pointer: a second
|
|
52
|
-
// finger elsewhere in the strip otherwise steers the first one's drag, and
|
|
53
|
-
// its release ends it. dockview tracks the same id for the same reason.
|
|
54
|
-
const pendingRef = useRef(undefined);
|
|
55
|
-
/**
|
|
56
|
-
* The drag, mirrored into a ref so the handlers can read it without depending
|
|
57
|
-
* on it — which keeps them identity-stable. They go into `PanelChrome`, so
|
|
58
|
-
* handlers that changed on every pointer move re-rendered every cell in the
|
|
59
|
-
* workspace, and a cell's render rebuilds its `ViewStack`.
|
|
60
|
-
*/
|
|
61
32
|
const dragRef = useRef(undefined);
|
|
62
33
|
const showDrag = useCallback((next) => {
|
|
63
34
|
if (samePlace(dragRef.current, next)) {
|
|
@@ -76,9 +47,7 @@ export function useLayoutDrag(layout) {
|
|
|
76
47
|
}
|
|
77
48
|
const panelId = panelEl.dataset.panelId;
|
|
78
49
|
const panelRect = panelEl.getBoundingClientRect();
|
|
79
|
-
//
|
|
80
|
-
// the strip sits inside the panel's top edge band, so `dropZoneAt` alone
|
|
81
|
-
// reads a drop between two tabs as "split this cell upwards".
|
|
50
|
+
// the strip first: it sits inside the panel's top edge band
|
|
82
51
|
const stripEl = under.find(el => 'tabStrip' in el.dataset);
|
|
83
52
|
if (stripEl) {
|
|
84
53
|
// panel-relative, so the caret can be drawn inside the panel's own box
|
|
@@ -99,134 +68,43 @@ export function useLayoutDrag(layout) {
|
|
|
99
68
|
}
|
|
100
69
|
return { panelId, zone: dropZoneAt(panelRect, x, y) };
|
|
101
70
|
}, []);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (event.button !== 0 || !event.isPrimary) {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
// a fresh press is a fresh gesture: anything left over from a drag that
|
|
120
|
-
// ended without a drop would otherwise skip the slop below and start on
|
|
121
|
-
// the first pixel
|
|
122
|
-
showDrag(undefined);
|
|
123
|
-
pendingRef.current = {
|
|
124
|
-
tabId,
|
|
125
|
-
pointerId: event.pointerId,
|
|
126
|
-
x: event.clientX,
|
|
127
|
-
y: event.clientY,
|
|
128
|
-
};
|
|
129
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
130
|
-
}, [showDrag]);
|
|
131
|
-
const onTabPointerMove = useCallback((event) => {
|
|
132
|
-
const pending = pendingRef.current;
|
|
133
|
-
if (pending?.pointerId !== event.pointerId) {
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
// a few pixels of slop, so a tab click is a click and not a zero-distance
|
|
137
|
-
// drag that lands the tab back where it started
|
|
138
|
-
const moved = Math.abs(event.clientX - pending.x) +
|
|
139
|
-
Math.abs(event.clientY - pending.y);
|
|
140
|
-
if (!dragRef.current && moved < 5) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
const target = resolveTarget(event.clientX, event.clientY);
|
|
144
|
-
const next = target ? { tabId: pending.tabId, ...target } : undefined;
|
|
145
|
-
showDrag(next && !declines(layout, next) ? next : undefined);
|
|
146
|
-
}, [layout, resolveTarget, showDrag]);
|
|
147
|
-
/**
|
|
148
|
-
* The gesture ended without a drop: the browser took the pointer away.
|
|
149
|
-
*
|
|
150
|
-
* A long-press on a touch device does exactly this — the platform opens its
|
|
151
|
-
* own context menu and cancels the pointer — and there is no `pointerup` to
|
|
152
|
-
* follow, so the indicator stayed painted over the cell and the drag resumed
|
|
153
|
-
* from the next move. The same clearing as Escape, and for the same reason:
|
|
154
|
-
* clearing only what is on screen leaves `pending` to rebuild it.
|
|
155
|
-
*
|
|
156
|
-
* Capture is already gone by the time this fires, so there is nothing to
|
|
157
|
-
* release.
|
|
158
|
-
*/
|
|
159
|
-
const onTabPointerCancel = useCallback((event) => {
|
|
160
|
-
const pending = pendingRef.current;
|
|
161
|
-
if (pending && pending.pointerId !== event.pointerId) {
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
pendingRef.current = undefined;
|
|
165
|
-
showDrag(undefined);
|
|
166
|
-
}, [showDrag]);
|
|
167
|
-
const onTabPointerUp = useCallback((event) => {
|
|
168
|
-
const pending = pendingRef.current;
|
|
169
|
-
// No pending at all still releases: Escape cancels the drag and leaves
|
|
170
|
-
// capture held, so this is the only thing that gives it back.
|
|
171
|
-
if (pending && pending.pointerId !== event.pointerId) {
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
pendingRef.current = undefined;
|
|
175
|
-
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
176
|
-
const current = dragRef.current;
|
|
177
|
-
showDrag(undefined);
|
|
178
|
-
if (!pending || !current) {
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
// a drop on the strip says where in the order; anywhere else appends
|
|
182
|
-
if (current.strip) {
|
|
183
|
-
layout.dropTabInPanel(current.tabId, current.panelId, current.strip.index);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
const split = splitForZone(current.zone);
|
|
187
|
-
if (split) {
|
|
188
|
-
layout.dropTabInNewSplit(current.tabId, current.panelId, split.direction, split.before);
|
|
189
|
-
}
|
|
190
|
-
else {
|
|
191
|
-
layout.dropTabInPanel(current.tabId, current.panelId);
|
|
192
|
-
}
|
|
193
|
-
}, [layout, showDrag]);
|
|
194
|
-
/**
|
|
195
|
-
* Escape abandons an in-flight drag.
|
|
196
|
-
*
|
|
197
|
-
* On `window`, because pointer capture routes POINTER events to the tab and
|
|
198
|
-
* does nothing for the keyboard — focus is wherever it was when the drag
|
|
199
|
-
* started, which is usually not the tab.
|
|
200
|
-
*
|
|
201
|
-
* Clearing `pendingRef` as well as the drag state is the part that matters:
|
|
202
|
-
* the drag is rebuilt from `pending` on every move, so cancelling the visible
|
|
203
|
-
* state alone would let the next pixel of movement resume it.
|
|
204
|
-
*
|
|
205
|
-
* `drag` is the dependency here on purpose, where the handlers avoid it: the
|
|
206
|
-
* listener should exist only while a drag does.
|
|
207
|
-
*/
|
|
208
|
-
useEffect(() => {
|
|
209
|
-
if (!drag) {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
function onKeyDown(event) {
|
|
213
|
-
if (event.key === 'Escape') {
|
|
214
|
-
pendingRef.current = undefined;
|
|
215
|
-
showDrag(undefined);
|
|
71
|
+
const handlers = usePointerGesture({
|
|
72
|
+
escapeCancels: true,
|
|
73
|
+
start(event) {
|
|
74
|
+
const { tabId } = event.currentTarget.dataset;
|
|
75
|
+
return tabId === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: { tabId, x: event.clientX, y: event.clientY };
|
|
78
|
+
},
|
|
79
|
+
move(pending, event) {
|
|
80
|
+
// slop, so a click is not a zero-distance drag
|
|
81
|
+
const moved = Math.abs(event.clientX - pending.x) +
|
|
82
|
+
Math.abs(event.clientY - pending.y);
|
|
83
|
+
if (!dragRef.current && moved < 5) {
|
|
84
|
+
return;
|
|
216
85
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
86
|
+
const target = resolveTarget(event.clientX, event.clientY);
|
|
87
|
+
const next = target ? { tabId: pending.tabId, ...target } : undefined;
|
|
88
|
+
showDrag(next && !declines(layout, next) ? next : undefined);
|
|
89
|
+
},
|
|
90
|
+
end(_pending, completed) {
|
|
91
|
+
const current = dragRef.current;
|
|
92
|
+
showDrag(undefined);
|
|
93
|
+
if (!completed || !current) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (current.strip) {
|
|
97
|
+
layout.dropTabInPanel(current.tabId, current.panelId, current.strip.index);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const split = splitForZone(current.zone);
|
|
101
|
+
if (split) {
|
|
102
|
+
layout.dropTabInNewSplit(current.tabId, current.panelId, split.direction, split.before);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
layout.dropTabInPanel(current.tabId, current.panelId);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
});
|
|
231
109
|
return { drag, handlers };
|
|
232
110
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface PointerGestureHandlers {
|
|
2
|
+
onPointerDown: (event: React.PointerEvent<HTMLElement>) => void;
|
|
3
|
+
onPointerMove: (event: React.PointerEvent<HTMLElement>) => void;
|
|
4
|
+
onPointerUp: (event: React.PointerEvent<HTMLElement>) => void;
|
|
5
|
+
onPointerCancel: (event: React.PointerEvent<HTMLElement>) => void;
|
|
6
|
+
onLostPointerCapture: (event: React.PointerEvent<HTMLElement>) => void;
|
|
7
|
+
}
|
|
8
|
+
export interface PointerGesture<S> {
|
|
9
|
+
/** the gesture's state, or undefined to decline the press */
|
|
10
|
+
start: (event: React.PointerEvent<HTMLElement>) => S | undefined;
|
|
11
|
+
move: (state: S, event: React.PointerEvent<HTMLElement>) => void;
|
|
12
|
+
/** `completed` is true for a release, false for any way of abandoning it */
|
|
13
|
+
end?: (state: S, completed: boolean) => void;
|
|
14
|
+
escapeCancels?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One pointer drag, owning the rules every gesture here needs: the primary
|
|
18
|
+
* button of the primary pointer starts it, one `pointerId` steers it, capture
|
|
19
|
+
* routes it, and a cancel, a lost capture or (opted in) Escape abandons it.
|
|
20
|
+
* The handlers are identity-stable, so they can sit in memoised chrome.
|
|
21
|
+
*/
|
|
22
|
+
export declare function usePointerGesture<S>(gesture: PointerGesture<S>): PointerGestureHandlers & {
|
|
23
|
+
cancel: () => void;
|
|
24
|
+
};
|