@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.
Files changed (52) hide show
  1. package/esm/JBrowseConfig/RootConfiguration.d.ts +1 -8
  2. package/esm/JBrowseConfig/RootConfiguration.js +2 -6
  3. package/esm/JBrowseConfig/index.d.ts +1 -8
  4. package/esm/JBrowseModel/index.d.ts +1 -8
  5. package/esm/JbApi/jbApi.d.ts +2 -2
  6. package/esm/JbApi/jbApi.js +12 -10
  7. package/esm/SessionSpec/loadSessionSpec.js +2 -12
  8. package/esm/WorkspaceLayout/LayoutRenderer.d.ts +0 -10
  9. package/esm/WorkspaceLayout/LayoutRenderer.js +34 -101
  10. package/esm/WorkspaceLayout/PanelView.js +19 -45
  11. package/esm/WorkspaceLayout/TabStrip.d.ts +2 -2
  12. package/esm/WorkspaceLayout/TabStrip.js +36 -104
  13. package/esm/WorkspaceLayout/WorkspaceContainer.d.ts +0 -9
  14. package/esm/WorkspaceLayout/WorkspaceContainer.js +9 -34
  15. package/esm/WorkspaceLayout/WorkspacePanelActions.d.ts +1 -16
  16. package/esm/WorkspaceLayout/WorkspacePanelActions.js +45 -50
  17. package/esm/WorkspaceLayout/WorkspaceTab.d.ts +1 -16
  18. package/esm/WorkspaceLayout/WorkspaceTab.js +11 -47
  19. package/esm/WorkspaceLayout/dropZone.d.ts +7 -37
  20. package/esm/WorkspaceLayout/dropZone.js +7 -44
  21. package/esm/WorkspaceLayout/model.d.ts +42 -112
  22. package/esm/WorkspaceLayout/model.js +139 -268
  23. package/esm/WorkspaceLayout/panelChrome.d.ts +4 -7
  24. package/esm/WorkspaceLayout/panelChrome.js +0 -2
  25. package/esm/WorkspaceLayout/spec.d.ts +10 -51
  26. package/esm/WorkspaceLayout/spec.js +12 -80
  27. package/esm/WorkspaceLayout/splitter.d.ts +3 -19
  28. package/esm/WorkspaceLayout/splitter.js +5 -21
  29. package/esm/WorkspaceLayout/tabName.d.ts +8 -0
  30. package/esm/WorkspaceLayout/tabName.js +14 -0
  31. package/esm/WorkspaceLayout/tree.d.ts +19 -76
  32. package/esm/WorkspaceLayout/tree.js +29 -122
  33. package/esm/WorkspaceLayout/useLayoutDrag.d.ts +4 -22
  34. package/esm/WorkspaceLayout/useLayoutDrag.js +46 -174
  35. package/esm/WorkspaceLayout/usePointerGesture.d.ts +24 -0
  36. package/esm/WorkspaceLayout/usePointerGesture.js +83 -0
  37. package/esm/WorkspaceLayout/workspaceTheme.d.ts +27 -0
  38. package/esm/WorkspaceLayout/workspaceTheme.js +37 -0
  39. package/esm/ui/App/App.js +3 -0
  40. package/esm/ui/App/JBrowseTabMenu.js +1 -1
  41. package/esm/ui/App/ThemeModeButton.js +5 -4
  42. package/esm/ui/App/ViewMenu.js +47 -29
  43. package/esm/ui/App/ViewsContainer.js +3 -4
  44. package/esm/ui/App/lazyParts.d.ts +0 -5
  45. package/esm/ui/App/lazyParts.js +0 -1
  46. package/esm/ui/App/preloadAppShell.js +3 -6
  47. package/esm/ui/App/types.d.ts +3 -2
  48. package/package.json +4 -4
  49. package/esm/WorkspaceLayout/dockviewTheme.d.ts +0 -69
  50. package/esm/WorkspaceLayout/dockviewTheme.js +0 -82
  51. package/esm/ui/App/ClassicViewsContainer.d.ts +0 -6
  52. package/esm/ui/App/ClassicViewsContainer.js +0 -22
@@ -2,37 +2,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { makeStyles } from '@jbrowse/core/util/tss-react';
4
4
  import { observer } from 'mobx-react';
5
- import { dv, tabColors } from './dockviewTheme.js';
6
5
  import { tabDomId, tabPanelDomId } from './panelChrome.js';
7
- /**
8
- * A panel's tab strip: the tabs, and the panel's own buttons beside them.
9
- *
10
- * Everything with state in a cell lives here — the roving tabindex, the wheel
11
- * translation, the scroll-into-view — which leaves `PanelView` a pure function
12
- * of its node. The strip is dockview's dark theme, transcribed in
13
- * `dockviewTheme.ts`; see there for why it is fixed rather than derived from
14
- * the MUI theme.
15
- */
6
+ import { tabColors, workspaceTheme } from './workspaceTheme.js';
16
7
  const useStyles = makeStyles()({
17
8
  strip: {
18
9
  display: 'flex',
19
10
  flexShrink: 0,
20
11
  boxSizing: 'border-box',
21
- height: dv.tabsHeight,
22
- fontSize: dv.tabsFontSize,
23
- background: dv.tabsBackground,
12
+ height: workspaceTheme.stripHeight,
13
+ fontSize: workspaceTheme.stripFontSize,
14
+ background: workspaceTheme.stripBackground,
24
15
  },
25
16
  tabs: {
26
17
  display: 'flex',
27
18
  overflowX: 'auto',
28
- // shrink but never GROW: the panel actions are the next child of the strip,
29
- // so a tab list that takes the leftover space pushes the `+` to the far
30
- // right edge, arbitrarily far from the tab it acts on. `0 1 auto` sizes the
31
- // list to its tabs and leaves the `+` beside the last one, and the shrink
32
- // half is what makes it scroll here rather than push the `+` off the strip.
19
+ // never grow, so the `+` after the list sits beside the last tab
33
20
  flex: '0 1 auto',
34
21
  minWidth: 0,
35
- // the strip is chrome, so a scrollbar across it would be noise
36
22
  scrollbarWidth: 'none',
37
23
  '&::-webkit-scrollbar': { display: 'none' },
38
24
  },
@@ -46,31 +32,16 @@ const useStyles = makeStyles()({
46
32
  cursor: 'pointer',
47
33
  userSelect: 'none',
48
34
  touchAction: 'none',
49
- borderRight: `1px solid ${dv.tabDividerColor}`,
35
+ borderRight: `1px solid ${workspaceTheme.tabDivider}`,
50
36
  '&:hover .jbrowse-tab-menu': { visibility: 'visible' },
51
- // a keyboard user needs the ⋮ too, and hover is not a thing they can do
52
37
  '&:focus-within .jbrowse-tab-menu': { visibility: 'visible' },
53
- // the strip is dark in either theme, so the focus ring is the drop
54
- // indicator's blue rather than the UA default black-on-dark
55
38
  '&:focus-visible': {
56
- outline: `2px solid ${dv.edgeDockIndicatorColor}`,
39
+ outline: `2px solid ${workspaceTheme.accent}`,
57
40
  outlineOffset: -2,
58
41
  },
59
42
  },
60
43
  });
61
- /**
62
- * A wheel event's delta in PIXELS.
63
- *
64
- * `deltaY` is only pixels when `deltaMode` says so. Firefox reports whole lines
65
- * for a mouse wheel — `deltaMode: 1`, `deltaY: ±3` — where Chrome reports
66
- * `deltaMode: 0`, `deltaY: ±100`. Using the raw number moves the strip three
67
- * pixels per notch there: scrolling, technically, and unusable in practice.
68
- *
69
- * The line height is nominal rather than measured. `getComputedStyle` per wheel
70
- * event to resolve a `line-height: normal` that is itself font-dependent buys
71
- * accuracy nobody can perceive in a scroll gesture, and the strip is a fixed
72
- * 35px of chrome in one size.
73
- */
44
+ // Firefox reports a mouse wheel in lines (`deltaMode: 1`, `deltaY: ±3`)
74
45
  const WHEEL_LINE_PX = 16;
75
46
  function wheelDeltaPixels(event, pageSize) {
76
47
  switch (event.deltaMode) {
@@ -82,40 +53,23 @@ function wheelDeltaPixels(event, pageSize) {
82
53
  return event.deltaY;
83
54
  }
84
55
  }
85
- export const TabStrip = observer(function TabStrip({ panel, layout, chrome, active, groupActive, }) {
56
+ export const TabStrip = observer(function TabStrip({ panel, layout, chrome, active, cellActive, }) {
86
57
  const { classes } = useStyles();
87
58
  const { renderPanelActions } = chrome;
88
- // Roving tabindex: the strip is ONE tab stop, and the arrow keys move within
89
- // it. Focus is tracked separately from selection because activation here is
90
- // manual (see `onKeyDown`), so the two genuinely differ while arrowing.
59
+ // roving tabindex; focus differs from selection because activation is manual
91
60
  const stripRef = useRef(null);
92
61
  const [focusedTabId, setFocusedTabId] = useState(undefined);
93
62
  const roving = panel.tabs.find(t => t.id === focusedTabId)?.id ?? active?.id;
94
- // by dataset rather than an interpolated attribute selector: a tab id is
95
- // nanoid output and would need CSS.escape, which jsdom does not have
63
+ // not an attribute selector: a nanoid needs CSS.escape, which jsdom lacks
96
64
  function tabElement(tabId) {
97
65
  return [...(stripRef.current?.children ?? [])].find(child => child.dataset.tabId === tabId);
98
66
  }
99
67
  function focusTab(tabId) {
100
68
  setFocusedTabId(tabId);
101
- // focusing scrolls it into view on its own, which is the whole reason the
102
- // keyboard could always reach an overflowing strip when the mouse could not
103
69
  tabElement(tabId)?.focus();
104
70
  }
105
- /**
106
- * Keep the shown tab in view.
107
- *
108
- * The strip scrolls, so a tab can become current while sitting outside it —
109
- * `+` on a full strip appends a tab, makes it active, and leaves the user
110
- * looking at the tabs it scrolled past. Clicking never needs this (you
111
- * clicked something visible) and arrowing gets it from `focus()`, so this is
112
- * for the tab that becomes current without being touched.
113
- *
114
- * `block: 'nearest'` so it cannot scroll an ancestor vertically. jsdom has no
115
- * scrollIntoView at all, but `config/jest/scrollIntoView.js` already no-ops it
116
- * for the whole suite — don't add an optional call for that, it reads as a
117
- * browser that might not have the method.
118
- */
71
+ // for a tab made current without being touched, e.g. by `+` on a full strip;
72
+ // `block: 'nearest'` keeps it from scrolling an ancestor vertically
119
73
  useEffect(() => {
120
74
  tabElement(active?.id)?.scrollIntoView({
121
75
  block: 'nearest',
@@ -123,14 +77,9 @@ export const TabStrip = observer(function TabStrip({ panel, layout, chrome, acti
123
77
  });
124
78
  }, [active?.id]);
125
79
  /**
126
- * MANUAL activation: arrows move focus, Enter/Space selects.
127
- *
128
- * The automatic form (arrowing selects as it goes) is the more common reading
129
- * of the tabs pattern, and is wrong here for the reason WAI-ARIA names as its
130
- * exception — showing a tab is expensive. Only the selected tab's views are
131
- * mounted, and a JBrowse view costs a WebGL2 context per display against a
132
- * ceiling of 16, so arrowing across five tabs would build and tear down five
133
- * sets of them to pass through.
80
+ * Manual activation (arrows focus, Enter/Space selects), the WAI-ARIA
81
+ * exception for expensive tabs: showing one mounts views that each cost
82
+ * WebGL2 contexts.
134
83
  */
135
84
  function onKeyDown(event, tabId) {
136
85
  const ids = panel.tabs.map(t => t.id);
@@ -157,70 +106,53 @@ export const TabStrip = observer(function TabStrip({ panel, layout, chrome, acti
157
106
  }
158
107
  }
159
108
  return (_jsxs("div", { "data-tab-strip": true, className: classes.strip,
160
- // Double-clicking the strip's empty space maximizes the cell and
161
- // restores it — the IDE convention, and free here because the strip's
162
- // only other `onDoubleClick` is the one on a tab's own label, for rename.
163
- //
164
- // `target === currentTarget` is what keeps those two apart, and it is the
165
- // test rather than a `stopPropagation` in `WorkspaceTab` because it holds
166
- // for anything ever put on the strip: a rename double-click bubbles out
167
- // of the label to here, and so would a double-click on the `+`.
109
+ // empty strip space only, so a tab's rename double-click does not bubble
110
+ // into a maximize
168
111
  onDoubleClick: event => {
169
112
  if (event.target === event.currentTarget) {
170
113
  layout.toggleMaximizedPanel(panel.id);
171
114
  }
172
- }, children: [_jsx("div", { role: "tablist", ref: stripRef, className: classes.tabs,
173
- // A mouse wheel only has a vertical axis, and this scrolls
174
- // horizontally — so without translating it, a strip with more tabs
175
- // than fit is reachable by trackpad swipe and by keyboard and NOT AT
176
- // ALL by mouse. The scrollbar is hidden on purpose (the strip is
177
- // chrome) which removes the other way of noticing there is more.
178
- //
179
- // `deltaX` is a trackpad's own horizontal axis and the browser has
180
- // already applied it; taking the larger axis leaves that gesture alone
181
- // rather than doubling it.
115
+ }, children: [_jsx("div", { role: "tablist", ref: stripRef, className: classes.tabs, onBlur: event => {
116
+ if (!event.currentTarget.contains(event.relatedTarget)) {
117
+ setFocusedTabId(undefined);
118
+ }
119
+ },
120
+ // translate a vertical wheel to horizontal scroll; a trackpad's larger
121
+ // `deltaX` is already applied by the browser
182
122
  onWheel: event => {
183
123
  const el = stripRef.current;
184
124
  if (!el || Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
185
125
  return;
186
126
  }
187
127
  el.scrollLeft += wheelDeltaPixels(event, el.clientWidth);
188
- }, children: panel.tabs.map(tab => (_jsx(Tab, { tab: tab, panel: panel, layout: layout, chrome: chrome, className: classes.tab, selected: tab.id === active?.id, groupActive: groupActive, tabIndex: tab.id === roving ? 0 : -1, onKeyDown: onKeyDown, onFocus: setFocusedTabId }, tab.id))) }), renderPanelActions?.(panel)] }));
128
+ }, children: panel.tabs.map(tab => (_jsx(Tab, { tab: tab, panel: panel, layout: layout, chrome: chrome, className: classes.tab, selected: tab.id === active?.id, cellActive: cellActive, tabIndex: tab.id === roving ? 0 : -1, onKeyDown: onKeyDown, onFocus: setFocusedTabId }, tab.id))) }), renderPanelActions?.(panel)] }));
189
129
  });
190
- const Tab = observer(function Tab({ tab, panel, layout, chrome, className, selected, groupActive, tabIndex, onKeyDown, onFocus, }) {
130
+ const Tab = observer(function Tab({ tab, panel, layout, chrome, className, selected, cellActive, tabIndex, onKeyDown, onFocus, }) {
191
131
  const { renderTabLabel, dragHandlers, onTabClose } = chrome;
192
132
  return (_jsx("div", { role: "tab", id: tabDomId(tab.id), "data-tab-id": tab.id, "aria-selected": selected, "aria-controls": selected ? tabPanelDomId(panel.id) : undefined, tabIndex: tabIndex, onKeyDown: event => {
193
- onKeyDown(event, tab.id);
133
+ // keys from a control inside the tab belong to that control
134
+ if (event.target === event.currentTarget) {
135
+ onKeyDown(event, tab.id);
136
+ }
194
137
  }, onFocus: () => {
195
138
  onFocus(tab.id);
196
139
  }, onPointerDown: event => {
197
- // The middle button closes rather than drags, and its default action is
198
- // the browser's autoscroll — which would otherwise start the moment a
199
- // tab is middle-pressed.
200
- //
201
- // Cancelling pointerdown suppresses the compatibility mouse events
202
- // (which is what stops the autoscroll) and NOT `click` / `auxclick`:
203
- // Pointer Events L3 dispatches those two independently of that mapping.
204
- // So the close below still fires, which is the thing this looks like it
205
- // would break.
140
+ // Stops middle-button autoscroll; Pointer Events L3 still dispatches
141
+ // the `auxclick` that closes the tab
206
142
  if (event.button === 1) {
207
143
  event.preventDefault();
208
144
  return;
209
145
  }
210
- // Showing a tab is expensive — it mounts a stack of views, each display
211
- // costing a WebGL2 context — so it is the LEFT button that asks for it,
212
- // the same gate dockview puts on `_activateOnPointerDown`. A right-press
213
- // is on its way to a context menu and is not a request to look at
214
- // anything.
146
+ // only the left button activates: showing a tab mounts views
215
147
  if (event.button !== 0) {
216
148
  return;
217
149
  }
218
150
  layout.setActiveTab(panel.id, tab.id);
219
- dragHandlers.onTabPointerDown(tab.id, event);
151
+ dragHandlers.onPointerDown(event);
220
152
  }, onAuxClick: event => {
221
153
  if (event.button === 1) {
222
154
  event.preventDefault();
223
155
  onTabClose?.(tab.id);
224
156
  }
225
- }, onPointerMove: dragHandlers.onTabPointerMove, onPointerUp: dragHandlers.onTabPointerUp, onPointerCancel: dragHandlers.onTabPointerCancel, className: className, style: tabColors(groupActive, selected), children: renderTabLabel(tab) }));
157
+ }, onPointerMove: dragHandlers.onPointerMove, onPointerUp: dragHandlers.onPointerUp, onPointerCancel: dragHandlers.onPointerCancel, onLostPointerCapture: dragHandlers.onLostPointerCapture, className: className, style: tabColors(cellActive, selected), children: renderTabLabel(tab) }));
226
158
  });
@@ -1,15 +1,6 @@
1
1
  import type { WorkspaceSessionType } from '../ui/App/types.ts';
2
2
  import type { WorkspaceLayout } from './model.ts';
3
3
  type WorkspaceSession = WorkspaceSessionType & WorkspaceLayout;
4
- /**
5
- * The workspace. Compare `TiledViewsContainer` + `useDockviewController`, which
6
- * this replaces: there is no api to hold, no `onReady`, no event to subscribe
7
- * to, and no reconciliation — the layout is session state and this renders it.
8
- *
9
- * The one reaction left is homing: `session.views` is owned by the session, so
10
- * a view launched from a menu arrives belonging to no tab and has to land
11
- * somewhere. That is one-directional and idempotent, and nothing reads back.
12
- */
13
4
  export declare const WorkspaceContainer: ({ session, }: {
14
5
  session: WorkspaceSession;
15
6
  }) => import("react").JSX.Element;
@@ -17,11 +17,6 @@ const useStyles = makeStyles()(theme => ({
17
17
  gridRow: 'components',
18
18
  background: theme.palette.background.default,
19
19
  },
20
- stack: {
21
- display: 'flex',
22
- flexDirection: 'column',
23
- minWidth: 0,
24
- },
25
20
  empty: {
26
21
  display: 'flex',
27
22
  alignItems: 'center',
@@ -29,30 +24,18 @@ const useStyles = makeStyles()(theme => ({
29
24
  height: '100%',
30
25
  },
31
26
  }));
32
- /**
33
- * The workspace. Compare `TiledViewsContainer` + `useDockviewController`, which
34
- * this replaces: there is no api to hold, no `onReady`, no event to subscribe
35
- * to, and no reconciliation — the layout is session state and this renders it.
36
- *
37
- * The one reaction left is homing: `session.views` is owned by the session, so
38
- * a view launched from a menu arrives belonging to no tab and has to land
39
- * somewhere. That is one-directional and idempotent, and nothing reads back.
40
- */
41
27
  export const WorkspaceContainer = observer(function WorkspaceContainer({ session, }) {
42
28
  const { classes } = useStyles();
43
29
  const { drag, handlers } = useLayoutDrag(session);
44
30
  // #region autorunInEffect
45
31
  useEffect(() => autorun(() => {
46
- // reads session.views itself, so it re-runs when the view set changes;
47
- // homeUnassignedViews is an action and would not be tracked from inside
32
+ // homes newly launched views; reads session.views here because an
33
+ // action's reads are untracked
48
34
  session.homeUnassignedViews(session.views.map(v => v.id));
49
35
  }), [session]);
50
36
  // #endregion
51
- // The layout does not own views, so closing anything that holds them is
52
- // explicitly the pair. Stated ONCE, here, because every spelling of it is a
53
- // chance to drop the node and leave its views in the session forever — and
54
- // three gestures want it now: the tab's ⋮ menu, middle-clicking the tab, and
55
- // the cell's ×.
37
+ // the layout does not own views, so every close gesture goes through here
38
+ // or leaks them
56
39
  const closeViews = useCallback((viewIds) => {
57
40
  for (const view of viewsOf(session, viewIds)) {
58
41
  session.removeView(view);
@@ -74,15 +57,8 @@ export const WorkspaceContainer = observer(function WorkspaceContainer({ session
74
57
  closeViews(panel.tabs.flatMap(t => t.viewIds));
75
58
  session.closePanel(panelId);
76
59
  }, [session, closeViews]);
77
- /**
78
- * Memoised because this reaches every panel, so a fresh one per render
79
- * defeats `observer`'s memo for all of them and each re-render rebuilds a
80
- * `ViewStack`. `drag` is deliberately NOT in here — it goes down its own prop
81
- * so it reaches only the cell it describes.
82
- *
83
- * Nothing does this for us: `observer(function(){})` is not compiled by the
84
- * React Compiler.
85
- */
60
+ // memoised by hand (the React Compiler skips `observer`): a fresh object
61
+ // re-renders every panel's ViewStack. `drag` stays out for the same reason.
86
62
  const chrome = useMemo(() => ({
87
63
  dragHandlers: handlers,
88
64
  onTabClose: closeTab,
@@ -94,13 +70,12 @@ export const WorkspaceContainer = observer(function WorkspaceContainer({ session
94
70
  } })),
95
71
  renderTabContent: tab => {
96
72
  const views = viewsOf(session, tab.viewIds);
97
- return views.length > 0 ? (_jsx(ViewStack, { views: views, session: session, className: classes.stack })) : (_jsx("div", { className: classes.empty, children: _jsx(Suspense, { fallback: null, children: _jsx(ViewLauncher, { session: session }) }) }));
73
+ return views.length > 0 ? (_jsx(ViewStack, { views: views, session: session })) : (_jsx("div", { className: classes.empty, children: _jsx(Suspense, { fallback: null, children: _jsx(ViewLauncher, { session: session }) }) }));
98
74
  },
99
- }), [session, handlers, closeTab, closePanel, classes.stack, classes.empty]);
75
+ }), [session, handlers, closeTab, closePanel, classes.empty]);
100
76
  return (_jsx("div", { className: classes.container, "data-testid": "workspace", children: _jsx(LayoutRenderer, { node: session.visibleTree, layout: session, drag: drag, chrome: chrome }) }));
101
77
  });
102
- // `session.views` is the one ordering of views, in both layout modes, so a
103
- // tab's membership list is filtered through it rather than read as an order.
78
+ // `session.views` is the order; a tab's `viewIds` is membership only
104
79
  function viewsOf(session, viewIds) {
105
80
  const members = new Set(viewIds);
106
81
  return session.views.filter(v => members.has(v.id));
@@ -1,24 +1,9 @@
1
1
  import type { WorkspaceSessionType } from '../ui/App/types.ts';
2
2
  import type { WorkspaceLayout } from './model.ts';
3
3
  import type { PanelNode } from './tree.ts';
4
- /**
5
- * The per-cell buttons: new tab, split, close.
6
- *
7
- * Every one of these is a single MST action on the layout, where the dockview
8
- * versions were an api call plus a session write that had to be kept consistent
9
- * with it — `handleSplit` was `containerApi.addGroup(...)` followed by
10
- * `addEmptyTab(newGroup)`, and the gap between those two was where the
11
- * "resource is already disposed" crash lived.
12
- */
13
4
  export declare const WorkspacePanelActions: ({ panel, session, onClose, }: {
14
5
  panel: PanelNode;
15
6
  session: WorkspaceSessionType & WorkspaceLayout;
16
- /**
17
- * Closing a cell closes the views its tabs held, and the layout does not own
18
- * views — so the pair is one function at the call site, the same way
19
- * `WorkspaceTab` takes its `onClose`. Spelled here as well, this file would
20
- * be the third place stating "and also remove the views", which is one more
21
- * than the number that can be kept in step.
22
- */
7
+ /** closes the cell and the views its tabs hold */
23
8
  onClose: () => void;
24
9
  }) => import("react").JSX.Element;
@@ -14,28 +14,20 @@ import ViewColumnIcon from '@mui/icons-material/ViewColumn';
14
14
  import ViewModuleIcon from '@mui/icons-material/ViewModule';
15
15
  import { IconButton, Tooltip } from '@mui/material';
16
16
  import { observer } from 'mobx-react';
17
- import { dv } from './dockviewTheme.js';
17
+ import { stripIconColor, workspaceTheme } from './workspaceTheme.js';
18
18
  const useStyles = makeStyles()({
19
19
  actions: { display: 'flex', alignItems: 'center', flex: '0 0 auto' },
20
- // dockview's `.dv-default-tab-action`: inherits the strip's colour, and takes
21
- // a small rounded chip on hover rather than MUI's circular ripple surface
22
20
  button: {
23
21
  padding: 4,
24
22
  borderRadius: 2,
25
- color: dv.activeGroupHiddenTabColor,
26
- '&:hover': { background: dv.iconHoverBackground, borderRadius: 2 },
23
+ color: stripIconColor,
24
+ '&:hover': {
25
+ background: workspaceTheme.iconHoverBackground,
26
+ borderRadius: 2,
27
+ },
27
28
  },
28
29
  icon: { fontSize: 16 },
29
30
  });
30
- /**
31
- * The per-cell buttons: new tab, split, close.
32
- *
33
- * Every one of these is a single MST action on the layout, where the dockview
34
- * versions were an api call plus a session write that had to be kept consistent
35
- * with it — `handleSplit` was `containerApi.addGroup(...)` followed by
36
- * `addEmptyTab(newGroup)`, and the gap between those two was where the
37
- * "resource is already disposed" crash lived.
38
- */
39
31
  export const WorkspacePanelActions = observer(function WorkspacePanelActions({ panel, session, onClose, }) {
40
32
  const { classes } = useStyles();
41
33
  const canClose = session.panels.length > 1;
@@ -52,23 +44,21 @@ export const WorkspacePanelActions = observer(function WorkspacePanelActions({ p
52
44
  },
53
45
  },
54
46
  {
55
- label: 'New empty split horizontal',
47
+ label: 'New empty split (right)',
56
48
  icon: VerticalSplitIcon,
57
49
  onClick: () => {
58
50
  session.splitPanel(panel.id, 'row');
59
51
  },
60
52
  },
61
53
  {
62
- label: 'New empty split vertical',
54
+ label: 'New empty split (below)',
63
55
  icon: HorizontalSplitIcon,
64
56
  onClick: () => {
65
57
  session.splitPanel(panel.id, 'column');
66
58
  },
67
59
  },
68
- // The strip's double-click is the gesture; this is how anyone finds
69
- // it, and the only way to reach it from the keyboard. Gated on there
70
- // being another cell to hide, since maximizing the only one is a
71
- // no-op with a label promising otherwise.
60
+ // the keyboard route to the strip's double-click; hidden when there
61
+ // is no other cell to hide
72
62
  ...(session.panels.length > 1 || maximized
73
63
  ? [
74
64
  {
@@ -80,41 +70,46 @@ export const WorkspacePanelActions = observer(function WorkspacePanelActions({ p
80
70
  },
81
71
  ]
82
72
  : []),
83
- // The whole-workspace commands, kept behind the same "Global:" prefix
84
- // and the same >1-view gate they had on the dockview header, so a
85
- // returning user finds them where they were and the label still says
86
- // that the button in THIS cell rearranges every cell.
87
73
  ...(session.views.length > 1
88
74
  ? [
89
75
  {
90
- label: 'Global: change layout into set of tabs',
91
- icon: DynamicFeedIcon,
92
- onClick: () => {
93
- tile('tabs');
94
- },
95
- },
96
- {
97
- label: 'Global: tile horizontally',
98
- icon: ViewColumnIcon,
99
- onClick: () => {
100
- tile('horizontal');
101
- },
102
- },
103
- {
104
- label: 'Global: tile vertically',
105
- icon: TableRowsIcon,
106
- onClick: () => {
107
- tile('vertical');
108
- },
109
- },
110
- {
111
- label: 'Global: tile grid',
76
+ label: 'Arrange all views',
112
77
  icon: ViewModuleIcon,
113
- onClick: () => {
114
- tile('grid');
115
- },
78
+ type: 'subMenu',
79
+ subMenu: [
80
+ {
81
+ label: 'As tabs',
82
+ icon: DynamicFeedIcon,
83
+ onClick: () => {
84
+ tile('tabs');
85
+ },
86
+ },
87
+ {
88
+ label: 'Side by side',
89
+ icon: ViewColumnIcon,
90
+ onClick: () => {
91
+ tile('horizontal');
92
+ },
93
+ },
94
+ {
95
+ label: 'Stacked',
96
+ icon: TableRowsIcon,
97
+ onClick: () => {
98
+ tile('vertical');
99
+ },
100
+ },
101
+ {
102
+ label: 'Grid',
103
+ icon: ViewModuleIcon,
104
+ onClick: () => {
105
+ tile('grid');
106
+ },
107
+ },
108
+ ],
116
109
  },
117
110
  ]
118
111
  : []),
119
- ], size: "small", className: classes.button, children: _jsx(AddIcon, { className: classes.icon }) }), canClose ? (_jsx(Tooltip, { title: "Close panel", children: _jsx(IconButton, { size: "small", className: classes.button, onClick: onClose, children: _jsx(CloseIcon, { className: classes.icon }) }) })) : null] }));
112
+ ], tooltip: "Panel menu", size: "small", className: classes.button, children: _jsx(AddIcon, { className: classes.icon }) }), maximized ? (_jsx(Tooltip, { title: "Restore panel", children: _jsx(IconButton, { size: "small", className: classes.button, onClick: () => {
113
+ session.restorePanels();
114
+ }, children: _jsx(CloseFullscreenIcon, { className: classes.icon }) }) })) : null, canClose ? (_jsx(Tooltip, { title: "Close panel", children: _jsx(IconButton, { size: "small", className: classes.button, onClick: onClose, children: _jsx(CloseIcon, { className: classes.icon }) }) })) : null] }));
120
115
  });
@@ -2,26 +2,11 @@ import type { WorkspaceSessionType } from '../ui/App/types.ts';
2
2
  import type { WorkspaceLayout } from './model.ts';
3
3
  import type { TabNode } from './tree.ts';
4
4
  import type { AbstractViewModel } from '@jbrowse/core/util';
5
- /**
6
- * A tab's name is **derived** from the views it holds, unless the user set one.
7
- *
8
- * `title === undefined` is the sentinel for "not renamed", which is a plain
9
- * `maybe` on the tab rather than the comparison the dockview version needed:
10
- * dockview restores an unset title as the panel id, so telling a real title
11
- * from a restored one meant testing `title !== panelId` and hoping nobody named
12
- * a tab after a panel.
13
- */
14
- export declare function tabDisplayName(tab: TabNode, views: AbstractViewModel[], session: WorkspaceSessionType): string;
15
5
  export declare const WorkspaceTab: ({ tab, views, session, layout, onClose, }: {
16
6
  tab: TabNode;
17
7
  views: AbstractViewModel[];
18
8
  session: WorkspaceSessionType;
19
9
  layout: WorkspaceLayout;
20
- /**
21
- * Closing a tab closes the views it held, and the layout does not own views —
22
- * so the pair is one function at the call site rather than spelled out here.
23
- * The strip's middle-click needs the same pair, and two spellings of "and
24
- * also remove the views" is how one of them comes to be missing it.
25
- */
10
+ /** closes the tab and the views it holds */
26
11
  onClose: () => void;
27
12
  }) => import("react").JSX.Element;
@@ -4,20 +4,15 @@ import { makeStyles } from '@jbrowse/core/util/tss-react';
4
4
  import { InputBase, Typography } from '@mui/material';
5
5
  import { observer } from 'mobx-react';
6
6
  import JBrowseTabMenu from '../ui/App/JBrowseTabMenu.js';
7
- import { viewName } from '../ui/App/viewTitle.js';
7
+ import { tabDisplayName } from './tabName.js';
8
8
  const useStyles = makeStyles()(theme => ({
9
- // Only the label and its menu. The background, the selected state and the
10
- // hover live on the strip's own `[role=tab]` wrapper, which is where the
11
- // selection is known — two places styling one tab is how they drift.
9
+ // the `[role=tab]` wrapper in TabStrip owns background and selected state
12
10
  tab: {
13
11
  display: 'flex',
14
12
  alignItems: 'center',
15
13
  gap: 4,
16
14
  minWidth: 0,
17
15
  },
18
- // dockview's `.dv-default-tab-content`: takes the space, truncates with an
19
- // ellipsis, and takes its colour from the tab, which is what encodes
20
- // active/inactive
21
16
  title: {
22
17
  flexGrow: 1,
23
18
  marginRight: 4,
@@ -36,38 +31,20 @@ const useStyles = makeStyles()(theme => ({
36
31
  flex: 1,
37
32
  },
38
33
  }));
39
- /**
40
- * A tab's name is **derived** from the views it holds, unless the user set one.
41
- *
42
- * `title === undefined` is the sentinel for "not renamed", which is a plain
43
- * `maybe` on the tab rather than the comparison the dockview version needed:
44
- * dockview restores an unset title as the panel id, so telling a real title
45
- * from a restored one meant testing `title !== panelId` and hoping nobody named
46
- * a tab after a panel.
47
- */
48
- export function tabDisplayName(tab, views, session) {
49
- if (tab.title) {
50
- return tab.title;
51
- }
52
- if (views.length === 0) {
53
- return 'Empty';
54
- }
55
- if (views.length === 1) {
56
- return viewName(views[0], r => session.assemblyManager.getDisplayName(r));
57
- }
58
- return `${views.length} views`;
59
- }
60
34
  export const WorkspaceTab = observer(function WorkspaceTab({ tab, views, session, layout, onClose, }) {
61
35
  const { classes } = useStyles();
62
36
  const [editing, setEditing] = useState(false);
63
37
  const [draft, setDraft] = useState('');
64
38
  const title = tabDisplayName(tab, views, session);
39
+ const startEditing = () => {
40
+ setDraft(title);
41
+ setEditing(true);
42
+ };
43
+ // an empty box or the unchanged automatic name keeps the name automatic
65
44
  const save = () => {
66
- // An empty box means "go back to the automatic name". `title === undefined`
67
- // is the sentinel for "not renamed" and `renameTab` takes it, but nothing
68
- // in the UI passed it: clearing the box just discarded the edit, so a
69
- // rename could be made and never unmade.
70
- layout.renameTab(tab.id, draft.trim() || undefined);
45
+ const name = draft.trim();
46
+ const automatic = tabDisplayName({ ...tab, title: undefined }, views, session);
47
+ layout.renameTab(tab.id, name && name !== automatic ? name : undefined);
71
48
  setEditing(false);
72
49
  };
73
50
  return (_jsx("div", { className: classes.tab, children: editing ? (_jsx(InputBase, { autoFocus: true, className: classes.editInput, value: draft, onChange: e => {
@@ -75,13 +52,6 @@ export const WorkspaceTab = observer(function WorkspaceTab({ tab, views, session
75
52
  }, onFocus: e => {
76
53
  e.target.select();
77
54
  }, onBlur: save, onKeyDown: e => {
78
- // The box sits INSIDE the `role="tab"` that owns the strip's roving
79
- // tabindex, and every key it handles — the arrows, Home, End, Enter
80
- // and Space — it also preventDefault()s. All of them reach that
81
- // handler by bubbling out of here, so while the box is open the
82
- // arrows jumped to the next tab instead of moving the caret and a
83
- // space never reached the input at all: a tab could not be given a
84
- // name with a space in it.
85
55
  e.stopPropagation();
86
56
  if (e.key === 'Enter') {
87
57
  save();
@@ -95,11 +65,5 @@ export const WorkspaceTab = observer(function WorkspaceTab({ tab, views, session
95
65
  e.stopPropagation();
96
66
  }, onClick: e => {
97
67
  e.stopPropagation();
98
- } })) : (_jsxs(_Fragment, { children: [_jsx(Typography, { className: classes.title, variant: "body2", onDoubleClick: () => {
99
- setDraft(title);
100
- setEditing(true);
101
- }, children: title }), _jsx(JBrowseTabMenu, { onRename: () => {
102
- setDraft(title);
103
- setEditing(true);
104
- }, onClose: onClose })] })) }));
68
+ } })) : (_jsxs(_Fragment, { children: [_jsx(Typography, { className: classes.title, variant: "body2", onDoubleClick: startEditing, children: title }), _jsx(JBrowseTabMenu, { onRename: startEditing, onClose: onClose })] })) }));
105
69
  });