@weasel-js/labkit 1.4.3 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-kTEZvEgE.d.ts} +1 -1
- package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-z7ker2Vx.d.ts} +4 -3
- package/dist/_dts/{index-DelFfeV0.d.ts → index-Blv9uQzu.d.ts} +27 -2
- package/dist/canvas/index.d.ts +4 -4
- package/dist/chrome/index.d.ts +2 -2
- package/dist/chrome/index.js +4 -4
- package/dist/{chunk-5TROQVQ2.js → chunk-2P6PP5N4.js} +10 -4
- package/dist/chunk-2P6PP5N4.js.map +1 -0
- package/dist/{chunk-C42T6DJT.js → chunk-64ZCN3DA.js} +5 -5
- package/dist/{chunk-C42T6DJT.js.map → chunk-64ZCN3DA.js.map} +1 -1
- package/dist/{chunk-ISCOFAUO.js → chunk-BKFVHKJH.js} +3 -3
- package/dist/{chunk-ISCOFAUO.js.map → chunk-BKFVHKJH.js.map} +1 -1
- package/dist/{chunk-QAGAHJKZ.js → chunk-MOM3GOVY.js} +3 -3
- package/dist/{chunk-QAGAHJKZ.js.map → chunk-MOM3GOVY.js.map} +1 -1
- package/dist/{chunk-2RQPGOQF.js → chunk-SMHP6XZ4.js} +3 -3
- package/dist/{chunk-2RQPGOQF.js.map → chunk-SMHP6XZ4.js.map} +1 -1
- package/dist/{chunk-ASWKLRKJ.js → chunk-U3IYHIAE.js} +11 -11
- package/dist/chunk-U3IYHIAE.js.map +1 -0
- package/dist/{chunk-MHAGC6VD.js → chunk-W3ECWC2K.js} +199 -184
- package/dist/chunk-W3ECWC2K.js.map +1 -0
- package/dist/controls/index.d.ts +1 -1
- package/dist/controls/index.js +2 -2
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +55 -9
- package/dist/index.js +128 -45
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +3 -3
- package/dist/passthrough/weasel-ui.d.ts +10 -0
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.js +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +81 -14
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +1 -1
- package/package.json +7 -7
- package/src/annotations/frac.test.ts +4 -4
- package/src/annotations/frac.ts +3 -2
- package/src/annotations/index.ts +1 -1
- package/src/annotations/store.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -2
- package/src/index.ts +1 -1
- package/src/lab/LabShell.tsx +14 -2
- package/src/lab/LabSwitcher.less +85 -0
- package/src/lab/LabSwitcher.test.tsx +96 -0
- package/src/lab/LabSwitcher.tsx +112 -0
- package/src/lab/index.ts +2 -0
- package/src/state/helpers.test.ts +20 -0
- package/src/state/helpers.ts +16 -0
- package/src/state/store.ts +2 -2
- package/src/styles.less +1 -0
- package/src/theme/base.less +4 -4
- package/src/trial/trialOps.ts +3 -2
- package/dist/chunk-5TROQVQ2.js.map +0 -1
- package/dist/chunk-ASWKLRKJ.js.map +0 -1
- package/dist/chunk-MHAGC6VD.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { fracContains,
|
|
2
|
+
import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
|
|
3
3
|
import type { FracRect } from './types';
|
|
4
4
|
|
|
5
5
|
const CONTENT = { w: 256, h: 170 };
|
|
@@ -51,9 +51,9 @@ describe('fraction hit geometry', () => {
|
|
|
51
51
|
expect(fracContains(box, { x: 0.18, y: 0.3 }, 0.05)).toBe(true);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it('
|
|
55
|
-
expect(
|
|
54
|
+
it('encloses only a box that lies wholly inside', () => {
|
|
55
|
+
expect(fracEncloses({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
|
|
56
56
|
// Overlapping is not containing: a marquee takes what it encloses.
|
|
57
|
-
expect(
|
|
57
|
+
expect(fracEncloses({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
|
|
58
58
|
});
|
|
59
59
|
});
|
package/src/annotations/frac.ts
CHANGED
|
@@ -55,8 +55,9 @@ export function fracContains(box: FracRect, pt: FracPoint, tol = 0): boolean {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Whether `outer` wholly encloses `inner`. A marquee takes what it encloses,
|
|
58
|
-
* not what it grazes — brushing selection is a different gesture
|
|
59
|
-
|
|
58
|
+
* not what it grazes — brushing selection is a different gesture, and this
|
|
59
|
+
* answers false for two rects that merely overlap. */
|
|
60
|
+
export function fracEncloses(outer: FracRect, inner: FracRect): boolean {
|
|
60
61
|
return (
|
|
61
62
|
inner.x >= outer.x &&
|
|
62
63
|
inner.y >= outer.y &&
|
package/src/annotations/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type { MarkDrawOptions } from './drawOne';
|
|
|
4
4
|
export { createMarkDrawOne, resolveMarkStyle } from './drawOne';
|
|
5
5
|
export { ExportMenu } from './ExportMenu';
|
|
6
6
|
export type { WorldRect } from './frac';
|
|
7
|
-
export { fracContains,
|
|
7
|
+
export { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
|
|
8
8
|
export type { HistoryScene } from './history';
|
|
9
9
|
export { MarkHistory } from './history';
|
|
10
10
|
export type { MarkListProps } from './MarkList';
|
package/src/annotations/store.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from '@weasel-js/core';
|
|
8
8
|
import { captureTarget } from './capture';
|
|
9
9
|
import type { WorldRect } from './frac';
|
|
10
|
-
import { fracContains,
|
|
10
|
+
import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
|
|
11
11
|
import { MarkHistory } from './history';
|
|
12
12
|
import { isStale as isStaleAgainst, seenFrom } from './staleness';
|
|
13
13
|
import type {
|
|
@@ -197,7 +197,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
197
197
|
},
|
|
198
198
|
|
|
199
199
|
within(target, box: FracRect) {
|
|
200
|
-
return marksOn(target).filter((a) =>
|
|
200
|
+
return marksOn(target).filter((a) => fracEncloses(box, a.frac));
|
|
201
201
|
},
|
|
202
202
|
|
|
203
203
|
isStale(a, config) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { DrawCommand, FillStyle, Stroke } from '@weasel-js/core';
|
|
2
|
-
import { resolveStrokeWidth } from '@weasel-js/core';
|
|
3
2
|
import type { SvgNode, SvgPaint, SvgStroke } from '@weasel-js/svg';
|
|
4
3
|
import { type MarkStyle, markCommands, type PaintableMark } from './paint';
|
|
5
4
|
|
|
@@ -23,7 +22,7 @@ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
|
|
|
23
22
|
function toSvgStroke(stroke: Stroke): SvgStroke {
|
|
24
23
|
return {
|
|
25
24
|
paint: toSvgPaint(stroke.paint),
|
|
26
|
-
width:
|
|
25
|
+
width: stroke.width ?? 1,
|
|
27
26
|
...(stroke.cap ? { cap: stroke.cap } : {}),
|
|
28
27
|
...(stroke.join ? { join: stroke.join } : {}),
|
|
29
28
|
...(stroke.dash ? { dash: [...stroke.dash] } : {}),
|
package/src/index.ts
CHANGED
package/src/lab/LabShell.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
|
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
import type { LabMode } from '../state/types';
|
|
4
4
|
import { interstellarTheme } from '../theme/interstellar';
|
|
5
|
+
import type { LabPage } from './LabSwitcher';
|
|
6
|
+
import { LabSwitcher } from './LabSwitcher';
|
|
5
7
|
import { useResolvedMode } from './useSystemMode';
|
|
6
8
|
|
|
7
9
|
/** Props for `<LabShell>`. */
|
|
@@ -14,12 +16,19 @@ export interface LabShellProps {
|
|
|
14
16
|
footer?: ReactNode;
|
|
15
17
|
/** Color mode. "auto" (default) follows prefers-color-scheme. */
|
|
16
18
|
mode?: LabMode;
|
|
19
|
+
/** The project's other labs. Given two or more, the title becomes the way
|
|
20
|
+
* to reach them; given none, it stays a plain heading. */
|
|
21
|
+
pages?: readonly LabPage[];
|
|
22
|
+
/** The path the switcher reads to mark the open page. Defaults to the
|
|
23
|
+
* current location. */
|
|
24
|
+
path?: string;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
/** Page frame for a lab: a titled header, a body, and an optional footer,
|
|
20
28
|
* themed for the resolved color mode. Presentational only — use `<Lab>` when
|
|
21
29
|
* the trial runtime is wanted too. */
|
|
22
|
-
export function LabShell({ title, children, header, footer, mode = 'auto'
|
|
30
|
+
export function LabShell({ title, children, header, footer, mode = 'auto',
|
|
31
|
+
pages, path }: LabShellProps) {
|
|
23
32
|
const resolved = useResolvedMode(mode);
|
|
24
33
|
const outer = useThemeOptional();
|
|
25
34
|
|
|
@@ -28,7 +37,10 @@ export function LabShell({ title, children, header, footer, mode = 'auto' }: Lab
|
|
|
28
37
|
// carries the element defaults a lab's bare markup is styled by.
|
|
29
38
|
<div className="lk-root lk-shell" data-wzl-portal-host="">
|
|
30
39
|
<header className="lk-shell-header">
|
|
31
|
-
|
|
40
|
+
{pages && pages.length > 0
|
|
41
|
+
? <LabSwitcher title={title} pages={pages} path={path}
|
|
42
|
+
className="lk-shell-title" />
|
|
43
|
+
: <h1 className="lk-shell-title">{title}</h1>}
|
|
32
44
|
{header && <div className="lk-shell-header-actions">{header}</div>}
|
|
33
45
|
</header>
|
|
34
46
|
<main className="lk-shell-body">{children}</main>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
.lk-switcher {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-block;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.lk-switcher-title {
|
|
7
|
+
margin: 0;
|
|
8
|
+
font-size: var(--wzl-font-size-xl);
|
|
9
|
+
line-height: var(--wzl-line-height-tight, 1.2);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.lk-switcher-trigger {
|
|
13
|
+
display: inline-flex;
|
|
14
|
+
align-items: baseline;
|
|
15
|
+
gap: var(--wzl-space-xs);
|
|
16
|
+
// The title IS the control, so it keeps the title's type and gives up every
|
|
17
|
+
// other button affordance until it is pointed at.
|
|
18
|
+
font: inherit;
|
|
19
|
+
color: inherit;
|
|
20
|
+
background: none;
|
|
21
|
+
border: 0;
|
|
22
|
+
padding: 0;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
|
|
25
|
+
&:hover { color: var(--wzl-text-accent, var(--wzl-text)); }
|
|
26
|
+
|
|
27
|
+
&:focus-visible {
|
|
28
|
+
outline: 2px solid var(--wzl-focus-ring, currentColor);
|
|
29
|
+
outline-offset: 2px;
|
|
30
|
+
border-radius: var(--wzl-radius-sm, 2px);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.lk-switcher-caret {
|
|
35
|
+
// Sized in em so it tracks the title rather than needing a second rule per
|
|
36
|
+
// heading size.
|
|
37
|
+
width: 0.36em;
|
|
38
|
+
height: 0.36em;
|
|
39
|
+
border-right: 0.09em solid currentColor;
|
|
40
|
+
border-bottom: 0.09em solid currentColor;
|
|
41
|
+
transform: translateY(-0.18em) rotate(45deg);
|
|
42
|
+
opacity: 0.6;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.lk-switcher-trigger[aria-expanded='true'] .lk-switcher-caret {
|
|
46
|
+
transform: translateY(0.06em) rotate(-135deg);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.lk-switcher-menu {
|
|
50
|
+
position: absolute;
|
|
51
|
+
top: calc(100% + var(--wzl-space-xs));
|
|
52
|
+
left: 0;
|
|
53
|
+
z-index: var(--wzl-z-popover, 60);
|
|
54
|
+
min-width: max(100%, 11rem);
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
padding: var(--wzl-space-xs);
|
|
58
|
+
background: var(--wzl-surface-raised);
|
|
59
|
+
border: 1px solid var(--wzl-border);
|
|
60
|
+
border-radius: var(--wzl-radius-md, 6px);
|
|
61
|
+
box-shadow: var(--wzl-shadow-md, 0 6px 20px rgb(0 0 0 / 35%));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.lk-switcher-item {
|
|
65
|
+
padding: var(--wzl-space-xs) var(--wzl-space-sm);
|
|
66
|
+
border-radius: var(--wzl-radius-sm, 3px);
|
|
67
|
+
color: var(--wzl-text);
|
|
68
|
+
font-size: var(--wzl-font-size-md);
|
|
69
|
+
text-decoration: none;
|
|
70
|
+
white-space: nowrap;
|
|
71
|
+
|
|
72
|
+
&:hover { background: var(--wzl-surface-hover, var(--wzl-surface)); }
|
|
73
|
+
|
|
74
|
+
&:focus-visible {
|
|
75
|
+
outline: 2px solid var(--wzl-focus-ring, currentColor);
|
|
76
|
+
outline-offset: -2px;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The open page stays in the list rather than being filtered out: a menu that
|
|
81
|
+
// drops its current entry shifts every other one as you move between pages.
|
|
82
|
+
.lk-switcher-item[aria-current='page'] {
|
|
83
|
+
color: var(--wzl-text-strong, var(--wzl-text));
|
|
84
|
+
font-weight: var(--wzl-font-weight-semibold, 600);
|
|
85
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { currentPage, LabSwitcher } from './LabSwitcher';
|
|
4
|
+
import { LabShell } from './LabShell';
|
|
5
|
+
|
|
6
|
+
const PAGES = [
|
|
7
|
+
{ href: '/corpus', label: 'Wall' },
|
|
8
|
+
{ href: '/stats', label: 'Dashboard' },
|
|
9
|
+
{ href: '/ingest', label: 'Ingestion' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const open = () => fireEvent.click(screen.getByRole('button', { name: /corpus stats/ }));
|
|
13
|
+
|
|
14
|
+
describe('currentPage', () => {
|
|
15
|
+
it('matches a bare path', () => {
|
|
16
|
+
expect(currentPage('/stats', PAGES)).toBe(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('survives a query string, a trailing slash and a leftover extension', () => {
|
|
20
|
+
// A dev server maps `/stats` to `stats.html` and serves both spellings, so
|
|
21
|
+
// an old bookmark must still resolve to the same page.
|
|
22
|
+
for (const path of ['/stats?kind=base', '/stats/', '/stats.html',
|
|
23
|
+
'/lab/stats.html#top']) {
|
|
24
|
+
expect(currentPage(path, PAGES)).toBe(1);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('reports -1 for a page that is not one of them', () => {
|
|
29
|
+
expect(currentPage('/bench', PAGES)).toBe(-1);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('<LabSwitcher>', () => {
|
|
34
|
+
it('shows the title and nothing else until it is opened', () => {
|
|
35
|
+
render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
|
|
36
|
+
expect(screen.getByRole('button', { name: /corpus stats/ })).toHaveAttribute(
|
|
37
|
+
'aria-expanded', 'false');
|
|
38
|
+
expect(screen.queryByRole('menu')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('opens a menu of every lab, the open one included', () => {
|
|
42
|
+
// Filtering the current page out shifts every other entry as you move
|
|
43
|
+
// between pages, so it stays and is marked instead.
|
|
44
|
+
render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
|
|
45
|
+
open();
|
|
46
|
+
const items = screen.getAllByRole('menuitem');
|
|
47
|
+
expect(items.map((i) => i.textContent)).toEqual(['Wall', 'Dashboard', 'Ingestion']);
|
|
48
|
+
expect(items[1]).toHaveAttribute('aria-current', 'page');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('offers real links, so cmd-click and the back button keep working', () => {
|
|
52
|
+
// These are separate documents. A <select> would fire a change event and
|
|
53
|
+
// get none of the three for free.
|
|
54
|
+
render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
|
|
55
|
+
open();
|
|
56
|
+
expect(screen.getByRole('menuitem', { name: 'Wall' }).tagName).toBe('A');
|
|
57
|
+
expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute(
|
|
58
|
+
'href', '/corpus');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('closes on Escape and on a press outside it', () => {
|
|
62
|
+
render(
|
|
63
|
+
<div>
|
|
64
|
+
<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />
|
|
65
|
+
<button type="button">elsewhere</button>
|
|
66
|
+
</div>);
|
|
67
|
+
open();
|
|
68
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
69
|
+
expect(screen.queryByRole('menu')).toBeNull();
|
|
70
|
+
|
|
71
|
+
open();
|
|
72
|
+
fireEvent.pointerDown(screen.getByRole('button', { name: 'elsewhere' }));
|
|
73
|
+
expect(screen.queryByRole('menu')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('stays a plain heading when there is nowhere to switch to', () => {
|
|
77
|
+
// A disclosure arrow promising a menu of the page you are already on is
|
|
78
|
+
// worse than no control.
|
|
79
|
+
render(<LabSwitcher title="corpus stats" pages={[PAGES[1]!]} path="/stats" />);
|
|
80
|
+
expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
|
|
81
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('<LabShell> with pages', () => {
|
|
86
|
+
it('turns its title into the switcher', () => {
|
|
87
|
+
render(<LabShell title="corpus stats" pages={PAGES} path="/stats">body</LabShell>);
|
|
88
|
+
expect(screen.getByRole('button', { name: /corpus stats/ })).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('leaves the title alone when no pages are given', () => {
|
|
92
|
+
render(<LabShell title="corpus stats">body</LabShell>);
|
|
93
|
+
expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
|
|
94
|
+
expect(screen.queryByRole('button', { name: /corpus stats/ })).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** One page in a project's set of labs. */
|
|
4
|
+
export interface LabPage {
|
|
5
|
+
/** The page's own URL, and how the switcher knows which page is open. */
|
|
6
|
+
href: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Props for `<LabSwitcher>`. */
|
|
11
|
+
export interface LabSwitcherProps {
|
|
12
|
+
/** The open page's title, shown as the control's own label. */
|
|
13
|
+
title: string;
|
|
14
|
+
pages: readonly LabPage[];
|
|
15
|
+
/** Defaults to the current location. Pass it in tests, or to render a
|
|
16
|
+
* switcher for a page other than the one being viewed. */
|
|
17
|
+
path?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Which of `pages` a path is on, or -1.
|
|
22
|
+
*
|
|
23
|
+
* Matched on the end of the path so a query string, a trailing slash or a
|
|
24
|
+
* leftover `.html` cannot lose it: a project whose dev server maps `/stats`
|
|
25
|
+
* to `stats.html` serves both spellings, and a bookmark from before the URLs
|
|
26
|
+
* lost their extension still resolves to the same page. */
|
|
27
|
+
export function currentPage(path: string, pages: readonly LabPage[]): number {
|
|
28
|
+
const here = path.split('?')[0]!.split('#')[0]!
|
|
29
|
+
.replace(/\/$/, '').replace(/\.html$/, '');
|
|
30
|
+
return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A project's lab title, doubling as the way to reach its other labs.
|
|
34
|
+
*
|
|
35
|
+
* The menu holds real anchors, not a `<select>` and not buttons. These are
|
|
36
|
+
* separate documents, so an `href` is what gets middle-click, cmd-click and
|
|
37
|
+
* the back button for free; a select fires a change event and can do none of
|
|
38
|
+
* the three. The trigger is a button because it opens something rather than
|
|
39
|
+
* going anywhere.
|
|
40
|
+
*
|
|
41
|
+
* Pages accumulate -- a project grows a wall, a dashboard, an ingest page, a
|
|
42
|
+
* bench -- and a tab strip is the layout that stops working first. */
|
|
43
|
+
export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps) {
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const root = useRef<HTMLDivElement>(null);
|
|
46
|
+
const menuId = useId();
|
|
47
|
+
const here = currentPage(path ?? (typeof window === 'undefined'
|
|
48
|
+
? '' : window.location.pathname), pages);
|
|
49
|
+
|
|
50
|
+
const close = useCallback(() => setOpen(false), []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!open) return;
|
|
54
|
+
const onKey = (e: KeyboardEvent) => {
|
|
55
|
+
if (e.key === 'Escape') {
|
|
56
|
+
close();
|
|
57
|
+
root.current?.querySelector('button')?.focus();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
// Pointer, not click: a press that starts outside should dismiss before
|
|
61
|
+
// whatever it lands on reacts, the way every other menu on the page does.
|
|
62
|
+
const onDown = (e: PointerEvent) => {
|
|
63
|
+
if (!root.current?.contains(e.target as Node)) close();
|
|
64
|
+
};
|
|
65
|
+
document.addEventListener('keydown', onKey);
|
|
66
|
+
document.addEventListener('pointerdown', onDown);
|
|
67
|
+
return () => {
|
|
68
|
+
document.removeEventListener('keydown', onKey);
|
|
69
|
+
document.removeEventListener('pointerdown', onDown);
|
|
70
|
+
};
|
|
71
|
+
}, [open, close]);
|
|
72
|
+
|
|
73
|
+
// One lab is not a set to choose from, and a disclosure arrow promising a
|
|
74
|
+
// menu of itself is worse than no control.
|
|
75
|
+
if (pages.length < 2) {
|
|
76
|
+
return <h1 className={`lk-switcher-title${className ? ` ${className}` : ''}`}>{title}</h1>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={`lk-switcher${className ? ` ${className}` : ''}`} ref={root}>
|
|
81
|
+
<h1 className="lk-switcher-title">
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="lk-switcher-trigger"
|
|
85
|
+
aria-expanded={open}
|
|
86
|
+
aria-haspopup="menu"
|
|
87
|
+
aria-controls={open ? menuId : undefined}
|
|
88
|
+
onClick={() => setOpen((was) => !was)}
|
|
89
|
+
>
|
|
90
|
+
{title}
|
|
91
|
+
<span className="lk-switcher-caret" aria-hidden="true" />
|
|
92
|
+
</button>
|
|
93
|
+
</h1>
|
|
94
|
+
{open && (
|
|
95
|
+
<div className="lk-switcher-menu" id={menuId} role="menu">
|
|
96
|
+
{pages.map((page, i) => (
|
|
97
|
+
<a
|
|
98
|
+
key={page.href}
|
|
99
|
+
href={page.href}
|
|
100
|
+
role="menuitem"
|
|
101
|
+
className="lk-switcher-item"
|
|
102
|
+
aria-current={i === here ? 'page' : undefined}
|
|
103
|
+
onClick={close}
|
|
104
|
+
>
|
|
105
|
+
{page.label}
|
|
106
|
+
</a>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/lab/index.ts
CHANGED
|
@@ -6,5 +6,7 @@ export type { LabPaletteProps } from './LabPalette';
|
|
|
6
6
|
export { LabPalette } from './LabPalette';
|
|
7
7
|
export type { LabShellProps } from './LabShell';
|
|
8
8
|
export { LabShell } from './LabShell';
|
|
9
|
+
export type { LabPage, LabSwitcherProps } from './LabSwitcher';
|
|
10
|
+
export { currentPage, LabSwitcher } from './LabSwitcher';
|
|
9
11
|
export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
|
|
10
12
|
export { Workspace } from './Workspace';
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
deserializeTrials,
|
|
5
5
|
emptyUndoStack,
|
|
6
6
|
encodeUrlHash,
|
|
7
|
+
newId,
|
|
7
8
|
labStorageKey,
|
|
8
9
|
serializeTrials,
|
|
9
10
|
} from './helpers';
|
|
@@ -114,4 +115,23 @@ describe('deserializeTrials', () => {
|
|
|
114
115
|
it('returns an empty list when given something that is not an array', () => {
|
|
115
116
|
expect(deserializeTrials(undefined as never, {})).toEqual([]);
|
|
116
117
|
});
|
|
118
|
+
|
|
119
|
+
describe('newId', () => {
|
|
120
|
+
it('mints distinct ids', () => {
|
|
121
|
+
expect(newId()).not.toBe(newId());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('falls back where crypto.randomUUID is absent', () => {
|
|
125
|
+
// What a lab reached by LAN IP gets: not a secure context, so the
|
|
126
|
+
// function is not there. It used to throw on the first render and show a
|
|
127
|
+
// blank page.
|
|
128
|
+
const real = globalThis.crypto;
|
|
129
|
+
Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true });
|
|
130
|
+
try {
|
|
131
|
+
expect(newId()).not.toBe(newId());
|
|
132
|
+
} finally {
|
|
133
|
+
Object.defineProperty(globalThis, 'crypto', { value: real, configurable: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
117
137
|
});
|
package/src/state/helpers.ts
CHANGED
|
@@ -25,6 +25,22 @@ export function decodeUrlHash(hash: string): string | null {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* An id for a trial or a snapshot.
|
|
30
|
+
*
|
|
31
|
+
* `crypto.randomUUID` exists only in a secure context, and a LAN address is
|
|
32
|
+
* not one — so a lab opened on a phone or a tablet by IP throws on its first
|
|
33
|
+
* render and shows a blank page with nothing in reach to say why. The counter
|
|
34
|
+
* is per page load, which is all these ids have to survive: they name records
|
|
35
|
+
* inside one session's store, and a saved snapshot carries its own name.
|
|
36
|
+
*/
|
|
37
|
+
let idCounter = 0;
|
|
38
|
+
export function newId(): string {
|
|
39
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
40
|
+
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
|
41
|
+
return `lk-${Date.now().toString(36)}-${idCounter++}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
/** A fresh, empty undo history. */
|
|
29
45
|
export function emptyUndoStack(): UndoStack {
|
|
30
46
|
return { past: [], future: [] };
|
package/src/state/store.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
readLegacyDocument,
|
|
12
12
|
runMigrations,
|
|
13
13
|
} from './document';
|
|
14
|
-
import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
|
|
14
|
+
import { deserializeTrials, emptyUndoStack, newId, serializeTrials } from './helpers';
|
|
15
15
|
import type {
|
|
16
16
|
CreateLabStoreOptions,
|
|
17
17
|
LabDocument,
|
|
@@ -221,7 +221,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
221
221
|
const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
|
|
222
222
|
const savedAt = Math.max(Date.now(), lastAt + 1);
|
|
223
223
|
const snapshot: SavedSnapshot = {
|
|
224
|
-
id:
|
|
224
|
+
id: newId(),
|
|
225
225
|
name,
|
|
226
226
|
trialId,
|
|
227
227
|
instrumentName: trial.instrumentName,
|
package/src/styles.less
CHANGED
package/src/theme/base.less
CHANGED
|
@@ -79,13 +79,13 @@
|
|
|
79
79
|
&::-webkit-slider-runnable-track {
|
|
80
80
|
height: var(--wzl-slider-track-h);
|
|
81
81
|
border-radius: var(--wzl-radius-sm);
|
|
82
|
-
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-
|
|
82
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
&::-moz-range-track {
|
|
86
86
|
height: var(--wzl-slider-track-h);
|
|
87
87
|
border-radius: var(--wzl-radius-sm);
|
|
88
|
-
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-
|
|
88
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
&::-webkit-slider-thumb {
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
height: var(--wzl-slider-thumb-size);
|
|
96
96
|
border: none;
|
|
97
97
|
border-radius: 50%;
|
|
98
|
-
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-
|
|
98
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
|
|
99
99
|
margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
height: var(--wzl-slider-thumb-size);
|
|
105
105
|
border: none;
|
|
106
106
|
border-radius: 50%;
|
|
107
|
-
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-
|
|
107
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
}
|
package/src/trial/trialOps.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fillConfigDefaults } from '../config/path';
|
|
2
2
|
import type { Instrument, InstrumentList } from '../instrument/types';
|
|
3
3
|
import type { TrialRecord } from '../state/types';
|
|
4
|
+
import { newId } from '../state/helpers';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_VIEW = { zoom: 1, pan: { x: 0, y: 0 } } as const;
|
|
6
7
|
|
|
@@ -47,7 +48,7 @@ export function addTrial(
|
|
|
47
48
|
const config = seedConfig(instrument.defaultConfig(), options.config);
|
|
48
49
|
const state = instrument.initialState(config);
|
|
49
50
|
const record: TrialRecord = {
|
|
50
|
-
id:
|
|
51
|
+
id: newId(),
|
|
51
52
|
instrumentName,
|
|
52
53
|
config,
|
|
53
54
|
state,
|
|
@@ -66,7 +67,7 @@ export function cloneTrial(trials: TrialRecord[], id: string): TrialRecord[] {
|
|
|
66
67
|
if (!source) return trials;
|
|
67
68
|
const clone: TrialRecord = {
|
|
68
69
|
...source,
|
|
69
|
-
id:
|
|
70
|
+
id: newId(),
|
|
70
71
|
config: structuredClone(source.config),
|
|
71
72
|
state: structuredClone(source.state),
|
|
72
73
|
view: structuredClone(source.view),
|