@weasel-js/labkit 1.3.0 → 1.4.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/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
- package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
- package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
- package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
- package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
- package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
- package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
- package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
- package/dist/canvas/index.d.ts +43 -25
- package/dist/canvas/index.js +5 -4
- package/dist/chrome/index.d.ts +9 -6
- package/dist/chrome/index.js +6 -6
- package/dist/chunk-2AYGEN57.js +32 -0
- package/dist/chunk-2AYGEN57.js.map +1 -0
- package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
- package/dist/chunk-3P5TTTA7.js.map +1 -0
- package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
- package/dist/chunk-3ZELRRBV.js.map +1 -0
- package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
- package/dist/chunk-4EY67BKJ.js.map +1 -0
- package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
- package/dist/chunk-5TCEVMPI.js.map +1 -0
- package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
- package/dist/chunk-6JMU56CU.js.map +1 -0
- package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
- package/dist/chunk-CQLQPQ4P.js.map +1 -0
- package/dist/chunk-CTRKTLYZ.js +33 -0
- package/dist/chunk-CTRKTLYZ.js.map +1 -0
- package/dist/chunk-EQIB2PGC.js +458 -0
- package/dist/chunk-EQIB2PGC.js.map +1 -0
- package/dist/chunk-HEORBLVT.js +7128 -0
- package/dist/chunk-HEORBLVT.js.map +1 -0
- package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
- package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
- package/dist/chunk-LITE5YFB.js +669 -0
- package/dist/chunk-LITE5YFB.js.map +1 -0
- package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
- package/dist/chunk-ROQDRXKW.js.map +1 -0
- package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
- package/dist/chunk-XJ6N32QP.js.map +1 -0
- package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
- package/dist/chunk-YQJNT4XQ.js.map +1 -0
- package/dist/controls/index.d.ts +4 -28
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +9 -5
- package/dist/dragdrop/index.js +3 -3
- package/dist/index.d.ts +518 -190
- package/dist/index.js +1753 -75
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +187 -0
- package/dist/loupe/index.js +7 -0
- package/dist/loupe/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +3 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +138 -91
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +5 -5
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +229 -1
- package/dist/surface/index.d.ts +18 -2
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +5 -4
- package/package.json +14 -7
- package/src/annotations/AnnotationOverlay.tsx +205 -0
- package/src/annotations/AnnotationTargets.tsx +37 -0
- package/src/annotations/Annotations.less +129 -0
- package/src/annotations/Annotations.meaning.test.tsx +111 -0
- package/src/annotations/Annotations.overlay.test.tsx +142 -0
- package/src/annotations/AnnotationsContext.ts +49 -0
- package/src/annotations/ExportMenu.test.tsx +86 -0
- package/src/annotations/ExportMenu.tsx +182 -0
- package/src/annotations/MarkList.tsx +75 -0
- package/src/annotations/capture.test.ts +140 -0
- package/src/annotations/capture.ts +237 -0
- package/src/annotations/drawOne.test.ts +56 -0
- package/src/annotations/drawOne.ts +47 -0
- package/src/annotations/frac.test.ts +59 -0
- package/src/annotations/frac.ts +66 -0
- package/src/annotations/history.test.ts +82 -0
- package/src/annotations/history.ts +118 -0
- package/src/annotations/index.ts +37 -0
- package/src/annotations/paint.test.ts +125 -0
- package/src/annotations/paint.ts +130 -0
- package/src/annotations/staleness.test.ts +56 -0
- package/src/annotations/staleness.ts +41 -0
- package/src/annotations/store.test.ts +175 -0
- package/src/annotations/store.ts +317 -0
- package/src/annotations/svgNodes.test.ts +77 -0
- package/src/annotations/svgNodes.ts +74 -0
- package/src/annotations/toolMap.test.ts +36 -0
- package/src/annotations/toolMap.ts +54 -0
- package/src/annotations/types.ts +222 -0
- package/src/annotations/view.test.ts +37 -0
- package/src/annotations/view.ts +40 -0
- package/src/canvas/AGENTS.md +55 -5
- package/src/canvas/CanvasStack.test.tsx +27 -1
- package/src/canvas/CanvasStack.tsx +29 -4
- package/src/canvas/CanvasStackContext.ts +23 -4
- package/src/canvas/camera.test.ts +78 -0
- package/src/canvas/camera.ts +62 -0
- package/src/canvas/canvasCoords.test.ts +26 -0
- package/src/canvas/canvasCoords.ts +20 -7
- package/src/canvas/index.ts +5 -1
- package/src/canvas/useLayerScheduler.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +36 -5
- package/src/canvas/usePanZoom.ts +20 -15
- package/src/canvas/worldSpec.test.ts +63 -0
- package/src/canvas/worldSpec.ts +48 -0
- package/src/chrome/builtins.test.ts +31 -1
- package/src/chrome/builtins.tsx +63 -19
- package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
- package/src/chrome/regions/PaletteRegion.tsx +4 -0
- package/src/chrome/regions/SidebarRegion.tsx +37 -13
- package/src/chrome/regions/ToolbarRegion.tsx +2 -1
- package/src/chrome/regions/ViewportRegion.tsx +9 -1
- package/src/chrome/regions/regions.test.tsx +6 -1
- package/src/chrome/types.ts +20 -0
- package/src/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +50 -3
- package/src/dragdrop/DragDropRuntime.tsx +8 -2
- package/src/dragdrop/dragDrop.test.tsx +29 -2
- package/src/index.test.ts +22 -0
- package/src/index.ts +87 -2
- package/src/instrument/SineWave.smoke.test.tsx +1 -0
- package/src/instrument/types.ts +24 -1
- package/src/lab/Lab.surface.test.tsx +162 -0
- package/src/lab/Lab.tsx +102 -17
- package/src/lab/Lab.undock.test.tsx +85 -0
- package/src/lab/LabFullChrome.stories.tsx +20 -0
- package/src/lab/LabShell.less +11 -0
- package/src/lab/Workspace.less +73 -0
- package/src/lab/Workspace.surface.test.tsx +2 -0
- package/src/lab/Workspace.tsx +97 -8
- package/src/lab/index.ts +1 -1
- package/src/lab/panelHost.ts +37 -0
- package/src/loupe/AGENTS.md +49 -0
- package/src/loupe/CanvasLoupe.tsx +83 -0
- package/src/loupe/DomLoupe.tsx +62 -0
- package/src/loupe/Loupe.less +38 -0
- package/src/loupe/LoupeBubble.tsx +32 -0
- package/src/loupe/TrialLoupe.tsx +93 -0
- package/src/loupe/canvasLens.test.ts +205 -0
- package/src/loupe/canvasLens.ts +141 -0
- package/src/loupe/index.ts +19 -0
- package/src/loupe/types.test.ts +29 -0
- package/src/loupe/types.ts +90 -0
- package/src/loupe/useHostSize.ts +30 -0
- package/src/loupe/useLoupe.test.tsx +197 -0
- package/src/loupe/useLoupe.ts +189 -0
- package/src/passthrough/weasel-ui.ts +3 -0
- package/src/primitives/FloatingPanel.test.tsx +76 -1
- package/src/primitives/FloatingPanel.tsx +19 -3
- package/src/primitives/ScaleIndicator.test.tsx +7 -2
- package/src/primitives/Sidebar.less +18 -0
- package/src/primitives/Toolbar.less +8 -0
- package/src/primitives/Toolbar.tsx +5 -0
- package/src/primitives/useRovingTabIndex.test.ts +21 -0
- package/src/primitives/useRovingTabIndex.ts +26 -17
- package/src/state/document.test.ts +23 -0
- package/src/state/document.ts +14 -2
- package/src/state/index.ts +2 -0
- package/src/state/store.test.ts +24 -0
- package/src/state/store.ts +35 -1
- package/src/state/types.ts +11 -0
- package/src/state/undock.test.ts +40 -0
- package/src/state/undock.ts +39 -0
- package/src/styles.less +2 -0
- package/src/surface/SurfaceContext.ts +4 -0
- package/src/surface/index.ts +8 -3
- package/src/surface/useSurfaceTile.test.tsx +2 -0
- package/src/surface/useSurfaceTile.ts +7 -1
- package/src/surface/useTiledSurface.test.tsx +58 -0
- package/src/surface/useTiledSurface.ts +38 -3
- package/src/trial/Trial.annotations.persist.test.tsx +166 -0
- package/src/trial/Trial.annotations.test.tsx +121 -0
- package/src/trial/Trial.canvas.test.tsx +76 -1
- package/src/trial/Trial.less +19 -0
- package/src/trial/Trial.loupe.test.tsx +128 -0
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +12 -0
- package/src/trial/Trial.trialId.test.tsx +36 -0
- package/src/trial/Trial.tsx +215 -40
- package/src/trial/TrialChrome.tsx +35 -0
- package/src/trial/UndockedSections.tsx +65 -0
- package/src/trial/trialOps.test.ts +21 -0
- package/src/trial/trialOps.ts +5 -3
- package/dist/_dts/types-BttTIent.d.ts +0 -314
- package/dist/chunk-3XXPU73K.js.map +0 -1
- package/dist/chunk-4TMMVIDM.js.map +0 -1
- package/dist/chunk-6X5RBC6G.js +0 -366
- package/dist/chunk-6X5RBC6G.js.map +0 -1
- package/dist/chunk-BDJXIBRZ.js.map +0 -1
- package/dist/chunk-CPUJ3QXL.js.map +0 -1
- package/dist/chunk-JDROYQM3.js.map +0 -1
- package/dist/chunk-LGKRIUSW.js.map +0 -1
- package/dist/chunk-QCICIHEO.js.map +0 -1
- package/dist/chunk-QRYPXSGP.js.map +0 -1
- package/dist/chunk-RGT4EJIB.js +0 -6961
- package/dist/chunk-RGT4EJIB.js.map +0 -1
- package/dist/chunk-T66HU3HX.js +0 -17
- package/dist/chunk-T66HU3HX.js.map +0 -1
- package/dist/chunk-WTI26YTM.js.map +0 -1
package/dist/surface/index.d.ts
CHANGED
|
@@ -50,8 +50,18 @@ interface SurfaceHandle {
|
|
|
50
50
|
* moved something a ResizeObserver cannot see. */
|
|
51
51
|
invalidateRects: () => void;
|
|
52
52
|
registerTile: (id: string, el: HTMLElement | null) => void;
|
|
53
|
+
/** Subscribe a tile to the frames it is dirty on. A tile paints on its own
|
|
54
|
+
* loop, so this is how the surface wakes one: a resize of the shared buffer
|
|
55
|
+
* clears every tile, not only the one that moved. */
|
|
56
|
+
registerPainter: (id: string, paint: TilePainter) => () => void;
|
|
53
57
|
containerRef: (el: HTMLElement | null) => void;
|
|
58
|
+
/** The element tile rects are measured against, and so the one a tile's own
|
|
59
|
+
* chrome positions itself inside. Null before the owner attaches it. */
|
|
60
|
+
getContainer: () => HTMLElement | null;
|
|
54
61
|
}
|
|
62
|
+
/** What a tile's painter is handed: where it sits on the surface now, and the
|
|
63
|
+
* frame that dirtied it. */
|
|
64
|
+
type TilePainter = (rect: Rect, frame: SurfaceFrame) => void;
|
|
55
65
|
interface UseTiledSurfaceOptions {
|
|
56
66
|
onFrame: (frame: SurfaceFrame) => void;
|
|
57
67
|
}
|
|
@@ -59,7 +69,13 @@ declare function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHa
|
|
|
59
69
|
|
|
60
70
|
/** Null when no surface owner is above — a lab with no shared surface at all. */
|
|
61
71
|
declare const SurfaceContext: react.Context<SurfaceHandle | null>;
|
|
72
|
+
/** The buffer the surface's tiles paint into. Null until the owner's canvas is
|
|
73
|
+
* mounted, and for an owner that keeps its surface unpainted. */
|
|
74
|
+
declare const SurfaceCanvasContext: react.Context<HTMLCanvasElement | null>;
|
|
62
75
|
|
|
76
|
+
/** The surface's shared buffer, or null where nothing paints one. What a tile
|
|
77
|
+
* hands to `paintInto`. */
|
|
78
|
+
declare function useSurfaceCanvas(): HTMLCanvasElement | null;
|
|
63
79
|
/** The surface above, or null. Use this where a surface is genuinely optional. */
|
|
64
80
|
declare function useSurfaceOptional(): SurfaceHandle | null;
|
|
65
81
|
/** The surface above. Throws where a caller cannot work without one. */
|
|
@@ -73,5 +89,5 @@ declare function useSurface(): SurfaceHandle;
|
|
|
73
89
|
*/
|
|
74
90
|
declare function useSurfaceTile(id: string): (el: HTMLElement | null) => void;
|
|
75
91
|
|
|
76
|
-
export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface };
|
|
77
|
-
export type { Box, Rect, SurfaceFrame, SurfaceHandle, UseTiledSurfaceOptions };
|
|
92
|
+
export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTiledSurface };
|
|
93
|
+
export type { Box, Rect, SurfaceFrame, SurfaceHandle, TilePainter, UseTiledSurfaceOptions };
|
package/dist/surface/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTiledSurface } from '../chunk-4EY67BKJ.js';
|
|
2
|
+
import '../chunk-ROQDRXKW.js';
|
|
3
3
|
//# sourceMappingURL=index.js.map
|
|
4
4
|
//# sourceMappingURL=index.js.map
|
package/dist/ui/layers/index.js
CHANGED
package/dist/undo/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { a0 as UndoCapability } from '../_dts/frac-Cp3NivlC.js';
|
|
2
2
|
import 'react';
|
|
3
|
-
import '../_dts/types-
|
|
4
|
-
import '../_dts/PrefsForm-
|
|
5
|
-
import '../_dts/types-
|
|
3
|
+
import '../_dts/types-D6s4b7if.js';
|
|
4
|
+
import '../_dts/PrefsForm-BkUJZx0A.js';
|
|
5
|
+
import '../_dts/types-DYMaEvM5.js';
|
|
6
6
|
import '../_dts/types-DJ79Tg5J.js';
|
|
7
|
+
import '../_dts/types-C-gh9Ap-.js';
|
|
7
8
|
|
|
8
9
|
/** A subscriber to a named event. */
|
|
9
10
|
type EventListener = () => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/labkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0-pre.0",
|
|
4
4
|
"description": "React widgets for building self-contained interactive lab pages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "orochi235",
|
|
@@ -82,7 +82,11 @@
|
|
|
82
82
|
"types": "./dist/ui/layers/index.d.ts",
|
|
83
83
|
"import": "./dist/ui/layers/index.js"
|
|
84
84
|
},
|
|
85
|
-
"./styles.css": "./dist/styles.css"
|
|
85
|
+
"./styles.css": "./dist/styles.css",
|
|
86
|
+
"./loupe": {
|
|
87
|
+
"types": "./dist/loupe/index.d.ts",
|
|
88
|
+
"import": "./dist/loupe/index.js"
|
|
89
|
+
}
|
|
86
90
|
},
|
|
87
91
|
"scripts": {
|
|
88
92
|
"dev": "vite",
|
|
@@ -102,16 +106,19 @@
|
|
|
102
106
|
"docs:dev": "vitepress dev docs",
|
|
103
107
|
"docs:build": "vitepress build docs",
|
|
104
108
|
"docs:preview": "vitepress preview docs",
|
|
105
|
-
"dev:schema": "LABKIT_EXAMPLE=schema-lab vite"
|
|
109
|
+
"dev:schema": "LABKIT_EXAMPLE=schema-lab vite",
|
|
110
|
+
"dev:annotate": "LABKIT_EXAMPLE=annotate-lab vite"
|
|
106
111
|
},
|
|
107
112
|
"peerDependencies": {
|
|
108
113
|
"react": "^19.0.0",
|
|
109
114
|
"react-dom": "^19.0.0"
|
|
110
115
|
},
|
|
111
116
|
"dependencies": {
|
|
112
|
-
"@weasel-js/core": "1.
|
|
113
|
-
"@weasel-js/
|
|
114
|
-
"@weasel-js/
|
|
117
|
+
"@weasel-js/core": "1.4.0-pre.0",
|
|
118
|
+
"@weasel-js/loupe": "1.4.0-pre.0",
|
|
119
|
+
"@weasel-js/svg": "1.4.0-pre.0",
|
|
120
|
+
"@weasel-js/theme": "1.4.0-pre.0",
|
|
121
|
+
"@weasel-js/ui": "1.4.0-pre.0",
|
|
115
122
|
"earcut": "2.2.4",
|
|
116
123
|
"polygon-clipping": "^0.15.7",
|
|
117
124
|
"react-aria-components": "^1.5.0",
|
|
@@ -122,7 +129,7 @@
|
|
|
122
129
|
"@biomejs/biome": "^2.4.12",
|
|
123
130
|
"@rollup/plugin-alias": "^6.0.0",
|
|
124
131
|
"@testing-library/jest-dom": "^6.6.0",
|
|
125
|
-
"@weasel-js/modes": "1.
|
|
132
|
+
"@weasel-js/modes": "1.4.0-pre.0",
|
|
126
133
|
"less": "^4.2.0",
|
|
127
134
|
"rollup-plugin-dts": "^6.4.1",
|
|
128
135
|
"tsx": "^4.19.0",
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type InsertNodeFactory,
|
|
3
|
+
SceneCanvas,
|
|
4
|
+
type SceneCanvasApi,
|
|
5
|
+
useActiveToolContext,
|
|
6
|
+
type View,
|
|
7
|
+
WeaselProvider,
|
|
8
|
+
} from '@weasel-js/core';
|
|
9
|
+
import { type CSSProperties, useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { createPortal } from 'react-dom';
|
|
11
|
+
import type { Rect } from '../surface/rect';
|
|
12
|
+
import { useSurfaceCanvas, useSurfaceOptional } from '../surface/useSurfaceTile';
|
|
13
|
+
import { createMarkDrawOne } from './drawOne';
|
|
14
|
+
import type { WorldRect } from './frac';
|
|
15
|
+
import { seenFrom } from './staleness';
|
|
16
|
+
import type { MarkScene } from './store';
|
|
17
|
+
import { ANNOTATION_WEASEL_TOOLS, annotationToolInfo } from './toolMap';
|
|
18
|
+
import type {
|
|
19
|
+
AnnotationData,
|
|
20
|
+
AnnotationKind,
|
|
21
|
+
AnnotationMeaning,
|
|
22
|
+
AnnotationTarget,
|
|
23
|
+
FracPoint,
|
|
24
|
+
} from './types';
|
|
25
|
+
import { fitView, toWeaselView } from './view';
|
|
26
|
+
|
|
27
|
+
/** Props for `<AnnotationOverlay>`. */
|
|
28
|
+
export interface AnnotationOverlayProps {
|
|
29
|
+
target: AnnotationTarget;
|
|
30
|
+
/** The trial's mark scene — shared by every target; a mark's own `target`
|
|
31
|
+
* field is what separates them. */
|
|
32
|
+
scene: MarkScene;
|
|
33
|
+
/** Snapshotted onto each new mark, so staleness can be answered later. */
|
|
34
|
+
config: unknown;
|
|
35
|
+
/** The trial's resolved tool slot. */
|
|
36
|
+
activeToolId: string | null;
|
|
37
|
+
/** The instrument's vocabulary, for a status's colour. */
|
|
38
|
+
meaning?: AnnotationMeaning;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sameRect = (a: Rect | null, b: Rect): boolean =>
|
|
42
|
+
a !== null && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
|
43
|
+
|
|
44
|
+
/** Pushes the trial's tool into the weasel scope this pane owns. Its own
|
|
45
|
+
* component because the context only exists below `<WeaselProvider>`. */
|
|
46
|
+
function ToolBridge({ toolId }: { toolId: string }) {
|
|
47
|
+
const tools = useActiveToolContext();
|
|
48
|
+
const setActive = tools.setActive;
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
setActive(toolId);
|
|
51
|
+
}, [toolId, setActive]);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every vertex the insert carried, in fractions of the content box. */
|
|
56
|
+
function pointsOf(
|
|
57
|
+
extras: Record<string, unknown>,
|
|
58
|
+
content: { w: number; h: number },
|
|
59
|
+
): readonly FracPoint[] | undefined {
|
|
60
|
+
const toFrac = (p: { x: number; y: number }): FracPoint => ({
|
|
61
|
+
x: content.w === 0 ? 0 : p.x / content.w,
|
|
62
|
+
y: content.h === 0 ? 0 : p.y / content.h,
|
|
63
|
+
});
|
|
64
|
+
if (extras.kind === 'line') {
|
|
65
|
+
return [toFrac(extras.a as FracPoint), toFrac(extras.b as FracPoint)];
|
|
66
|
+
}
|
|
67
|
+
if (extras.kind === 'pencil') {
|
|
68
|
+
return (extras.samples as { x: number; y: number }[]).map(toFrac);
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One target's drawing surface: a transparent box over the target's element
|
|
75
|
+
* taking the input, and a `<SceneCanvas>` painting into the lab's shared
|
|
76
|
+
* buffer at that box's rect.
|
|
77
|
+
*
|
|
78
|
+
* The box is portalled into the surface container because that is what tile
|
|
79
|
+
* rects are measured against; positioning it among the instrument's own DOM
|
|
80
|
+
* would put it in whatever positioning context happened to be there.
|
|
81
|
+
*/
|
|
82
|
+
export function AnnotationOverlay({
|
|
83
|
+
target,
|
|
84
|
+
scene,
|
|
85
|
+
config,
|
|
86
|
+
activeToolId,
|
|
87
|
+
meaning,
|
|
88
|
+
}: AnnotationOverlayProps) {
|
|
89
|
+
const surface = useSurfaceOptional();
|
|
90
|
+
const canvas = useSurfaceCanvas();
|
|
91
|
+
const [rect, setRect] = useState<Rect | null>(null);
|
|
92
|
+
const [input, setInput] = useState<HTMLDivElement | null>(null);
|
|
93
|
+
const sceneCanvas = useRef<SceneCanvasApi | null>(null);
|
|
94
|
+
|
|
95
|
+
// Read in the insert factory, which is built once and must see the tool the
|
|
96
|
+
// pane is holding now — `arrow` and `stroke` are indistinguishable from
|
|
97
|
+
// `line` and `pencil` at the weasel end.
|
|
98
|
+
const toolRef = useRef(activeToolId);
|
|
99
|
+
toolRef.current = activeToolId;
|
|
100
|
+
const configRef = useRef(config);
|
|
101
|
+
configRef.current = config;
|
|
102
|
+
|
|
103
|
+
const id = target.id;
|
|
104
|
+
const lastTile = useRef<HTMLElement | null>(null);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const el = target.ref.current ?? null;
|
|
107
|
+
if (el === lastTile.current) return;
|
|
108
|
+
lastTile.current = el;
|
|
109
|
+
surface?.registerTile(id, el);
|
|
110
|
+
});
|
|
111
|
+
useEffect(
|
|
112
|
+
() => () => {
|
|
113
|
+
// Clearing this is what lets the effect above re-register on the next
|
|
114
|
+
// commit. StrictMode's mount / unmount / mount would otherwise take the
|
|
115
|
+
// tile out and never put it back, and the surface would measure nothing.
|
|
116
|
+
lastTile.current = null;
|
|
117
|
+
surface?.registerTile(id, null);
|
|
118
|
+
},
|
|
119
|
+
[surface, id],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
useEffect(
|
|
123
|
+
() =>
|
|
124
|
+
surface?.registerPainter(id, (next) => {
|
|
125
|
+
setRect((prev) => (sameRect(prev, next) ? prev : next));
|
|
126
|
+
// The shared buffer was cleared if it resized this frame; this pane's
|
|
127
|
+
// own loop has no way to know that.
|
|
128
|
+
sceneCanvas.current?.requestRedraw();
|
|
129
|
+
}),
|
|
130
|
+
[surface, id],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const container = surface?.getContainer() ?? null;
|
|
134
|
+
if (!container || !rect) return null;
|
|
135
|
+
|
|
136
|
+
const view: View = toWeaselView(target.view ?? fitView(target.content, { w: rect.w, h: rect.h }));
|
|
137
|
+
|
|
138
|
+
const drawOne = createMarkDrawOne({
|
|
139
|
+
content: target.content,
|
|
140
|
+
positionDependsOn: target.positionDependsOn,
|
|
141
|
+
config,
|
|
142
|
+
meaning,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const factories: Record<string, InsertNodeFactory> = {};
|
|
146
|
+
for (const weaselTool of ANNOTATION_WEASEL_TOOLS) {
|
|
147
|
+
factories[weaselTool] = (_bounds, extras) => {
|
|
148
|
+
const info = annotationToolInfo(toolRef.current);
|
|
149
|
+
const kind: AnnotationKind | undefined = info?.kind;
|
|
150
|
+
if (!kind) return null;
|
|
151
|
+
const points = pointsOf(extras as unknown as Record<string, unknown>, target.content);
|
|
152
|
+
const data: AnnotationData = {
|
|
153
|
+
target: id,
|
|
154
|
+
kind,
|
|
155
|
+
...(points ? { points } : {}),
|
|
156
|
+
seen: seenFrom(configRef.current, target.positionDependsOn ?? []),
|
|
157
|
+
};
|
|
158
|
+
return { data };
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A measured rect has nowhere to live but the element's own style. Set as
|
|
163
|
+
// custom properties, which the stylesheet reads — the sanctioned use.
|
|
164
|
+
const box = {
|
|
165
|
+
['--lk-anno-x' as string]: `${rect.x}px`,
|
|
166
|
+
['--lk-anno-y' as string]: `${rect.y}px`,
|
|
167
|
+
['--lk-anno-w' as string]: `${rect.w}px`,
|
|
168
|
+
['--lk-anno-h' as string]: `${rect.h}px`,
|
|
169
|
+
} as CSSProperties;
|
|
170
|
+
|
|
171
|
+
return createPortal(
|
|
172
|
+
<>
|
|
173
|
+
<div
|
|
174
|
+
ref={setInput}
|
|
175
|
+
className="lk-annotate__input"
|
|
176
|
+
data-annotation-target={id}
|
|
177
|
+
role="application"
|
|
178
|
+
aria-label={`Annotations on ${id}`}
|
|
179
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: role="application" IS the interactive case — a region with its own keymap. The rule's allowed-role list stops at the native widget roles and does not include it, and the gesture dispatcher listens on this element, so without focus no tool's keyboard binding ever fires.
|
|
180
|
+
tabIndex={0}
|
|
181
|
+
style={box}
|
|
182
|
+
/>
|
|
183
|
+
{canvas && input ? (
|
|
184
|
+
// One scope per pane: a shared <ActionsProvider> lets only the newest
|
|
185
|
+
// canvas under it respond to input, and the rest go silently dead.
|
|
186
|
+
<WeaselProvider isolate>
|
|
187
|
+
<ToolBridge toolId={annotationToolInfo(activeToolId)?.weaselTool ?? 'select'} />
|
|
188
|
+
<SceneCanvas<AnnotationData, 'marks', WorldRect>
|
|
189
|
+
ref={sceneCanvas}
|
|
190
|
+
scene={scene}
|
|
191
|
+
width={rect.w}
|
|
192
|
+
height={rect.h}
|
|
193
|
+
view={view}
|
|
194
|
+
paintInto={{ canvas, x: rect.x, y: rect.y }}
|
|
195
|
+
inputElement={input}
|
|
196
|
+
defaultTools={ANNOTATION_WEASEL_TOOLS}
|
|
197
|
+
insertNodeFactories={factories}
|
|
198
|
+
layers={{ scene: { drawOne } }}
|
|
199
|
+
/>
|
|
200
|
+
</WeaselProvider>
|
|
201
|
+
) : null}
|
|
202
|
+
</>,
|
|
203
|
+
container,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AnnotationOverlay } from './AnnotationOverlay';
|
|
2
|
+
import type { AnnotationsApi, AnnotationsCapability } from './types';
|
|
3
|
+
|
|
4
|
+
/** Props for `<AnnotationTargets>`. */
|
|
5
|
+
export interface AnnotationTargetsProps {
|
|
6
|
+
capability: AnnotationsCapability;
|
|
7
|
+
state: unknown;
|
|
8
|
+
config: unknown;
|
|
9
|
+
annotations: AnnotationsApi;
|
|
10
|
+
activeToolId: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One overlay per region the instrument said accepts marks. */
|
|
14
|
+
export function AnnotationTargets({
|
|
15
|
+
capability,
|
|
16
|
+
state,
|
|
17
|
+
config,
|
|
18
|
+
annotations,
|
|
19
|
+
activeToolId,
|
|
20
|
+
}: AnnotationTargetsProps) {
|
|
21
|
+
const targets = capability.targets(state, config);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
{targets.map((t) => (
|
|
26
|
+
<AnnotationOverlay
|
|
27
|
+
key={t.id}
|
|
28
|
+
target={t}
|
|
29
|
+
scene={annotations.sceneFor(t.id)}
|
|
30
|
+
meaning={capability.meaning}
|
|
31
|
+
config={config}
|
|
32
|
+
activeToolId={activeToolId}
|
|
33
|
+
/>
|
|
34
|
+
))}
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The box a target takes its marks' input from: transparent, over the target's
|
|
2
|
+
// element, positioned in the surface container the tile rects are measured
|
|
3
|
+
// against. Above the shared buffer, because the buffer takes no input at all.
|
|
4
|
+
.lk-annotate__input {
|
|
5
|
+
position: absolute;
|
|
6
|
+
left: var(--lk-anno-x);
|
|
7
|
+
top: var(--lk-anno-y);
|
|
8
|
+
width: var(--lk-anno-w);
|
|
9
|
+
height: var(--lk-anno-h);
|
|
10
|
+
z-index: 2;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The sidebar list of marks. One row per mark: what it is, where it is, what
|
|
14
|
+
// it says, and whether it still describes the picture under it.
|
|
15
|
+
.lk-mark-list {
|
|
16
|
+
list-style: none;
|
|
17
|
+
margin: 0;
|
|
18
|
+
padding: 0;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
gap: var(--wzl-space-xs);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.lk-mark-list__empty {
|
|
25
|
+
color: var(--wzl-fg-muted);
|
|
26
|
+
font-size: var(--wzl-font-size-sm);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.lk-mark-list__row {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
gap: var(--wzl-space-xs);
|
|
33
|
+
padding: var(--wzl-space-xs);
|
|
34
|
+
border: 1px solid var(--wzl-border);
|
|
35
|
+
border-radius: var(--wzl-radius-sm);
|
|
36
|
+
background: var(--wzl-surface-raised);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.lk-mark-list__head {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: var(--wzl-space-xs);
|
|
43
|
+
font-size: var(--wzl-font-size-sm);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.lk-mark-list__target {
|
|
47
|
+
color: var(--wzl-fg-muted);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.lk-mark-list__stale {
|
|
51
|
+
color: var(--wzl-warning);
|
|
52
|
+
font-size: var(--wzl-font-size-xs);
|
|
53
|
+
text-transform: uppercase;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.lk-mark-list__drop {
|
|
57
|
+
margin-left: auto;
|
|
58
|
+
background: none;
|
|
59
|
+
border: 0;
|
|
60
|
+
color: var(--wzl-fg-muted);
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The export panel: a target to export, how, and at what resolution. Held in
|
|
65
|
+
// a popover because a mark belongs to a target — with more than one pane,
|
|
66
|
+
// "export" on its own does not say what.
|
|
67
|
+
.lk-export__panel {
|
|
68
|
+
display: grid;
|
|
69
|
+
grid-template-columns: auto 1fr;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: var(--wzl-space-xs) var(--wzl-space-sm);
|
|
72
|
+
min-width: 220px;
|
|
73
|
+
font-size: var(--wzl-font-size-sm);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.lk-export__title {
|
|
77
|
+
grid-column: 1 / -1;
|
|
78
|
+
font-weight: var(--wzl-font-weight-medium);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A row spans both columns and re-declares the same tracks, so every row's
|
|
82
|
+
// label lines up whether it is a <label> or a <fieldset>: subgrid is what
|
|
83
|
+
// keeps one column definition rather than three copies.
|
|
84
|
+
.lk-export__row {
|
|
85
|
+
display: grid;
|
|
86
|
+
grid-column: 1 / -1;
|
|
87
|
+
grid-template-columns: subgrid;
|
|
88
|
+
align-items: center;
|
|
89
|
+
margin: 0;
|
|
90
|
+
padding: 0;
|
|
91
|
+
border: 0;
|
|
92
|
+
|
|
93
|
+
> span:first-child {
|
|
94
|
+
grid-column: 1;
|
|
95
|
+
grid-row: 1;
|
|
96
|
+
color: var(--wzl-fg-muted);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
> select {
|
|
100
|
+
grid-column: 2;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.lk-export__choices {
|
|
105
|
+
grid-column: 2;
|
|
106
|
+
grid-row: 1;
|
|
107
|
+
display: flex;
|
|
108
|
+
gap: var(--wzl-space-sm);
|
|
109
|
+
|
|
110
|
+
label {
|
|
111
|
+
display: inline-flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: 3px;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.lk-export__actions {
|
|
118
|
+
grid-column: 1 / -1;
|
|
119
|
+
display: flex;
|
|
120
|
+
gap: var(--wzl-space-xs);
|
|
121
|
+
margin-top: var(--wzl-space-xs);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.lk-export__error {
|
|
125
|
+
grid-column: 1 / -1;
|
|
126
|
+
margin: 0;
|
|
127
|
+
color: var(--wzl-danger);
|
|
128
|
+
font-size: var(--wzl-font-size-xs);
|
|
129
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The meaning tier: what a mark says, as opposed to where it is. The store
|
|
3
|
+
* already carries `title` / `status` / `tags`; this is the chrome over them,
|
|
4
|
+
* plus the two things a reader needs at a glance — which target a mark is on,
|
|
5
|
+
* and whether it still describes the picture under it.
|
|
6
|
+
*/
|
|
7
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { AnnotationsContext } from './AnnotationsContext';
|
|
10
|
+
import { MarkList } from './MarkList';
|
|
11
|
+
import { createAnnotationStore } from './store';
|
|
12
|
+
import type { AnnotationMeaning, AnnotationsApi, AnnotationTargetInfo } from './types';
|
|
13
|
+
|
|
14
|
+
const TARGETS: AnnotationTargetInfo[] = [
|
|
15
|
+
{ id: 'flat', content: { w: 200, h: 100 }, positionDependsOn: ['angle'] },
|
|
16
|
+
{ id: 'shaded', content: { w: 200, h: 100 } },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const MEANING: AnnotationMeaning = {
|
|
20
|
+
statuses: [
|
|
21
|
+
{ id: 'open', label: 'Open', color: '#e5484d' },
|
|
22
|
+
{ id: 'fixed', label: 'Fixed', color: '#30a46c' },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function mount(store: AnnotationsApi, meaning?: AnnotationMeaning, config: unknown = { angle: 0 }) {
|
|
27
|
+
return render(
|
|
28
|
+
<AnnotationsContext.Provider value={store}>
|
|
29
|
+
<MarkList meaning={meaning} config={config} />
|
|
30
|
+
</AnnotationsContext.Provider>,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function seeded() {
|
|
35
|
+
const store = createAnnotationStore({ targets: () => TARGETS });
|
|
36
|
+
store.add(
|
|
37
|
+
{
|
|
38
|
+
target: 'flat',
|
|
39
|
+
kind: 'rect',
|
|
40
|
+
frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 },
|
|
41
|
+
title: 'spurious ring',
|
|
42
|
+
status: 'open',
|
|
43
|
+
},
|
|
44
|
+
{ angle: 0 },
|
|
45
|
+
);
|
|
46
|
+
store.add({ target: 'shaded', kind: 'line', frac: { x: 0.4, y: 0.4, w: 0.2, h: 0 } }, {});
|
|
47
|
+
return store;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('<MarkList>', () => {
|
|
51
|
+
it('lists every mark, with the target it is on', () => {
|
|
52
|
+
mount(seeded(), MEANING);
|
|
53
|
+
const rows = screen.getAllByRole('listitem');
|
|
54
|
+
expect(rows).toHaveLength(2);
|
|
55
|
+
expect(rows[0]?.textContent).toContain('flat');
|
|
56
|
+
expect(rows[1]?.textContent).toContain('shaded');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('writes a status through the store, and re-renders from it', () => {
|
|
60
|
+
const store = seeded();
|
|
61
|
+
mount(store, MEANING);
|
|
62
|
+
const picker = screen.getAllByRole('combobox')[0];
|
|
63
|
+
if (!picker) throw new Error('no status picker');
|
|
64
|
+
fireEvent.change(picker, { target: { value: 'fixed' } });
|
|
65
|
+
|
|
66
|
+
expect(store.query({ status: 'fixed' })).toHaveLength(1);
|
|
67
|
+
// The list is not holding its own copy: the value it shows came back out
|
|
68
|
+
// of the store, which is what makes a mark drawn on the canvas appear here.
|
|
69
|
+
expect((screen.getAllByRole('combobox')[0] as HTMLSelectElement).value).toBe('fixed');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renames a mark through the store', () => {
|
|
73
|
+
const store = seeded();
|
|
74
|
+
mount(store, MEANING);
|
|
75
|
+
const field = screen.getAllByRole('textbox')[0];
|
|
76
|
+
if (!field) throw new Error('no title field');
|
|
77
|
+
fireEvent.change(field, { target: { value: 'missing edge' } });
|
|
78
|
+
expect(store.query({ where: (a) => a.title === 'missing edge' })).toHaveLength(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("marks a row stale when its target's declared config moved", () => {
|
|
82
|
+
const store = seeded();
|
|
83
|
+
// `flat` snapshotted angle 0 and declares it; `shaded` declares nothing
|
|
84
|
+
// and can never go stale.
|
|
85
|
+
mount(store, MEANING, { angle: 30 });
|
|
86
|
+
const rows = screen.getAllByRole('listitem');
|
|
87
|
+
expect(rows[0]?.textContent).toContain('stale');
|
|
88
|
+
expect(rows[1]?.textContent).not.toContain('stale');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('drops a mark on request', () => {
|
|
92
|
+
const store = seeded();
|
|
93
|
+
mount(store, MEANING);
|
|
94
|
+
fireEvent.click(screen.getAllByRole('button', { name: /remove/i })[0] as HTMLElement);
|
|
95
|
+
expect(store.query()).toHaveLength(1);
|
|
96
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('still lists marks for an instrument that declared no vocabulary', () => {
|
|
100
|
+
// No `meaning` is "this host owns what a mark means", not "hide the list".
|
|
101
|
+
mount(seeded());
|
|
102
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
|
103
|
+
expect(screen.queryAllByRole('combobox')).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('says so when there are none', () => {
|
|
107
|
+
mount(createAnnotationStore({ targets: () => TARGETS }), MEANING);
|
|
108
|
+
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
|
|
109
|
+
expect(screen.getByText(/no marks/i)).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
});
|