@weasel-js/labkit 1.1.0 → 1.3.0-pre.0
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/README.md +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
- package/dist/canvas/index.d.ts +8 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/chunk-HFDVGF4X.js +170 -0
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +5 -2
- package/dist/index.d.ts +307 -107
- package/dist/index.js +797 -540
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3804 -3661
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +543 -144
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +4 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +5 -2
- package/package.json +24 -7
- package/src/canvas/AGENTS.md +12 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.test.ts +18 -0
- package/src/index.ts +38 -3
- package/src/instrument/SineWave.smoke.test.tsx +9 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +38 -3
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.stories.tsx +1 -2
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +19 -9
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +16 -2
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +8 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +6 -0
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +161 -0
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +73 -0
- package/src/trial/Trial.less +121 -4
- package/src/trial/Trial.stories.tsx +9 -1
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +112 -45
- package/src/trial/TrialChrome.tsx +110 -64
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js +0 -5986
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
- package/dist/chunk-NRKWVTVT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -53
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
|
|
5
|
+
/** Props for `<SidebarRegion>`. */
|
|
6
|
+
export interface SidebarRegionProps {
|
|
7
|
+
contributions: readonly TrialContribution[];
|
|
8
|
+
ctx: TrialChromeContext;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Section({
|
|
12
|
+
title,
|
|
13
|
+
defaultCollapsed,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
title: string;
|
|
17
|
+
defaultCollapsed: boolean;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}) {
|
|
20
|
+
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
21
|
+
return (
|
|
22
|
+
<section className="lk-sidebar-section">
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
className="lk-sidebar-section__head"
|
|
26
|
+
aria-expanded={!collapsed}
|
|
27
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
28
|
+
>
|
|
29
|
+
{title}
|
|
30
|
+
</button>
|
|
31
|
+
{collapsed ? null : <div className="lk-sidebar-section__body">{children}</div>}
|
|
32
|
+
</section>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Lays a trial's `sidebar` contributions out as titled, collapsible sections. */
|
|
37
|
+
export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
|
|
38
|
+
if (contributions.length === 0) return null;
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
{contributions.map((c) => {
|
|
42
|
+
if (c.render) return <div key={c.id}>{c.render(ctx)}</div>;
|
|
43
|
+
if (c.region !== 'sidebar' || !c.item) return null;
|
|
44
|
+
return (
|
|
45
|
+
<Section
|
|
46
|
+
key={c.id}
|
|
47
|
+
title={c.item.title}
|
|
48
|
+
defaultCollapsed={c.item.defaultCollapsed ?? false}
|
|
49
|
+
>
|
|
50
|
+
{c.item.body}
|
|
51
|
+
</Section>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { StatusBar } from '../../primitives/StatusBar';
|
|
2
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
3
|
+
|
|
4
|
+
/** Props for `<StatusRegion>`. */
|
|
5
|
+
export interface StatusRegionProps {
|
|
6
|
+
contributions: readonly TrialContribution[];
|
|
7
|
+
ctx: TrialChromeContext;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Lays a trial's `status` contributions out as readouts. */
|
|
11
|
+
export function StatusRegion({ contributions, ctx }: StatusRegionProps) {
|
|
12
|
+
if (contributions.length === 0) return null;
|
|
13
|
+
return (
|
|
14
|
+
<StatusBar>
|
|
15
|
+
{contributions.map((c) => {
|
|
16
|
+
if (c.render)
|
|
17
|
+
return (
|
|
18
|
+
<StatusBar.Section key={c.id} end={c.end}>
|
|
19
|
+
{c.render(ctx)}
|
|
20
|
+
</StatusBar.Section>
|
|
21
|
+
);
|
|
22
|
+
if (c.region !== 'status' || !c.item) return null;
|
|
23
|
+
return (
|
|
24
|
+
<StatusBar.Section key={c.id} end={c.end}>
|
|
25
|
+
<span title={c.item.title}>{c.item.text}</span>
|
|
26
|
+
</StatusBar.Section>
|
|
27
|
+
);
|
|
28
|
+
})}
|
|
29
|
+
</StatusBar>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
3
|
+
|
|
4
|
+
/** Props for `<TitleBarRegion>`. */
|
|
5
|
+
export interface TitleBarRegionProps {
|
|
6
|
+
contributions: readonly TrialContribution[];
|
|
7
|
+
ctx: TrialChromeContext;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
11
|
+
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
12
|
+
if (c.region !== 'titlebar' || !c.item) return null;
|
|
13
|
+
const { icon: Icon, label, shortcut, disabled, danger } = c.item;
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
key={c.id}
|
|
17
|
+
type="button"
|
|
18
|
+
className={`lk-titlebar-button${danger ? ' lk-titlebar-button--danger' : ''}`}
|
|
19
|
+
disabled={disabled}
|
|
20
|
+
aria-label={label}
|
|
21
|
+
title={shortcut ? `${label} (${shortcut})` : label}
|
|
22
|
+
onClick={c.item.onActivate}
|
|
23
|
+
// The bar behind these buttons is the window drag surface, so a press
|
|
24
|
+
// that lands here must not also start a drag.
|
|
25
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
26
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
27
|
+
>
|
|
28
|
+
<Icon size={14} />
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Lays a trial's `titlebar` contributions out, at the far end of the bar. */
|
|
34
|
+
export function TitleBarRegion({ contributions, ctx }: TitleBarRegionProps) {
|
|
35
|
+
if (contributions.length === 0) return null;
|
|
36
|
+
return (
|
|
37
|
+
<span className="lk-trial__titlebar-actions">
|
|
38
|
+
{contributions.map((c) => renderEntry(c, ctx))}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
import { ToolbarRegion } from './ToolbarRegion';
|
|
5
|
+
|
|
6
|
+
const Glyph = () => <svg data-testid="glyph" />;
|
|
7
|
+
|
|
8
|
+
const ctx = { trialId: 't1' } as unknown as TrialChromeContext;
|
|
9
|
+
|
|
10
|
+
function item(id: string, over: Partial<TrialContribution> = {}): TrialContribution {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
region: 'toolbar',
|
|
14
|
+
item: { icon: Glyph, label: id, onActivate: () => {} },
|
|
15
|
+
...over,
|
|
16
|
+
} as TrialContribution;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('ToolbarRegion', () => {
|
|
20
|
+
it('renders nothing when it has no contributions', () => {
|
|
21
|
+
const { container } = render(<ToolbarRegion contributions={[]} ctx={ctx} />);
|
|
22
|
+
expect(container).toBeEmptyDOMElement();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders one button per contribution, labelled', () => {
|
|
26
|
+
render(<ToolbarRegion contributions={[item('undo'), item('redo')]} ctx={ctx} />);
|
|
27
|
+
expect(screen.getByRole('button', { name: 'undo' })).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByRole('button', { name: 'redo' })).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('fires onActivate on click', () => {
|
|
32
|
+
const onActivate = vi.fn();
|
|
33
|
+
render(
|
|
34
|
+
<ToolbarRegion
|
|
35
|
+
contributions={[
|
|
36
|
+
{ id: 'go', region: 'toolbar', item: { icon: Glyph, label: 'Go', onActivate } },
|
|
37
|
+
]}
|
|
38
|
+
ctx={ctx}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
screen.getByRole('button', { name: 'Go' }).click();
|
|
42
|
+
expect(onActivate).toHaveBeenCalledOnce();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('groups contributions sharing a group into one group element', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ToolbarRegion
|
|
48
|
+
contributions={[
|
|
49
|
+
item('undo', { group: 'history' }),
|
|
50
|
+
item('redo', { group: 'history' }),
|
|
51
|
+
item('close', { group: 'trial' }),
|
|
52
|
+
]}
|
|
53
|
+
ctx={ctx}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
expect(screen.getAllByRole('group')).toHaveLength(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('renders a render-escape contribution verbatim', () => {
|
|
60
|
+
render(
|
|
61
|
+
<ToolbarRegion
|
|
62
|
+
contributions={[{ id: 'custom', region: 'toolbar', render: () => <b>custom</b> }]}
|
|
63
|
+
ctx={ctx}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
expect(screen.getByText('custom')).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('disables a button whose item says so', () => {
|
|
70
|
+
render(
|
|
71
|
+
<ToolbarRegion
|
|
72
|
+
contributions={[
|
|
73
|
+
item('undo', {
|
|
74
|
+
item: { icon: Glyph, label: 'Undo', disabled: true, onActivate: () => {} },
|
|
75
|
+
}),
|
|
76
|
+
]}
|
|
77
|
+
ctx={ctx}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Toolbar } from '../../primitives/Toolbar';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
|
|
5
|
+
/** Props for `<ToolbarRegion>`. */
|
|
6
|
+
export interface ToolbarRegionProps {
|
|
7
|
+
contributions: readonly TrialContribution[];
|
|
8
|
+
ctx: TrialChromeContext;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Group {
|
|
12
|
+
key: string;
|
|
13
|
+
end: boolean;
|
|
14
|
+
entries: TrialContribution[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Bucket by group, preserving first-appearance order. Ungrouped
|
|
18
|
+
* contributions each become their own bucket so they stay in place. */
|
|
19
|
+
function groupsOf(contributions: readonly TrialContribution[]): Group[] {
|
|
20
|
+
const groups: Group[] = [];
|
|
21
|
+
const byKey = new Map<string, Group>();
|
|
22
|
+
for (const c of contributions) {
|
|
23
|
+
if (c.group == null) {
|
|
24
|
+
groups.push({ key: c.id, end: c.end ?? false, entries: [c] });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let g = byKey.get(c.group);
|
|
28
|
+
if (!g) {
|
|
29
|
+
g = { key: c.group, end: c.end ?? false, entries: [] };
|
|
30
|
+
byKey.set(c.group, g);
|
|
31
|
+
groups.push(g);
|
|
32
|
+
}
|
|
33
|
+
g.entries.push(c);
|
|
34
|
+
}
|
|
35
|
+
return groups;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
39
|
+
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
40
|
+
if (c.region !== 'toolbar' || !c.item) return null;
|
|
41
|
+
const { icon: Icon, label, shortcut, disabled, danger, showLabel } = c.item;
|
|
42
|
+
return (
|
|
43
|
+
<Toolbar.Button
|
|
44
|
+
key={c.id}
|
|
45
|
+
iconOnly={!showLabel}
|
|
46
|
+
variant={danger ? 'danger' : 'default'}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
aria-label={label}
|
|
49
|
+
title={shortcut ? `${label} (${shortcut})` : label}
|
|
50
|
+
onClick={c.item.onActivate}
|
|
51
|
+
>
|
|
52
|
+
<Icon size={16} />
|
|
53
|
+
{showLabel ? <span>{label}</span> : null}
|
|
54
|
+
</Toolbar.Button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Lays a trial's `toolbar` contributions out, grouped by their `group`. */
|
|
59
|
+
export function ToolbarRegion({ contributions, ctx }: ToolbarRegionProps) {
|
|
60
|
+
if (contributions.length === 0) return null;
|
|
61
|
+
const groups = groupsOf(contributions);
|
|
62
|
+
return (
|
|
63
|
+
<Toolbar aria-label="Trial actions">
|
|
64
|
+
{groups.map((g) => (
|
|
65
|
+
<Toolbar.Group key={g.key} end={g.end} aria-label={g.key}>
|
|
66
|
+
{g.entries.map((c) => renderEntry(c, ctx))}
|
|
67
|
+
</Toolbar.Group>
|
|
68
|
+
))}
|
|
69
|
+
</Toolbar>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
.lk-viewport-controls {
|
|
2
|
+
position: absolute;
|
|
3
|
+
right: var(--wzl-space-sm);
|
|
4
|
+
bottom: var(--wzl-space-sm);
|
|
5
|
+
z-index: var(--wzl-z-overlay);
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-wrap: wrap;
|
|
8
|
+
justify-content: flex-end;
|
|
9
|
+
// Bounded by the well it floats in, so a wide control cluster wraps into the
|
|
10
|
+
// corner rather than running out under the sidebar.
|
|
11
|
+
max-width: calc(100% - 2 * var(--wzl-space-sm));
|
|
12
|
+
gap: var(--wzl-space-xs);
|
|
13
|
+
padding: var(--wzl-space-xs);
|
|
14
|
+
border: var(--wzl-border-w) solid var(--wzl-border);
|
|
15
|
+
border-radius: var(--wzl-radius-md);
|
|
16
|
+
background: var(--wzl-surface-raised);
|
|
17
|
+
backdrop-filter: blur(var(--wzl-glass-blur));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.lk-viewport-controls__button {
|
|
21
|
+
display: inline-flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
width: var(--wzl-control-h);
|
|
25
|
+
height: var(--wzl-control-h);
|
|
26
|
+
padding: 0;
|
|
27
|
+
border: 0;
|
|
28
|
+
border-radius: var(--wzl-radius-sm);
|
|
29
|
+
background: transparent;
|
|
30
|
+
color: var(--wzl-fg-muted);
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
|
|
33
|
+
&:hover:not(:disabled) {
|
|
34
|
+
background: var(--wzl-surface-hover);
|
|
35
|
+
color: var(--wzl-fg);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&:disabled {
|
|
39
|
+
opacity: 0.4;
|
|
40
|
+
cursor: default;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Props for `<ViewportRegion>`. */
|
|
4
|
+
export interface ViewportRegionProps {
|
|
5
|
+
contributions: readonly TrialContribution[];
|
|
6
|
+
ctx: TrialChromeContext;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Controls acting on the view of a trial. Anchored inside the content well
|
|
11
|
+
* rather than in the toolbar, which acts on the trial itself.
|
|
12
|
+
*/
|
|
13
|
+
export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
|
|
14
|
+
if (contributions.length === 0) return null;
|
|
15
|
+
return (
|
|
16
|
+
<div className="lk-viewport-controls" role="toolbar" aria-label="View">
|
|
17
|
+
{contributions.map((c) => {
|
|
18
|
+
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
19
|
+
if (c.region !== 'viewport' || !c.item) return null;
|
|
20
|
+
const { icon: Icon, label, disabled } = c.item;
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
key={c.id}
|
|
24
|
+
type="button"
|
|
25
|
+
className="lk-viewport-controls__button"
|
|
26
|
+
aria-label={label}
|
|
27
|
+
title={label}
|
|
28
|
+
disabled={disabled}
|
|
29
|
+
onClick={c.item.onActivate}
|
|
30
|
+
>
|
|
31
|
+
<Icon size={16} />
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
})}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
import { SidebarRegion } from './SidebarRegion';
|
|
5
|
+
import { StatusRegion } from './StatusRegion';
|
|
6
|
+
import { ViewportRegion } from './ViewportRegion';
|
|
7
|
+
|
|
8
|
+
const Glyph = () => <svg />;
|
|
9
|
+
const ctx = { trialId: 't1' } as unknown as TrialChromeContext;
|
|
10
|
+
|
|
11
|
+
describe('SidebarRegion', () => {
|
|
12
|
+
it('renders nothing when empty', () => {
|
|
13
|
+
const { container } = render(<SidebarRegion contributions={[]} ctx={ctx} />);
|
|
14
|
+
expect(container).toBeEmptyDOMElement();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders each section with its title and body', () => {
|
|
18
|
+
const contributions: TrialContribution[] = [
|
|
19
|
+
{ id: 'settings', region: 'sidebar', item: { title: 'Settings', body: <p>fields</p> } },
|
|
20
|
+
{ id: 'layers', region: 'sidebar', item: { title: 'Layers', body: <p>list</p> } },
|
|
21
|
+
];
|
|
22
|
+
render(<SidebarRegion contributions={contributions} ctx={ctx} />);
|
|
23
|
+
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText('fields')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText('Layers')).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('starts a section collapsed when it asks to', () => {
|
|
29
|
+
render(
|
|
30
|
+
<SidebarRegion
|
|
31
|
+
contributions={[
|
|
32
|
+
{
|
|
33
|
+
id: 's',
|
|
34
|
+
region: 'sidebar',
|
|
35
|
+
item: { title: 'S', defaultCollapsed: true, body: <p>hidden</p> },
|
|
36
|
+
},
|
|
37
|
+
]}
|
|
38
|
+
ctx={ctx}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
expect(screen.queryByText('hidden')).not.toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('StatusRegion', () => {
|
|
46
|
+
it('renders nothing when empty', () => {
|
|
47
|
+
const { container } = render(<StatusRegion contributions={[]} ctx={ctx} />);
|
|
48
|
+
expect(container).toBeEmptyDOMElement();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders each readout as text', () => {
|
|
52
|
+
render(
|
|
53
|
+
<StatusRegion
|
|
54
|
+
contributions={[{ id: 'zoom', region: 'status', item: { text: '150%' } }]}
|
|
55
|
+
ctx={ctx}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByText('150%')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('ViewportRegion', () => {
|
|
63
|
+
it('renders nothing when empty', () => {
|
|
64
|
+
const { container } = render(<ViewportRegion contributions={[]} ctx={ctx} />);
|
|
65
|
+
expect(container).toBeEmptyDOMElement();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fires a control on click', () => {
|
|
69
|
+
const onActivate = vi.fn();
|
|
70
|
+
render(
|
|
71
|
+
<ViewportRegion
|
|
72
|
+
contributions={[
|
|
73
|
+
{ id: 'fit', region: 'viewport', item: { icon: Glyph, label: 'Fit', onActivate } },
|
|
74
|
+
]}
|
|
75
|
+
ctx={ctx}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
screen.getByRole('button', { name: 'Fit' }).click();
|
|
79
|
+
expect(onActivate).toHaveBeenCalledOnce();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { ResolvedConfig } from '../config/types';
|
|
3
|
+
import type { ConfigField } from '../controls/types';
|
|
4
|
+
import type { SavedSnapshot } from '../state/types';
|
|
5
|
+
|
|
6
|
+
/** A named position in a trial's chrome. Content is not a region — that is
|
|
7
|
+
* the instrument. */
|
|
8
|
+
export type TrialRegion = 'titlebar' | 'toolbar' | 'palette' | 'sidebar' | 'viewport' | 'status';
|
|
9
|
+
|
|
10
|
+
/** An icon component taking a pixel size, as `@weasel-js/ui` glyphs do. */
|
|
11
|
+
export type IconComponent = ComponentType<{ size?: number }>;
|
|
12
|
+
|
|
13
|
+
/** A button in the trial toolbar. */
|
|
14
|
+
export interface ToolbarItem {
|
|
15
|
+
icon: IconComponent;
|
|
16
|
+
label: string;
|
|
17
|
+
/** Shown in the tooltip. Not bound here — the trial owns its keymap. */
|
|
18
|
+
shortcut?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Reddens on hover. For actions that discard work. */
|
|
21
|
+
danger?: boolean;
|
|
22
|
+
/** Render the label beside the glyph rather than only in the tooltip. */
|
|
23
|
+
showLabel?: boolean;
|
|
24
|
+
onActivate: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A selectable tool in the palette region. */
|
|
28
|
+
export interface ToolItem {
|
|
29
|
+
icon: IconComponent;
|
|
30
|
+
label: string;
|
|
31
|
+
shortcut?: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A titled block in the sidebar. */
|
|
36
|
+
export interface SidebarSection {
|
|
37
|
+
title: string;
|
|
38
|
+
/** Starts collapsed. The open/closed state itself is the region's. */
|
|
39
|
+
defaultCollapsed?: boolean;
|
|
40
|
+
body: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A control acting on the view of the trial, not on the trial. */
|
|
44
|
+
export interface ViewportControl {
|
|
45
|
+
icon: IconComponent;
|
|
46
|
+
label: string;
|
|
47
|
+
disabled?: boolean;
|
|
48
|
+
onActivate: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A readout in the status bar. */
|
|
52
|
+
export interface StatusReadout {
|
|
53
|
+
/** Short enough for a status bar. Rendered as text. */
|
|
54
|
+
text: string;
|
|
55
|
+
/** Tooltip. */
|
|
56
|
+
title?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What every contribution shares. */
|
|
60
|
+
interface ContributionBase {
|
|
61
|
+
id: string;
|
|
62
|
+
/** Groups sort by first appearance; items sort within a group by
|
|
63
|
+
* declaration order. Contributions with no group sort after grouped ones. */
|
|
64
|
+
group?: string;
|
|
65
|
+
/** Pushes this contribution, and its group, to the far end of the region. */
|
|
66
|
+
end?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A contribution is data the chrome renders, keyed to a region. Supplying
|
|
71
|
+
* `render` instead of `item` opts out of the chrome's layout — deliberate,
|
|
72
|
+
* and visible in the declaration.
|
|
73
|
+
*/
|
|
74
|
+
export type TrialContribution =
|
|
75
|
+
| (ContributionBase & { region: 'titlebar'; item: ToolbarItem; render?: never })
|
|
76
|
+
| (ContributionBase & { region: 'toolbar'; item: ToolbarItem; render?: never })
|
|
77
|
+
| (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
|
|
78
|
+
| (ContributionBase & { region: 'sidebar'; item: SidebarSection; render?: never })
|
|
79
|
+
| (ContributionBase & { region: 'viewport'; item: ViewportControl; render?: never })
|
|
80
|
+
| (ContributionBase & { region: 'status'; item: StatusReadout; render?: never })
|
|
81
|
+
| (ContributionBase & {
|
|
82
|
+
region: TrialRegion;
|
|
83
|
+
item?: never;
|
|
84
|
+
render: (ctx: TrialChromeContext) => ReactNode;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Everything a contribution can read about the trial it is being rendered
|
|
89
|
+
* into. Replaces the three separate slot contexts, which each carried a
|
|
90
|
+
* hand-picked subset.
|
|
91
|
+
*/
|
|
92
|
+
export interface TrialChromeContext {
|
|
93
|
+
trialId: string;
|
|
94
|
+
instrumentName: string;
|
|
95
|
+
isLastTrial: boolean;
|
|
96
|
+
|
|
97
|
+
/** Null when the trial holds a view that is not the 2D one. */
|
|
98
|
+
zoom: number | null;
|
|
99
|
+
setZoom: (z: number) => void;
|
|
100
|
+
|
|
101
|
+
canUndo: boolean;
|
|
102
|
+
canRedo: boolean;
|
|
103
|
+
undo: () => void;
|
|
104
|
+
redo: () => void;
|
|
105
|
+
|
|
106
|
+
/** The instrument's controls, resolved against the lab's rules. Always
|
|
107
|
+
* populated: a legacy `configSchema()` is adapted into the same shape. */
|
|
108
|
+
configSchema: ResolvedConfig;
|
|
109
|
+
/** @deprecated Read `configSchema`. Empty for an instrument declaring
|
|
110
|
+
* `config`, since a builder schema has no `ConfigField[]` form. */
|
|
111
|
+
configFields: ConfigField[];
|
|
112
|
+
config: unknown;
|
|
113
|
+
setConfig: (key: string, value: unknown) => void;
|
|
114
|
+
|
|
115
|
+
savedSnapshots: SavedSnapshot[];
|
|
116
|
+
saveSnapshot: (name?: string) => void;
|
|
117
|
+
loadSnapshot: (snapshotId: string) => void;
|
|
118
|
+
|
|
119
|
+
clone: () => void;
|
|
120
|
+
reset: () => void;
|
|
121
|
+
close: () => void;
|
|
122
|
+
|
|
123
|
+
/** Resolved active tool: the trial's slot, or the lab's when the trial has
|
|
124
|
+
* none. Null when neither holds one. */
|
|
125
|
+
activeToolId: string | null;
|
|
126
|
+
setActiveTool: (id: string) => void;
|
|
127
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { f } from './builder';
|
|
3
|
+
|
|
4
|
+
describe('builder', () => {
|
|
5
|
+
it('carries kind and default', () => {
|
|
6
|
+
const n = f.number(20);
|
|
7
|
+
expect(n.kind).toBe('number');
|
|
8
|
+
expect(n.default).toBe(20);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('chains annotations immutably', () => {
|
|
12
|
+
const base = f.number(20);
|
|
13
|
+
const a = base.label('A');
|
|
14
|
+
const b = base.label('B');
|
|
15
|
+
expect(a.annotations.name).toBe('A');
|
|
16
|
+
expect(b.annotations.name).toBe('B');
|
|
17
|
+
expect(base.annotations.name).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('keeps the subclass across a chain, so kind-specific methods survive', () => {
|
|
21
|
+
const n = f.number(20).label('A').range(5, 80).describe('help').step(5);
|
|
22
|
+
expect(n.kind).toBe('number');
|
|
23
|
+
expect(n.annotations).toMatchObject({ name: 'A', description: 'help', min: 5, max: 80, step: 5 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps a custom node kind across a chain', () => {
|
|
27
|
+
const n = f.custom('vector2', { x: 0 }).label('Offset');
|
|
28
|
+
expect(n.kind).toBe('vector2');
|
|
29
|
+
expect(n.annotations.name).toBe('Offset');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('expands a bare enum option list', () => {
|
|
33
|
+
const n = f.enum('fast', ['fast', 'accurate']);
|
|
34
|
+
expect(n.annotations.options).toEqual([
|
|
35
|
+
{ value: 'fast', label: 'fast' },
|
|
36
|
+
{ value: 'accurate', label: 'accurate' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('takes labeled enum options as given', () => {
|
|
41
|
+
const n = f.enum('fast', [{ value: 'fast', label: 'Fast' }]);
|
|
42
|
+
expect(n.annotations.options).toEqual([{ value: 'fast', label: 'Fast' }]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('f.value carries no kind', () => {
|
|
46
|
+
expect(f.value(3).kind).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('holds section, showIf and render off the annotation bag', () => {
|
|
50
|
+
const n = f
|
|
51
|
+
.number(1)
|
|
52
|
+
.section('Advanced')
|
|
53
|
+
.showIf((c) => c.showGrid === true)
|
|
54
|
+
.render(() => null);
|
|
55
|
+
expect(n.options.section).toBe('Advanced');
|
|
56
|
+
expect(n.options.showIf?.({ showGrid: true })).toBe(true);
|
|
57
|
+
expect(n.options.render).toBeTypeOf('function');
|
|
58
|
+
expect(n.annotations).toEqual({});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('schema.defaults() collects every default', () => {
|
|
62
|
+
const s = f.schema({ showGrid: f.boolean(true), cellSize: f.number(20) });
|
|
63
|
+
expect(s.defaults()).toEqual({ showGrid: true, cellSize: 20 });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('builder / node option collisions', () => {
|
|
68
|
+
// `options` is the node's extras bag AND was briefly an EnumNode method, so
|
|
69
|
+
// section/showIf/render silently vanished on every enum leaf. tsc caught it;
|
|
70
|
+
// this keeps a runtime witness.
|
|
71
|
+
it('every node kind exposes options as the extras bag, not a method', () => {
|
|
72
|
+
const nodes = [
|
|
73
|
+
f.number(1),
|
|
74
|
+
f.boolean(true),
|
|
75
|
+
f.string(''),
|
|
76
|
+
f.color('#fff'),
|
|
77
|
+
f.enum('a', ['a', 'b']),
|
|
78
|
+
f.value(1),
|
|
79
|
+
f.custom('vector2', 0),
|
|
80
|
+
];
|
|
81
|
+
for (const node of nodes) {
|
|
82
|
+
expect(typeof node.options).toBe('object');
|
|
83
|
+
expect(node.section('S').options.section).toBe('S');
|
|
84
|
+
expect(node.render(() => null).options.render).toBeTypeOf('function');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|