@jbrowse/app-core 5.0.0-beta.11 → 5.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/esm/AppSession/AppSessionMixin.d.ts +110 -92
  2. package/esm/Assemblies/AssembliesMixin.d.ts +30 -0
  3. package/esm/HistoryManagement/index.d.ts +2 -0
  4. package/esm/HistoryManagement/index.js +2 -0
  5. package/esm/JBrowseConfig/RootConfiguration.d.ts +4 -10
  6. package/esm/JBrowseConfig/RootConfiguration.js +5 -8
  7. package/esm/JBrowseConfig/index.d.ts +3 -11
  8. package/esm/JBrowseConfig/index.js +2 -3
  9. package/esm/JBrowseModel/index.d.ts +10 -9
  10. package/esm/JBrowseModel/index.js +8 -7
  11. package/esm/JbApi/jbApi.d.ts +2 -2
  12. package/esm/JbApi/jbApi.js +16 -11
  13. package/esm/RootMenu/index.d.ts +2 -0
  14. package/esm/RootMenu/index.js +2 -0
  15. package/esm/SessionSpec/index.d.ts +2 -2
  16. package/esm/SessionSpec/index.js +1 -1
  17. package/esm/SessionSpec/loadSessionSpec.js +2 -12
  18. package/esm/SessionSpec/parseSessionSpecUrl.d.ts +19 -0
  19. package/esm/SessionSpec/parseSessionSpecUrl.js +29 -0
  20. package/esm/WorkspaceLayout/LayoutRenderer.d.ts +0 -10
  21. package/esm/WorkspaceLayout/LayoutRenderer.js +34 -101
  22. package/esm/WorkspaceLayout/PanelView.js +19 -45
  23. package/esm/WorkspaceLayout/TabStrip.d.ts +2 -2
  24. package/esm/WorkspaceLayout/TabStrip.js +36 -104
  25. package/esm/WorkspaceLayout/WorkspaceContainer.d.ts +0 -9
  26. package/esm/WorkspaceLayout/WorkspaceContainer.js +9 -42
  27. package/esm/WorkspaceLayout/WorkspacePanelActions.d.ts +1 -16
  28. package/esm/WorkspaceLayout/WorkspacePanelActions.js +46 -51
  29. package/esm/WorkspaceLayout/WorkspaceTab.d.ts +3 -19
  30. package/esm/WorkspaceLayout/WorkspaceTab.js +12 -48
  31. package/esm/WorkspaceLayout/dropZone.d.ts +7 -37
  32. package/esm/WorkspaceLayout/dropZone.js +7 -44
  33. package/esm/WorkspaceLayout/model.d.ts +45 -120
  34. package/esm/WorkspaceLayout/model.js +145 -303
  35. package/esm/WorkspaceLayout/panelChrome.d.ts +4 -7
  36. package/esm/WorkspaceLayout/panelChrome.js +0 -2
  37. package/esm/WorkspaceLayout/spec.d.ts +10 -51
  38. package/esm/WorkspaceLayout/spec.js +12 -80
  39. package/esm/WorkspaceLayout/splitter.d.ts +3 -19
  40. package/esm/WorkspaceLayout/splitter.js +5 -21
  41. package/esm/WorkspaceLayout/tabName.d.ts +8 -0
  42. package/esm/WorkspaceLayout/tabName.js +14 -0
  43. package/esm/WorkspaceLayout/tree.d.ts +19 -79
  44. package/esm/WorkspaceLayout/tree.js +34 -131
  45. package/esm/WorkspaceLayout/useLayoutDrag.d.ts +4 -22
  46. package/esm/WorkspaceLayout/useLayoutDrag.js +55 -177
  47. package/esm/WorkspaceLayout/usePointerGesture.d.ts +24 -0
  48. package/esm/WorkspaceLayout/usePointerGesture.js +83 -0
  49. package/esm/WorkspaceLayout/workspaceTheme.d.ts +27 -0
  50. package/esm/WorkspaceLayout/workspaceTheme.js +37 -0
  51. package/esm/index.d.ts +5 -3
  52. package/esm/index.js +3 -2
  53. package/esm/queryParams.d.ts +2 -0
  54. package/esm/queryParams.js +16 -5
  55. package/esm/sessionSummary.d.ts +16 -0
  56. package/esm/sessionSummary.js +57 -0
  57. package/esm/ui/App/App.js +3 -0
  58. package/esm/ui/App/DrawerHeaderHelpDialog.d.ts +3 -2
  59. package/esm/ui/App/DrawerHeaderHelpDialog.js +4 -2
  60. package/esm/ui/App/JBrowseTabMenu.d.ts +3 -2
  61. package/esm/ui/App/JBrowseTabMenu.js +5 -3
  62. package/esm/ui/App/ThemeModeButton.d.ts +4 -5
  63. package/esm/ui/App/ThemeModeButton.js +22 -14
  64. package/esm/ui/App/ViewContainer.js +7 -1
  65. package/esm/ui/App/ViewMenu.js +69 -42
  66. package/esm/ui/App/ViewWrapper.js +2 -2
  67. package/esm/ui/App/ViewsContainer.js +3 -4
  68. package/esm/ui/App/lazyParts.d.ts +0 -5
  69. package/esm/ui/App/lazyParts.js +0 -1
  70. package/esm/ui/App/preloadAppShell.js +3 -6
  71. package/esm/ui/App/types.d.ts +3 -2
  72. package/esm/ui/App/viewTitle.d.ts +6 -1
  73. package/esm/ui/App/viewTitle.js +7 -2
  74. package/package.json +7 -7
  75. package/esm/WorkspaceLayout/dockviewTheme.d.ts +0 -69
  76. package/esm/WorkspaceLayout/dockviewTheme.js +0 -82
  77. package/esm/ui/App/ClassicViewsContainer.d.ts +0 -6
  78. package/esm/ui/App/ClassicViewsContainer.js +0 -22
@@ -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
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The workspace chrome: tab strips, splitters and drop indicators.
3
+ *
4
+ * Fixed and dark in every JBrowse theme, the way the app bar is, so the frame
5
+ * never reads as content. The cell body is not chrome and follows
6
+ * `theme.palette.background.default` in `PanelView`.
7
+ */
8
+ export declare const workspaceTheme: {
9
+ readonly stripBackground: '#252526';
10
+ readonly stripHeight: 35;
11
+ readonly stripFontSize: 13;
12
+ readonly tabDivider: '#1e1e1e';
13
+ readonly selectedTabBackground: '#1e1e1e';
14
+ readonly tabBackground: '#2d2d2d';
15
+ readonly iconHoverBackground: 'rgba(90, 93, 94, 0.31)';
16
+ readonly splitterLine: 'rgb(68, 68, 68)';
17
+ /** grab area; the line drawn inside it is 1px */
18
+ readonly splitterSize: 4;
19
+ readonly dropWash: 'rgba(83, 89, 93, 0.5)';
20
+ readonly accent: 'rgba(56, 139, 253, 0.9)';
21
+ };
22
+ export declare function tabColors(cellActive: boolean, selected: boolean): {
23
+ background: "#1e1e1e" | "#2d2d2d";
24
+ color: string;
25
+ };
26
+ /** the panel actions' icons, which sit on the strip beside unselected tabs */
27
+ export declare const stripIconColor: string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The workspace chrome: tab strips, splitters and drop indicators.
3
+ *
4
+ * Fixed and dark in every JBrowse theme, the way the app bar is, so the frame
5
+ * never reads as content. The cell body is not chrome and follows
6
+ * `theme.palette.background.default` in `PanelView`.
7
+ */
8
+ export const workspaceTheme = {
9
+ stripBackground: '#252526',
10
+ stripHeight: 35,
11
+ stripFontSize: 13,
12
+ tabDivider: '#1e1e1e',
13
+ selectedTabBackground: '#1e1e1e',
14
+ tabBackground: '#2d2d2d',
15
+ iconHoverBackground: 'rgba(90, 93, 94, 0.31)',
16
+ splitterLine: 'rgb(68, 68, 68)',
17
+ /** grab area; the line drawn inside it is 1px */
18
+ splitterSize: 4,
19
+ dropWash: 'rgba(83, 89, 93, 0.5)',
20
+ accent: 'rgba(56, 139, 253, 0.9)',
21
+ };
22
+ // the active cell's selected tab is the one fully white label on screen
23
+ const tabTextColors = {
24
+ activeCell: { selected: 'white', other: '#969696' },
25
+ otherCell: { selected: '#8f8f8f', other: '#626262' },
26
+ };
27
+ export function tabColors(cellActive, selected) {
28
+ const text = cellActive ? tabTextColors.activeCell : tabTextColors.otherCell;
29
+ return {
30
+ background: selected
31
+ ? workspaceTheme.selectedTabBackground
32
+ : workspaceTheme.tabBackground,
33
+ color: selected ? text.selected : text.other,
34
+ };
35
+ }
36
+ /** the panel actions' icons, which sit on the strip beside unselected tabs */
37
+ export const stripIconColor = tabTextColors.activeCell.other;
package/esm/index.d.ts CHANGED
@@ -8,11 +8,13 @@ export type { AppRootModel } from './AppSession/index.ts';
8
8
  export { RootAppMenuMixin } from './RootMenu/index.ts';
9
9
  export { WorkspaceLayoutMixin, isSessionWithWorkspaceLayout, } from './WorkspaceLayout/index.ts';
10
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';
11
+ export { addSessionTracks, buildLgvInit, buildLgvInitFromParams, hubConnectionSpec, loadSessionSpec, parseSessionSnapshotUrl, parseSessionSpecUrl, readHubUrlParam, readNavParam, readTracklistParam, shortHubLabel, splitHighlights, } from './SessionSpec/index.ts';
12
+ export type { LayoutNode, LgvUrlInit, ParsedSessionSnapshotUrl, ParsedSessionSpec, ViewSpec, } from './SessionSpec/index.ts';
13
13
  export type { SessionModelFactory } from './sessionModelFactory.ts';
14
- export { deleteQueryParams, readAllQueryParams, readQueryParams, setQueryParams, useQueryParam, } from './queryParams.ts';
14
+ export { deleteQueryParams, queryParamsHref, readAllQueryParams, readQueryParams, setQueryParams, useQueryParam, } from './queryParams.ts';
15
15
  export { processMutableMenuActions, resolveMenus } from './menus.ts';
16
+ export { formatSessionSummary, summarizeSession } from './sessionSummary.ts';
17
+ export type { SessionSummary } from './sessionSummary.ts';
16
18
  export type { Menu, MenuAction } from './menus.ts';
17
19
  export { createJbApi, ensureReExports, safeJson, sessionOf, undeliveredNotifications, waitReady, } from './JbApi/index.ts';
18
20
  export type { JbApi } from './JbApi/index.ts';
package/esm/index.js CHANGED
@@ -6,11 +6,12 @@ export { AssembliesMixin } from './Assemblies/index.js';
6
6
  export { AppSessionMixin } from './AppSession/index.js';
7
7
  export { RootAppMenuMixin } from './RootMenu/index.js';
8
8
  export { WorkspaceLayoutMixin, isSessionWithWorkspaceLayout, } from './WorkspaceLayout/index.js';
9
- export { addSessionTracks, buildLgvInit, buildLgvInitFromParams, hubConnectionSpec, loadSessionSpec, parseSessionSpecUrl, readHubUrlParam, readNavParam, readTracklistParam, shortHubLabel, splitHighlights, } from './SessionSpec/index.js';
9
+ export { addSessionTracks, buildLgvInit, buildLgvInitFromParams, hubConnectionSpec, loadSessionSpec, parseSessionSnapshotUrl, parseSessionSpecUrl, readHubUrlParam, readNavParam, readTracklistParam, shortHubLabel, splitHighlights, } from './SessionSpec/index.js';
10
10
  // URL params, for the two products that own their page (web, desktop). An
11
11
  // embedded product must not rewrite its host's URL — see queryParams.ts
12
- export { deleteQueryParams, readAllQueryParams, readQueryParams, setQueryParams, useQueryParam, } from './queryParams.js';
12
+ export { deleteQueryParams, queryParamsHref, readAllQueryParams, readQueryParams, setQueryParams, useQueryParam, } from './queryParams.js';
13
13
  export { processMutableMenuActions, resolveMenus } from './menus.js';
14
+ export { formatSessionSummary, summarizeSession } from './sessionSummary.js';
14
15
  // The helper library an agent drives the app through: desktop serves it to
15
16
  // run_javascript over MCP, web hands it to the page as window.jb
16
17
  export { createJbApi, ensureReExports, safeJson, sessionOf, undeliveredNotifications, waitReady, } from './JbApi/index.js';
@@ -2,6 +2,8 @@ export declare function readQueryParams<T extends string>(keys: T[]): Record<T,
2
2
  export declare function readAllQueryParams(url?: URL | Location): URLSearchParams;
3
3
  export declare function deleteQueryParams(keys: readonly string[]): void;
4
4
  export declare function setQueryParams(values: Record<string, string | undefined>): void;
5
+ /** The page's own URL with `values` applied, for a link to another state. */
6
+ export declare function queryParamsHref(values: Record<string, string | undefined>): string;
5
7
  /**
6
8
  * Read one param and keep re-rendering as it changes, including across the
7
9
  * `replaceState` writes above, which fire no event of their own.
@@ -23,7 +23,7 @@ function subscribe(callback) {
23
23
  }
24
24
  // Writes params back into `loc`, leaving the other URL component untouched — the
25
25
  // URL hash/search setters each replace only their own component.
26
- function writeParams(loc, params) {
26
+ function urlWith(loc, params) {
27
27
  const url = new URL(window.location.href);
28
28
  const str = params.toString();
29
29
  if (loc === 'hash') {
@@ -32,7 +32,10 @@ function writeParams(loc, params) {
32
32
  else {
33
33
  url.search = str;
34
34
  }
35
- window.history.replaceState(null, '', url.href);
35
+ return url.href;
36
+ }
37
+ function writeParams(loc, params) {
38
+ window.history.replaceState(null, '', urlWith(loc, params));
36
39
  // Copied, which this note has claimed while the loop read the live set. A
37
40
  // listener is React's store-change callback, and React may flush the re-render
38
41
  // synchronously inside it, so the set can be mutated mid-notify: an unmounted
@@ -67,8 +70,7 @@ export function deleteQueryParams(keys) {
67
70
  }
68
71
  writeParams(loc, params);
69
72
  }
70
- export function setQueryParams(values) {
71
- const loc = paramLocation();
73
+ function paramsWith(loc, values) {
72
74
  const params = readParams(loc);
73
75
  for (const [key, value] of Object.entries(values)) {
74
76
  if (value === undefined) {
@@ -78,7 +80,16 @@ export function setQueryParams(values) {
78
80
  params.set(key, value);
79
81
  }
80
82
  }
81
- writeParams(loc, params);
83
+ return params;
84
+ }
85
+ export function setQueryParams(values) {
86
+ const loc = paramLocation();
87
+ writeParams(loc, paramsWith(loc, values));
88
+ }
89
+ /** The page's own URL with `values` applied, for a link to another state. */
90
+ export function queryParamsHref(values) {
91
+ const loc = paramLocation();
92
+ return urlWith(loc, paramsWith(loc, values));
82
93
  }
83
94
  /**
84
95
  * Read one param and keep re-rendering as it changes, including across the
@@ -0,0 +1,16 @@
1
+ /**
2
+ * What a saved session holds, for telling saved sessions apart in a recent
3
+ * list without opening them. Read off the snapshot rather than a live model,
4
+ * so it can be taken wherever the snapshot is written.
5
+ */
6
+ export interface SessionSummary {
7
+ assemblies: string[];
8
+ views: number;
9
+ tracks: number;
10
+ }
11
+ /**
12
+ * Walks every view, including the subviews a comparative view nests, so a
13
+ * synteny view's assemblies and tracks count the same as two plain views'.
14
+ */
15
+ export declare function summarizeSession(snap: unknown): SessionSummary;
16
+ export declare function formatSessionSummary({ assemblies, views, tracks, }: SessionSummary): string;
@@ -0,0 +1,57 @@
1
+ function isRecord(x) {
2
+ return typeof x === 'object' && x !== null;
3
+ }
4
+ /**
5
+ * Walks every view, including the subviews a comparative view nests, so a
6
+ * synteny view's assemblies and tracks count the same as two plain views'.
7
+ */
8
+ export function summarizeSession(snap) {
9
+ const assemblies = new Set();
10
+ let tracks = 0;
11
+ const visit = (node) => {
12
+ if (Array.isArray(node)) {
13
+ for (const child of node) {
14
+ visit(child);
15
+ }
16
+ }
17
+ else if (isRecord(node)) {
18
+ for (const [key, value] of Object.entries(node)) {
19
+ if (key === 'assemblyName' && typeof value === 'string') {
20
+ assemblies.add(value);
21
+ }
22
+ else if (key === 'assemblyNames' && Array.isArray(value)) {
23
+ for (const name of value) {
24
+ if (typeof name === 'string') {
25
+ assemblies.add(name);
26
+ }
27
+ }
28
+ }
29
+ else if (key === 'tracks' && Array.isArray(value)) {
30
+ tracks += value.length;
31
+ }
32
+ else {
33
+ visit(value);
34
+ }
35
+ }
36
+ }
37
+ };
38
+ const views = isRecord(snap) && Array.isArray(snap.views) ? snap.views : [];
39
+ visit(views);
40
+ return { assemblies: [...assemblies], views: views.length, tracks };
41
+ }
42
+ function plural(n, word) {
43
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
44
+ }
45
+ export function formatSessionSummary({ assemblies, views, tracks, }) {
46
+ const shown = assemblies.slice(0, 2).join(', ');
47
+ const more = assemblies.length - 2;
48
+ return views
49
+ ? [
50
+ more > 0 ? `${shown} +${more} more` : shown,
51
+ plural(views, 'view'),
52
+ tracks ? plural(tracks, 'track') : '',
53
+ ]
54
+ .filter(Boolean)
55
+ .join(' · ')
56
+ : 'No views';
57
+ }
package/esm/ui/App/App.js CHANGED
@@ -38,6 +38,9 @@ const useStyles = makeStyles()(theme => ({
38
38
  appBar: {
39
39
  flexGrow: 1,
40
40
  gridRow: 'menubar',
41
+ // above the workspace, whose cells paint a background its shadow falls on
42
+ position: 'relative',
43
+ zIndex: 1,
41
44
  },
42
45
  }));
43
46
  const App = observer(function App(props) {
@@ -1,4 +1,5 @@
1
- export default function DrawerHeaderHelpDialog({ onClose, helpText, }: {
1
+ declare const DrawerHeaderHelpDialog: ({ onClose, helpText, }: {
2
2
  onClose: () => void;
3
3
  helpText: React.ReactNode;
4
- }): import("react").JSX.Element;
4
+ }) => import("react").JSX.Element;
5
+ export default DrawerHeaderHelpDialog;
@@ -1,14 +1,16 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import InfoDialog from '@jbrowse/core/ui/InfoDialog';
3
3
  import { makeStyles } from '@jbrowse/core/util/tss-react';
4
+ import { observer } from 'mobx-react';
4
5
  const useStyles = makeStyles()({
5
6
  max: {
6
7
  minWidth: '40em',
7
8
  },
8
9
  });
9
- export default function DrawerHeaderHelpDialog({ onClose, helpText, }) {
10
+ const DrawerHeaderHelpDialog = observer(function DrawerHeaderHelpDialog({ onClose, helpText, }) {
10
11
  const { classes } = useStyles();
11
12
  return (_jsx(InfoDialog, { open: true, onClose: onClose, title: "Help", maxWidth: "xl", onClick: e => {
12
13
  e.stopPropagation();
13
14
  }, children: _jsx("div", { className: classes.max, children: helpText }) }));
14
- }
15
+ });
16
+ export default DrawerHeaderHelpDialog;
@@ -1,4 +1,5 @@
1
- export default function JBrowseTabMenu({ onRename, onClose, }: {
1
+ declare const JBrowseTabMenu: ({ onRename, onClose, }: {
2
2
  onRename: () => void;
3
3
  onClose: () => void;
4
- }): import("react").JSX.Element;
4
+ }) => import("react").JSX.Element;
5
+ export default JBrowseTabMenu;
@@ -5,6 +5,7 @@ import { makeStyles } from '@jbrowse/core/util/tss-react';
5
5
  import CloseIcon from '@mui/icons-material/Close';
6
6
  import EditIcon from '@mui/icons-material/Edit';
7
7
  import MoreVertIcon from '@mui/icons-material/MoreVert';
8
+ import { observer } from 'mobx-react';
8
9
  const useStyles = makeStyles()({
9
10
  // hidden until the tab is hovered or focused, by a rule in `TabStrip` keyed
10
11
  // on the `jbrowse-tab-menu` class — hence the plain class name beside the
@@ -30,7 +31,7 @@ const useStyles = makeStyles()({
30
31
  fontSize: 14,
31
32
  },
32
33
  });
33
- export default function JBrowseTabMenu({ onRename, onClose, }) {
34
+ const JBrowseTabMenu = observer(function JBrowseTabMenu({ onRename, onClose, }) {
34
35
  const { classes, cx } = useStyles();
35
36
  const [open, setOpen] = useState(false);
36
37
  return (_jsx("div", { className: cx('jbrowse-tab-menu', classes.tabIcons, open && classes.open),
@@ -54,5 +55,6 @@ export default function JBrowseTabMenu({ onRename, onClose, }) {
54
55
  icon: CloseIcon,
55
56
  onClick: onClose,
56
57
  },
57
- ], size: "small", className: classes.tabIcon, setOpen: setOpen, stopPropagation: true, children: _jsx(MoreVertIcon, { className: classes.smallIcon }) }) }));
58
- }
58
+ ], tooltip: "Tab menu", size: "small", className: classes.tabIcon, setOpen: setOpen, stopPropagation: true, children: _jsx(MoreVertIcon, { className: classes.smallIcon }) }) }));
59
+ });
60
+ export default JBrowseTabMenu;
@@ -1,10 +1,9 @@
1
1
  import type { ThemeSwitchSession } from './types.ts';
2
2
  /**
3
- * A light/dark switch for a session that follows the OS, showing the mode
4
- * drawn. A click holds the other mode, a second click follows the OS again. A
5
- * session on an explicit light or dark gets nothing, which is every session
6
- * that has not asked for this in Preferences, so a light reader is offered no
7
- * route into dark.
3
+ * A light/dark switch for a session that follows the OS or sits on dark,
4
+ * showing the mode drawn. A click holds the other mode, a second click returns
5
+ * to the setting. A session on an explicit light gets nothing, so a reader who
6
+ * never asked for dark is offered no route into it.
8
7
  */
9
8
  declare const ThemeModeButton: ({ session, }: {
10
9
  session: ThemeSwitchSession;
@@ -4,29 +4,37 @@ import LightModeIcon from '@mui/icons-material/LightMode';
4
4
  import { IconButton, Tooltip } from '@mui/material';
5
5
  import { observer } from 'mobx-react';
6
6
  /**
7
- * A light/dark switch for a session that follows the OS, showing the mode
8
- * drawn. A click holds the other mode, a second click follows the OS again. A
9
- * session on an explicit light or dark gets nothing, which is every session
10
- * that has not asked for this in Preferences, so a light reader is offered no
11
- * route into dark.
7
+ * A light/dark switch for a session that follows the OS or sits on dark,
8
+ * showing the mode drawn. A click holds the other mode, a second click returns
9
+ * to the setting. A session on an explicit light gets nothing, so a reader who
10
+ * never asked for dark is offered no route into it.
12
11
  */
13
12
  const ThemeModeButton = observer(function ThemeModeButton({ session, }) {
14
13
  const dark = session.themeIsDark;
15
14
  const drawnMode = dark ? 'dark' : 'light';
16
- // A palette pinned to its own mode draws dark whatever the OS says, so the
17
- // two disagree and the system is steering nothing to report. Everything else
18
- // that hides this control is `themeMode`.
19
- const followsSystem = session.themeMode === 'system' && drawnMode === session.effectiveThemeMode;
20
- if (!followsSystem) {
15
+ // A palette pinned to its own mode, or Dark Reader, draws dark whatever the
16
+ // OS says, so a click here would change nothing.
17
+ const switchable = session.themeMode !== 'light' &&
18
+ !session.darkReaderDark &&
19
+ drawnMode === session.effectiveThemeMode;
20
+ if (!switchable) {
21
21
  return null;
22
22
  }
23
23
  const held = session.systemThemeOverride;
24
24
  const otherMode = dark ? 'light' : 'dark';
25
25
  const Icon = dark ? DarkModeIcon : LightModeIcon;
26
- const label = held
27
- ? `${dark ? 'Dark' : 'Light'} until your system theme changes`
28
- : `Following your system theme (${drawnMode})`;
29
- const action = held ? `to follow it (${otherMode})` : `for ${otherMode}`;
26
+ const system = session.themeMode === 'system';
27
+ const modeName = dark ? 'Dark' : 'Light';
28
+ const label = system
29
+ ? held
30
+ ? `${modeName} until your system theme changes`
31
+ : `Following your system theme (${drawnMode})`
32
+ : held
33
+ ? `${modeName} instead of your dark setting`
34
+ : modeName;
35
+ const action = held
36
+ ? `to ${system ? 'follow it' : 'return'} (${otherMode})`
37
+ : `for ${otherMode}`;
30
38
  return (_jsx(Tooltip, { title: `${label}. Click ${action}.`, arrow: true, children: _jsx(IconButton, { "data-testid": "theme-mode-button", "aria-label": label, color: "inherit", size: "small", onClick: () => {
31
39
  session.setSystemThemeOverride(held ? undefined : otherMode);
32
40
  }, children: _jsx(Icon, { fontSize: "small" }) }) }));
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
+ import LoadingEllipses from '@jbrowse/core/ui/LoadingEllipses';
3
4
  import { useFocusOnInteraction, useWidthSetter } from '@jbrowse/core/util/hooks';
4
5
  import { cx, makeStyles } from '@jbrowse/core/util/tss-react';
5
6
  import { isAlive } from '@jbrowse/mobx-state-tree';
@@ -47,6 +48,11 @@ function viewPhase(view) {
47
48
  : 'ready';
48
49
  }
49
50
  const useStyles = makeStyles()(theme => ({
51
+ // ViewLoadingScreen's label and place, which the body shows once mounted
52
+ pending: {
53
+ paddingTop: 20,
54
+ textAlign: 'center',
55
+ },
50
56
  viewContainer: {
51
57
  margin: theme.spacing(0.5),
52
58
  padding: `0 ${theme.spacing(1)} ${theme.spacing(1)}`,
@@ -148,6 +154,6 @@ const ViewContainer = observer(function ViewContainer({ view, session, scrollOnM
148
154
  // landmark list, so "jump to the second view" stops being a Tab count.
149
155
  role: "region", "aria-label": viewTitle(view, r => assemblyManager.getDisplayName(r)), "data-testid": `view-container-${view.id}`, "data-view-phase": viewPhase(view), children: [_jsx(ViewHeader, { view: view, className: backgroundColorClassName, scrollOnMount: scrollOnMount }), _jsx(Paper, { elevation: 0, children: _jsx("div", { ref: bodyRef, style: reserveSpace
150
156
  ? { height: measuredHeight ?? viewHeight(view) }
151
- : undefined, children: showBody ? _jsx(ViewWrapper, { view: view, session: session }) : null }) })] }));
157
+ : undefined, children: showBody ? (_jsx(ViewWrapper, { view: view, session: session })) : reserveSpace && measuredHeight === undefined ? (_jsx(LoadingEllipses, { variant: "h6", busy: false, className: classes.pending })) : null }) })] }));
152
158
  });
153
159
  export default ViewContainer;
@@ -4,18 +4,19 @@ import { getSession } from '@jbrowse/core/util';
4
4
  import { renameIds } from '@jbrowse/core/util/types/mst';
5
5
  import { getSnapshot } from '@jbrowse/mobx-state-tree';
6
6
  import ContentCopyIcon from '@mui/icons-material/ContentCopy';
7
+ import HorizontalSplitIcon from '@mui/icons-material/HorizontalSplit';
7
8
  import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
8
9
  import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
9
10
  import KeyboardDoubleArrowDownIcon from '@mui/icons-material/KeyboardDoubleArrowDown';
10
11
  import KeyboardDoubleArrowUpIcon from '@mui/icons-material/KeyboardDoubleArrowUp';
11
12
  import MenuIcon from '@mui/icons-material/Menu';
12
13
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
14
+ import TabIcon from '@mui/icons-material/Tab';
13
15
  import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
14
16
  import { observer } from 'mobx-react';
15
- // takes the icon's class, not an SvgIconProps object: the object was built
16
- // inline by ViewHeader and so was new on each of its renders, which defeated
17
- // mobx-react's memo for this whole menu (a Tooltip and an IconButton) every
18
- // time anything re-rendered the header
17
+ import { tabDisplayName } from '../../WorkspaceLayout/tabName.js';
18
+ // takes a class string rather than an SvgIconProps object, which a caller
19
+ // builds inline and so defeats this observer's memo
19
20
  const ViewMenu = observer(function ViewMenu({ model, className, }) {
20
21
  const session = getSession(model);
21
22
  const moves = {
@@ -24,36 +25,32 @@ const ViewMenu = observer(function ViewMenu({ model, className, }) {
24
25
  down: session.moveViewDown,
25
26
  bottom: session.moveViewToBottom,
26
27
  };
27
- // Give this view a home of its own: its own tab beside the rest, or its own
28
- // split to their right. One path, whether or not the workspace is already up
29
- // — the layout is session state either way, so there is nothing to defer and
30
- // no second implementation for "the panels do not exist yet". That fork is
31
- // the reason `init` existed.
32
- const moveViewOut = (direction) => {
33
- const allViewIds = session.views.map(v => v.id);
34
- if (direction === 'tabs') {
35
- session.moveViewToNewTab(model.id, allViewIds);
28
+ const moveViewOut = (to) => {
29
+ if (to === 'tab') {
30
+ session.moveViewToNewTab(model.id);
36
31
  }
37
32
  else {
38
- session.moveViewToSplitRight(model.id, allViewIds);
33
+ session.moveViewToSplit(model.id, to);
39
34
  }
40
- session.setUseWorkspaces(true);
35
+ };
36
+ // views render in `session.views` order within a tab, so a view joining one
37
+ // goes to its bottom rather than wherever its old slot falls
38
+ const moveViewToTab = (tabId) => {
39
+ const members = session.findTab(tabId)?.tab.viewIds ?? [];
40
+ session.moveViewToTab(model.id, tabId);
41
+ session.moveViewToBottom(model.id, [...members, model.id]);
41
42
  };
42
43
  return (_jsx(CascadingMenuButton, { "data-testid": "view_menu_icon", tooltip: "View menu", menuItems: () => {
43
- // The views this move is relative to: in a workspace, the ones sharing
44
- // this view's panel; in the classic stack, all of them. `session.views`
45
- // is the order either way, so the mode decides the SCOPE of a move and
46
- // nothing else. There is one implementation of "move a view" again.
47
- //
48
- // Resolved here rather than during render. It scans every panel's
49
- // assignment list and copies one of them, and reading those lists in
50
- // render would also subscribe this menu to them, so a view moving
51
- // between panels anywhere re-rendered every view's menu. None of it is
52
- // needed until the menu opens.
53
- const scopeIds = session.effectiveUseWorkspaces
54
- ? session.tabContainingView(model.id)?.tab.viewIds.slice()
55
- : undefined;
44
+ // A move's scope is this view's tab. Read on open rather than in
45
+ // render, so the menu does not observe every tab's membership.
46
+ const home = session.tabContainingView(model.id);
47
+ const scopeIds = home?.tab.viewIds.slice();
48
+ const otherTabs = home
49
+ ? session.tabs.filter(t => t.id !== home.tab.id)
50
+ : [];
56
51
  const viewCount = scopeIds?.length ?? session.views.length;
52
+ const aloneInTab = viewCount <= 1;
53
+ const aloneInCell = aloneInTab && (home?.panel.tabs.length ?? 1) <= 1;
57
54
  return [
58
55
  {
59
56
  label: 'View options',
@@ -66,20 +63,50 @@ const ViewMenu = observer(function ViewMenu({ model, className, }) {
66
63
  session.addView(model.type, renameIds(getSnapshot(model)));
67
64
  },
68
65
  },
69
- {
70
- label: 'Move to new tab',
71
- icon: OpenInNewIcon,
72
- onClick: () => {
73
- moveViewOut('tabs');
74
- },
75
- },
76
- {
77
- label: 'Move to split view (right side of screen)',
78
- icon: VerticalSplitIcon,
79
- onClick: () => {
80
- moveViewOut('horizontal');
81
- },
82
- },
66
+ ...(aloneInTab
67
+ ? []
68
+ : [
69
+ {
70
+ label: 'Move to new tab',
71
+ icon: OpenInNewIcon,
72
+ onClick: () => {
73
+ moveViewOut('tab');
74
+ },
75
+ },
76
+ ]),
77
+ ...(otherTabs.length > 0
78
+ ? [
79
+ {
80
+ label: 'Move to tab',
81
+ icon: TabIcon,
82
+ type: 'subMenu',
83
+ subMenu: otherTabs.map(tab => ({
84
+ label: tabDisplayName(tab, session.views.filter(v => tab.viewIds.includes(v.id)), session),
85
+ onClick: () => {
86
+ moveViewToTab(tab.id);
87
+ },
88
+ })),
89
+ },
90
+ ]
91
+ : []),
92
+ ...(aloneInCell
93
+ ? []
94
+ : [
95
+ {
96
+ label: 'Move to split view (right)',
97
+ icon: VerticalSplitIcon,
98
+ onClick: () => {
99
+ moveViewOut('row');
100
+ },
101
+ },
102
+ {
103
+ label: 'Move to split view (below)',
104
+ icon: HorizontalSplitIcon,
105
+ onClick: () => {
106
+ moveViewOut('column');
107
+ },
108
+ },
109
+ ]),
83
110
  // 'top'/'bottom' only mean something with a view above *and* below
84
111
  ...[
85
112
  ['top', 'Move view to top', KeyboardDoubleArrowUpIcon, 2],