@jbrowse/app-core 5.0.0-beta.10 → 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.
Files changed (55) hide show
  1. package/esm/HistoryManagement/index.d.ts +3 -0
  2. package/esm/JBrowseConfig/RootConfiguration.d.ts +1 -8
  3. package/esm/JBrowseConfig/RootConfiguration.js +2 -6
  4. package/esm/JBrowseConfig/index.d.ts +1 -8
  5. package/esm/JBrowseModel/index.d.ts +1 -8
  6. package/esm/JbApi/jbApi.d.ts +2 -2
  7. package/esm/JbApi/jbApi.js +12 -10
  8. package/esm/SessionSpec/loadSessionSpec.js +2 -12
  9. package/esm/WorkspaceLayout/LayoutRenderer.d.ts +0 -10
  10. package/esm/WorkspaceLayout/LayoutRenderer.js +34 -101
  11. package/esm/WorkspaceLayout/PanelView.js +19 -45
  12. package/esm/WorkspaceLayout/TabStrip.d.ts +2 -2
  13. package/esm/WorkspaceLayout/TabStrip.js +36 -104
  14. package/esm/WorkspaceLayout/WorkspaceContainer.d.ts +0 -9
  15. package/esm/WorkspaceLayout/WorkspaceContainer.js +9 -34
  16. package/esm/WorkspaceLayout/WorkspacePanelActions.d.ts +1 -16
  17. package/esm/WorkspaceLayout/WorkspacePanelActions.js +45 -50
  18. package/esm/WorkspaceLayout/WorkspaceTab.d.ts +1 -16
  19. package/esm/WorkspaceLayout/WorkspaceTab.js +11 -47
  20. package/esm/WorkspaceLayout/dropZone.d.ts +7 -37
  21. package/esm/WorkspaceLayout/dropZone.js +7 -44
  22. package/esm/WorkspaceLayout/model.d.ts +42 -112
  23. package/esm/WorkspaceLayout/model.js +139 -268
  24. package/esm/WorkspaceLayout/panelChrome.d.ts +4 -7
  25. package/esm/WorkspaceLayout/panelChrome.js +0 -2
  26. package/esm/WorkspaceLayout/spec.d.ts +10 -51
  27. package/esm/WorkspaceLayout/spec.js +12 -80
  28. package/esm/WorkspaceLayout/splitter.d.ts +3 -19
  29. package/esm/WorkspaceLayout/splitter.js +5 -21
  30. package/esm/WorkspaceLayout/tabName.d.ts +8 -0
  31. package/esm/WorkspaceLayout/tabName.js +14 -0
  32. package/esm/WorkspaceLayout/tree.d.ts +19 -76
  33. package/esm/WorkspaceLayout/tree.js +29 -122
  34. package/esm/WorkspaceLayout/useLayoutDrag.d.ts +4 -22
  35. package/esm/WorkspaceLayout/useLayoutDrag.js +46 -174
  36. package/esm/WorkspaceLayout/usePointerGesture.d.ts +24 -0
  37. package/esm/WorkspaceLayout/usePointerGesture.js +83 -0
  38. package/esm/WorkspaceLayout/workspaceTheme.d.ts +27 -0
  39. package/esm/WorkspaceLayout/workspaceTheme.js +37 -0
  40. package/esm/ui/App/App.js +3 -0
  41. package/esm/ui/App/JBrowseTabMenu.js +1 -1
  42. package/esm/ui/App/ThemeModeButton.js +5 -4
  43. package/esm/ui/App/ViewMenu.js +47 -29
  44. package/esm/ui/App/ViewsContainer.js +3 -4
  45. package/esm/ui/App/lazyParts.d.ts +0 -5
  46. package/esm/ui/App/lazyParts.js +0 -1
  47. package/esm/ui/App/preloadAppShell.js +3 -6
  48. package/esm/ui/App/types.d.ts +3 -2
  49. package/esm/ui/App/useViewVisibility.d.ts +1 -1
  50. package/esm/ui/App/useViewVisibility.js +1 -1
  51. package/package.json +5 -5
  52. package/esm/WorkspaceLayout/dockviewTheme.d.ts +0 -69
  53. package/esm/WorkspaceLayout/dockviewTheme.js +0 -82
  54. package/esm/ui/App/ClassicViewsContainer.d.ts +0 -6
  55. package/esm/ui/App/ClassicViewsContainer.js +0 -22
@@ -1,18 +1,7 @@
1
1
  /**
2
- * The layout tree, as pure functions over plain snapshots.
3
- *
4
- * This is the half of an MST-native workspace that carries the risk, so it is
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
- * Rule 3 is the one dockview cannot express: it forces orientation to alternate
62
- * by depth, so `row` inside `row` is not representable and a nested split gets
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 whose sizes are already right are left ALONE, rather than divided by
87
- * a sum of 1 and multiplied by 1.
88
- *
89
- * That round trip is not the identity in floating point, and normalisation runs
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: this function is pure, and the new panel's id
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
- // Removing the only panel leaves nothing to render, which is not a state the
162
- // workspace has: the caller gets an empty panel to put the next tab in.
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
- ? // Fall to the neighbour on the left — which for the leftmost tab is
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
- * **`index` counts the tabs as they are ordered NOW**, before the move. That is
240
- * the only reading a caller can supply, because it is the strip the user is
241
- * looking at — and it differs from the post-removal ordering exactly when the
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
- // The target has to be checked BEFORE the removal, not after. This function
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 the session's list of views.
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
- // A panel with no tabs is legal, but it cannot be where a view lands — so
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 that has been left with no tabs, unless it is the only one.
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 that a move has left with no views, unless it is the panel's last.
362
- *
363
- * The mirror of `pruneEmptyPanel`, and deliberately as narrow: an empty tab is
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: pointer events, not HTML5 drag-and-drop.
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
- onTabPointerDown: (tabId: string, event: React.PointerEvent<HTMLElement>) => void;
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, useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useCallback, useRef, useState } from 'react';
2
2
  import { dropZoneAt, splitForZone, stripDropAt } from './dropZone.js';
3
- /**
4
- * Two drags that would paint the same thing.
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
- * A drop `dropTabInPanel` would decline, so there is nothing to draw for it —
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: pointer events, not HTML5 drag-and-drop.
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
- // The strip is a finer answer than `center`, and it has to be tested first:
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
- * A drag starts on the PRIMARY button of the primary pointer, and on nothing
104
- * else.
105
- *
106
- * dockview never had to say this: its tab drags over HTML5 dnd, where the
107
- * browser owns the rule, and its pointer source is touch/pen only by default.
108
- * Pointer events here are for capture (release outside the window ends the
109
- * drag), which means owning the rule too — and without it a right-drag moved
110
- * a tab, and a right-press left `pending` armed with no `pointerup` to clear
111
- * it, because the native context menu eats that one. The next move of a
112
- * button-less pointer then dragged the tab, and the click that dismissed the
113
- * menu dropped it.
114
- */
115
- const onTabPointerDown = useCallback((tabId, event) => {
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
- window.addEventListener('keydown', onKeyDown);
219
- return () => {
220
- window.removeEventListener('keydown', onKeyDown);
221
- };
222
- }, [drag, showDrag]);
223
- // one object, memoised: it goes into `PanelChrome`, which every panel holds,
224
- // so a fresh one per render is a re-render of every cell in the workspace
225
- const handlers = useMemo(() => ({
226
- onTabPointerDown,
227
- onTabPointerMove,
228
- onTabPointerUp,
229
- onTabPointerCancel,
230
- }), [onTabPointerDown, onTabPointerMove, onTabPointerUp, onTabPointerCancel]);
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
+ }