@weasel-js/labkit 1.6.1 → 1.7.1
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 +3 -3
- package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
- package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-D6t7o0pF.d.ts} +59 -18
- package/dist/_dts/{index-CKyX0ME_.d.ts → index-BTmaRy6a.d.ts} +10 -6
- package/dist/_dts/{types-B7yBTTF9.d.ts → index-DCMA4Fy1.d.ts} +59 -113
- package/dist/_dts/{types-DTuViBvc.d.ts → types-BUBSIZuV.d.ts} +18 -8
- package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
- package/dist/canvas/index.d.ts +8 -6
- package/dist/canvas/index.js +6 -6
- package/dist/chrome/index.d.ts +6 -4
- package/dist/chrome/index.js +11 -10
- package/dist/chunk-2ES4W47N.js +8 -0
- package/dist/chunk-2ES4W47N.js.map +1 -0
- package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
- package/dist/chunk-2N7UBSS2.js.map +1 -0
- package/dist/chunk-4KSC7NXL.js +15491 -0
- package/dist/chunk-4KSC7NXL.js.map +1 -0
- package/dist/{chunk-SLAQPUI4.js → chunk-CEGFXTBD.js} +10 -63
- package/dist/chunk-CEGFXTBD.js.map +1 -0
- package/dist/chunk-CZZPXUOU.js +3 -0
- package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
- package/dist/{chunk-QGMOA2YE.js → chunk-E23Q6A3Y.js} +296 -104
- package/dist/chunk-E23Q6A3Y.js.map +1 -0
- package/dist/{chunk-GHGO52SO.js → chunk-E6NARGU7.js} +5 -5
- package/dist/{chunk-GHGO52SO.js.map → chunk-E6NARGU7.js.map} +1 -1
- package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
- package/dist/chunk-EESJUSYR.js.map +1 -0
- package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
- package/dist/chunk-EFXRM6XQ.js.map +1 -0
- package/dist/chunk-F4ACIK7Z.js +210 -0
- package/dist/chunk-F4ACIK7Z.js.map +1 -0
- package/dist/{chunk-6FLXL3I7.js → chunk-FB4MIZOO.js} +77 -45
- package/dist/chunk-FB4MIZOO.js.map +1 -0
- package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
- package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
- package/dist/{chunk-4TT3PNPR.js → chunk-GAHELLJT.js} +95 -95
- package/dist/chunk-GAHELLJT.js.map +1 -0
- package/dist/{chunk-HKV7LRHY.js → chunk-IBVRMHW5.js} +5 -5
- package/dist/{chunk-HKV7LRHY.js.map → chunk-IBVRMHW5.js.map} +1 -1
- package/dist/{chunk-ASOK76ZH.js → chunk-JFOJESJF.js} +72 -29
- package/dist/chunk-JFOJESJF.js.map +1 -0
- package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
- package/dist/chunk-PMLZP7RT.js.map +1 -0
- package/dist/{chunk-TJ77A4A3.js → chunk-READTDQX.js} +5 -9
- package/dist/chunk-READTDQX.js.map +1 -0
- package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
- package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
- package/dist/{chunk-CGHF6RZV.js → chunk-T6DLGEJB.js} +18 -18
- package/dist/chunk-T6DLGEJB.js.map +1 -0
- package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
- package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
- package/dist/{chunk-DWMSB4CA.js → chunk-VYVGVBGY.js} +95 -32
- package/dist/chunk-VYVGVBGY.js.map +1 -0
- package/dist/{chunk-IRTGUQ5O.js → chunk-WLMDTI2Q.js} +16 -6
- package/dist/chunk-WLMDTI2Q.js.map +1 -0
- package/dist/{chunk-NEXR37YJ.js → chunk-XE5HJOPL.js} +6 -8
- package/dist/chunk-XE5HJOPL.js.map +1 -0
- package/dist/chunk-XVUKDSYV.js +38 -0
- package/dist/chunk-XVUKDSYV.js.map +1 -0
- package/dist/{chunk-O6FFFOHS.js → chunk-YCFKW5RX.js} +16 -8
- package/dist/chunk-YCFKW5RX.js.map +1 -0
- package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
- package/dist/chunk-ZQIHJXP4.js.map +1 -0
- package/dist/config/index.d.ts +28 -13
- package/dist/config/index.js +4 -4
- package/dist/controls/index.d.ts +4 -7
- package/dist/controls/index.js +6 -5
- package/dist/dragdrop/index.d.ts +23 -3
- package/dist/dragdrop/index.js +4 -3
- package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
- package/dist/index.d.ts +72 -487
- package/dist/index.js +603 -441
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +8 -0
- package/dist/job/index.js +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -2
- package/dist/loupe/index.d.ts +100 -30
- package/dist/loupe/index.js +585 -6
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.d.ts +4 -2
- package/dist/overview/index.js +20 -18
- package/dist/overview/index.js.map +1 -1
- package/dist/passthrough/weasel-canvas.d.ts +2 -0
- package/dist/passthrough/weasel-ui.d.ts +2 -2701
- package/dist/passthrough/weasel-ui.js +4 -3
- package/dist/primitives/index.d.ts +37 -2
- package/dist/primitives/index.js +9 -8
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +6 -6
- package/dist/styles.css +150 -126
- package/dist/surface/index.d.ts +10 -2
- package/dist/surface/index.js +1 -1
- package/dist/undo/index.d.ts +5 -3
- package/package.json +14 -13
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
- package/src/annotations/AnnotationOverlay.tsx +52 -33
- package/src/annotations/Annotations.less +0 -10
- package/src/annotations/Annotations.overlay.test.tsx +31 -0
- package/src/annotations/MarkList.tsx +4 -8
- package/src/annotations/capture.browser.test.ts +41 -0
- package/src/annotations/capture.ts +14 -7
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/frac.test.ts +44 -1
- package/src/annotations/frac.ts +46 -18
- package/src/annotations/index.ts +1 -0
- package/src/annotations/paint.test.ts +76 -46
- package/src/annotations/paint.ts +65 -43
- package/src/annotations/store.test.ts +212 -2
- package/src/annotations/store.ts +130 -27
- package/src/annotations/svgNodes.test.ts +28 -19
- package/src/annotations/svgNodes.ts +6 -6
- package/src/annotations/types.ts +29 -10
- package/src/canvas/AGENTS.md +1 -0
- package/src/canvas/CameraInput.test.tsx +30 -0
- package/src/canvas/CameraInput.tsx +60 -48
- package/src/canvas/CanvasStack.tsx +11 -6
- package/src/canvas/CanvasStackContext.ts +4 -1
- package/src/canvas/LinkedCursor.less +1 -1
- package/src/canvas/Stage.test.tsx +33 -4
- package/src/canvas/Stage.tsx +9 -4
- package/src/canvas/cameraRegistry.test.ts +55 -0
- package/src/canvas/cameraRegistry.ts +87 -0
- package/src/canvas/cameraZoom.test.ts +46 -0
- package/src/canvas/cameraZoom.ts +24 -0
- package/src/canvas/index.ts +14 -2
- package/src/canvas/sharedScope.test.tsx +94 -0
- package/src/canvas/worldSpec.ts +3 -1
- package/src/chrome/ChromeRegions.stories.tsx +1 -0
- package/src/chrome/builtins.test.ts +7 -6
- package/src/chrome/builtins.tsx +5 -1
- package/src/chrome/index.ts +1 -1
- package/src/chrome/merge.test.ts +5 -0
- package/src/chrome/merge.ts +4 -2
- package/src/chrome/regions/PaletteRegion.less +0 -7
- package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
- package/src/chrome/regions/PaletteRegion.tsx +3 -4
- package/src/chrome/regions/SidebarRegion.less +12 -2
- package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
- package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
- package/src/chrome/regions/ViewportRegion.less +6 -0
- package/src/chrome/regions/ViewportRegion.tsx +10 -4
- package/src/chrome/types.ts +4 -2
- package/src/config/auto.ts +1 -0
- package/src/config/builder.test.ts +2 -1
- package/src/config/builder.ts +18 -5
- package/src/config/fromConfigField.test.ts +14 -0
- package/src/config/fromConfigField.ts +18 -4
- package/src/config/path.ts +2 -5
- package/src/config/resolve.ts +4 -0
- package/src/config/types.ts +14 -9
- package/src/controls/ControlPanel.less +1 -1
- package/src/controls/ControlPanel.stories.tsx +3 -2
- package/src/controls/ControlPanel.test.tsx +98 -11
- package/src/controls/ControlPanel.tsx +61 -2
- package/src/dragdrop/DragDropRuntime.tsx +22 -13
- package/src/dragdrop/DragGhost.tsx +3 -0
- package/src/dragdrop/Palette.tsx +2 -0
- package/src/index.test.ts +5 -0
- package/src/index.ts +16 -2
- package/src/instrument/types.ts +0 -6
- package/src/job/useJob.ts +14 -9
- package/src/lab/Lab.tsx +88 -73
- package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
- package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
- package/src/lab/LabFullChrome.stories.tsx +1 -0
- package/src/lab/LabHeader.test.tsx +24 -14
- package/src/lab/LabHeader.tsx +11 -12
- package/src/lab/LabShell.less +9 -17
- package/src/lab/LabSwitcher.browser.test.tsx +27 -0
- package/src/lab/LabSwitcher.less +8 -6
- package/src/lab/LabZoom.test.tsx +223 -0
- package/src/lab/LabZoom.tsx +170 -0
- package/src/lab/Workspace.tsx +48 -52
- package/src/lab/index.ts +1 -0
- package/src/loupe/AGENTS.md +28 -15
- package/src/loupe/CanvasLoupe.tsx +2 -3
- package/src/loupe/DomLoupe.tsx +1 -7
- package/src/loupe/Loupe.less +16 -0
- package/src/loupe/LoupeGestures.tsx +1 -1
- package/src/loupe/TrialLoupe.tsx +120 -57
- package/src/loupe/index.ts +1 -6
- package/src/loupe/types.test.ts +3 -3
- package/src/loupe/types.ts +24 -27
- package/src/loupe/useLoupe.test.tsx +29 -4
- package/src/loupe/useLoupe.ts +20 -17
- package/src/overview/OverviewMarks.tsx +12 -4
- package/src/overview/TrialOverview.tsx +10 -8
- package/src/passthrough/weasel-ui.test.ts +2 -0
- package/src/passthrough/weasel-ui.ts +17 -5
- package/src/primitives/FloatingPanel.test.tsx +11 -0
- package/src/primitives/FloatingPanel.tsx +8 -7
- package/src/primitives/JobProgress.less +2 -1
- package/src/primitives/Legend.stories.tsx +1 -0
- package/src/primitives/Readout.browser.test.tsx +137 -0
- package/src/primitives/Readout.less +12 -0
- package/src/primitives/Readout.stories.tsx +58 -0
- package/src/primitives/Readout.test.tsx +93 -0
- package/src/primitives/Readout.tsx +56 -0
- package/src/primitives/Split.tsx +69 -41
- package/src/primitives/Toolbar.tsx +3 -3
- package/src/primitives/ZoomControl.less +1 -2
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.stories.tsx +1 -0
- package/src/specimen/Specimen.tsx +2 -3
- package/src/state/Persistence.browser.test.tsx +47 -0
- package/src/state/store.test.ts +5 -1
- package/src/state/undock.ts +3 -0
- package/src/state/useOpenOnce.ts +20 -16
- package/src/state/usePersistedState.test.tsx +1 -1
- package/src/state/view.ts +1 -0
- package/src/styles.less +1 -0
- package/src/surface/index.ts +1 -1
- package/src/surface/useTiledSurface.test.tsx +14 -0
- package/src/surface/useTiledSurface.ts +11 -3
- package/src/theme/Interstellar.stories.tsx +3 -1
- package/src/theme/base.less +21 -47
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.theme.json +10 -3
- package/src/trial/Trial.annotations.test.tsx +73 -0
- package/src/trial/Trial.less +0 -29
- package/src/trial/Trial.loupe.test.tsx +28 -11
- package/src/trial/Trial.tsx +85 -67
- package/src/trial/TrialChrome.tsx +27 -6
- package/src/trial/index.ts +2 -0
- package/src/trial/loupeSwitch.ts +15 -0
- package/dist/_dts/LayerList.d-BoELAMsM.d.ts +0 -83
- package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
- package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
- package/dist/_dts/Select.d-Cr8-zoup.d.ts +0 -569
- package/dist/chunk-4TT3PNPR.js.map +0 -1
- package/dist/chunk-6FLXL3I7.js.map +0 -1
- package/dist/chunk-ASOK76ZH.js.map +0 -1
- package/dist/chunk-CGHF6RZV.js.map +0 -1
- package/dist/chunk-CQLQPQ4P.js.map +0 -1
- package/dist/chunk-DWMSB4CA.js.map +0 -1
- package/dist/chunk-HXXTULDN.js.map +0 -1
- package/dist/chunk-IJJUK4FU.js.map +0 -1
- package/dist/chunk-IRTGUQ5O.js.map +0 -1
- package/dist/chunk-KHK5FGER.js +0 -145
- package/dist/chunk-KHK5FGER.js.map +0 -1
- package/dist/chunk-NEXR37YJ.js.map +0 -1
- package/dist/chunk-O6FFFOHS.js.map +0 -1
- package/dist/chunk-QGMOA2YE.js.map +0 -1
- package/dist/chunk-RWNDDKOH.js.map +0 -1
- package/dist/chunk-S4NCI32V.js +0 -549
- package/dist/chunk-S4NCI32V.js.map +0 -1
- package/dist/chunk-SBXNQW4G.js +0 -3
- package/dist/chunk-SLAQPUI4.js.map +0 -1
- package/dist/chunk-TJ77A4A3.js.map +0 -1
- package/dist/chunk-TYN6MLXX.js +0 -13864
- package/dist/chunk-TYN6MLXX.js.map +0 -1
- package/dist/chunk-WPLAANH4.js.map +0 -1
- package/src/primitives/useRovingTabIndex.test.ts +0 -48
- package/src/primitives/useRovingTabIndex.ts +0 -88
- package/src/state/Persistence.stories.tsx +0 -75
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { asNodeId } from '@weasel-js/core';
|
|
1
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { markCommands } from './paint';
|
|
2
4
|
import { annotationsFromJSON, createAnnotationStore } from './store';
|
|
3
|
-
import type { AnnotationInit, AnnotationTargetInfo } from './types';
|
|
5
|
+
import type { AnnotationInit, AnnotationsApi, AnnotationTargetInfo, FracPoint } from './types';
|
|
4
6
|
|
|
5
7
|
const TARGETS: AnnotationTargetInfo[] = [
|
|
6
8
|
{ id: 'naive', content: { w: 256, h: 170 }, positionDependsOn: ['angle', 'shading'] },
|
|
@@ -162,7 +164,7 @@ describe('the annotation store', () => {
|
|
|
162
164
|
// because jsdom has no WebGL2 and the overlay's canvas never paints or
|
|
163
165
|
// hit-tests. That the canvas is bound to this scene is core's claim.
|
|
164
166
|
it('merges the selection across targets, and maps it back to annotation ids', () => {
|
|
165
|
-
const store =
|
|
167
|
+
const store = createAnnotationStore({ targets: () => TARGETS, selection: 'per-target' });
|
|
166
168
|
const onNaive = store.add(RING);
|
|
167
169
|
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
168
170
|
|
|
@@ -227,6 +229,86 @@ describe('the annotation store', () => {
|
|
|
227
229
|
expect(fn.mock.calls.length).toBe(before);
|
|
228
230
|
});
|
|
229
231
|
|
|
232
|
+
describe('across targets', () => {
|
|
233
|
+
/** Selects a mark the way its pane's canvas does: on that target's scene. */
|
|
234
|
+
const click = (store: AnnotationsApi, id: string): void => {
|
|
235
|
+
const [target, node] = id.split('/') as [string, string];
|
|
236
|
+
store.sceneFor(target).setSelection([asNodeId(node)]);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
it('clears a selection standing in one target when a mark is selected in another', () => {
|
|
240
|
+
const store = makeStore();
|
|
241
|
+
const onNaive = store.add(RING);
|
|
242
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
243
|
+
|
|
244
|
+
click(store, onNaive);
|
|
245
|
+
click(store, onOcct);
|
|
246
|
+
expect(store.selection()).toEqual([onOcct]);
|
|
247
|
+
expect(store.sceneFor('naive').getSelection()).toEqual([]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('tells subscribers once, after the other target is already cleared', () => {
|
|
251
|
+
const store = makeStore();
|
|
252
|
+
const onNaive = store.add(RING);
|
|
253
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
254
|
+
click(store, onNaive);
|
|
255
|
+
|
|
256
|
+
const seen: (readonly string[])[] = [];
|
|
257
|
+
store.subscribe(() => seen.push(store.selection()));
|
|
258
|
+
click(store, onOcct);
|
|
259
|
+
expect(seen).toEqual([[onOcct]]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('keeps a setSelection naming two targets to the first one, in one event', () => {
|
|
263
|
+
const store = makeStore();
|
|
264
|
+
const onNaive = store.add(RING);
|
|
265
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
266
|
+
click(store, onNaive);
|
|
267
|
+
|
|
268
|
+
const fn = vi.fn();
|
|
269
|
+
store.subscribe(fn);
|
|
270
|
+
store.setSelection([onOcct, onNaive]);
|
|
271
|
+
expect(store.selection()).toEqual([onOcct]);
|
|
272
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('leaves the other target alone for a change that selects nothing new', () => {
|
|
276
|
+
const store = makeStore();
|
|
277
|
+
const onNaive = store.add(RING);
|
|
278
|
+
store.add({ ...RING, target: 'occt' });
|
|
279
|
+
click(store, onNaive);
|
|
280
|
+
|
|
281
|
+
store.add({ ...RING, target: 'occt' });
|
|
282
|
+
store.sceneFor('occt').setSelection([]);
|
|
283
|
+
expect(store.selection()).toEqual([onNaive]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("keeps each target's own selection under `selection: 'per-target'`", () => {
|
|
287
|
+
const store = createAnnotationStore({ targets: () => TARGETS, selection: 'per-target' });
|
|
288
|
+
const onNaive = store.add(RING);
|
|
289
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
290
|
+
|
|
291
|
+
click(store, onNaive);
|
|
292
|
+
click(store, onOcct);
|
|
293
|
+
expect(store.selection()).toEqual([onNaive, onOcct]);
|
|
294
|
+
|
|
295
|
+
store.setSelection([onOcct, onNaive]);
|
|
296
|
+
expect(store.selection()).toEqual([onNaive, onOcct]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('reads the mode at each change, for a capability swapped under a live store', () => {
|
|
300
|
+
let mode: 'exclusive' | 'per-target' = 'per-target';
|
|
301
|
+
const store = createAnnotationStore({ targets: () => TARGETS, selection: () => mode });
|
|
302
|
+
const onNaive = store.add(RING);
|
|
303
|
+
const onOcct = store.add({ ...RING, target: 'occt' });
|
|
304
|
+
|
|
305
|
+
click(store, onNaive);
|
|
306
|
+
mode = 'exclusive';
|
|
307
|
+
click(store, onOcct);
|
|
308
|
+
expect(store.selection()).toEqual([onOcct]);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
230
312
|
it('round-trips through JSON, and the snapshot is JSON-clean', () => {
|
|
231
313
|
const store = makeStore();
|
|
232
314
|
const id = store.add(RING, { angle: 'iso', shading: 'outline' });
|
|
@@ -295,6 +377,134 @@ describe('the annotation store', () => {
|
|
|
295
377
|
});
|
|
296
378
|
});
|
|
297
379
|
|
|
380
|
+
/** What a target's first mark draws, as its path's points in fractions of the
|
|
381
|
+
* content box — the drawn side, to hold against what the store hit-tests. */
|
|
382
|
+
function drawn(store: AnnotationsApi, target = 'naive'): FracPoint[] {
|
|
383
|
+
const { w, h } = TARGETS.find((t) => t.id === target)?.content ?? { w: 0, h: 0 };
|
|
384
|
+
const [painted] = store.paintedMarks(target);
|
|
385
|
+
if (!painted) throw new Error('no mark painted');
|
|
386
|
+
const [cmd] = markCommands(painted.mark, painted.style);
|
|
387
|
+
if (cmd?.kind !== 'path') throw new Error('expected a path command');
|
|
388
|
+
const coords = [...((cmd.path as { coords?: Float32Array }).coords ?? [])];
|
|
389
|
+
const out: FracPoint[] = [];
|
|
390
|
+
for (let i = 0; i < coords.length; i += 2) {
|
|
391
|
+
out.push({ x: (coords[i] ?? 0) / w, y: (coords[i + 1] ?? 0) / h });
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const centerOf = (pts: FracPoint[]): FracPoint => {
|
|
397
|
+
const xs = pts.map((p) => p.x);
|
|
398
|
+
const ys = pts.map((p) => p.y);
|
|
399
|
+
return {
|
|
400
|
+
x: (Math.min(...xs) + Math.max(...xs)) / 2,
|
|
401
|
+
y: (Math.min(...ys) + Math.max(...ys)) / 2,
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const expectNear = (got: readonly FracPoint[], want: readonly FracPoint[]) => {
|
|
406
|
+
expect(got).toHaveLength(want.length);
|
|
407
|
+
got.forEach((p, i) => {
|
|
408
|
+
expect(p.x).toBeCloseTo(want[i]?.x ?? Number.NaN, 3);
|
|
409
|
+
expect(p.y).toBeCloseTo(want[i]?.y ?? Number.NaN, 3);
|
|
410
|
+
});
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
describe('a moved mark', () => {
|
|
414
|
+
const POINT: AnnotationInit = {
|
|
415
|
+
target: 'naive',
|
|
416
|
+
kind: 'point',
|
|
417
|
+
frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
|
|
418
|
+
points: [{ x: 0.25, y: 0.5 }],
|
|
419
|
+
};
|
|
420
|
+
const LINE: AnnotationInit = {
|
|
421
|
+
target: 'naive',
|
|
422
|
+
kind: 'line',
|
|
423
|
+
frac: { x: 0.1, y: 0.2, w: 0.5, h: 0.4 },
|
|
424
|
+
points: [
|
|
425
|
+
{ x: 0.1, y: 0.6 },
|
|
426
|
+
{ x: 0.6, y: 0.2 },
|
|
427
|
+
],
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
it('draws a point where the store hit-tests it after update moves it', () => {
|
|
431
|
+
const store = makeStore();
|
|
432
|
+
const id = store.add(POINT);
|
|
433
|
+
store.update(id, { frac: { x: 0.75, y: 0.25, w: 0, h: 0 } });
|
|
434
|
+
|
|
435
|
+
expectNear([centerOf(drawn(store))], [{ x: 0.75, y: 0.25 }]);
|
|
436
|
+
expect(store.hitTest('naive', { x: 0.75, y: 0.25 }, 0.001).map((a) => a.id)).toEqual([id]);
|
|
437
|
+
expect(store.get(id)?.points).toEqual([{ x: 0.75, y: 0.25 }]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('carries a line and a stroke along with their bounds', () => {
|
|
441
|
+
for (const kind of ['line', 'arrow', 'stroke'] as const) {
|
|
442
|
+
const store = makeStore();
|
|
443
|
+
const id = store.add({ ...LINE, kind });
|
|
444
|
+
store.update(id, { frac: { x: 0.3, y: 0.3, w: 0.5, h: 0.4 } });
|
|
445
|
+
const moved = [
|
|
446
|
+
{ x: 0.3, y: 0.7 },
|
|
447
|
+
{ x: 0.8, y: 0.3 },
|
|
448
|
+
];
|
|
449
|
+
expectNear(drawn(store), moved);
|
|
450
|
+
expect(store.get(id)?.points).toEqual(moved);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('stretches stored vertices with a resized box', () => {
|
|
455
|
+
const store = makeStore();
|
|
456
|
+
const id = store.add(LINE);
|
|
457
|
+
store.update(id, { frac: { x: 0.1, y: 0.2, w: 0.25, h: 0.8 } });
|
|
458
|
+
expectNear(drawn(store), [
|
|
459
|
+
{ x: 0.1, y: 1 },
|
|
460
|
+
{ x: 0.35, y: 0.2 },
|
|
461
|
+
]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('follows a pose written straight to the scene, the way the pane moves a mark', () => {
|
|
465
|
+
const store = makeStore();
|
|
466
|
+
const id = store.add(POINT);
|
|
467
|
+
const node = asNodeId(id.slice(id.lastIndexOf('/') + 1));
|
|
468
|
+
store.sceneFor('naive').setPose(node, { x: 0.5 * 256, y: 0.1 * 170, width: 0, height: 0 });
|
|
469
|
+
|
|
470
|
+
expectNear([centerOf(drawn(store))], [{ x: 0.5, y: 0.1 }]);
|
|
471
|
+
expect(store.get(id)?.points).toEqual([{ x: 0.5, y: 0.1 }]);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('moves the bounds when only the points are patched', () => {
|
|
475
|
+
const store = makeStore();
|
|
476
|
+
const id = store.add(POINT);
|
|
477
|
+
store.update(id, { points: [{ x: 0.9, y: 0.9 }] });
|
|
478
|
+
|
|
479
|
+
expect(store.get(id)?.frac).toEqual({ x: 0.9, y: 0.9, w: 0, h: 0 });
|
|
480
|
+
expectNear([centerOf(drawn(store))], [{ x: 0.9, y: 0.9 }]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('reads points a store saved in content fractions, before they followed the bounds', () => {
|
|
484
|
+
const legacy = {
|
|
485
|
+
version: 1,
|
|
486
|
+
scenes: {
|
|
487
|
+
naive: {
|
|
488
|
+
version: 1,
|
|
489
|
+
systemLayers: [{ id: 'marks' }],
|
|
490
|
+
nodes: [
|
|
491
|
+
{
|
|
492
|
+
id: 'n1',
|
|
493
|
+
kind: 'leaf',
|
|
494
|
+
layer: 'marks',
|
|
495
|
+
pose: { x: 0.1 * 256, y: 0.2 * 170, width: 0.5 * 256, height: 0.4 * 170 },
|
|
496
|
+
data: { target: 'naive', kind: 'line', points: LINE.points },
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
const store = annotationsFromJSON(legacy, () => TARGETS);
|
|
503
|
+
expectNear(drawn(store), LINE.points ?? []);
|
|
504
|
+
expect(store.get('naive/n1')?.points).toEqual(LINE.points);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
298
508
|
describe('paintedMarks', () => {
|
|
299
509
|
it("hands over a target's marks in world units with their resolved style", () => {
|
|
300
510
|
let config: Record<string, string> = { angle: 'iso', shading: 'outline' };
|
package/src/annotations/store.ts
CHANGED
|
@@ -8,7 +8,17 @@ import {
|
|
|
8
8
|
import { captureTarget } from './capture';
|
|
9
9
|
import { type MarkDrawOptions, resolveMarkStyle } from './drawOne';
|
|
10
10
|
import type { WorldRect } from './frac';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
boundsOf,
|
|
13
|
+
fracContains,
|
|
14
|
+
fracEncloses,
|
|
15
|
+
fracToWorld,
|
|
16
|
+
fromShape,
|
|
17
|
+
roundFrac,
|
|
18
|
+
roundPoints,
|
|
19
|
+
toShape,
|
|
20
|
+
worldToFrac,
|
|
21
|
+
} from './frac';
|
|
12
22
|
import { MarkHistory } from './history';
|
|
13
23
|
import { isStale as isStaleAgainst, seenFrom } from './staleness';
|
|
14
24
|
import type {
|
|
@@ -18,6 +28,7 @@ import type {
|
|
|
18
28
|
AnnotationMeaning,
|
|
19
29
|
AnnotationPatch,
|
|
20
30
|
AnnotationQuery,
|
|
31
|
+
AnnotationSelectionMode,
|
|
21
32
|
AnnotationsApi,
|
|
22
33
|
AnnotationTargetInfo,
|
|
23
34
|
CaptureOptions,
|
|
@@ -38,6 +49,7 @@ export function createAnnotationScene(): MarkScene {
|
|
|
38
49
|
return createScene<AnnotationData, 'marks', MarkPose>({ systemLayers: [{ id: 'marks' }] });
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
/** Inputs to `createAnnotationStore`. */
|
|
41
53
|
export interface AnnotationStoreOptions {
|
|
42
54
|
/** Re-read on every call, so a target resizing or gaining a dependency takes
|
|
43
55
|
* effect without rebuilding the store. */
|
|
@@ -53,6 +65,10 @@ export interface AnnotationStoreOptions {
|
|
|
53
65
|
config?: () => unknown;
|
|
54
66
|
/** Notified after every finished export. */
|
|
55
67
|
onCapture?: (result: CaptureResult) => void;
|
|
68
|
+
/** Whether a selection can stand in several targets at once. `'exclusive'`
|
|
69
|
+
* (the default) clears every other target when a mark is selected in one.
|
|
70
|
+
* A thunk is read at each selection change. */
|
|
71
|
+
selection?: AnnotationSelectionMode | (() => AnnotationSelectionMode | undefined);
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
/** Everything a capture needs that is not the store's own state. Split out so
|
|
@@ -66,6 +82,34 @@ export interface CaptureDeps {
|
|
|
66
82
|
|
|
67
83
|
const NO_CONTENT = { w: 0, h: 0 };
|
|
68
84
|
|
|
85
|
+
/** Where a mark's vertices sit in its own bounds, from points in the content
|
|
86
|
+
* box. A point has none to store: its bounds are its position. */
|
|
87
|
+
function shapeFor(
|
|
88
|
+
kind: AnnotationData['kind'],
|
|
89
|
+
points: readonly FracPoint[] | undefined,
|
|
90
|
+
frac: FracRect,
|
|
91
|
+
): readonly FracPoint[] | undefined {
|
|
92
|
+
if (!points || kind === 'point') return undefined;
|
|
93
|
+
return roundPoints(toShape(points, frac));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type SerializedMarks = SerializedScene<AnnotationData, 'marks', MarkPose>;
|
|
97
|
+
|
|
98
|
+
/** Scenes saved before vertices followed the bounds hold them as `points` in
|
|
99
|
+
* the content box. Restated against each mark's bounds as saved. */
|
|
100
|
+
function migrateLegacyPoints(
|
|
101
|
+
raw: SerializedMarks,
|
|
102
|
+
content: { w: number; h: number },
|
|
103
|
+
): SerializedMarks {
|
|
104
|
+
const nodes = raw.nodes.map((n) => {
|
|
105
|
+
const { points, ...data } = n.data as AnnotationData & { points?: readonly FracPoint[] };
|
|
106
|
+
if (!points || data.shape) return n;
|
|
107
|
+
const shape = shapeFor(data.kind, points, worldToFrac(n.pose, content));
|
|
108
|
+
return { ...n, data: { ...data, ...(shape ? { shape } : {}) } };
|
|
109
|
+
});
|
|
110
|
+
return { ...raw, nodes };
|
|
111
|
+
}
|
|
112
|
+
|
|
69
113
|
/** A mark's id is its target and its node. One scene per target, so a node id
|
|
70
114
|
* is only unique within one — and an annotation is addressed by both. */
|
|
71
115
|
function splitId(id: string): { target: string; node: string } | undefined {
|
|
@@ -92,21 +136,73 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
92
136
|
for (const fn of subs) fn();
|
|
93
137
|
};
|
|
94
138
|
|
|
139
|
+
// Scene changes made while held are told to subscribers as one, once the
|
|
140
|
+
// outermost hold ends — so a click that also clears another target, or a
|
|
141
|
+
// `setSelection` spanning several, is one event with the final answer.
|
|
142
|
+
let holding = 0;
|
|
143
|
+
let heldDirty = false;
|
|
144
|
+
const hold = (fn: () => void): void => {
|
|
145
|
+
holding += 1;
|
|
146
|
+
try {
|
|
147
|
+
fn();
|
|
148
|
+
} finally {
|
|
149
|
+
holding -= 1;
|
|
150
|
+
}
|
|
151
|
+
if (holding === 0 && heldDirty) {
|
|
152
|
+
heldDirty = false;
|
|
153
|
+
notify();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const exclusive = (): boolean =>
|
|
158
|
+
(typeof opts.selection === 'function' ? opts.selection() : opts.selection) !== 'per-target';
|
|
159
|
+
|
|
160
|
+
/** Each scene's selection as last seen, to tell a selection change from any
|
|
161
|
+
* other change arriving on the same channel. */
|
|
162
|
+
const lastSelection = new Map<string, string>();
|
|
163
|
+
const selectionKey = (scene: MarkScene): string => scene.getSelection().map(String).join(' ');
|
|
164
|
+
|
|
165
|
+
const clearOthers = (keep: string): void => {
|
|
166
|
+
for (const [target, scene] of scenes) {
|
|
167
|
+
if (target !== keep && scene.getSelection().length > 0) scene.setSelection([]);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const targetOf = (id: string): AnnotationTargetInfo | undefined =>
|
|
172
|
+
targets().find((t) => t.id === id);
|
|
173
|
+
|
|
174
|
+
const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
|
|
175
|
+
|
|
95
176
|
const sceneFor = (target: string): MarkScene => {
|
|
96
177
|
const known = scenes.get(target);
|
|
97
178
|
if (known) return known;
|
|
98
179
|
const raw = restore?.[target];
|
|
99
180
|
const scene = raw
|
|
100
181
|
? sceneFromJSON<AnnotationData, 'marks', MarkPose>(
|
|
101
|
-
raw as
|
|
182
|
+
migrateLegacyPoints(raw as SerializedMarks, contentOf(target)),
|
|
102
183
|
{},
|
|
103
184
|
)
|
|
104
185
|
: createAnnotationScene();
|
|
105
186
|
scenes.set(target, scene);
|
|
106
187
|
history.track(target, scene);
|
|
188
|
+
lastSelection.set(target, selectionKey(scene));
|
|
107
189
|
scene.subscribe(() => {
|
|
108
190
|
history.observe(target, scene);
|
|
109
|
-
|
|
191
|
+
const selected = selectionKey(scene);
|
|
192
|
+
const newlySelected = selected !== '' && selected !== lastSelection.get(target);
|
|
193
|
+
lastSelection.set(target, selected);
|
|
194
|
+
if (holding > 0) {
|
|
195
|
+
heldDirty = true;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (newlySelected && exclusive()) {
|
|
199
|
+
hold(() => {
|
|
200
|
+
heldDirty = true;
|
|
201
|
+
clearOthers(target);
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
notify();
|
|
205
|
+
}
|
|
110
206
|
});
|
|
111
207
|
return scene;
|
|
112
208
|
};
|
|
@@ -120,10 +216,6 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
120
216
|
// a mark a sidebar cannot list.
|
|
121
217
|
for (const target of Object.keys(restore ?? {})) sceneFor(target);
|
|
122
218
|
|
|
123
|
-
const targetOf = (id: string): AnnotationTargetInfo | undefined =>
|
|
124
|
-
targets().find((t) => t.id === id);
|
|
125
|
-
|
|
126
|
-
const contentOf = (id: string) => targetOf(id)?.content ?? NO_CONTENT;
|
|
127
219
|
/** How a target's marks are drawn right now — one answer for the pane, an
|
|
128
220
|
* export and an overview, so none can disagree on a color or a dash. */
|
|
129
221
|
const drawOptionsFor = (info: AnnotationTargetInfo): MarkDrawOptions => ({
|
|
@@ -136,12 +228,13 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
136
228
|
const project = (target: string, node: string): Annotation | undefined => {
|
|
137
229
|
const found = scenes.get(target)?.get(asNodeId(node));
|
|
138
230
|
if (!found) return undefined;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
231
|
+
const { shape, ...data } = found.data;
|
|
232
|
+
const frac = roundFrac(worldToFrac(found.pose as MarkPose, contentOf(target)));
|
|
233
|
+
const points =
|
|
234
|
+
data.kind === 'point'
|
|
235
|
+
? [{ x: frac.x, y: frac.y }]
|
|
236
|
+
: shape && roundPoints(fromShape(shape, frac));
|
|
237
|
+
return { ...data, target, id: `${target}/${node}`, frac, ...(points ? { points } : {}) };
|
|
145
238
|
};
|
|
146
239
|
|
|
147
240
|
/** Every target with a scene, in declaration order — then any whose target
|
|
@@ -245,20 +338,24 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
245
338
|
|
|
246
339
|
setSelection(ids) {
|
|
247
340
|
const wanted = new Map<string, string[]>();
|
|
341
|
+
const only = exclusive();
|
|
248
342
|
for (const id of ids) {
|
|
249
343
|
const found = nodeOf(id);
|
|
250
344
|
if (!found) continue;
|
|
345
|
+
if (only && wanted.size > 0 && !wanted.has(found.target)) continue;
|
|
251
346
|
const node = String(found.node.id);
|
|
252
347
|
const on = wanted.get(found.target);
|
|
253
348
|
if (!on) wanted.set(found.target, [node]);
|
|
254
349
|
else if (!on.includes(node)) on.push(node);
|
|
255
350
|
}
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
351
|
+
hold(() => {
|
|
352
|
+
for (const [target, scene] of scenes) {
|
|
353
|
+
const next = wanted.get(target) ?? [];
|
|
354
|
+
const now = scene.getSelection();
|
|
355
|
+
if (now.length === next.length && next.every((n, i) => String(now[i]) === n)) continue;
|
|
356
|
+
scene.setSelection(next.map(asNodeId));
|
|
357
|
+
}
|
|
358
|
+
});
|
|
262
359
|
},
|
|
263
360
|
|
|
264
361
|
subscribe(fn) {
|
|
@@ -270,10 +367,12 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
270
367
|
|
|
271
368
|
add(init: AnnotationInit, config?: unknown) {
|
|
272
369
|
const target = targetOf(init.target);
|
|
370
|
+
const frac = roundFrac(init.frac);
|
|
371
|
+
const shape = shapeFor(init.kind, init.points, frac);
|
|
273
372
|
const data: AnnotationData = {
|
|
274
373
|
target: init.target,
|
|
275
374
|
kind: init.kind,
|
|
276
|
-
...(
|
|
375
|
+
...(shape ? { shape } : {}),
|
|
277
376
|
...(init.title !== undefined ? { title: init.title } : {}),
|
|
278
377
|
...(init.status !== undefined ? { status: init.status } : {}),
|
|
279
378
|
...(init.tags !== undefined ? { tags: init.tags } : {}),
|
|
@@ -283,7 +382,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
283
382
|
const node = sceneFor(init.target).add({
|
|
284
383
|
kind: 'leaf',
|
|
285
384
|
layer: 'marks',
|
|
286
|
-
pose: fracToWorld(
|
|
385
|
+
pose: fracToWorld(frac, target?.content ?? NO_CONTENT),
|
|
287
386
|
data,
|
|
288
387
|
});
|
|
289
388
|
return `${init.target}/${String(node)}`;
|
|
@@ -292,14 +391,18 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
292
391
|
update(id, patch: AnnotationPatch) {
|
|
293
392
|
const found = nodeOf(id);
|
|
294
393
|
if (!found) return;
|
|
295
|
-
const { frac, ...meaning } = patch;
|
|
394
|
+
const { frac: fracPatch, points, ...meaning } = patch;
|
|
296
395
|
const nid = asNodeId(found.node.id);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
396
|
+
const content = contentOf(found.target);
|
|
397
|
+
const frac = fracPatch ?? (points ? boundsOf(points) : null);
|
|
398
|
+
const bounds = roundFrac(frac ?? worldToFrac(found.node.pose as MarkPose, content));
|
|
399
|
+
const shape = points ? shapeFor(found.node.data.kind, points, bounds) : undefined;
|
|
400
|
+
if (Object.keys(meaning).length > 0 || shape) {
|
|
401
|
+
found.scene.update(nid, {
|
|
402
|
+
data: { ...found.node.data, ...meaning, ...(shape ? { shape } : {}) },
|
|
403
|
+
});
|
|
302
404
|
}
|
|
405
|
+
if (frac) found.scene.setPose(nid, fracToWorld(bounds, content));
|
|
303
406
|
},
|
|
304
407
|
|
|
305
408
|
setMeta(id, meta) {
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import { serializeSvg } from '@weasel-js/svg';
|
|
1
|
+
import { serializeSvg, svgNeeds } from '@weasel-js/svg';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import { markCommands } from './paint';
|
|
4
4
|
import { markSvgNodes } from './svgNodes';
|
|
5
5
|
import type { AnnotationData, AnnotationKind } from './types';
|
|
6
6
|
|
|
7
|
-
const CONTENT = { w: 100, h: 60 };
|
|
8
|
-
|
|
9
7
|
const mark = (kind: AnnotationKind, extra: Partial<AnnotationData> = {}) => ({
|
|
10
8
|
pose: { x: 10, y: 12, width: 30, height: 20 },
|
|
11
9
|
data: {
|
|
12
10
|
target: 'flat',
|
|
13
11
|
kind,
|
|
14
|
-
|
|
12
|
+
shape: [
|
|
15
13
|
{ x: 0.1, y: 0.2 },
|
|
16
14
|
{ x: 0.5, y: 0.6 },
|
|
17
15
|
],
|
|
@@ -24,8 +22,8 @@ const KINDS: AnnotationKind[] = ['rect', 'ellipse', 'line', 'arrow', 'stroke'];
|
|
|
24
22
|
describe('a mark as vector', () => {
|
|
25
23
|
it.each(KINDS)('emits %s off the same geometry the screen draws', (kind) => {
|
|
26
24
|
const m = mark(kind);
|
|
27
|
-
const cmds = markCommands(m
|
|
28
|
-
const nodes = markSvgNodes(m
|
|
25
|
+
const cmds = markCommands(m);
|
|
26
|
+
const nodes = markSvgNodes(m);
|
|
29
27
|
expect(nodes).toHaveLength(cmds.length);
|
|
30
28
|
for (const [i, cmd] of cmds.entries()) {
|
|
31
29
|
if (cmd.kind !== 'path') throw new Error('expected path commands');
|
|
@@ -46,32 +44,43 @@ describe('a mark as vector', () => {
|
|
|
46
44
|
});
|
|
47
45
|
|
|
48
46
|
it('carries a stale mark′s dash through', () => {
|
|
49
|
-
const [node] = markSvgNodes(mark('rect'),
|
|
47
|
+
const [node] = markSvgNodes(mark('rect'), { stale: true });
|
|
50
48
|
if (node?.kind !== 'path') throw new Error('expected a path node');
|
|
51
49
|
expect(node.stroke?.dash).toEqual([6, 4]);
|
|
52
50
|
});
|
|
53
51
|
|
|
54
|
-
it("
|
|
55
|
-
const nodes = markSvgNodes(mark('arrow')
|
|
56
|
-
expect(nodes
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(node.stroke?.markerEnd).toBeUndefined();
|
|
62
|
-
}
|
|
63
|
-
expect(serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: 100, height: 60 } })).not.toContain(
|
|
52
|
+
it("keeps the arrow's marker, so the export draws its head", () => {
|
|
53
|
+
const nodes = markSvgNodes(mark('arrow'));
|
|
54
|
+
expect(nodes).toHaveLength(1);
|
|
55
|
+
const [node] = nodes;
|
|
56
|
+
if (node?.kind !== 'path') throw new Error('expected a path node');
|
|
57
|
+
expect(node.stroke?.markerEnd).toBe('arrow');
|
|
58
|
+
expect(serializeSvg(nodes, { viewBox: { x: 0, y: 0, width: 100, height: 60 } })).toContain(
|
|
64
59
|
'<marker',
|
|
65
60
|
);
|
|
66
61
|
});
|
|
67
62
|
|
|
68
63
|
it('emits text as text, at the pose, in the mark color', () => {
|
|
69
64
|
const m = mark('text', { title: 'missing edge' });
|
|
70
|
-
const [node] = markSvgNodes(m
|
|
65
|
+
const [node] = markSvgNodes(m);
|
|
71
66
|
expect(node).toMatchObject({ kind: 'text', text: 'missing edge', x: 10, y: 12 });
|
|
72
67
|
});
|
|
73
68
|
|
|
74
69
|
it('emits nothing for a text mark with no words', () => {
|
|
75
|
-
expect(markSvgNodes(mark('text')
|
|
70
|
+
expect(markSvgNodes(mark('text'))).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Why `composeCaptureSvg` and `<OverviewMarks>` serialize without awaiting
|
|
74
|
+
// `warmSvg`: a mark exports nothing that loads on demand. A mark gaining a
|
|
75
|
+
// gradient paint or a script run fails this, and those two then need it.
|
|
76
|
+
it('needs nothing loaded to serialize, stale or not', () => {
|
|
77
|
+
const all: AnnotationKind[] = [...KINDS, 'point', 'text'];
|
|
78
|
+
for (const stale of [false, true]) {
|
|
79
|
+
const nodes = all.flatMap((k) =>
|
|
80
|
+
markSvgNodes(mark(k, { title: 'words' }), { stale, color: '#123456' }),
|
|
81
|
+
);
|
|
82
|
+
expect(nodes.length).toBeGreaterThan(all.length - 1);
|
|
83
|
+
expect(svgNeeds(nodes)).toEqual({ fonts: [], paintKinds: [] });
|
|
84
|
+
}
|
|
76
85
|
});
|
|
77
86
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DrawCommand, FillStyle, Stroke } from '@weasel-js/core';
|
|
1
|
+
import type { DrawCommand, FillStyle, Stroke, View } from '@weasel-js/core';
|
|
2
2
|
import type { SvgNode, SvgPaint, SvgStroke } from '@weasel-js/svg';
|
|
3
3
|
import { type MarkStyle, markCommands, type PaintableMark } from './paint';
|
|
4
4
|
|
|
@@ -16,9 +16,6 @@ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
|
|
|
16
16
|
return { kind: 'gradient', paint };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** Marker references are deliberately dropped: `markCommands` already resolves
|
|
20
|
-
* a marker into its own path command, and keeping the reference would make
|
|
21
|
-
* the serializer emit a `<marker>` def that draws the head a second time. */
|
|
22
19
|
function toSvgStroke(stroke: Stroke): SvgStroke {
|
|
23
20
|
return {
|
|
24
21
|
paint: toSvgPaint(stroke.paint),
|
|
@@ -28,6 +25,9 @@ function toSvgStroke(stroke: Stroke): SvgStroke {
|
|
|
28
25
|
...(stroke.dash ? { dash: [...stroke.dash] } : {}),
|
|
29
26
|
...(stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}),
|
|
30
27
|
...(stroke.align ? { align: stroke.align } : {}),
|
|
28
|
+
...(stroke.markerStart ? { markerStart: stroke.markerStart } : {}),
|
|
29
|
+
...(stroke.markerMid ? { markerMid: stroke.markerMid } : {}),
|
|
30
|
+
...(stroke.markerEnd ? { markerEnd: stroke.markerEnd } : {}),
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -67,8 +67,8 @@ function toSvgNode(cmd: DrawCommand, m: PaintableMark): SvgNode {
|
|
|
67
67
|
*/
|
|
68
68
|
export function markSvgNodes(
|
|
69
69
|
m: PaintableMark,
|
|
70
|
-
content: { w: number; h: number },
|
|
71
70
|
style: MarkStyle = {},
|
|
71
|
+
scale?: View['scale'],
|
|
72
72
|
): SvgNode[] {
|
|
73
|
-
return markCommands(m,
|
|
73
|
+
return markCommands(m, style, scale).map((cmd) => toSvgNode(cmd, m));
|
|
74
74
|
}
|