cursedbelt 5.95.0 → 5.96.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.
@@ -14,6 +14,6 @@ export type { CanvasZoomHintProps, HudButtonProps, HudZoomControlsProps } from '
14
14
  export { CanvasZoomHint, HudButton, HudZoomControls } from './HudControls.js';
15
15
  export type { CanvasWheelMode, CanvasWheelOptions } from './useCanvasWheel.js';
16
16
  export { readWheel, useCanvasWheel, useLevelOfDetail } from './useCanvasWheel.js';
17
- export type { CanvasBounds, CanvasPoint, CanvasSize, CanvasView, FitOptions, LevelOfDetail, LevelOfDetailThresholds, } from './viewport.js';
18
- export { centerView, clampScale, fitScale, fitView, LEVEL_OF_DETAIL, levelOfDetail, visibleBounds, wheelZoomFactor, zoomAt, } from './viewport.js';
17
+ export type { CanvasBounds, CanvasPoint, CanvasSize, CanvasView, FitOptions, ImageZoomLimits, LevelOfDetail, LevelOfDetailThresholds, } from './viewport.js';
18
+ export { centerView, clampImageView, clampScale, fitScale, fitView, IMAGE_FIT, IMAGE_ZOOM_STEPS, imageWheelFactor, imageZoomLabel, isImageZoomed, LEVEL_OF_DETAIL, levelOfDetail, panImageBy, stepImageScale, visibleBounds, wheelZoomFactor, zoomAt, zoomImageAt, } from './viewport.js';
19
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/canvas-hud/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAClG,YAAY,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACzE,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC1G,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxF,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC9E,YAAY,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAClF,YAAY,EACV,YAAY,EACZ,WAAW,EACX,UAAU,EACV,UAAU,EACV,UAAU,EACV,aAAa,EACb,uBAAuB,GACxB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,UAAU,EACV,UAAU,EACV,QAAQ,EACR,OAAO,EACP,eAAe,EACf,aAAa,EACb,aAAa,EACb,eAAe,EACf,MAAM,GACP,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/canvas-hud/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAClG,YAAY,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACzE,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC1G,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxF,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC9E,YAAY,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAClF,YAAY,EACV,YAAY,EACZ,WAAW,EACX,UAAU,EACV,UAAU,EACV,UAAU,EACV,eAAe,EACf,aAAa,EACb,uBAAuB,GACxB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,UAAU,EACV,cAAc,EACd,UAAU,EACV,QAAQ,EACR,OAAO,EACP,SAAS,EACT,gBAAgB,EAChB,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,UAAU,EACV,cAAc,EACd,aAAa,EACb,eAAe,EACf,MAAM,EACN,WAAW,GACZ,MAAM,eAAe,CAAC"}
@@ -6,5 +6,5 @@ export { CanvasSearchField } from './CanvasSearchField.js';
6
6
  export { CanvasToolbar, controlReleasesFocus, isTextControl } from './CanvasToolbar.js';
7
7
  export { CanvasZoomHint, HudButton, HudZoomControls } from './HudControls.js';
8
8
  export { readWheel, useCanvasWheel, useLevelOfDetail } from './useCanvasWheel.js';
9
- export { centerView, clampScale, fitScale, fitView, LEVEL_OF_DETAIL, levelOfDetail, visibleBounds, wheelZoomFactor, zoomAt, } from './viewport.js';
9
+ export { centerView, clampImageView, clampScale, fitScale, fitView, IMAGE_FIT, IMAGE_ZOOM_STEPS, imageWheelFactor, imageZoomLabel, isImageZoomed, LEVEL_OF_DETAIL, levelOfDetail, panImageBy, stepImageScale, visibleBounds, wheelZoomFactor, zoomAt, zoomImageAt, } from './viewport.js';
10
10
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/canvas-hud/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElG,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAE3D,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExF,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE9E,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAUlF,OAAO,EACL,UAAU,EACV,UAAU,EACV,QAAQ,EACR,OAAO,EACP,eAAe,EACf,aAAa,EACb,aAAa,EACb,eAAe,EACf,MAAM,GACP,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/canvas-hud/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElG,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAE3D,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExF,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE9E,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAWlF,OAAO,EACL,UAAU,EACV,cAAc,EACd,UAAU,EACV,QAAQ,EACR,OAAO,EACP,SAAS,EACT,gBAAgB,EAChB,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,UAAU,EACV,cAAc,EACd,aAAa,EACb,eAAe,EACf,MAAM,EACN,WAAW,GACZ,MAAM,eAAe,CAAC"}
@@ -65,4 +65,36 @@ export interface LevelOfDetailThresholds {
65
65
  export declare const LEVEL_OF_DETAIL: LevelOfDetailThresholds;
66
66
  /** CNV-5 — which density a canvas draws at this scale. */
67
67
  export declare function levelOfDetail(scale: number, thresholds?: LevelOfDetailThresholds): LevelOfDetail;
68
+ /** How far a picture viewer zooms: `min` 1 never goes below the fit (photos); under 1 shows it smaller (family's wall-sized prints). */
69
+ export interface ImageZoomLimits {
70
+ min: number;
71
+ max: number;
72
+ }
73
+ /** The fit: the picture whole and centered. */
74
+ export declare const IMAGE_FIT: CanvasView;
75
+ /** The stops the − and + buttons walk, inside a viewer's limits; a pinch or a wheel lands anywhere between. */
76
+ export declare const IMAGE_ZOOM_STEPS: readonly number[];
77
+ /**
78
+ * The view held inside what the scaled `picture` (its laid-out size at the fit) can show on `stage`:
79
+ * centered on an axis it does not fill, else panned no further than its edge — the picture never
80
+ * slides off and leaves a gap where it could have filled the stage.
81
+ */
82
+ export declare function clampImageView(view: CanvasView, picture: CanvasSize, stage: CanvasSize, limits: ImageZoomLimits): CanvasView;
83
+ /** Zoom to `scale` keeping `anchor` (screen pixels from the stage's center) over the same spot of the picture, then clamp. */
84
+ export declare function zoomImageAt(view: CanvasView, scale: number, anchor: CanvasPoint, picture: CanvasSize, stage: CanvasSize, limits: ImageZoomLimits): CanvasView;
85
+ /** Move the picture by a drag's or a scroll's screen delta, then clamp. */
86
+ export declare function panImageBy(view: CanvasView, dx: number, dy: number, picture: CanvasSize, stage: CanvasSize, limits: ImageZoomLimits): CanvasView;
87
+ /** The next button stop in `direction` from wherever a pinch left the scale, inside `limits`. */
88
+ export declare function stepImageScale(scale: number, direction: 1 | -1, limits: ImageZoomLimits, steps?: readonly number[]): number;
89
+ /**
90
+ * One wheel event's factor for a picture: a trackpad pinch (a ctrl-wheel stream of small deltas)
91
+ * tracks the fingers about 1:1, and a mouse notch (a delta of ~100) is capped at one comfortable
92
+ * step. Faster than {@link wheelZoomFactor}, which a canvas spends over a range of 0.02–4×; a
93
+ * picture's whole range is a few doublings.
94
+ */
95
+ export declare function imageWheelFactor(deltaY: number): number;
96
+ /** Is the view anything but the fit's scale? */
97
+ export declare const isImageZoomed: (view: CanvasView) => boolean;
98
+ /** The readout between the buttons: "Fit", "1.5×", "2×", "0.5×". */
99
+ export declare function imageZoomLabel(scale: number): string;
68
100
  //# sourceMappingURL=viewport.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"viewport.d.ts","sourceRoot":"","sources":["../../../src/react/canvas-hud/viewport.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,gFAAgF;AAChF,MAAM,WAAW,YAAa,SAAQ,WAAW,EAAE,UAAU;CAAG;AAEhE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAE1E;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,UAAU,CAQvF;AAED;gFACgF;AAChF,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,MAAM,WAAW,UAAU;IACzB,iEAAiE;IACjE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;yDACyD;AACzD,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,GAAE,UAAe,GAAG,MAAM,GAAG,IAAI,CAS1G;AAED,wFAAwF;AACxF,wBAAgB,OAAO,CAAC,MAAM,EAAE,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,GAAE,UAAe,GAAG,UAAU,GAAG,IAAI,CAI/G;AAED,iGAAiG;AACjG,wBAAgB,UAAU,CAAC,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,UAAU,CAM9F;AAED,qFAAqF;AACrF,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,UAAU,GAAG,YAAY,CAOlF;AAED,+BAA+B;AAC/B,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,KAAK,GAAG,OAAO,CAAC;AAEpD,MAAM,WAAW,uBAAuB;IACtC,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,eAAe,EAAE,uBAAkD,CAAC;AAEjF,0DAA0D;AAC1D,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,GAAE,uBAAyC,GAAG,aAAa,CAIjH"}
1
+ {"version":3,"file":"viewport.d.ts","sourceRoot":"","sources":["../../../src/react/canvas-hud/viewport.ts"],"names":[],"mappings":"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"}
@@ -74,4 +74,55 @@ export function levelOfDetail(scale, thresholds = LEVEL_OF_DETAIL) {
74
74
  return 'mid';
75
75
  return 'far';
76
76
  }
77
+ /** The fit: the picture whole and centered. */
78
+ export const IMAGE_FIT = { scale: 1, x: 0, y: 0 };
79
+ /** The stops the − and + buttons walk, inside a viewer's limits; a pinch or a wheel lands anywhere between. */
80
+ export const IMAGE_ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8];
81
+ const finiteScale = (scale, limits) => clampScale(Number.isFinite(scale) ? scale : 1, limits.min, limits.max);
82
+ /**
83
+ * The view held inside what the scaled `picture` (its laid-out size at the fit) can show on `stage`:
84
+ * centered on an axis it does not fill, else panned no further than its edge — the picture never
85
+ * slides off and leaves a gap where it could have filled the stage.
86
+ */
87
+ export function clampImageView(view, picture, stage, limits) {
88
+ const scale = finiteScale(view.scale, limits);
89
+ const room = (content, box) => Math.max(0, (content * scale - box) / 2);
90
+ const rx = room(picture.width, stage.width);
91
+ const ry = room(picture.height, stage.height);
92
+ // `+ 0` turns a -0 into 0, so a clamped view compares equal to the fit.
93
+ return { scale, x: clampScale(view.x, -rx, rx) + 0, y: clampScale(view.y, -ry, ry) + 0 };
94
+ }
95
+ /** Zoom to `scale` keeping `anchor` (screen pixels from the stage's center) over the same spot of the picture, then clamp. */
96
+ export function zoomImageAt(view, scale, anchor, picture, stage, limits) {
97
+ return clampImageView(zoomAt(view, finiteScale(scale, limits), anchor), picture, stage, limits);
98
+ }
99
+ /** Move the picture by a drag's or a scroll's screen delta, then clamp. */
100
+ export function panImageBy(view, dx, dy, picture, stage, limits) {
101
+ return clampImageView({ ...view, x: view.x + dx, y: view.y + dy }, picture, stage, limits);
102
+ }
103
+ /** The next button stop in `direction` from wherever a pinch left the scale, inside `limits`. */
104
+ export function stepImageScale(scale, direction, limits, steps = IMAGE_ZOOM_STEPS) {
105
+ const stops = [...new Set([limits.min, ...steps.filter((s) => s > limits.min && s < limits.max), limits.max])];
106
+ if (direction > 0)
107
+ return stops.find((s) => s > scale + 0.01) ?? limits.max;
108
+ return stops.reverse().find((s) => s < scale - 0.01) ?? limits.min;
109
+ }
110
+ /**
111
+ * One wheel event's factor for a picture: a trackpad pinch (a ctrl-wheel stream of small deltas)
112
+ * tracks the fingers about 1:1, and a mouse notch (a delta of ~100) is capped at one comfortable
113
+ * step. Faster than {@link wheelZoomFactor}, which a canvas spends over a range of 0.02–4×; a
114
+ * picture's whole range is a few doublings.
115
+ */
116
+ export function imageWheelFactor(deltaY) {
117
+ return Math.exp(-clampScale(Number.isFinite(deltaY) ? deltaY : 0, -60, 60) / 120);
118
+ }
119
+ /** Is the view anything but the fit's scale? */
120
+ export const isImageZoomed = (view) => Math.abs(view.scale - 1) > 0.001;
121
+ /** The readout between the buttons: "Fit", "1.5×", "2×", "0.5×". */
122
+ export function imageZoomLabel(scale) {
123
+ if (Math.abs(scale - 1) <= 0.001)
124
+ return 'Fit';
125
+ const digits = scale < 1 ? 2 : scale < 2 ? 1 : 0;
126
+ return `${Number(scale.toFixed(digits)).toString()}×`;
127
+ }
77
128
  //# sourceMappingURL=viewport.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"viewport.js","sourceRoot":"","sources":["../../../src/react/canvas-hud/viewport.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"viewport.js","sourceRoot":"","sources":["../../../src/react/canvas-hud/viewport.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
+ import { type FloatingSide } from '../lib/floatingSide.js';
2
3
  export { FloatingContainer } from '../lib/floatingContainer.js';
3
4
  export { type UseOutsidePressOptions, useOutsidePress } from '../hooks/useOutsidePress.js';
4
5
  /**
@@ -28,7 +29,12 @@ export interface PopoverProps {
28
29
  open?: boolean;
29
30
  onOpenChange?: (open: boolean) => void;
30
31
  align?: 'start' | 'center' | 'end';
31
- side?: 'top' | 'right' | 'bottom' | 'left';
32
+ /**
33
+ * The side to open on when the panel fits there. When it does not, the panel opens on the
34
+ * opposite side, else beside the anchor, else on the roomier of the two — see
35
+ * `floatingSideFor`. Default `bottom`.
36
+ */
37
+ side?: FloatingSide;
32
38
  sideOffset?: number;
33
39
  /** Width of the popover panel. Default: auto (sized to content). */
34
40
  width?: string | number;
@@ -1 +1 @@
1
- {"version":3,"file":"Popover.d.ts","sourceRoot":"","sources":["../../../src/react/components/Popover.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAA0C,MAAM,OAAO,CAAC;AAgB/E,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,KAAK,sBAAsB,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAE3F;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,uFAAuF;IACvF,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,YAAY,GAAG,IAAI,CAAC;IACvC,QAAQ,EAAE,SAAS,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;IACnC,IAAI,CAAC,EAAE,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,2FAA2F;IAC3F,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,SAAS,CAAC;CACtC;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,OAAO,CAAC,EACtB,OAAO,EACP,MAAM,EACN,QAAQ,EACR,IAAI,EACJ,YAAY,EACZ,KAAe,EACf,IAAe,EACf,UAAc,EACd,KAAK,EACL,KAAK,EACL,SAAS,EACT,QAAgB,EAChB,KAAa,EACb,MAAa,EACb,SAAgB,EAChB,KAAK,EACL,IAAI,EACJ,GAAG,IAAI,EACR,EAAE,YAAY,+BAiGd;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAE1D"}
1
+ {"version":3,"file":"Popover.d.ts","sourceRoot":"","sources":["../../../src/react/components/Popover.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAqD,MAAM,OAAO,CAAC;AAK1F,OAAO,EAAE,KAAK,YAAY,EAAmB,MAAM,wBAAwB,CAAC;AAY5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,KAAK,sBAAsB,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAE3F;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,uFAAuF;IACvF,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,YAAY,GAAG,IAAI,CAAC;IACvC,QAAQ,EAAE,SAAS,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;IACnC;;;;OAIG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,2FAA2F;IAC3F,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,SAAS,CAAC;CACtC;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,OAAO,CAAC,EACtB,OAAO,EACP,MAAM,EACN,QAAQ,EACR,IAAI,EACJ,YAAY,EACZ,KAAe,EACf,IAAe,EACf,UAAc,EACd,KAAK,EACL,KAAK,EACL,SAAS,EACT,QAAgB,EAChB,KAAa,EACb,MAAa,EACb,SAAgB,EAChB,KAAK,EACL,IAAI,EACJ,GAAG,IAAI,EACR,EAAE,YAAY,+BAoJd;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAE1D"}
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as RPopover from '@radix-ui/react-popover';
3
- import { useCallback, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useOutsidePress } from '../hooks/useOutsidePress.js';
5
5
  import { cn } from '../lib/cn.js';
6
6
  import { CardFrameContext } from './cardFrame.js';
7
7
  import { useFloatingContainer } from '../lib/floatingContainer.js';
8
+ import { floatingSideFor } from '../lib/floatingSide.js';
8
9
  import { FLOATING_ANIMATION, FLOATING_COLLISION_PADDING, FLOATING_CONTENT_BASE, } from '../lib/floatingStyles.js';
9
10
  /*
10
11
  * Re-exported so an app that imports only this subpath (desk, which has no Tailwind and
@@ -43,6 +44,57 @@ export function Popover({ trigger, anchor, children, open, onOpenChange, align =
43
44
  return { current: { getBoundingClientRect: measure } };
44
45
  }, [anchor]);
45
46
  const anchorTrigger = anchor ? (isPoint(anchor) ? anchor.trigger : anchor) : null;
47
+ // Typed as Radix types its Trigger; with `asChild` it is whatever element `trigger` is.
48
+ const triggerRef = useRef(null);
49
+ /*
50
+ * 🔴 The side, chosen from the panel's natural height — Radix's own flip never fires for a
51
+ * belt panel, because the height cap makes it always "fit" (`floatingSide.ts` has the
52
+ * measurement). Measured when the panel attaches, before it paints, and again whenever its
53
+ * content grows (a list that loads after the panel opens). It only ever moves AWAY from a
54
+ * side the panel no longer fits: a list that shrinks while you type never sends it back.
55
+ */
56
+ const [placed, setPlaced] = useState(null);
57
+ const placedRef = useRef(null);
58
+ const observerRef = useRef(null);
59
+ const measureAnchorRef = useRef(() => null);
60
+ measureAnchorRef.current = () => virtualRef?.current.getBoundingClientRect() ?? triggerRef.current?.getBoundingClientRect() ?? null;
61
+ const contentRef = useCallback((node) => {
62
+ observerRef.current?.disconnect();
63
+ observerRef.current = null;
64
+ if (!node) {
65
+ placedRef.current = null;
66
+ setPlaced(null);
67
+ return;
68
+ }
69
+ const choose = () => {
70
+ const rect = measureAnchorRef.current();
71
+ if (!rect)
72
+ return;
73
+ const current = placedRef.current ?? side;
74
+ const viewport = {
75
+ width: window.innerWidth,
76
+ height: window.visualViewport?.height ?? window.innerHeight,
77
+ };
78
+ const panel = { width: node.offsetWidth, height: node.scrollHeight };
79
+ const gap = { offset: sideOffset, padding: FLOATING_COLLISION_PADDING };
80
+ // Keep the side it is on while the panel still fits there.
81
+ if (placedRef.current && floatingSideFor(current, rect, viewport, panel, gap) === current)
82
+ return;
83
+ const next = floatingSideFor(side, rect, viewport, panel, gap);
84
+ if (next === placedRef.current)
85
+ return;
86
+ placedRef.current = next;
87
+ setPlaced(next);
88
+ };
89
+ choose();
90
+ if (typeof ResizeObserver === 'undefined')
91
+ return;
92
+ const observer = new ResizeObserver(choose);
93
+ for (const child of Array.from(node.children))
94
+ observer.observe(child);
95
+ observerRef.current = observer;
96
+ }, [side, sideOffset]);
97
+ useEffect(() => () => observerRef.current?.disconnect(), []);
46
98
  // Always controlled underneath, so the outside-press fallback below can close an
47
99
  // uncontrolled popover too.
48
100
  const [ownOpen, setOwnOpen] = useState(false);
@@ -61,8 +113,6 @@ export function Popover({ trigger, anchor, children, open, onOpenChange, align =
61
113
  * phase, before anything can stop it; when Radix has already closed the panel, its second
62
114
  * close is a no-op. Escape and `modal` stay Radix's.
63
115
  */
64
- // Typed as Radix types its Trigger; with `asChild` it is whatever element `trigger` is.
65
- const triggerRef = useRef(null);
66
116
  const pressInside = useOutsidePress({
67
117
  open: isOpen,
68
118
  onDismiss: () => setOpen(false),
@@ -72,7 +122,7 @@ export function Popover({ trigger, anchor, children, open, onOpenChange, align =
72
122
  // `decideOn` in useOutsidePress.
73
123
  decideOn: modal ? 'pointerdown' : 'click',
74
124
  });
75
- const content = (_jsxs(RPopover.Content, { ...rest, align: align, side: side, sideOffset: sideOffset, collisionPadding: FLOATING_COLLISION_PADDING, style: width != null ? { width } : undefined, "aria-label": label, "data-cb-prose": '', ...(role ? { role } : {}), onOpenAutoFocus: autoFocus ? undefined : (event) => event.preventDefault(), onInteractOutside: (event) => {
125
+ const content = (_jsxs(RPopover.Content, { ...rest, ref: contentRef, align: align, side: placed ?? side, sideOffset: sideOffset, collisionPadding: FLOATING_COLLISION_PADDING, style: width != null ? { width } : undefined, "aria-label": label, "data-cb-prose": '', ...(role ? { role } : {}), onOpenAutoFocus: autoFocus ? undefined : (event) => event.preventDefault(), onInteractOutside: (event) => {
76
126
  // The anchor's own control decides open/shut with its click — see PopoverPoint.
77
127
  if (anchorTrigger?.contains(event.target))
78
128
  event.preventDefault();
@@ -1 +1 @@
1
- {"version":3,"file":"Popover.js","sourceRoot":"","sources":["../../../src/react/components/Popover.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"Popover.js","sourceRoot":"","sources":["../../../src/react/components/Popover.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Which side of its anchor a floating panel opens on, decided from the panel's NATURAL size.
3
+ *
4
+ * 🔴 Radix's `flip` cannot do this for the belt's panels. `FLOATING_CONTENT_BASE` caps a panel's
5
+ * height at the room on its side (`--radix-popper-available-height`) so a long menu scrolls
6
+ * instead of running off the screen, and a panel capped to the room it has never overflows, so
7
+ * `flip` never fires. A tall panel opened toward the cramped side stayed there and shrank, with
8
+ * the roomy side empty. Measured 2026-10-26 in photos' "Who is this?" (forge task 3776): at
9
+ * 1280×720, a face with 322px above and 335px below kept the panel below it at 295px, its last
10
+ * button at y=841, off the screen.
11
+ *
12
+ * So the side is chosen here, against the height the content WOULD take uncapped (its
13
+ * `scrollHeight`): the side asked for if the panel fits there, else the opposite side, else
14
+ * BESIDE the anchor (where a wide screen has the whole height), else whichever of the asked-for
15
+ * side and its opposite has more room. The height cap stays. It is what keeps the last case on
16
+ * the screen.
17
+ */
18
+ export type FloatingSide = 'top' | 'right' | 'bottom' | 'left';
19
+ interface Rect {
20
+ top: number;
21
+ right: number;
22
+ bottom: number;
23
+ left: number;
24
+ }
25
+ interface Size {
26
+ width: number;
27
+ height: number;
28
+ }
29
+ /**
30
+ * @param requested the side the caller asked for
31
+ * @param anchor the trigger's or anchor's rect, in viewport pixels
32
+ * @param viewport the visible viewport
33
+ * @param panel the panel's natural size, uncapped
34
+ * @param gap the space kept between anchor and panel (`sideOffset`), plus the collision
35
+ * padding kept from the screen's edge
36
+ */
37
+ export declare function floatingSideFor(requested: FloatingSide, anchor: Rect, viewport: Size, panel: Size, gap: {
38
+ offset: number;
39
+ padding: number;
40
+ }): FloatingSide;
41
+ export {};
42
+ //# sourceMappingURL=floatingSide.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floatingSide.d.ts","sourceRoot":"","sources":["../../../src/react/lib/floatingSide.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE/D,UAAU,IAAI;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,IAAI;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAWD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,YAAY,EACvB,MAAM,EAAE,IAAI,EACZ,QAAQ,EAAE,IAAI,EACd,KAAK,EAAE,IAAI,EACX,GAAG,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACvC,YAAY,CAiBd"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which side of its anchor a floating panel opens on, decided from the panel's NATURAL size.
3
+ *
4
+ * 🔴 Radix's `flip` cannot do this for the belt's panels. `FLOATING_CONTENT_BASE` caps a panel's
5
+ * height at the room on its side (`--radix-popper-available-height`) so a long menu scrolls
6
+ * instead of running off the screen, and a panel capped to the room it has never overflows, so
7
+ * `flip` never fires. A tall panel opened toward the cramped side stayed there and shrank, with
8
+ * the roomy side empty. Measured 2026-10-26 in photos' "Who is this?" (forge task 3776): at
9
+ * 1280×720, a face with 322px above and 335px below kept the panel below it at 295px, its last
10
+ * button at y=841, off the screen.
11
+ *
12
+ * So the side is chosen here, against the height the content WOULD take uncapped (its
13
+ * `scrollHeight`): the side asked for if the panel fits there, else the opposite side, else
14
+ * BESIDE the anchor (where a wide screen has the whole height), else whichever of the asked-for
15
+ * side and its opposite has more room. The height cap stays. It is what keeps the last case on
16
+ * the screen.
17
+ */
18
+ const OPPOSITE = {
19
+ top: 'bottom',
20
+ bottom: 'top',
21
+ left: 'right',
22
+ right: 'left',
23
+ };
24
+ const isVertical = (side) => side === 'top' || side === 'bottom';
25
+ /**
26
+ * @param requested the side the caller asked for
27
+ * @param anchor the trigger's or anchor's rect, in viewport pixels
28
+ * @param viewport the visible viewport
29
+ * @param panel the panel's natural size, uncapped
30
+ * @param gap the space kept between anchor and panel (`sideOffset`), plus the collision
31
+ * padding kept from the screen's edge
32
+ */
33
+ export function floatingSideFor(requested, anchor, viewport, panel, gap) {
34
+ const room = {
35
+ top: anchor.top - gap.offset - gap.padding,
36
+ bottom: viewport.height - anchor.bottom - gap.offset - gap.padding,
37
+ left: anchor.left - gap.offset - gap.padding,
38
+ right: viewport.width - anchor.right - gap.offset - gap.padding,
39
+ };
40
+ const fits = (side) => isVertical(side)
41
+ ? room[side] >= panel.height && panel.width <= viewport.width - 2 * gap.padding
42
+ : room[side] >= panel.width && panel.height <= viewport.height - 2 * gap.padding;
43
+ const opposite = OPPOSITE[requested];
44
+ if (fits(requested))
45
+ return requested;
46
+ if (fits(opposite))
47
+ return opposite;
48
+ const beside = isVertical(requested) ? ['right', 'left'] : ['bottom', 'top'];
49
+ for (const side of beside)
50
+ if (fits(side))
51
+ return side;
52
+ return room[requested] >= room[opposite] ? requested : opposite;
53
+ }
54
+ //# sourceMappingURL=floatingSide.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"floatingSide.js","sourceRoot":"","sources":["../../../src/react/lib/floatingSide.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAgBH,MAAM,QAAQ,GAAuC;IACnD,GAAG,EAAE,QAAQ;IACb,MAAM,EAAE,KAAK;IACb,IAAI,EAAE,OAAO;IACb,KAAK,EAAE,MAAM;CACd,CAAC;AAEF,MAAM,UAAU,GAAG,CAAC,IAAkB,EAAW,EAAE,CAAC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,QAAQ,CAAC;AAExF;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAC7B,SAAuB,EACvB,MAAY,EACZ,QAAc,EACd,KAAW,EACX,GAAwC;IAExC,MAAM,IAAI,GAAiC;QACzC,GAAG,EAAE,MAAM,CAAC,GAAG,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,OAAO;QAC1C,MAAM,EAAE,QAAQ,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,OAAO;QAClE,IAAI,EAAE,MAAM,CAAC,IAAI,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,OAAO;QAC5C,KAAK,EAAE,QAAQ,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,OAAO;KAChE,CAAC;IACF,MAAM,IAAI,GAAG,CAAC,IAAkB,EAAW,EAAE,CAC3C,UAAU,CAAC,IAAI,CAAC;QACd,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,KAAK,IAAI,QAAQ,CAAC,KAAK,GAAG,CAAC,GAAG,GAAG,CAAC,OAAO;QAC/E,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC;IACrF,MAAM,QAAQ,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAC;IACrC,IAAI,IAAI,CAAC,SAAS,CAAC;QAAE,OAAO,SAAS,CAAC;IACtC,IAAI,IAAI,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAC;IACpC,MAAM,MAAM,GAAmB,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAC7F,KAAK,MAAM,IAAI,IAAI,MAAM;QAAE,IAAI,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;IACvD,OAAO,IAAI,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC;AAClE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.95.0",
3
+ "version": "5.96.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -100,6 +100,7 @@
100
100
  "e2e:page-layout": "playwright test --config e2e/playwright.config.ts page-layout.spec.ts",
101
101
  "e2e:touch": "playwright test --config e2e/playwright.config.ts --project=touch",
102
102
  "e2e:tap-away": "playwright test --config e2e/playwright.config.ts tap-away.spec.ts",
103
+ "e2e:popover-room": "playwright test --config e2e/playwright.config.ts popover-room.spec.ts",
103
104
  "done:serial": "bun run typecheck && bun run lint && bun run test && bun run build",
104
105
  "paths": "bun run scripts/paths.ts",
105
106
  "surface": "public-surface",
@@ -951,7 +952,7 @@
951
952
  "@vidstack/react": "1.15.6",
952
953
  "@vitejs/plugin-react": "^5.2.0",
953
954
  "cursedbelt-server": "^4.43.1",
954
- "cursedops": "^0.19.0",
955
+ "cursedops": "^0.20.0",
955
956
  "diff": "^9.0.0",
956
957
  "emoji-mart": "^5.6.0",
957
958
  "hls.js": "^1.5.0",
@@ -24,17 +24,27 @@ export type {
24
24
  CanvasSize,
25
25
  CanvasView,
26
26
  FitOptions,
27
+ ImageZoomLimits,
27
28
  LevelOfDetail,
28
29
  LevelOfDetailThresholds,
29
30
  } from './viewport.js';
30
31
  export {
31
32
  centerView,
33
+ clampImageView,
32
34
  clampScale,
33
35
  fitScale,
34
36
  fitView,
37
+ IMAGE_FIT,
38
+ IMAGE_ZOOM_STEPS,
39
+ imageWheelFactor,
40
+ imageZoomLabel,
41
+ isImageZoomed,
35
42
  LEVEL_OF_DETAIL,
36
43
  levelOfDetail,
44
+ panImageBy,
45
+ stepImageScale,
37
46
  visibleBounds,
38
47
  wheelZoomFactor,
39
48
  zoomAt,
49
+ zoomImageAt,
40
50
  } from './viewport.js';
@@ -1,13 +1,21 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  centerView,
4
+ clampImageView,
4
5
  clampScale,
5
6
  fitScale,
6
7
  fitView,
8
+ IMAGE_FIT,
9
+ imageWheelFactor,
10
+ imageZoomLabel,
11
+ isImageZoomed,
7
12
  levelOfDetail,
13
+ panImageBy,
14
+ stepImageScale,
8
15
  visibleBounds,
9
16
  wheelZoomFactor,
10
17
  zoomAt,
18
+ zoomImageAt,
11
19
  } from './viewport.js';
12
20
 
13
21
  /** Where a world point lands on screen under a view. */
@@ -103,3 +111,65 @@ describe('levelOfDetail (CNV-5)', () => {
103
111
  expect(levelOfDetail(0.4, { mid: 0.2, close: 0.4 })).toBe('close');
104
112
  });
105
113
  });
114
+
115
+ describe('the image viewer (a picture laid out at its fit, viewed about the stage center; forge task 3780)', () => {
116
+ const picture = { width: 400, height: 300 };
117
+ const stage = { width: 800, height: 300 };
118
+ const photos = { min: 1, max: 8 };
119
+ const family = { min: 0.25, max: 8 };
120
+
121
+ test('🔴 zooming at a point keeps that point of the picture under it', () => {
122
+ // The pointer 100px right of center, over the picture point 100px right of its center.
123
+ const view = zoomImageAt(IMAGE_FIT, 2, { x: 100, y: 0 }, picture, picture, photos);
124
+ expect(view).toEqual({ scale: 2, x: -100, y: 0 });
125
+ expect(100 * view.scale + view.x).toBe(100);
126
+ });
127
+
128
+ test('🔴 a picture is never dragged off the stage: centered on an axis it does not fill, no gap on one it does', () => {
129
+ const view = clampImageView({ scale: 1.5, x: 999, y: 999 }, picture, stage, photos);
130
+ // 600 wide in an 800 stage: centered. 450 tall in a 300 stage: at most 75 either way.
131
+ expect(view).toEqual({ scale: 1.5, x: 0, y: 75 });
132
+ expect(panImageBy(view, 0, -1000, picture, stage, photos).y).toBe(-75);
133
+ });
134
+
135
+ test('the scale stays inside the limits, the fit has no offset, and below the fit the picture stays centered', () => {
136
+ expect(clampImageView({ scale: 0.2, x: 50, y: 50 }, picture, stage, photos)).toEqual(IMAGE_FIT);
137
+ expect(clampImageView({ scale: 40, x: 0, y: 0 }, picture, stage, photos).scale).toBe(8);
138
+ expect(zoomImageAt({ scale: 3, x: 40, y: 10 }, 1, { x: 0, y: 0 }, picture, stage, photos)).toEqual(IMAGE_FIT);
139
+ expect(clampImageView({ scale: 0.1, x: 50, y: -50 }, picture, stage, family)).toEqual({ scale: 0.25, x: 0, y: 0 });
140
+ expect(clampImageView({ scale: Number.NaN, x: 0, y: 0 }, picture, stage, family)).toEqual(IMAGE_FIT);
141
+ });
142
+
143
+ test('the buttons walk the stops inside the limits, from wherever a pinch left the scale', () => {
144
+ expect(stepImageScale(1, 1, photos)).toBe(1.5);
145
+ expect(stepImageScale(2.3, 1, photos)).toBe(3);
146
+ expect(stepImageScale(2.3, -1, photos)).toBe(2);
147
+ expect(stepImageScale(1.2, -1, photos)).toBe(1);
148
+ expect(stepImageScale(1, -1, photos)).toBe(1);
149
+ expect(stepImageScale(8, 1, photos)).toBe(8);
150
+ expect(stepImageScale(1, -1, family)).toBe(0.75);
151
+ expect(stepImageScale(0.25, -1, family)).toBe(0.25);
152
+ expect(stepImageScale(5, 1, { min: 1, max: 5 })).toBe(5);
153
+ expect(stepImageScale(4, 1, { min: 1, max: 5 })).toBe(5);
154
+ });
155
+
156
+ test('a wheel zooms in on a negative delta, out on a positive one, a flung wheel is capped, and it outpaces the canvas', () => {
157
+ expect(imageWheelFactor(-10)).toBeGreaterThan(1);
158
+ expect(imageWheelFactor(10)).toBeLessThan(1);
159
+ expect(imageWheelFactor(-5000)).toBe(imageWheelFactor(-60));
160
+ expect(imageWheelFactor(Number.NaN)).toBe(1);
161
+ expect(imageWheelFactor(-10)).toBeGreaterThan(wheelZoomFactor(-10));
162
+ });
163
+
164
+ test('the readout', () => {
165
+ expect(imageZoomLabel(1)).toBe('Fit');
166
+ expect(imageZoomLabel(1.5)).toBe('1.5×');
167
+ expect(imageZoomLabel(2)).toBe('2×');
168
+ expect(imageZoomLabel(3.4)).toBe('3×');
169
+ expect(imageZoomLabel(0.5)).toBe('0.5×');
170
+ expect(imageZoomLabel(0.25)).toBe('0.25×');
171
+ expect(isImageZoomed(IMAGE_FIT)).toBe(false);
172
+ expect(isImageZoomed({ scale: 1.2, x: 0, y: 0 })).toBe(true);
173
+ expect(isImageZoomed({ scale: 0.5, x: 0, y: 0 })).toBe(true);
174
+ });
175
+ });
@@ -122,3 +122,80 @@ export function levelOfDetail(scale: number, thresholds: LevelOfDetailThresholds
122
122
  if (scale >= thresholds.mid) return 'mid';
123
123
  return 'far';
124
124
  }
125
+
126
+ // ── The image viewer's half ──────────────────────────────────────────────────────────────────
127
+ //
128
+ // A picture viewer (photos' lightbox, family's document canvas) is a canvas whose world is ONE
129
+ // picture, already laid out at its fit. Its view is the same `CanvasView`, read about the STAGE'S
130
+ // CENTER instead of its corner: `x`/`y` are how far the picture's center sits from the stage's,
131
+ // in screen pixels, and `scale` 1 is the fit — so the CSS is `translate(x, y) scale(scale)` with
132
+ // `transform-origin: center`, and an anchor is a point measured from the stage's center. That is
133
+ // why it is centered rather than cornered: the fit is `{ scale: 1, x: 0, y: 0 }` at every stage
134
+ // size, so a resized window or a turned phone keeps the view's meaning, and only the clamp moves.
135
+ // The anchoring arithmetic is the same either way, so it is {@link zoomAt}'s.
136
+
137
+ /** How far a picture viewer zooms: `min` 1 never goes below the fit (photos); under 1 shows it smaller (family's wall-sized prints). */
138
+ export interface ImageZoomLimits {
139
+ min: number;
140
+ max: number;
141
+ }
142
+
143
+ /** The fit: the picture whole and centered. */
144
+ export const IMAGE_FIT: CanvasView = { scale: 1, x: 0, y: 0 };
145
+
146
+ /** The stops the − and + buttons walk, inside a viewer's limits; a pinch or a wheel lands anywhere between. */
147
+ export const IMAGE_ZOOM_STEPS: readonly number[] = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8];
148
+
149
+ const finiteScale = (scale: number, limits: ImageZoomLimits): number =>
150
+ clampScale(Number.isFinite(scale) ? scale : 1, limits.min, limits.max);
151
+
152
+ /**
153
+ * The view held inside what the scaled `picture` (its laid-out size at the fit) can show on `stage`:
154
+ * centered on an axis it does not fill, else panned no further than its edge — the picture never
155
+ * slides off and leaves a gap where it could have filled the stage.
156
+ */
157
+ export function clampImageView(view: CanvasView, picture: CanvasSize, stage: CanvasSize, limits: ImageZoomLimits): CanvasView {
158
+ const scale = finiteScale(view.scale, limits);
159
+ const room = (content: number, box: number) => Math.max(0, (content * scale - box) / 2);
160
+ const rx = room(picture.width, stage.width);
161
+ const ry = room(picture.height, stage.height);
162
+ // `+ 0` turns a -0 into 0, so a clamped view compares equal to the fit.
163
+ return { scale, x: clampScale(view.x, -rx, rx) + 0, y: clampScale(view.y, -ry, ry) + 0 };
164
+ }
165
+
166
+ /** Zoom to `scale` keeping `anchor` (screen pixels from the stage's center) over the same spot of the picture, then clamp. */
167
+ export function zoomImageAt(view: CanvasView, scale: number, anchor: CanvasPoint, picture: CanvasSize, stage: CanvasSize, limits: ImageZoomLimits): CanvasView {
168
+ return clampImageView(zoomAt(view, finiteScale(scale, limits), anchor), picture, stage, limits);
169
+ }
170
+
171
+ /** Move the picture by a drag's or a scroll's screen delta, then clamp. */
172
+ export function panImageBy(view: CanvasView, dx: number, dy: number, picture: CanvasSize, stage: CanvasSize, limits: ImageZoomLimits): CanvasView {
173
+ return clampImageView({ ...view, x: view.x + dx, y: view.y + dy }, picture, stage, limits);
174
+ }
175
+
176
+ /** The next button stop in `direction` from wherever a pinch left the scale, inside `limits`. */
177
+ export function stepImageScale(scale: number, direction: 1 | -1, limits: ImageZoomLimits, steps: readonly number[] = IMAGE_ZOOM_STEPS): number {
178
+ const stops = [...new Set([limits.min, ...steps.filter((s) => s > limits.min && s < limits.max), limits.max])];
179
+ if (direction > 0) return stops.find((s) => s > scale + 0.01) ?? limits.max;
180
+ return stops.reverse().find((s) => s < scale - 0.01) ?? limits.min;
181
+ }
182
+
183
+ /**
184
+ * One wheel event's factor for a picture: a trackpad pinch (a ctrl-wheel stream of small deltas)
185
+ * tracks the fingers about 1:1, and a mouse notch (a delta of ~100) is capped at one comfortable
186
+ * step. Faster than {@link wheelZoomFactor}, which a canvas spends over a range of 0.02–4×; a
187
+ * picture's whole range is a few doublings.
188
+ */
189
+ export function imageWheelFactor(deltaY: number): number {
190
+ return Math.exp(-clampScale(Number.isFinite(deltaY) ? deltaY : 0, -60, 60) / 120);
191
+ }
192
+
193
+ /** Is the view anything but the fit's scale? */
194
+ export const isImageZoomed = (view: CanvasView): boolean => Math.abs(view.scale - 1) > 0.001;
195
+
196
+ /** The readout between the buttons: "Fit", "1.5×", "2×", "0.5×". */
197
+ export function imageZoomLabel(scale: number): string {
198
+ if (Math.abs(scale - 1) <= 0.001) return 'Fit';
199
+ const digits = scale < 1 ? 2 : scale < 2 ? 1 : 0;
200
+ return `${Number(scale.toFixed(digits)).toString()}×`;
201
+ }
@@ -1,9 +1,10 @@
1
1
  import * as RPopover from '@radix-ui/react-popover';
2
- import { type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
2
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useOutsidePress } from '../hooks/useOutsidePress.js';
4
4
  import { cn } from '../lib/cn.js';
5
5
  import { CardFrameContext } from './cardFrame.js';
6
6
  import { useFloatingContainer } from '../lib/floatingContainer.js';
7
+ import { type FloatingSide, floatingSideFor } from '../lib/floatingSide.js';
7
8
  import {
8
9
  FLOATING_ANIMATION,
9
10
  FLOATING_COLLISION_PADDING,
@@ -46,7 +47,12 @@ export interface PopoverProps {
46
47
  open?: boolean;
47
48
  onOpenChange?: (open: boolean) => void;
48
49
  align?: 'start' | 'center' | 'end';
49
- side?: 'top' | 'right' | 'bottom' | 'left';
50
+ /**
51
+ * The side to open on when the panel fits there. When it does not, the panel opens on the
52
+ * opposite side, else beside the anchor, else on the roomier of the two — see
53
+ * `floatingSideFor`. Default `bottom`.
54
+ */
55
+ side?: FloatingSide;
50
56
  sideOffset?: number;
51
57
  /** Width of the popover panel. Default: auto (sized to content). */
52
58
  width?: string | number;
@@ -132,6 +138,58 @@ export function Popover({
132
138
  }, [anchor]);
133
139
  const anchorTrigger = anchor ? (isPoint(anchor) ? anchor.trigger : anchor) : null;
134
140
 
141
+ // Typed as Radix types its Trigger; with `asChild` it is whatever element `trigger` is.
142
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
143
+
144
+ /*
145
+ * 🔴 The side, chosen from the panel's natural height — Radix's own flip never fires for a
146
+ * belt panel, because the height cap makes it always "fit" (`floatingSide.ts` has the
147
+ * measurement). Measured when the panel attaches, before it paints, and again whenever its
148
+ * content grows (a list that loads after the panel opens). It only ever moves AWAY from a
149
+ * side the panel no longer fits: a list that shrinks while you type never sends it back.
150
+ */
151
+ const [placed, setPlaced] = useState<FloatingSide | null>(null);
152
+ const placedRef = useRef<FloatingSide | null>(null);
153
+ const observerRef = useRef<ResizeObserver | null>(null);
154
+ const measureAnchorRef = useRef<() => DOMRect | null>(() => null);
155
+ measureAnchorRef.current = () =>
156
+ virtualRef?.current.getBoundingClientRect() ?? triggerRef.current?.getBoundingClientRect() ?? null;
157
+ const contentRef = useCallback(
158
+ (node: HTMLDivElement | null) => {
159
+ observerRef.current?.disconnect();
160
+ observerRef.current = null;
161
+ if (!node) {
162
+ placedRef.current = null;
163
+ setPlaced(null);
164
+ return;
165
+ }
166
+ const choose = () => {
167
+ const rect = measureAnchorRef.current();
168
+ if (!rect) return;
169
+ const current = placedRef.current ?? side;
170
+ const viewport = {
171
+ width: window.innerWidth,
172
+ height: window.visualViewport?.height ?? window.innerHeight,
173
+ };
174
+ const panel = { width: node.offsetWidth, height: node.scrollHeight };
175
+ const gap = { offset: sideOffset, padding: FLOATING_COLLISION_PADDING };
176
+ // Keep the side it is on while the panel still fits there.
177
+ if (placedRef.current && floatingSideFor(current, rect, viewport, panel, gap) === current) return;
178
+ const next = floatingSideFor(side, rect, viewport, panel, gap);
179
+ if (next === placedRef.current) return;
180
+ placedRef.current = next;
181
+ setPlaced(next);
182
+ };
183
+ choose();
184
+ if (typeof ResizeObserver === 'undefined') return;
185
+ const observer = new ResizeObserver(choose);
186
+ for (const child of Array.from(node.children)) observer.observe(child);
187
+ observerRef.current = observer;
188
+ },
189
+ [side, sideOffset],
190
+ );
191
+ useEffect(() => () => observerRef.current?.disconnect(), []);
192
+
135
193
  // Always controlled underneath, so the outside-press fallback below can close an
136
194
  // uncontrolled popover too.
137
195
  const [ownOpen, setOwnOpen] = useState(false);
@@ -153,8 +211,6 @@ export function Popover({
153
211
  * phase, before anything can stop it; when Radix has already closed the panel, its second
154
212
  * close is a no-op. Escape and `modal` stay Radix's.
155
213
  */
156
- // Typed as Radix types its Trigger; with `asChild` it is whatever element `trigger` is.
157
- const triggerRef = useRef<HTMLButtonElement | null>(null);
158
214
  const pressInside = useOutsidePress({
159
215
  open: isOpen,
160
216
  onDismiss: () => setOpen(false),
@@ -168,8 +224,9 @@ export function Popover({
168
224
  const content = (
169
225
  <RPopover.Content
170
226
  {...rest}
227
+ ref={contentRef}
171
228
  align={align}
172
- side={side}
229
+ side={placed ?? side}
173
230
  sideOffset={sideOffset}
174
231
  collisionPadding={FLOATING_COLLISION_PADDING}
175
232
  style={width != null ? { width } : undefined}
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { floatingSideFor } from './floatingSide.js';
3
+
4
+ const gap = { offset: 4, padding: 16 };
5
+ /** An anchor box from its top and bottom edges, centered on a 1280-wide screen unless told. */
6
+ const box = (top: number, bottom: number, left = 600, right = 680) => ({ top, bottom, left, right });
7
+
8
+ describe('floatingSideFor (forge task 3776)', () => {
9
+ test('the side asked for, when the panel fits there', () => {
10
+ expect(floatingSideFor('bottom', box(100, 140), { width: 1280, height: 900 }, { width: 320, height: 500 }, gap)).toBe('bottom');
11
+ expect(floatingSideFor('top', box(700, 740), { width: 1280, height: 900 }, { width: 320, height: 500 }, gap)).toBe('top');
12
+ });
13
+
14
+ test('the opposite side when only that one has the room', () => {
15
+ expect(floatingSideFor('bottom', box(600, 640), { width: 1280, height: 900 }, { width: 320, height: 500 }, gap)).toBe('top');
16
+ expect(floatingSideFor('top', box(100, 140), { width: 1280, height: 900 }, { width: 320, height: 500 }, gap)).toBe('bottom');
17
+ });
18
+
19
+ test("beside the anchor when neither has it — photos' face at 1280×720, 322px above and 335px below", () => {
20
+ expect(floatingSideFor('bottom', box(322, 385, 600, 666), { width: 1280, height: 720 }, { width: 336, height: 540 }, gap)).toBe('right');
21
+ expect(floatingSideFor('bottom', box(322, 385, 900, 1000), { width: 1280, height: 720 }, { width: 336, height: 540 }, gap)).toBe('left');
22
+ });
23
+
24
+ test('a phone with no room anywhere: the roomier of the asked-for side and its opposite', () => {
25
+ expect(floatingSideFor('bottom', box(300, 340, 20, 370), { width: 390, height: 640 }, { width: 350, height: 600 }, gap)).toBe('bottom');
26
+ expect(floatingSideFor('bottom', box(400, 440, 20, 370), { width: 390, height: 640 }, { width: 350, height: 600 }, gap)).toBe('top');
27
+ });
28
+
29
+ test('a side panel too tall for the screen is not "beside" — it would be capped there too', () => {
30
+ expect(floatingSideFor('bottom', box(322, 385, 100, 160), { width: 1280, height: 720 }, { width: 336, height: 800 }, gap)).toBe('bottom');
31
+ });
32
+
33
+ test('a left/right panel flips across, then goes below or above', () => {
34
+ expect(floatingSideFor('right', box(300, 340, 1000, 1100), { width: 1280, height: 900 }, { width: 320, height: 200 }, gap)).toBe('left');
35
+ expect(floatingSideFor('right', box(100, 140, 400, 900), { width: 1280, height: 900 }, { width: 420, height: 300 }, gap)).toBe('bottom');
36
+ });
37
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Which side of its anchor a floating panel opens on, decided from the panel's NATURAL size.
3
+ *
4
+ * 🔴 Radix's `flip` cannot do this for the belt's panels. `FLOATING_CONTENT_BASE` caps a panel's
5
+ * height at the room on its side (`--radix-popper-available-height`) so a long menu scrolls
6
+ * instead of running off the screen, and a panel capped to the room it has never overflows, so
7
+ * `flip` never fires. A tall panel opened toward the cramped side stayed there and shrank, with
8
+ * the roomy side empty. Measured 2026-10-26 in photos' "Who is this?" (forge task 3776): at
9
+ * 1280×720, a face with 322px above and 335px below kept the panel below it at 295px, its last
10
+ * button at y=841, off the screen.
11
+ *
12
+ * So the side is chosen here, against the height the content WOULD take uncapped (its
13
+ * `scrollHeight`): the side asked for if the panel fits there, else the opposite side, else
14
+ * BESIDE the anchor (where a wide screen has the whole height), else whichever of the asked-for
15
+ * side and its opposite has more room. The height cap stays. It is what keeps the last case on
16
+ * the screen.
17
+ */
18
+
19
+ export type FloatingSide = 'top' | 'right' | 'bottom' | 'left';
20
+
21
+ interface Rect {
22
+ top: number;
23
+ right: number;
24
+ bottom: number;
25
+ left: number;
26
+ }
27
+
28
+ interface Size {
29
+ width: number;
30
+ height: number;
31
+ }
32
+
33
+ const OPPOSITE: Record<FloatingSide, FloatingSide> = {
34
+ top: 'bottom',
35
+ bottom: 'top',
36
+ left: 'right',
37
+ right: 'left',
38
+ };
39
+
40
+ const isVertical = (side: FloatingSide): boolean => side === 'top' || side === 'bottom';
41
+
42
+ /**
43
+ * @param requested the side the caller asked for
44
+ * @param anchor the trigger's or anchor's rect, in viewport pixels
45
+ * @param viewport the visible viewport
46
+ * @param panel the panel's natural size, uncapped
47
+ * @param gap the space kept between anchor and panel (`sideOffset`), plus the collision
48
+ * padding kept from the screen's edge
49
+ */
50
+ export function floatingSideFor(
51
+ requested: FloatingSide,
52
+ anchor: Rect,
53
+ viewport: Size,
54
+ panel: Size,
55
+ gap: { offset: number; padding: number },
56
+ ): FloatingSide {
57
+ const room: Record<FloatingSide, number> = {
58
+ top: anchor.top - gap.offset - gap.padding,
59
+ bottom: viewport.height - anchor.bottom - gap.offset - gap.padding,
60
+ left: anchor.left - gap.offset - gap.padding,
61
+ right: viewport.width - anchor.right - gap.offset - gap.padding,
62
+ };
63
+ const fits = (side: FloatingSide): boolean =>
64
+ isVertical(side)
65
+ ? room[side] >= panel.height && panel.width <= viewport.width - 2 * gap.padding
66
+ : room[side] >= panel.width && panel.height <= viewport.height - 2 * gap.padding;
67
+ const opposite = OPPOSITE[requested];
68
+ if (fits(requested)) return requested;
69
+ if (fits(opposite)) return opposite;
70
+ const beside: FloatingSide[] = isVertical(requested) ? ['right', 'left'] : ['bottom', 'top'];
71
+ for (const side of beside) if (fits(side)) return side;
72
+ return room[requested] >= room[opposite] ? requested : opposite;
73
+ }