@jbrowse/app-core 4.3.0 → 5.0.0-beta.10

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 (165) hide show
  1. package/esm/AppSession/AppRootModel.d.ts +30 -0
  2. package/esm/AppSession/AppRootModel.js +1 -0
  3. package/esm/AppSession/AppSessionMixin.d.ts +1324 -0
  4. package/esm/AppSession/AppSessionMixin.js +62 -0
  5. package/esm/AppSession/index.d.ts +2 -0
  6. package/esm/AppSession/index.js +1 -0
  7. package/esm/Assemblies/AssembliesMixin.d.ts +294 -0
  8. package/esm/Assemblies/AssembliesMixin.js +154 -0
  9. package/esm/Assemblies/index.d.ts +1 -2
  10. package/esm/Assemblies/index.js +1 -2
  11. package/esm/HistoryManagement/index.d.ts +11 -0
  12. package/esm/HistoryManagement/index.js +43 -10
  13. package/esm/JBrowseConfig/RootConfiguration.d.ts +123 -78
  14. package/esm/JBrowseConfig/RootConfiguration.js +79 -7
  15. package/esm/JBrowseConfig/defaultShareUrl.d.ts +1 -0
  16. package/esm/JBrowseConfig/defaultShareUrl.js +5 -0
  17. package/esm/JBrowseConfig/index.d.ts +175 -84
  18. package/esm/JBrowseConfig/index.js +64 -2
  19. package/esm/JBrowseModel/index.d.ts +175 -97
  20. package/esm/JBrowseModel/index.js +132 -31
  21. package/esm/JbApi/index.d.ts +2 -0
  22. package/esm/JbApi/index.js +1 -0
  23. package/esm/JbApi/jbApi.d.ts +2767 -0
  24. package/esm/JbApi/jbApi.js +1383 -0
  25. package/esm/RootMenu/index.d.ts +69 -3
  26. package/esm/RootMenu/index.js +96 -61
  27. package/esm/SessionSpec/index.d.ts +6 -0
  28. package/esm/SessionSpec/index.js +3 -0
  29. package/esm/SessionSpec/lgvUrlInit.d.ts +79 -0
  30. package/esm/SessionSpec/lgvUrlInit.js +161 -0
  31. package/esm/SessionSpec/loadSessionSpec.d.ts +75 -0
  32. package/esm/SessionSpec/loadSessionSpec.js +473 -0
  33. package/esm/SessionSpec/parseSessionSpecUrl.d.ts +7 -0
  34. package/esm/SessionSpec/parseSessionSpecUrl.js +181 -0
  35. package/esm/SessionSpec/types.d.ts +37 -0
  36. package/esm/SessionSpec/types.js +1 -0
  37. package/esm/WorkspaceLayout/LayoutRenderer.d.ts +24 -0
  38. package/esm/WorkspaceLayout/LayoutRenderer.js +185 -0
  39. package/esm/WorkspaceLayout/PanelView.d.ts +13 -0
  40. package/esm/WorkspaceLayout/PanelView.js +99 -0
  41. package/esm/WorkspaceLayout/TabStrip.d.ts +14 -0
  42. package/esm/WorkspaceLayout/TabStrip.js +226 -0
  43. package/esm/WorkspaceLayout/WorkspaceContainer.d.ts +16 -0
  44. package/esm/WorkspaceLayout/WorkspaceContainer.js +107 -0
  45. package/esm/WorkspaceLayout/WorkspacePanelActions.d.ts +24 -0
  46. package/esm/WorkspaceLayout/WorkspacePanelActions.js +120 -0
  47. package/esm/WorkspaceLayout/WorkspaceTab.d.ts +27 -0
  48. package/esm/WorkspaceLayout/WorkspaceTab.js +105 -0
  49. package/esm/WorkspaceLayout/dockviewTheme.d.ts +69 -0
  50. package/esm/WorkspaceLayout/dockviewTheme.js +82 -0
  51. package/esm/WorkspaceLayout/dropZone.d.ts +105 -0
  52. package/esm/WorkspaceLayout/dropZone.js +113 -0
  53. package/esm/WorkspaceLayout/index.d.ts +8 -0
  54. package/esm/WorkspaceLayout/index.js +4 -0
  55. package/esm/WorkspaceLayout/model.d.ts +260 -0
  56. package/esm/WorkspaceLayout/model.js +586 -0
  57. package/esm/WorkspaceLayout/panelChrome.d.ts +19 -0
  58. package/esm/WorkspaceLayout/panelChrome.js +4 -0
  59. package/esm/WorkspaceLayout/spec.d.ts +88 -0
  60. package/esm/WorkspaceLayout/spec.js +226 -0
  61. package/esm/WorkspaceLayout/splitter.d.ts +25 -0
  62. package/esm/WorkspaceLayout/splitter.js +37 -0
  63. package/esm/WorkspaceLayout/tree.d.ts +158 -0
  64. package/esm/WorkspaceLayout/tree.js +379 -0
  65. package/esm/WorkspaceLayout/useLayoutDrag.d.ts +34 -0
  66. package/esm/WorkspaceLayout/useLayoutDrag.js +232 -0
  67. package/esm/index.d.ts +18 -9
  68. package/esm/index.js +16 -9
  69. package/esm/menus.d.ts +55 -68
  70. package/esm/menus.js +117 -86
  71. package/esm/queryParams.d.ts +9 -0
  72. package/esm/queryParams.js +93 -0
  73. package/esm/sessionModelFactory.d.ts +18 -0
  74. package/esm/sessionModelFactory.js +1 -0
  75. package/esm/ui/App/App.d.ts +3 -14
  76. package/esm/ui/App/App.js +39 -19
  77. package/esm/ui/App/AppFab.d.ts +12 -3
  78. package/esm/ui/App/AppFab.js +15 -8
  79. package/esm/ui/App/AppToolbar.d.ts +2 -14
  80. package/esm/ui/App/AppToolbar.js +45 -31
  81. package/esm/ui/App/ClassicViewsContainer.d.ts +4 -10
  82. package/esm/ui/App/ClassicViewsContainer.js +9 -8
  83. package/esm/ui/App/DialogQueue.d.ts +17 -1
  84. package/esm/ui/App/DialogQueue.js +30 -1
  85. package/esm/ui/App/Drawer.d.ts +1 -1
  86. package/esm/ui/App/Drawer.js +10 -39
  87. package/esm/ui/App/DrawerHeader.d.ts +2 -3
  88. package/esm/ui/App/DrawerHeader.js +24 -11
  89. package/esm/ui/App/DrawerHeaderHelpButton.d.ts +1 -1
  90. package/esm/ui/App/DrawerHeaderHelpButton.js +3 -3
  91. package/esm/ui/App/DrawerHeaderHelpDialog.d.ts +2 -2
  92. package/esm/ui/App/DrawerHeaderHelpDialog.js +3 -4
  93. package/esm/ui/App/DrawerWidget.d.ts +1 -1
  94. package/esm/ui/App/DrawerWidget.js +9 -21
  95. package/esm/ui/App/JBrowseTabMenu.d.ts +2 -3
  96. package/esm/ui/App/JBrowseTabMenu.js +24 -5
  97. package/esm/ui/App/ThemeModeButton.d.ts +12 -0
  98. package/esm/ui/App/ThemeModeButton.js +34 -0
  99. package/esm/ui/App/ViewButtons.d.ts +2 -4
  100. package/esm/ui/App/ViewButtons.js +15 -3
  101. package/esm/ui/App/ViewContainer.d.ts +3 -2
  102. package/esm/ui/App/ViewContainer.js +128 -24
  103. package/esm/ui/App/ViewContainerTitle.d.ts +1 -1
  104. package/esm/ui/App/ViewContainerTitle.js +21 -11
  105. package/esm/ui/App/ViewErrorFallback.d.ts +18 -0
  106. package/esm/ui/App/ViewErrorFallback.js +42 -0
  107. package/esm/ui/App/ViewHeader.d.ts +3 -4
  108. package/esm/ui/App/ViewHeader.js +28 -17
  109. package/esm/ui/App/ViewLauncher.d.ts +2 -8
  110. package/esm/ui/App/ViewLauncher.js +7 -5
  111. package/esm/ui/App/ViewMenu.d.ts +3 -5
  112. package/esm/ui/App/ViewMenu.js +87 -92
  113. package/esm/ui/App/ViewStack.d.ts +7 -0
  114. package/esm/ui/App/ViewStack.js +48 -0
  115. package/esm/ui/App/ViewWrapper.d.ts +15 -1
  116. package/esm/ui/App/ViewWrapper.js +26 -10
  117. package/esm/ui/App/ViewsContainer.d.ts +4 -11
  118. package/esm/ui/App/ViewsContainer.js +6 -9
  119. package/esm/ui/App/index.d.ts +2 -1
  120. package/esm/ui/App/index.js +3 -2
  121. package/esm/ui/App/lazyChunk.d.ts +13 -0
  122. package/esm/ui/App/lazyChunk.js +91 -0
  123. package/esm/ui/App/lazyParts.d.ts +32 -0
  124. package/esm/ui/App/lazyParts.js +8 -0
  125. package/esm/ui/App/preloadAppShell.d.ts +14 -0
  126. package/esm/ui/App/preloadAppShell.js +41 -0
  127. package/esm/ui/App/types.d.ts +17 -3
  128. package/esm/ui/App/types.js +1 -1
  129. package/esm/ui/App/useViewVisibility.d.ts +35 -0
  130. package/esm/ui/App/useViewVisibility.js +88 -0
  131. package/esm/ui/App/viewTitle.d.ts +12 -0
  132. package/esm/ui/App/viewTitle.js +17 -0
  133. package/esm/ui/index.d.ts +1 -1
  134. package/esm/ui/index.js +1 -1
  135. package/package.json +12 -17
  136. package/esm/AppFocus/index.d.ts +0 -5
  137. package/esm/AppFocus/index.js +0 -12
  138. package/esm/Assemblies/SessionAssembliesMixin.d.ts +0 -92
  139. package/esm/Assemblies/SessionAssembliesMixin.js +0 -53
  140. package/esm/Assemblies/TemporaryAssembliesMixin.d.ts +0 -10
  141. package/esm/Assemblies/TemporaryAssembliesMixin.js +0 -37
  142. package/esm/DockviewLayout/index.d.ts +0 -35
  143. package/esm/DockviewLayout/index.js +0 -111
  144. package/esm/ui/App/DockviewContext.d.ts +0 -22
  145. package/esm/ui/App/DockviewContext.js +0 -29
  146. package/esm/ui/App/DockviewLeftHeaderActions.d.ts +0 -2
  147. package/esm/ui/App/DockviewLeftHeaderActions.js +0 -58
  148. package/esm/ui/App/DockviewRightHeaderActions.d.ts +0 -2
  149. package/esm/ui/App/DockviewRightHeaderActions.js +0 -100
  150. package/esm/ui/App/DrawerControls.d.ts +0 -5
  151. package/esm/ui/App/DrawerControls.js +0 -25
  152. package/esm/ui/App/DrawerWidgetSelector.d.ts +0 -5
  153. package/esm/ui/App/DrawerWidgetSelector.js +0 -47
  154. package/esm/ui/App/JBrowseViewPanel.d.ts +0 -8
  155. package/esm/ui/App/JBrowseViewPanel.js +0 -40
  156. package/esm/ui/App/JBrowseViewTab.d.ts +0 -8
  157. package/esm/ui/App/JBrowseViewTab.js +0 -97
  158. package/esm/ui/App/ModalWidget.d.ts +0 -6
  159. package/esm/ui/App/ModalWidget.js +0 -48
  160. package/esm/ui/App/TiledViewsContainer.d.ts +0 -6
  161. package/esm/ui/App/TiledViewsContainer.js +0 -366
  162. package/esm/ui/App/copyView.d.ts +0 -1
  163. package/esm/ui/App/copyView.js +0 -29
  164. package/esm/ui/App/dockviewUtils.d.ts +0 -46
  165. package/esm/ui/App/dockviewUtils.js +0 -55
@@ -0,0 +1,379 @@
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.
16
+ */
17
+ export function isBranch(node) {
18
+ return 'children' in node;
19
+ }
20
+ export function panels(node) {
21
+ return isBranch(node) ? node.children.flatMap(panels) : [node];
22
+ }
23
+ function findPanel(node, panelId) {
24
+ return panels(node).find(p => p.id === panelId);
25
+ }
26
+ export function tabs(node) {
27
+ return panels(node).flatMap(p => p.tabs);
28
+ }
29
+ function homeOf(node, match) {
30
+ for (const panel of panels(node)) {
31
+ const tab = panel.tabs.find(match);
32
+ if (tab) {
33
+ return { panel, tab };
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+ export function findTab(node, tabId) {
39
+ return homeOf(node, t => t.id === tabId);
40
+ }
41
+ /** The tab a panel is showing: the one it names, or its first. */
42
+ export function activeTabIn(panel) {
43
+ return panel.tabs.find(t => t.id === panel.activeTabId) ?? panel.tabs[0];
44
+ }
45
+ export function tabContainingView(node, viewId) {
46
+ return homeOf(node, t => t.viewIds.includes(viewId));
47
+ }
48
+ export function panelContainingView(node, viewId) {
49
+ return tabContainingView(node, viewId)?.panel;
50
+ }
51
+ /**
52
+ * Put a tree back in canonical form. Four rules, applied bottom-up:
53
+ *
54
+ * 1. a branch with no children is dropped by its parent
55
+ * 2. a branch with one child is replaced by that child, which inherits the
56
+ * branch's size — so the space the branch occupied does not move
57
+ * 3. a branch directly inside a branch of the same direction is flattened into
58
+ * it, its children's sizes scaled to preserve their share of the whole
59
+ * 4. sizes are renormalised so a node's siblings always sum to 1
60
+ *
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.
69
+ */
70
+ export function normalize(node) {
71
+ if (!isBranch(node)) {
72
+ return node;
73
+ }
74
+ const children = node.children
75
+ .map(normalize)
76
+ .filter(child => !(isBranch(child) && child.children.length === 0))
77
+ .flatMap(child => isBranch(child) && child.direction === node.direction
78
+ ? scaleSizes(child.children, child.size)
79
+ : [child]);
80
+ if (children.length === 1) {
81
+ return { ...children[0], size: node.size };
82
+ }
83
+ return { ...node, children: scaleSizes(children) };
84
+ }
85
+ /**
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.
100
+ */
101
+ 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
+ */
110
+ function scaleSizes(children, total = 1) {
111
+ const sum = children.reduce((acc, c) => acc + c.size, 0);
112
+ if (sum <= 0) {
113
+ return children.map(c => ({ ...c, size: total / children.length }));
114
+ }
115
+ return Math.abs(sum - total) < SIZE_EPSILON
116
+ ? children
117
+ : children.map(c => ({ ...c, size: (c.size / sum) * total }));
118
+ }
119
+ /** Rebuild `node` with `replacer` applied to the subtree with id `targetId`. */
120
+ function mapNode(node, targetId, replacer) {
121
+ if (node.id === targetId) {
122
+ return replacer(node);
123
+ }
124
+ if (!isBranch(node)) {
125
+ return node;
126
+ }
127
+ return {
128
+ ...node,
129
+ children: node.children.flatMap(child => {
130
+ const mapped = mapNode(child, targetId, replacer);
131
+ return mapped ? [mapped] : [];
132
+ }),
133
+ };
134
+ }
135
+ /**
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.
142
+ */
143
+ 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
146
+ const branchId = `branch-${newPanel.id}`;
147
+ const split = mapNode(root, panelId, found => {
148
+ const pair = before ? [newPanel, found] : [found, newPanel];
149
+ return {
150
+ id: branchId,
151
+ size: found.size,
152
+ direction,
153
+ children: pair.map(child => ({ ...child, size: 1 })),
154
+ };
155
+ });
156
+ return normalize(split ?? root);
157
+ }
158
+ /** Drop a panel. Its space goes back to its siblings via renormalisation. */
159
+ export function removePanel(root, panelId) {
160
+ 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).
166
+ return removed
167
+ ? normalize(removed)
168
+ : { ...root, tabs: [], activeTabId: undefined };
169
+ }
170
+ /** Set the sizes of one branch's children, e.g. from a splitter drag. */
171
+ export function setSizes(root, branchId, sizes) {
172
+ return (mapNode(root, branchId, found => isBranch(found) && sizes.length === found.children.length
173
+ ? {
174
+ ...found,
175
+ children: scaleSizes(found.children.map((child, i) => ({ ...child, size: sizes[i] }))),
176
+ }
177
+ : found) ?? root);
178
+ }
179
+ /** Rebuild `root` with `replacer` applied to the panel with id `panelId`. */
180
+ function mapPanel(root, panelId, replacer) {
181
+ return (mapNode(root, panelId, found => isBranch(found) ? found : replacer(found)) ?? root);
182
+ }
183
+ /** Rebuild `root` with `replacer` applied to the tab with id `tabId`. */
184
+ function mapTab(root, tabId, replacer) {
185
+ const home = findTab(root, tabId);
186
+ return home
187
+ ? mapPanel(root, home.panel.id, panel => ({
188
+ ...panel,
189
+ tabs: panel.tabs.map(t => (t.id === tabId ? replacer(t) : t)),
190
+ }))
191
+ : root;
192
+ }
193
+ // --- 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
+ export function addTab(root, panelId, tab) {
199
+ return mapPanel(root, panelId, panel => ({
200
+ ...panel,
201
+ tabs: [...panel.tabs, tab],
202
+ activeTabId: tab.id,
203
+ }));
204
+ }
205
+ /**
206
+ * Close a tab. The panel stays — an empty panel shows the view launcher, which
207
+ * is a state the user can reach deliberately.
208
+ */
209
+ export function removeTab(root, tabId) {
210
+ const home = findTab(root, tabId);
211
+ if (!home) {
212
+ return root;
213
+ }
214
+ return mapPanel(root, home.panel.id, panel => {
215
+ const at = panel.tabs.findIndex(t => t.id === tabId);
216
+ const remaining = panel.tabs.filter(t => t.id !== tabId);
217
+ return {
218
+ ...panel,
219
+ tabs: remaining,
220
+ 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.
227
+ remaining[Math.max(at - 1, 0)]?.id
228
+ : panel.activeTabId,
229
+ };
230
+ });
231
+ }
232
+ /**
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.
238
+ *
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.
252
+ */
253
+ export function moveTabToPanel(root, tabId, targetPanelId, index) {
254
+ 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.
258
+ if (!home || !findPanel(root, targetPanelId)) {
259
+ return root;
260
+ }
261
+ const { tab } = home;
262
+ const from = home.panel.id === targetPanelId
263
+ ? home.panel.tabs.findIndex(t => t.id === tabId)
264
+ : -1;
265
+ 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
+ const at = index === undefined
271
+ ? panel.tabs.length
272
+ : Math.min(Math.max(from >= 0 && from < index ? index - 1 : index, 0), panel.tabs.length);
273
+ return {
274
+ ...panel,
275
+ tabs: [...panel.tabs.slice(0, at), tab, ...panel.tabs.slice(at)],
276
+ activeTabId: tab.id,
277
+ };
278
+ });
279
+ }
280
+ export function setActiveTab(root, panelId, tabId) {
281
+ return mapPanel(root, panelId, panel => panel.tabs.some(t => t.id === tabId)
282
+ ? { ...panel, activeTabId: tabId }
283
+ : panel);
284
+ }
285
+ export function renameTab(root, tabId, title) {
286
+ return mapTab(root, tabId, tab => ({ ...tab, title }));
287
+ }
288
+ // --- views inside tabs -----------------------------------------------------
289
+ export function addViewToTab(root, tabId, viewId) {
290
+ return mapTab(root, tabId, tab => tab.viewIds.includes(viewId)
291
+ ? tab
292
+ : { ...tab, viewIds: [...tab.viewIds, viewId] });
293
+ }
294
+ /** Take a view out of whatever tab holds it, leaving the tab in place. */
295
+ export function removeView(root, viewId) {
296
+ const home = tabContainingView(root, viewId);
297
+ return home
298
+ ? mapTab(root, home.tab.id, tab => ({
299
+ ...tab,
300
+ viewIds: tab.viewIds.filter(id => id !== viewId),
301
+ }))
302
+ : root;
303
+ }
304
+ /**
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.
313
+ */
314
+ export function homeViews(root, viewIds, activePanelId, nextTabId) {
315
+ const all = panels(root);
316
+ const target = all.find(p => p.id === activePanelId) ?? all[0];
317
+ if (!target) {
318
+ return root;
319
+ }
320
+ let next = root;
321
+ let homeTabId = activeTabIn(target)?.id;
322
+ 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.
326
+ homeTabId = nextTabId();
327
+ next = addTab(next, target.id, { id: homeTabId, viewIds: [] });
328
+ }
329
+ for (const viewId of viewIds) {
330
+ if (!tabContainingView(next, viewId)) {
331
+ next = addViewToTab(next, homeTabId, viewId);
332
+ }
333
+ }
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
+ const owned = new Set(viewIds);
337
+ const departed = tabs(next)
338
+ .flatMap(t => t.viewIds)
339
+ .filter(id => !owned.has(id));
340
+ for (const viewId of departed) {
341
+ next = removeView(next, viewId);
342
+ }
343
+ return next;
344
+ }
345
+ /**
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.
353
+ */
354
+ export function pruneEmptyPanel(root, panelId) {
355
+ const found = findPanel(root, panelId);
356
+ return found && found.tabs.length === 0 && panels(root).length > 1
357
+ ? removePanel(root, panelId)
358
+ : root;
359
+ }
360
+ /**
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.
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;
379
+ }
@@ -0,0 +1,34 @@
1
+ import type { DropTarget } from './dropZone.ts';
2
+ import type { WorkspaceLayout } from './model.ts';
3
+ export interface DragState extends DropTarget {
4
+ tabId: string;
5
+ panelId: string;
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
+ /**
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.
25
+ */
26
+ export declare function useLayoutDrag(layout: WorkspaceLayout): {
27
+ 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;
33
+ };
34
+ };
@@ -0,0 +1,232 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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
+ */
14
+ function samePlace(a, b) {
15
+ return (a?.tabId === b?.tabId &&
16
+ a?.panelId === b?.panelId &&
17
+ a?.zone === b?.zone &&
18
+ a?.strip?.index === b?.strip?.index &&
19
+ a?.strip?.left === b?.strip?.left);
20
+ }
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
+ */
30
+ function declines(layout, drag) {
31
+ return (!drag.strip &&
32
+ drag.zone === 'center' &&
33
+ layout.findTab(drag.tabId)?.panel.id === drag.panelId);
34
+ }
35
+ /**
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.
47
+ */
48
+ export function useLayoutDrag(layout) {
49
+ 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
+ const dragRef = useRef(undefined);
62
+ const showDrag = useCallback((next) => {
63
+ if (samePlace(dragRef.current, next)) {
64
+ return;
65
+ }
66
+ dragRef.current = next;
67
+ setDrag(next);
68
+ }, []);
69
+ const resolveTarget = useCallback((x, y) => {
70
+ const under = document
71
+ .elementsFromPoint(x, y)
72
+ .filter(el => el instanceof HTMLElement);
73
+ const panelEl = under.find(el => el.dataset.panelId);
74
+ if (!panelEl?.dataset.panelId) {
75
+ return undefined;
76
+ }
77
+ const panelId = panelEl.dataset.panelId;
78
+ 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".
82
+ const stripEl = under.find(el => 'tabStrip' in el.dataset);
83
+ if (stripEl) {
84
+ // panel-relative, so the caret can be drawn inside the panel's own box
85
+ const rects = [...stripEl.querySelectorAll('[data-tab-id]')].map(el => {
86
+ const r = el.getBoundingClientRect();
87
+ return {
88
+ left: r.left - panelRect.left,
89
+ top: r.top - panelRect.top,
90
+ width: r.width,
91
+ height: r.height,
92
+ };
93
+ });
94
+ return {
95
+ panelId,
96
+ zone: 'center',
97
+ strip: stripDropAt(rects, x - panelRect.left),
98
+ };
99
+ }
100
+ return { panelId, zone: dropZoneAt(panelRect, x, y) };
101
+ }, []);
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);
216
+ }
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]);
231
+ return { drag, handlers };
232
+ }
package/esm/index.d.ts CHANGED
@@ -1,9 +1,18 @@
1
- export * from './ui/index.ts';
2
- export * from './HistoryManagement/index.ts';
3
- export * from './AppFocus/index.ts';
4
- export * from './JBrowseConfig/index.ts';
5
- export * from './JBrowseModel/index.ts';
6
- export * from './Assemblies/index.ts';
7
- export * from './RootMenu/index.ts';
8
- export * from './DockviewLayout/index.ts';
9
- export * from './menus.ts';
1
+ export { App, DialogQueue, preloadAppFrame, preloadAppShell, } from './ui/index.ts';
2
+ export { HistoryManagementMixin } from './HistoryManagement/index.ts';
3
+ export { DEFAULT_SHARE_URL, JBrowseConfigF } from './JBrowseConfig/index.ts';
4
+ export { JBrowseModelF } from './JBrowseModel/index.ts';
5
+ export { AssembliesMixin } from './Assemblies/index.ts';
6
+ export { AppSessionMixin } from './AppSession/index.ts';
7
+ export type { AppRootModel } from './AppSession/index.ts';
8
+ export { RootAppMenuMixin } from './RootMenu/index.ts';
9
+ export { WorkspaceLayoutMixin, isSessionWithWorkspaceLayout, } from './WorkspaceLayout/index.ts';
10
+ export type { WorkspaceLayoutMixinType, WorkspaceLayout, LayoutTree, PanelNode, TabNode, LayoutSpecNode, PendingMove, TileMode, TabHome, } from './WorkspaceLayout/index.ts';
11
+ export { addSessionTracks, buildLgvInit, buildLgvInitFromParams, hubConnectionSpec, loadSessionSpec, parseSessionSpecUrl, readHubUrlParam, readNavParam, readTracklistParam, shortHubLabel, splitHighlights, } from './SessionSpec/index.ts';
12
+ export type { LayoutNode, LgvUrlInit, ParsedSessionSpec, ViewSpec, } from './SessionSpec/index.ts';
13
+ export type { SessionModelFactory } from './sessionModelFactory.ts';
14
+ export { deleteQueryParams, readAllQueryParams, readQueryParams, setQueryParams, useQueryParam, } from './queryParams.ts';
15
+ export { processMutableMenuActions, resolveMenus } from './menus.ts';
16
+ export type { Menu, MenuAction } from './menus.ts';
17
+ export { createJbApi, ensureReExports, safeJson, sessionOf, undeliveredNotifications, waitReady, } from './JbApi/index.ts';
18
+ export type { JbApi } from './JbApi/index.ts';