@weasel-js/labkit 0.1.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 +72 -0
- package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
- package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
- package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
- package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
- package/dist/canvas/index.d.ts +34 -0
- package/dist/canvas/index.js +4 -0
- package/dist/canvas/index.js.map +1 -0
- package/dist/chunk-2QNYYL3V.js +86 -0
- package/dist/chunk-2QNYYL3V.js.map +1 -0
- package/dist/chunk-54IQ2DX7.js +222 -0
- package/dist/chunk-54IQ2DX7.js.map +1 -0
- package/dist/chunk-6FO7G5BB.js +6095 -0
- package/dist/chunk-6FO7G5BB.js.map +1 -0
- package/dist/chunk-7BKDG73Z.js +54 -0
- package/dist/chunk-7BKDG73Z.js.map +1 -0
- package/dist/chunk-B5ZUYRKN.js +23330 -0
- package/dist/chunk-B5ZUYRKN.js.map +1 -0
- package/dist/chunk-HXZHVU4G.js +126 -0
- package/dist/chunk-HXZHVU4G.js.map +1 -0
- package/dist/chunk-PWC7AQZM.js +8 -0
- package/dist/chunk-PWC7AQZM.js.map +1 -0
- package/dist/chunk-SD3HDXR7.js +161 -0
- package/dist/chunk-SD3HDXR7.js.map +1 -0
- package/dist/chunk-SSMHPMDV.js +350 -0
- package/dist/chunk-SSMHPMDV.js.map +1 -0
- package/dist/chunk-VLAHRJOC.js +201 -0
- package/dist/chunk-VLAHRJOC.js.map +1 -0
- package/dist/controls/index.d.ts +13 -0
- package/dist/controls/index.js +3 -0
- package/dist/controls/index.js.map +1 -0
- package/dist/dragdrop/index.d.ts +3 -0
- package/dist/dragdrop/index.js +3 -0
- package/dist/dragdrop/index.js.map +1 -0
- package/dist/fonts/oswald-latin-variable.woff2 +0 -0
- package/dist/index.d.ts +410 -0
- package/dist/index.js +1425 -0
- package/dist/index.js.map +1 -0
- package/dist/layers/index.d.ts +17 -0
- package/dist/layers/index.js +3 -0
- package/dist/layers/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +258 -0
- package/dist/passthrough/weasel-canvas.js +3 -0
- package/dist/passthrough/weasel-canvas.js.map +1 -0
- package/dist/passthrough/weasel-ui.d.ts +2826 -0
- package/dist/passthrough/weasel-ui.js +4 -0
- package/dist/passthrough/weasel-ui.js.map +1 -0
- package/dist/primitives/index.d.ts +57 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/state/index.d.ts +33 -0
- package/dist/state/index.js +34 -0
- package/dist/state/index.js.map +1 -0
- package/dist/styles.css +1224 -0
- package/dist/theme-interstellar.css +25 -0
- package/dist/theme-light.css +13 -0
- package/dist/ui/layers/index.d.ts +42 -0
- package/dist/ui/layers/index.js +5 -0
- package/dist/ui/layers/index.js.map +1 -0
- package/dist/undo/index.d.ts +30 -0
- package/dist/undo/index.js +3 -0
- package/dist/undo/index.js.map +1 -0
- package/package.json +99 -0
- package/src/canvas/AGENTS.md +72 -0
- package/src/canvas/CanvasStack.less +22 -0
- package/src/canvas/CanvasStack.stories.tsx +63 -0
- package/src/canvas/CanvasStack.test.tsx +82 -0
- package/src/canvas/CanvasStack.tsx +108 -0
- package/src/canvas/CanvasStackContext.ts +8 -0
- package/src/canvas/canvasCoords.test.ts +30 -0
- package/src/canvas/canvasCoords.ts +15 -0
- package/src/canvas/index.ts +6 -0
- package/src/canvas/useLayerScheduler.ts +72 -0
- package/src/canvas/usePanZoom.ts +98 -0
- package/src/controls/ControlPanel.less +61 -0
- package/src/controls/ControlPanel.stories.tsx +77 -0
- package/src/controls/ControlPanel.test.tsx +149 -0
- package/src/controls/ControlPanel.tsx +249 -0
- package/src/controls/index.ts +12 -0
- package/src/controls/types.ts +61 -0
- package/src/dragdrop/DragDropRuntime.tsx +135 -0
- package/src/dragdrop/DragGhost.less +17 -0
- package/src/dragdrop/DragGhost.tsx +24 -0
- package/src/dragdrop/Palette.less +34 -0
- package/src/dragdrop/Palette.tsx +27 -0
- package/src/dragdrop/dragDrop.test.tsx +145 -0
- package/src/dragdrop/index.ts +1 -0
- package/src/fonts/oswald-latin-variable.woff2 +0 -0
- package/src/index.ts +70 -0
- package/src/instrument/SineWave.smoke.test.tsx +94 -0
- package/src/instrument/capabilityDetector.test.ts +64 -0
- package/src/instrument/capabilityDetector.ts +17 -0
- package/src/instrument/defineInstrument.test.ts +27 -0
- package/src/instrument/defineInstrument.ts +5 -0
- package/src/instrument/index.ts +19 -0
- package/src/instrument/types.ts +65 -0
- package/src/instrument/validateConfigSchema.test.ts +125 -0
- package/src/instrument/validateConfigSchema.ts +65 -0
- package/src/lab/Lab.less +3 -0
- package/src/lab/Lab.stories.tsx +51 -0
- package/src/lab/Lab.test.tsx +126 -0
- package/src/lab/Lab.tsx +196 -0
- package/src/lab/LabContext.test.ts +11 -0
- package/src/lab/LabContext.ts +28 -0
- package/src/lab/LabShell.less +45 -0
- package/src/lab/LabShell.stories.tsx +39 -0
- package/src/lab/LabShell.test.tsx +59 -0
- package/src/lab/LabShell.tsx +33 -0
- package/src/lab/WorkspaceGrid.less +8 -0
- package/src/lab/WorkspaceGrid.stories.tsx +61 -0
- package/src/lab/WorkspaceGrid.test.tsx +40 -0
- package/src/lab/WorkspaceGrid.tsx +20 -0
- package/src/lab/gridDims.test.ts +35 -0
- package/src/lab/gridDims.ts +11 -0
- package/src/lab/index.ts +10 -0
- package/src/layers/AGENTS.md +65 -0
- package/src/layers/LayerList.less +53 -0
- package/src/layers/LayerList.test.tsx +49 -0
- package/src/layers/LayerList.tsx +105 -0
- package/src/layers/index.ts +3 -0
- package/src/passthrough/weasel-canvas.test.ts +30 -0
- package/src/passthrough/weasel-canvas.ts +23 -0
- package/src/passthrough/weasel-ui.test.ts +79 -0
- package/src/passthrough/weasel-ui.ts +150 -0
- package/src/primitives/FpsMeter.less +14 -0
- package/src/primitives/FpsMeter.stories.tsx +14 -0
- package/src/primitives/FpsMeter.test.tsx +38 -0
- package/src/primitives/FpsMeter.tsx +36 -0
- package/src/primitives/ScaleIndicator.less +25 -0
- package/src/primitives/ScaleIndicator.stories.tsx +16 -0
- package/src/primitives/ScaleIndicator.test.tsx +46 -0
- package/src/primitives/ScaleIndicator.tsx +48 -0
- package/src/primitives/Sidebar.less +57 -0
- package/src/primitives/Sidebar.stories.tsx +38 -0
- package/src/primitives/Sidebar.test.tsx +74 -0
- package/src/primitives/Sidebar.tsx +32 -0
- package/src/primitives/StatusBar.less +21 -0
- package/src/primitives/StatusBar.stories.tsx +24 -0
- package/src/primitives/StatusBar.test.tsx +25 -0
- package/src/primitives/StatusBar.tsx +18 -0
- package/src/primitives/Toolbar.less +43 -0
- package/src/primitives/Toolbar.stories.tsx +36 -0
- package/src/primitives/Toolbar.test.tsx +65 -0
- package/src/primitives/Toolbar.tsx +44 -0
- package/src/primitives/fpsAverage.test.ts +21 -0
- package/src/primitives/fpsAverage.ts +5 -0
- package/src/primitives/index.ts +9 -0
- package/src/state/SingletonExperiment.test.tsx +101 -0
- package/src/state/SingletonExperiment.tsx +52 -0
- package/src/state/adapters.test.ts +94 -0
- package/src/state/adapters.ts +102 -0
- package/src/state/context.tsx +42 -0
- package/src/state/helpers.test.ts +89 -0
- package/src/state/helpers.ts +61 -0
- package/src/state/index.ts +40 -0
- package/src/state/store.test.ts +243 -0
- package/src/state/store.ts +225 -0
- package/src/state/types.ts +53 -0
- package/src/state/useExperimentState.test.tsx +102 -0
- package/src/state/useExperimentState.ts +28 -0
- package/src/styles.less +20 -0
- package/src/test-setup.ts +17 -0
- package/src/theme/Interstellar.stories.less +92 -0
- package/src/theme/Interstellar.stories.tsx +243 -0
- package/src/theme/base.less +122 -0
- package/src/theme/interstellar.less +34 -0
- package/src/theme/light.less +14 -0
- package/src/theme/tokens.less +85 -0
- package/src/ui/format.ts +19 -0
- package/src/ui/layers/LayerStack.less +143 -0
- package/src/ui/layers/LayerStack.stories.tsx +45 -0
- package/src/ui/layers/LayerStack.test.tsx +170 -0
- package/src/ui/layers/LayerStack.tsx +215 -0
- package/src/ui/layers/index.ts +1 -0
- package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
- package/src/ui/properties/ColorRow.stories.tsx +106 -0
- package/src/ui/properties/CurveField.less +48 -0
- package/src/ui/properties/CurveField.stories.tsx +20 -0
- package/src/ui/properties/CurveField.test.tsx +127 -0
- package/src/ui/properties/CurveField.tsx +181 -0
- package/src/ui/properties/EffectCard.tsx +312 -0
- package/src/ui/properties/Gallery.stories.tsx +178 -0
- package/src/ui/properties/NumberRow.stories.tsx +65 -0
- package/src/ui/properties/PropertyGroup.less +50 -0
- package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
- package/src/ui/properties/PropertyGroup.test.tsx +33 -0
- package/src/ui/properties/PropertyGroup.tsx +44 -0
- package/src/ui/properties/PropertyList.stories.tsx +83 -0
- package/src/ui/properties/PropertyPanel.less +592 -0
- package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
- package/src/ui/properties/PropertyPanel.test.tsx +193 -0
- package/src/ui/properties/PropertyPanel.tsx +441 -0
- package/src/ui/properties/PropertyRow.stories.tsx +53 -0
- package/src/ui/properties/SelectRow.stories.tsx +58 -0
- package/src/ui/properties/SliderRow.stories.tsx +74 -0
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
- package/src/ui/properties/TextRow.stories.tsx +62 -0
- package/src/ui/properties/ToggleRow.stories.tsx +57 -0
- package/src/ui/properties/index.ts +39 -0
- package/src/ui/properties/storyLayouts.tsx +36 -0
- package/src/undo/eventBus.test.ts +49 -0
- package/src/undo/eventBus.ts +32 -0
- package/src/undo/index.ts +5 -0
- package/src/undo/undoStack.test.ts +70 -0
- package/src/undo/undoStack.ts +38 -0
- package/src/workspace/DefaultSidebar.tsx +26 -0
- package/src/workspace/DefaultStatusBar.tsx +15 -0
- package/src/workspace/DefaultToolbar.tsx +88 -0
- package/src/workspace/Workspace.less +58 -0
- package/src/workspace/Workspace.stories.tsx +78 -0
- package/src/workspace/Workspace.test.tsx +186 -0
- package/src/workspace/Workspace.tsx +255 -0
- package/src/workspace/WorkspaceChrome.tsx +150 -0
- package/src/workspace/index.ts +24 -0
- package/src/workspace/slotTypes.ts +44 -0
- package/src/workspace/workspaceOps.test.ts +142 -0
- package/src/workspace/workspaceOps.ts +78 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-exports from `@weasel-js/ui` so consumers import primitives
|
|
3
|
+
* through `@weasel-js/labkit/weasel-ui` rather than depending on the
|
|
4
|
+
* weasel-ui package directly. Lets labkit swap implementations in the
|
|
5
|
+
* future without consumer churn.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
ActionBar,
|
|
10
|
+
type ActionBarProps,
|
|
11
|
+
ActionsBar,
|
|
12
|
+
type ActionsBarItem,
|
|
13
|
+
type ActionsBarProps,
|
|
14
|
+
type ActionsBarSize,
|
|
15
|
+
type ActionsBarVariant,
|
|
16
|
+
type AddPointMode,
|
|
17
|
+
type AnchorRenderProps,
|
|
18
|
+
type AxesSettings,
|
|
19
|
+
Badge,
|
|
20
|
+
type BadgeProps,
|
|
21
|
+
type BadgeShape,
|
|
22
|
+
type BadgeSize,
|
|
23
|
+
type BadgeTone,
|
|
24
|
+
type BadgeVariant,
|
|
25
|
+
type BoundsCtx,
|
|
26
|
+
type BuiltInEdgeName,
|
|
27
|
+
Button,
|
|
28
|
+
type ButtonProps,
|
|
29
|
+
type ButtonSize,
|
|
30
|
+
type ButtonVariant,
|
|
31
|
+
Checkbox,
|
|
32
|
+
type CheckboxProps,
|
|
33
|
+
type ChromaCurve,
|
|
34
|
+
type ChromaCurvePoint,
|
|
35
|
+
ComboBox,
|
|
36
|
+
ComboBoxItem,
|
|
37
|
+
type ComboBoxItemProps,
|
|
38
|
+
type ComboBoxOption,
|
|
39
|
+
type ComboBoxProps,
|
|
40
|
+
type ControlPoint,
|
|
41
|
+
type CurveDomain,
|
|
42
|
+
CurveEditor,
|
|
43
|
+
type CurveEditorProps,
|
|
44
|
+
chromaAt,
|
|
45
|
+
DataGrid,
|
|
46
|
+
type DataGridColumn,
|
|
47
|
+
type DataGridProps,
|
|
48
|
+
Dialog,
|
|
49
|
+
type DialogProps,
|
|
50
|
+
detectPlatform,
|
|
51
|
+
dlog,
|
|
52
|
+
EDGE_PROFILES,
|
|
53
|
+
type EdgeCap,
|
|
54
|
+
type EdgeProfile,
|
|
55
|
+
type EndpointMode,
|
|
56
|
+
Field,
|
|
57
|
+
type FieldOrientation,
|
|
58
|
+
type FieldProps,
|
|
59
|
+
type FillSettings,
|
|
60
|
+
fieldClasses,
|
|
61
|
+
formatNumber,
|
|
62
|
+
formatShortcut,
|
|
63
|
+
formatShortcutParts,
|
|
64
|
+
type GradientTrackOpts,
|
|
65
|
+
type GridSettings,
|
|
66
|
+
Input,
|
|
67
|
+
type InputProps,
|
|
68
|
+
type InterpolationMode,
|
|
69
|
+
inferKeycapKind,
|
|
70
|
+
isDebugEnabled,
|
|
71
|
+
KeyCap,
|
|
72
|
+
type KeyCapProps,
|
|
73
|
+
type KeyCapVariant,
|
|
74
|
+
type KeycapKind,
|
|
75
|
+
KeySequence,
|
|
76
|
+
type KeySequenceProps,
|
|
77
|
+
type KeySpec,
|
|
78
|
+
keyGlyph,
|
|
79
|
+
keySpecFromKey,
|
|
80
|
+
keySpecsFromMods,
|
|
81
|
+
type LayerListItem,
|
|
82
|
+
type LogicalMod,
|
|
83
|
+
type LogicalModSpec,
|
|
84
|
+
MINUS_SIGN,
|
|
85
|
+
NumberField,
|
|
86
|
+
type NumberFieldProps,
|
|
87
|
+
OptionsBar,
|
|
88
|
+
type OptionsBarItem,
|
|
89
|
+
type OptionsBarProps,
|
|
90
|
+
type OptionsBarSize,
|
|
91
|
+
type OptionsBarVariant,
|
|
92
|
+
oklchToHex,
|
|
93
|
+
type Platform,
|
|
94
|
+
Plot2D,
|
|
95
|
+
type Plot2DCoords,
|
|
96
|
+
type Plot2DHandle,
|
|
97
|
+
type Plot2DProps,
|
|
98
|
+
PointPlotter,
|
|
99
|
+
type PointPlotterProps,
|
|
100
|
+
Powerline,
|
|
101
|
+
type PowerlineProps,
|
|
102
|
+
type PowerlineSegment,
|
|
103
|
+
paintGradientTrack,
|
|
104
|
+
Radio,
|
|
105
|
+
RadioGroup,
|
|
106
|
+
type RadioGroupProps,
|
|
107
|
+
type RadioProps,
|
|
108
|
+
RangeSlider,
|
|
109
|
+
type RangeSliderProps,
|
|
110
|
+
type ReorderDragHandlers,
|
|
111
|
+
type ReorderDragState,
|
|
112
|
+
Select,
|
|
113
|
+
SelectItem,
|
|
114
|
+
type SelectItemProps,
|
|
115
|
+
type SelectOption,
|
|
116
|
+
type SelectProps,
|
|
117
|
+
Sidebar,
|
|
118
|
+
SidebarPanel,
|
|
119
|
+
type SidebarPanelProps,
|
|
120
|
+
type SidebarProps,
|
|
121
|
+
Slider,
|
|
122
|
+
type SliderProps,
|
|
123
|
+
Switch,
|
|
124
|
+
type SwitchProps,
|
|
125
|
+
Tab,
|
|
126
|
+
TabList,
|
|
127
|
+
type TabListProps,
|
|
128
|
+
TabPanel,
|
|
129
|
+
type TabPanelProps,
|
|
130
|
+
type TabProps,
|
|
131
|
+
Tabs,
|
|
132
|
+
type TabsProps,
|
|
133
|
+
type Thumb,
|
|
134
|
+
type ThumbRenderCtx,
|
|
135
|
+
type ThumbShape,
|
|
136
|
+
ToggleBar,
|
|
137
|
+
type ToggleBarItem,
|
|
138
|
+
type ToggleBarProps,
|
|
139
|
+
type ToggleBarSize,
|
|
140
|
+
type ToggleBarVariant,
|
|
141
|
+
ToolButton,
|
|
142
|
+
type ToolButtonProps,
|
|
143
|
+
ToolGroup,
|
|
144
|
+
type ToolGroupProps,
|
|
145
|
+
ToolPalette,
|
|
146
|
+
type ToolPaletteProps,
|
|
147
|
+
type TrackCtx,
|
|
148
|
+
type UseReorderDragListOptions,
|
|
149
|
+
useReorderDragList,
|
|
150
|
+
} from '@weasel-js/ui';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.lk-fps-meter {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--lk-spacing-xs);
|
|
5
|
+
font-family: var(--lk-font-mono);
|
|
6
|
+
font-size: var(--lk-font-size-sm);
|
|
7
|
+
color: var(--lk-text-muted);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.lk-fps-meter-value {
|
|
11
|
+
color: var(--lk-text);
|
|
12
|
+
min-width: 2.5ch;
|
|
13
|
+
text-align: right;
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { FpsMeter } from './FpsMeter';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof FpsMeter> = {
|
|
5
|
+
title: 'labkit/Primitives/FpsMeter',
|
|
6
|
+
component: FpsMeter,
|
|
7
|
+
};
|
|
8
|
+
export default meta;
|
|
9
|
+
|
|
10
|
+
type Story = StoryObj<typeof FpsMeter>;
|
|
11
|
+
|
|
12
|
+
export const Default: Story = {
|
|
13
|
+
render: () => <FpsMeter />,
|
|
14
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
3
|
+
import { FpsMeter } from './FpsMeter';
|
|
4
|
+
|
|
5
|
+
describe('FpsMeter', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('renders an FPS label', () => {
|
|
11
|
+
render(<FpsMeter />);
|
|
12
|
+
expect(screen.getByText(/fps/i)).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('uses lk-fps-meter class', () => {
|
|
16
|
+
const { container } = render(<FpsMeter />);
|
|
17
|
+
expect((container.firstChild as HTMLElement).className).toBe('lk-fps-meter');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('updates after a tick', () => {
|
|
21
|
+
let raf: FrameRequestCallback | null = null;
|
|
22
|
+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
23
|
+
raf = cb;
|
|
24
|
+
return 1;
|
|
25
|
+
});
|
|
26
|
+
vi.stubGlobal('cancelAnimationFrame', () => {});
|
|
27
|
+
|
|
28
|
+
render(<FpsMeter />);
|
|
29
|
+
act(() => {
|
|
30
|
+
raf?.(0);
|
|
31
|
+
raf?.(16.67);
|
|
32
|
+
raf?.(33.34);
|
|
33
|
+
});
|
|
34
|
+
expect(screen.getByText(/FPS\s+\d+/)).toBeInTheDocument();
|
|
35
|
+
|
|
36
|
+
vi.unstubAllGlobals();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { rollingAverage } from './fpsAverage';
|
|
3
|
+
|
|
4
|
+
const SAMPLE_WINDOW = 30;
|
|
5
|
+
|
|
6
|
+
export function FpsMeter() {
|
|
7
|
+
const [fps, setFps] = useState(0);
|
|
8
|
+
const samplesRef = useRef<number[]>([]);
|
|
9
|
+
const lastTimeRef = useRef<number | null>(null);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let rafId = 0;
|
|
13
|
+
const tick = (time: number) => {
|
|
14
|
+
const last = lastTimeRef.current;
|
|
15
|
+
if (last !== null) {
|
|
16
|
+
const delta = time - last;
|
|
17
|
+
if (delta > 0) {
|
|
18
|
+
const samples = samplesRef.current;
|
|
19
|
+
samples.push(1000 / delta);
|
|
20
|
+
if (samples.length > SAMPLE_WINDOW) samples.shift();
|
|
21
|
+
setFps(rollingAverage(samples));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
lastTimeRef.current = time;
|
|
25
|
+
rafId = requestAnimationFrame(tick);
|
|
26
|
+
};
|
|
27
|
+
rafId = requestAnimationFrame(tick);
|
|
28
|
+
return () => cancelAnimationFrame(rafId);
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="lk-fps-meter">
|
|
33
|
+
<span className="lk-fps-meter-value">FPS {fps}</span>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.lk-scale-indicator {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--lk-spacing-xs);
|
|
5
|
+
font-family: var(--lk-font-mono);
|
|
6
|
+
font-size: var(--lk-font-size-sm);
|
|
7
|
+
color: var(--lk-text-muted);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.lk-scale-indicator-bar {
|
|
11
|
+
height: 6px;
|
|
12
|
+
border: 1px solid var(--lk-text-muted);
|
|
13
|
+
border-top: none;
|
|
14
|
+
background: linear-gradient(
|
|
15
|
+
to right,
|
|
16
|
+
var(--lk-text-muted) 1px,
|
|
17
|
+
transparent 1px,
|
|
18
|
+
transparent calc(100% - 1px),
|
|
19
|
+
var(--lk-text-muted) calc(100% - 1px)
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.lk-scale-indicator-label {
|
|
24
|
+
white-space: nowrap;
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ScaleIndicator } from './ScaleIndicator';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ScaleIndicator> = {
|
|
5
|
+
title: 'labkit/Primitives/ScaleIndicator',
|
|
6
|
+
component: ScaleIndicator,
|
|
7
|
+
args: { unit: 'ft', pixelsPerUnit: 50 },
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof ScaleIndicator>;
|
|
12
|
+
|
|
13
|
+
export const NoZoom: Story = { args: { zoom: 1 } };
|
|
14
|
+
export const ZoomedIn: Story = { args: { zoom: 4 } };
|
|
15
|
+
export const ZoomedOut: Story = { args: { zoom: 0.25 } };
|
|
16
|
+
export const Meters: Story = { args: { zoom: 1, unit: 'm', pixelsPerUnit: 100 } };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { CanvasStackContext } from '../canvas/CanvasStackContext';
|
|
4
|
+
import { ScaleIndicator } from './ScaleIndicator';
|
|
5
|
+
|
|
6
|
+
describe('ScaleIndicator', () => {
|
|
7
|
+
test('renders the unit label', () => {
|
|
8
|
+
render(<ScaleIndicator zoom={1} pixelsPerUnit={50} unit="ft" />);
|
|
9
|
+
expect(screen.getByText(/ft/)).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('shows scale value scaled by zoom', () => {
|
|
13
|
+
render(<ScaleIndicator zoom={2} pixelsPerUnit={50} unit="ft" />);
|
|
14
|
+
expect(screen.getByText(/1\s*ft/)).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('rounds bar to a nice number when target spans multiple units', () => {
|
|
18
|
+
// zoom=0.5, pixelsPerUnit=50 → effective 25 px/unit → 100px ≈ 4 units → niceNumber → 5
|
|
19
|
+
render(<ScaleIndicator zoom={0.5} pixelsPerUnit={50} unit="ft" />);
|
|
20
|
+
expect(screen.getByText(/5\s*ft/)).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('uses lk-scale-indicator class', () => {
|
|
24
|
+
const { container } = render(<ScaleIndicator zoom={1} pixelsPerUnit={50} unit="ft" />);
|
|
25
|
+
expect((container.firstChild as HTMLElement).className).toBe('lk-scale-indicator');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('reads zoom from CanvasStackContext when prop omitted', () => {
|
|
29
|
+
render(
|
|
30
|
+
<CanvasStackContext.Provider value={{ view: { zoom: 2.5, pan: { x: 0, y: 0 } } }}>
|
|
31
|
+
<ScaleIndicator pixelsPerUnit={50} unit="ft" />
|
|
32
|
+
</CanvasStackContext.Provider>,
|
|
33
|
+
);
|
|
34
|
+
// zoom 2.5 × 50 = 125 px/unit → niceNumber(100/125 = 0.8) → 1
|
|
35
|
+
expect(screen.getByText(/1\s*ft/)).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('explicit zoom prop overrides context', () => {
|
|
39
|
+
render(
|
|
40
|
+
<CanvasStackContext.Provider value={{ view: { zoom: 100, pan: { x: 0, y: 0 } } }}>
|
|
41
|
+
<ScaleIndicator zoom={1} pixelsPerUnit={50} unit="ft" />
|
|
42
|
+
</CanvasStackContext.Provider>,
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByText(/2\s*ft/)).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { CanvasStackContext } from '../canvas/CanvasStackContext';
|
|
3
|
+
|
|
4
|
+
export interface ScaleIndicatorProps {
|
|
5
|
+
/** Current view zoom factor. If omitted, reads from CanvasStackContext. Defaults to 1. */
|
|
6
|
+
zoom?: number;
|
|
7
|
+
/** World pixels per unit at zoom=1. Default: 1. */
|
|
8
|
+
pixelsPerUnit?: number;
|
|
9
|
+
/** Unit label. Default: 'px'. */
|
|
10
|
+
unit?: string;
|
|
11
|
+
/** Target bar width in display pixels. Default: 100. */
|
|
12
|
+
targetWidth?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Rounds n to a "nice" number — 1, 2, 5, 10, 20, 50, ...
|
|
16
|
+
function niceNumber(n: number): number {
|
|
17
|
+
if (n <= 0) return 1;
|
|
18
|
+
const exp = Math.floor(Math.log10(n));
|
|
19
|
+
const fraction = n / 10 ** exp;
|
|
20
|
+
let nice: number;
|
|
21
|
+
if (fraction < 1.5) nice = 1;
|
|
22
|
+
else if (fraction < 3.5) nice = 2;
|
|
23
|
+
else if (fraction < 7.5) nice = 5;
|
|
24
|
+
else nice = 10;
|
|
25
|
+
return nice * 10 ** exp;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ScaleIndicator({
|
|
29
|
+
zoom,
|
|
30
|
+
pixelsPerUnit = 1,
|
|
31
|
+
unit = 'px',
|
|
32
|
+
targetWidth = 100,
|
|
33
|
+
}: ScaleIndicatorProps) {
|
|
34
|
+
const ctxValue = useContext(CanvasStackContext);
|
|
35
|
+
const effectiveZoom = zoom ?? ctxValue?.view.zoom ?? 1;
|
|
36
|
+
const effectivePxPerUnit = pixelsPerUnit * effectiveZoom;
|
|
37
|
+
const targetUnits = targetWidth / effectivePxPerUnit;
|
|
38
|
+
const niceUnits = niceNumber(targetUnits);
|
|
39
|
+
const barWidth = niceUnits * effectivePxPerUnit;
|
|
40
|
+
return (
|
|
41
|
+
<div className="lk-scale-indicator" role="img" aria-label={`Scale: ${niceUnits} ${unit}`}>
|
|
42
|
+
<div className="lk-scale-indicator-bar" style={{ width: `${barWidth}px` }} />
|
|
43
|
+
<span className="lk-scale-indicator-label">
|
|
44
|
+
{niceUnits} {unit}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
.lk-sidebar {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
width: 280px;
|
|
5
|
+
background: var(--lk-bg-elevated);
|
|
6
|
+
border-left: 1px solid var(--lk-border);
|
|
7
|
+
transition: width 150ms ease;
|
|
8
|
+
|
|
9
|
+
&--collapsed {
|
|
10
|
+
width: 32px;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.lk-sidebar-header {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
padding: var(--lk-spacing-xs) var(--lk-spacing-sm);
|
|
18
|
+
border-bottom: 1px solid var(--lk-divider);
|
|
19
|
+
min-height: var(--lk-control-height);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.lk-sidebar-title {
|
|
23
|
+
flex: 1;
|
|
24
|
+
font-weight: 600;
|
|
25
|
+
color: var(--lk-text);
|
|
26
|
+
font-size: var(--lk-font-size);
|
|
27
|
+
|
|
28
|
+
.lk-sidebar--collapsed & {
|
|
29
|
+
display: none;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.lk-sidebar-toggle {
|
|
34
|
+
height: 24px;
|
|
35
|
+
width: 24px;
|
|
36
|
+
padding: 0;
|
|
37
|
+
background: transparent;
|
|
38
|
+
border: 1px solid transparent;
|
|
39
|
+
border-radius: var(--lk-radius-sm);
|
|
40
|
+
color: var(--lk-text-muted);
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
|
|
43
|
+
&:hover {
|
|
44
|
+
background: var(--lk-bg);
|
|
45
|
+
color: var(--lk-text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.lk-sidebar-body {
|
|
50
|
+
flex: 1;
|
|
51
|
+
overflow: auto;
|
|
52
|
+
padding: var(--lk-spacing-sm);
|
|
53
|
+
|
|
54
|
+
.lk-sidebar--collapsed & {
|
|
55
|
+
display: none;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Sidebar } from './Sidebar';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Sidebar> = {
|
|
6
|
+
title: 'labkit/Primitives/Sidebar',
|
|
7
|
+
component: Sidebar,
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof Sidebar>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
render: () => (
|
|
15
|
+
<div style={{ display: 'flex', height: 400 }}>
|
|
16
|
+
<div style={{ flex: 1 }}>main content area</div>
|
|
17
|
+
<Sidebar title="Controls">
|
|
18
|
+
<p>One slider here</p>
|
|
19
|
+
<p>Another slider</p>
|
|
20
|
+
</Sidebar>
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const Collapsible: Story = {
|
|
26
|
+
render: function Render() {
|
|
27
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
28
|
+
return (
|
|
29
|
+
<div style={{ display: 'flex', height: 400 }}>
|
|
30
|
+
<div style={{ flex: 1, padding: 16 }}>main content area</div>
|
|
31
|
+
<Sidebar title="Controls" collapsed={collapsed} onToggle={() => setCollapsed((c) => !c)}>
|
|
32
|
+
<p>One slider here</p>
|
|
33
|
+
<p>Another slider</p>
|
|
34
|
+
</Sidebar>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { Sidebar } from './Sidebar';
|
|
4
|
+
|
|
5
|
+
describe('Sidebar', () => {
|
|
6
|
+
test('renders children when expanded (default)', () => {
|
|
7
|
+
render(
|
|
8
|
+
<Sidebar>
|
|
9
|
+
<p>content</p>
|
|
10
|
+
</Sidebar>,
|
|
11
|
+
);
|
|
12
|
+
expect(screen.getByText('content')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('renders title when provided', () => {
|
|
16
|
+
render(
|
|
17
|
+
<Sidebar title="Controls">
|
|
18
|
+
<p>x</p>
|
|
19
|
+
</Sidebar>,
|
|
20
|
+
);
|
|
21
|
+
expect(screen.getByText('Controls')).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('marks the sidebar collapsed and nests body inside the collapsed root', () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<Sidebar collapsed>
|
|
27
|
+
<p>content</p>
|
|
28
|
+
</Sidebar>,
|
|
29
|
+
);
|
|
30
|
+
const root = container.querySelector('.lk-sidebar');
|
|
31
|
+
const body = container.querySelector('.lk-sidebar-body');
|
|
32
|
+
expect(root).not.toBeNull();
|
|
33
|
+
expect(body).not.toBeNull();
|
|
34
|
+
expect(root?.classList.contains('lk-sidebar--collapsed')).toBe(true);
|
|
35
|
+
// Body is still rendered (children stay in DOM), but lives inside the collapsed
|
|
36
|
+
// root so the CSS rule `.lk-sidebar--collapsed .lk-sidebar-body { display:none }` applies.
|
|
37
|
+
expect(body?.parentElement).toBe(root);
|
|
38
|
+
expect(body?.textContent).toBe('content');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('toggle button calls onToggle when clicked', () => {
|
|
42
|
+
let toggled = false;
|
|
43
|
+
render(
|
|
44
|
+
<Sidebar
|
|
45
|
+
title="C"
|
|
46
|
+
onToggle={() => {
|
|
47
|
+
toggled = true;
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<p>x</p>
|
|
51
|
+
</Sidebar>,
|
|
52
|
+
);
|
|
53
|
+
screen.getByRole('button', { name: /collapse|expand/i }).click();
|
|
54
|
+
expect(toggled).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('shows expand label when collapsed', () => {
|
|
58
|
+
render(
|
|
59
|
+
<Sidebar title="C" collapsed onToggle={() => {}}>
|
|
60
|
+
<p>x</p>
|
|
61
|
+
</Sidebar>,
|
|
62
|
+
);
|
|
63
|
+
expect(screen.getByRole('button', { name: /expand/i })).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('shows collapse label when expanded', () => {
|
|
67
|
+
render(
|
|
68
|
+
<Sidebar title="C" onToggle={() => {}}>
|
|
69
|
+
<p>x</p>
|
|
70
|
+
</Sidebar>,
|
|
71
|
+
);
|
|
72
|
+
expect(screen.getByRole('button', { name: /collapse/i })).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SidebarProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
title?: string;
|
|
6
|
+
collapsed?: boolean;
|
|
7
|
+
onToggle?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Sidebar({ children, title, collapsed = false, onToggle }: SidebarProps) {
|
|
11
|
+
const className = `lk-sidebar${collapsed ? ' lk-sidebar--collapsed' : ''}`;
|
|
12
|
+
return (
|
|
13
|
+
<aside className={className}>
|
|
14
|
+
{(title || onToggle) && (
|
|
15
|
+
<div className="lk-sidebar-header">
|
|
16
|
+
{title && <span className="lk-sidebar-title">{title}</span>}
|
|
17
|
+
{onToggle && (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
className="lk-sidebar-toggle"
|
|
21
|
+
onClick={onToggle}
|
|
22
|
+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
23
|
+
>
|
|
24
|
+
{collapsed ? '›' : '‹'}
|
|
25
|
+
</button>
|
|
26
|
+
)}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
<div className="lk-sidebar-body">{children}</div>
|
|
30
|
+
</aside>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
.lk-status-bar {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--lk-spacing-md);
|
|
5
|
+
padding: var(--lk-spacing-xs) var(--lk-spacing-sm);
|
|
6
|
+
background: var(--lk-bg-elevated);
|
|
7
|
+
border-top: 1px solid var(--lk-border);
|
|
8
|
+
font-size: var(--lk-font-size-sm);
|
|
9
|
+
color: var(--lk-text-muted);
|
|
10
|
+
font-family: var(--lk-font-mono);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.lk-status-bar-section {
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
gap: var(--lk-spacing-xs);
|
|
17
|
+
padding: 0 var(--lk-spacing-xs);
|
|
18
|
+
border-right: 1px solid var(--lk-divider);
|
|
19
|
+
|
|
20
|
+
&:last-child { border-right: none; }
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { StatusBar } from './StatusBar';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof StatusBar> = {
|
|
5
|
+
title: 'labkit/Primitives/StatusBar',
|
|
6
|
+
component: StatusBar,
|
|
7
|
+
};
|
|
8
|
+
export default meta;
|
|
9
|
+
|
|
10
|
+
type Story = StoryObj<typeof StatusBar>;
|
|
11
|
+
|
|
12
|
+
export const Plain: Story = {
|
|
13
|
+
render: () => <StatusBar>Ready</StatusBar>,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const Sections: Story = {
|
|
17
|
+
render: () => (
|
|
18
|
+
<StatusBar>
|
|
19
|
+
<StatusBar.Section>Items: 12</StatusBar.Section>
|
|
20
|
+
<StatusBar.Section>Zoom: 100%</StatusBar.Section>
|
|
21
|
+
<StatusBar.Section>FPS: 60</StatusBar.Section>
|
|
22
|
+
</StatusBar>
|
|
23
|
+
),
|
|
24
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { StatusBar } from './StatusBar';
|
|
4
|
+
|
|
5
|
+
describe('StatusBar', () => {
|
|
6
|
+
test('renders children', () => {
|
|
7
|
+
render(<StatusBar>3 items</StatusBar>);
|
|
8
|
+
expect(screen.getByText('3 items')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('uses lk-status-bar class', () => {
|
|
12
|
+
const { container } = render(<StatusBar>x</StatusBar>);
|
|
13
|
+
expect((container.firstChild as HTMLElement).className).toBe('lk-status-bar');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('renders multiple section children separated', () => {
|
|
17
|
+
const { container } = render(
|
|
18
|
+
<StatusBar>
|
|
19
|
+
<StatusBar.Section>left</StatusBar.Section>
|
|
20
|
+
<StatusBar.Section>right</StatusBar.Section>
|
|
21
|
+
</StatusBar>,
|
|
22
|
+
);
|
|
23
|
+
expect(container.querySelectorAll('.lk-status-bar-section').length).toBe(2);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface StatusBarProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function StatusBar({ children }: StatusBarProps) {
|
|
8
|
+
return <div className="lk-status-bar">{children}</div>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SectionProps {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
function Section({ children }: SectionProps) {
|
|
15
|
+
return <span className="lk-status-bar-section">{children}</span>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
StatusBar.Section = Section;
|