@jbrowse/app-core 5.0.0-beta.11 → 5.0.0-beta.13
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/JBrowseConfig/RootConfiguration.d.ts +1 -8
- package/esm/JBrowseConfig/RootConfiguration.js +2 -6
- package/esm/JBrowseConfig/index.d.ts +1 -8
- package/esm/JBrowseModel/index.d.ts +1 -8
- package/esm/JbApi/jbApi.d.ts +2 -2
- package/esm/JbApi/jbApi.js +12 -10
- package/esm/SessionSpec/loadSessionSpec.js +2 -12
- 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 -34
- package/esm/WorkspaceLayout/WorkspacePanelActions.d.ts +1 -16
- package/esm/WorkspaceLayout/WorkspacePanelActions.js +45 -50
- package/esm/WorkspaceLayout/WorkspaceTab.d.ts +1 -16
- package/esm/WorkspaceLayout/WorkspaceTab.js +11 -47
- package/esm/WorkspaceLayout/dropZone.d.ts +7 -37
- package/esm/WorkspaceLayout/dropZone.js +7 -44
- package/esm/WorkspaceLayout/model.d.ts +42 -112
- package/esm/WorkspaceLayout/model.js +139 -268
- 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 -76
- package/esm/WorkspaceLayout/tree.js +29 -122
- package/esm/WorkspaceLayout/useLayoutDrag.d.ts +4 -22
- package/esm/WorkspaceLayout/useLayoutDrag.js +46 -174
- 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/ui/App/App.js +3 -0
- package/esm/ui/App/JBrowseTabMenu.js +1 -1
- package/esm/ui/App/ThemeModeButton.js +5 -4
- package/esm/ui/App/ViewMenu.js +47 -29
- 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/package.json +4 -4
- 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;
|
|
@@ -58,14 +47,8 @@ export function panelContainingView(node, viewId) {
|
|
|
58
47
|
* it, its children's sizes scaled to preserve their share of the whole
|
|
59
48
|
* 4. sizes are renormalised so a node's siblings always sum to 1
|
|
60
49
|
*
|
|
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.
|
|
50
|
+
* Empty panels and empty tabs stay: "new empty tab" creates one on purpose, to
|
|
51
|
+
* show the view launcher.
|
|
69
52
|
*/
|
|
70
53
|
export function normalize(node) {
|
|
71
54
|
if (!isBranch(node)) {
|
|
@@ -83,30 +66,13 @@ export function normalize(node) {
|
|
|
83
66
|
return { ...node, children: scaleSizes(children) };
|
|
84
67
|
}
|
|
85
68
|
/**
|
|
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.
|
|
69
|
+
* Siblings already summing to the target within this tolerance are left alone.
|
|
70
|
+
* Rescaling is not the identity in floating point (seven equal panes oscillate
|
|
71
|
+
* between sums of 0.9999999999999998 and 1.0000000000000002), so without it
|
|
72
|
+
* `normalize` has no fixed point and every action fills undo with a no-op.
|
|
100
73
|
*/
|
|
101
74
|
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
|
-
*/
|
|
75
|
+
/** Rescale siblings to sum to `total`; siblings summing to zero share equally. */
|
|
110
76
|
function scaleSizes(children, total = 1) {
|
|
111
77
|
const sum = children.reduce((acc, c) => acc + c.size, 0);
|
|
112
78
|
if (sum <= 0) {
|
|
@@ -133,16 +99,11 @@ function mapNode(node, targetId, replacer) {
|
|
|
133
99
|
};
|
|
134
100
|
}
|
|
135
101
|
/**
|
|
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.
|
|
102
|
+
* Split `panelId`, putting `newPanel` beside it in `direction`. Always wraps
|
|
103
|
+
* the panel in a branch and lets `normalize` flatten it when directions agree.
|
|
142
104
|
*/
|
|
143
105
|
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
|
|
106
|
+
// derived rather than minted: the new panel's id is unique, so this is too
|
|
146
107
|
const branchId = `branch-${newPanel.id}`;
|
|
147
108
|
const split = mapNode(root, panelId, found => {
|
|
148
109
|
const pair = before ? [newPanel, found] : [found, newPanel];
|
|
@@ -158,11 +119,8 @@ export function splitPanel(root, panelId, direction, newPanel, before = false) {
|
|
|
158
119
|
/** Drop a panel. Its space goes back to its siblings via renormalisation. */
|
|
159
120
|
export function removePanel(root, panelId) {
|
|
160
121
|
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).
|
|
122
|
+
// The only panel empties instead, and `activeTabId` goes with its tabs so it
|
|
123
|
+
// cannot dangle
|
|
166
124
|
return removed
|
|
167
125
|
? normalize(removed)
|
|
168
126
|
: { ...root, tabs: [], activeTabId: undefined };
|
|
@@ -191,10 +149,6 @@ function mapTab(root, tabId, replacer) {
|
|
|
191
149
|
: root;
|
|
192
150
|
}
|
|
193
151
|
// --- 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
152
|
export function addTab(root, panelId, tab) {
|
|
199
153
|
return mapPanel(root, panelId, panel => ({
|
|
200
154
|
...panel,
|
|
@@ -218,43 +172,22 @@ export function removeTab(root, tabId) {
|
|
|
218
172
|
...panel,
|
|
219
173
|
tabs: remaining,
|
|
220
174
|
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.
|
|
175
|
+
? // the left neighbour, or for the leftmost tab the one that slid in
|
|
227
176
|
remaining[Math.max(at - 1, 0)]?.id
|
|
228
177
|
: panel.activeTabId,
|
|
229
178
|
};
|
|
230
179
|
});
|
|
231
180
|
}
|
|
232
181
|
/**
|
|
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.
|
|
182
|
+
* Move a tab into another panel, at `index` if given, or append.
|
|
238
183
|
*
|
|
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.
|
|
184
|
+
* `index` counts the strip the user sees, before the move. Within one panel a
|
|
185
|
+
* tab left of the gap shifts it by one once removed: dragging A between B and C
|
|
186
|
+
* in `[A, B, C]` is index 2 on screen but 1 after A comes out.
|
|
252
187
|
*/
|
|
253
188
|
export function moveTabToPanel(root, tabId, targetPanelId, index) {
|
|
254
189
|
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.
|
|
190
|
+
// checked before the removal, or a missing target would delete the tab
|
|
258
191
|
if (!home || !findPanel(root, targetPanelId)) {
|
|
259
192
|
return root;
|
|
260
193
|
}
|
|
@@ -263,10 +196,6 @@ export function moveTabToPanel(root, tabId, targetPanelId, index) {
|
|
|
263
196
|
? home.panel.tabs.findIndex(t => t.id === tabId)
|
|
264
197
|
: -1;
|
|
265
198
|
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
199
|
const at = index === undefined
|
|
271
200
|
? panel.tabs.length
|
|
272
201
|
: Math.min(Math.max(from >= 0 && from < index ? index - 1 : index, 0), panel.tabs.length);
|
|
@@ -302,14 +231,8 @@ export function removeView(root, viewId) {
|
|
|
302
231
|
: root;
|
|
303
232
|
}
|
|
304
233
|
/**
|
|
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.
|
|
234
|
+
* Make the tree's membership agree with `session.views`, which owns which views
|
|
235
|
+
* exist. `viewIds` is the WHOLE set: a shorter list unhomes the rest.
|
|
313
236
|
*/
|
|
314
237
|
export function homeViews(root, viewIds, activePanelId, nextTabId) {
|
|
315
238
|
const all = panels(root);
|
|
@@ -320,9 +243,7 @@ export function homeViews(root, viewIds, activePanelId, nextTabId) {
|
|
|
320
243
|
let next = root;
|
|
321
244
|
let homeTabId = activeTabIn(target)?.id;
|
|
322
245
|
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.
|
|
246
|
+
// a tabless panel is legal but cannot hold a view, so give it a tab
|
|
326
247
|
homeTabId = nextTabId();
|
|
327
248
|
next = addTab(next, target.id, { id: homeTabId, viewIds: [] });
|
|
328
249
|
}
|
|
@@ -331,8 +252,6 @@ export function homeViews(root, viewIds, activePanelId, nextTabId) {
|
|
|
331
252
|
next = addViewToTab(next, homeTabId, viewId);
|
|
332
253
|
}
|
|
333
254
|
}
|
|
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
255
|
const owned = new Set(viewIds);
|
|
337
256
|
const departed = tabs(next)
|
|
338
257
|
.flatMap(t => t.viewIds)
|
|
@@ -343,13 +262,8 @@ export function homeViews(root, viewIds, activePanelId, nextTabId) {
|
|
|
343
262
|
return next;
|
|
344
263
|
}
|
|
345
264
|
/**
|
|
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.
|
|
265
|
+
* Drop a panel left with no tabs, unless it is the only one. Not a
|
|
266
|
+
* normalisation rule: only the gesture that emptied the panel calls it.
|
|
353
267
|
*/
|
|
354
268
|
export function pruneEmptyPanel(root, panelId) {
|
|
355
269
|
const found = findPanel(root, panelId);
|
|
@@ -358,16 +272,9 @@ export function pruneEmptyPanel(root, panelId) {
|
|
|
358
272
|
: root;
|
|
359
273
|
}
|
|
360
274
|
/**
|
|
361
|
-
* Drop a tab
|
|
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.
|
|
275
|
+
* Drop a tab a move has left with no views, unless it is the panel's last.
|
|
276
|
+
* Closing a tab's last view leaves the tab standing, as a place to open the
|
|
277
|
+
* next one; only a move out of it prunes.
|
|
371
278
|
*/
|
|
372
279
|
export function pruneEmptyTabIn(root, panelId, tabId) {
|
|
373
280
|
const found = findPanel(root, panelId);
|
|
@@ -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,19 @@ 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 centre drop on the tab's own cell, which `dropTabInPanel` declines, so it
|
|
14
|
+
// paints nothing either
|
|
30
15
|
function declines(layout, drag) {
|
|
31
16
|
return (!drag.strip &&
|
|
32
17
|
drag.zone === 'center' &&
|
|
33
18
|
layout.findTab(drag.tabId)?.panel.id === drag.panelId);
|
|
34
19
|
}
|
|
35
20
|
/**
|
|
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.
|
|
21
|
+
* Dragging a tab. Pointer events rather than HTML5 dnd, for capture; the
|
|
22
|
+
* in-flight drag is React state rather than MST, so hovers stay out of undo.
|
|
47
23
|
*/
|
|
48
24
|
export function useLayoutDrag(layout) {
|
|
49
25
|
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
26
|
const dragRef = useRef(undefined);
|
|
62
27
|
const showDrag = useCallback((next) => {
|
|
63
28
|
if (samePlace(dragRef.current, next)) {
|
|
@@ -76,9 +41,7 @@ export function useLayoutDrag(layout) {
|
|
|
76
41
|
}
|
|
77
42
|
const panelId = panelEl.dataset.panelId;
|
|
78
43
|
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".
|
|
44
|
+
// the strip first: it sits inside the panel's top edge band
|
|
82
45
|
const stripEl = under.find(el => 'tabStrip' in el.dataset);
|
|
83
46
|
if (stripEl) {
|
|
84
47
|
// panel-relative, so the caret can be drawn inside the panel's own box
|
|
@@ -99,134 +62,43 @@ export function useLayoutDrag(layout) {
|
|
|
99
62
|
}
|
|
100
63
|
return { panelId, zone: dropZoneAt(panelRect, x, y) };
|
|
101
64
|
}, []);
|
|
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);
|
|
65
|
+
const handlers = usePointerGesture({
|
|
66
|
+
escapeCancels: true,
|
|
67
|
+
start(event) {
|
|
68
|
+
const { tabId } = event.currentTarget.dataset;
|
|
69
|
+
return tabId === undefined
|
|
70
|
+
? undefined
|
|
71
|
+
: { tabId, x: event.clientX, y: event.clientY };
|
|
72
|
+
},
|
|
73
|
+
move(pending, event) {
|
|
74
|
+
// slop, so a click is not a zero-distance drag
|
|
75
|
+
const moved = Math.abs(event.clientX - pending.x) +
|
|
76
|
+
Math.abs(event.clientY - pending.y);
|
|
77
|
+
if (!dragRef.current && moved < 5) {
|
|
78
|
+
return;
|
|
216
79
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
80
|
+
const target = resolveTarget(event.clientX, event.clientY);
|
|
81
|
+
const next = target ? { tabId: pending.tabId, ...target } : undefined;
|
|
82
|
+
showDrag(next && !declines(layout, next) ? next : undefined);
|
|
83
|
+
},
|
|
84
|
+
end(_pending, completed) {
|
|
85
|
+
const current = dragRef.current;
|
|
86
|
+
showDrag(undefined);
|
|
87
|
+
if (!completed || !current) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (current.strip) {
|
|
91
|
+
layout.dropTabInPanel(current.tabId, current.panelId, current.strip.index);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const split = splitForZone(current.zone);
|
|
95
|
+
if (split) {
|
|
96
|
+
layout.dropTabInNewSplit(current.tabId, current.panelId, split.direction, split.before);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
layout.dropTabInPanel(current.tabId, current.panelId);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
231
103
|
return { drag, handlers };
|
|
232
104
|
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* One pointer drag, owning the rules every gesture here needs: the primary
|
|
4
|
+
* button of the primary pointer starts it, one `pointerId` steers it, capture
|
|
5
|
+
* routes it, and a cancel, a lost capture or (opted in) Escape abandons it.
|
|
6
|
+
* The handlers are identity-stable, so they can sit in memoised chrome.
|
|
7
|
+
*/
|
|
8
|
+
export function usePointerGesture(gesture) {
|
|
9
|
+
const gestureRef = useRef(gesture);
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
gestureRef.current = gesture;
|
|
12
|
+
});
|
|
13
|
+
const activeRef = useRef(undefined);
|
|
14
|
+
const handlers = useMemo(() => {
|
|
15
|
+
function finish(completed) {
|
|
16
|
+
const active = activeRef.current;
|
|
17
|
+
if (!active) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
activeRef.current = undefined;
|
|
21
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
22
|
+
if (active.element.hasPointerCapture(active.pointerId)) {
|
|
23
|
+
active.element.releasePointerCapture(active.pointerId);
|
|
24
|
+
}
|
|
25
|
+
gestureRef.current.end?.(active.state, completed);
|
|
26
|
+
}
|
|
27
|
+
function onKeyDown(event) {
|
|
28
|
+
if (event.key === 'Escape') {
|
|
29
|
+
finish(false);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function owns(event) {
|
|
33
|
+
return activeRef.current?.pointerId === event.pointerId;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
onPointerDown(event) {
|
|
37
|
+
if (event.button !== 0 || !event.isPrimary) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
finish(false);
|
|
41
|
+
const state = gestureRef.current.start(event);
|
|
42
|
+
if (state === undefined) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
activeRef.current = {
|
|
46
|
+
pointerId: event.pointerId,
|
|
47
|
+
element: event.currentTarget,
|
|
48
|
+
state,
|
|
49
|
+
};
|
|
50
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
51
|
+
if (gestureRef.current.escapeCancels) {
|
|
52
|
+
window.addEventListener('keydown', onKeyDown);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
onPointerMove(event) {
|
|
56
|
+
const active = activeRef.current;
|
|
57
|
+
if (active && owns(event)) {
|
|
58
|
+
gestureRef.current.move(active.state, event);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
onPointerUp(event) {
|
|
62
|
+
if (owns(event)) {
|
|
63
|
+
finish(true);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
onPointerCancel(event) {
|
|
67
|
+
if (owns(event)) {
|
|
68
|
+
finish(false);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
onLostPointerCapture(event) {
|
|
72
|
+
if (owns(event)) {
|
|
73
|
+
finish(false);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
cancel() {
|
|
77
|
+
finish(false);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}, []);
|
|
81
|
+
useEffect(() => handlers.cancel, [handlers]);
|
|
82
|
+
return handlers;
|
|
83
|
+
}
|