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.
- package/dist/react/canvas-hud/index.d.ts +2 -2
- package/dist/react/canvas-hud/index.d.ts.map +1 -1
- package/dist/react/canvas-hud/index.js +1 -1
- package/dist/react/canvas-hud/index.js.map +1 -1
- package/dist/react/canvas-hud/viewport.d.ts +32 -0
- package/dist/react/canvas-hud/viewport.d.ts.map +1 -1
- package/dist/react/canvas-hud/viewport.js +51 -0
- package/dist/react/canvas-hud/viewport.js.map +1 -1
- package/dist/react/components/Popover.d.ts +7 -1
- package/dist/react/components/Popover.d.ts.map +1 -1
- package/dist/react/components/Popover.js +54 -4
- package/dist/react/components/Popover.js.map +1 -1
- package/dist/react/lib/floatingSide.d.ts +42 -0
- package/dist/react/lib/floatingSide.d.ts.map +1 -0
- package/dist/react/lib/floatingSide.js +54 -0
- package/dist/react/lib/floatingSide.js.map +1 -0
- package/package.json +3 -2
- package/src/react/canvas-hud/index.ts +10 -0
- package/src/react/canvas-hud/viewport.spec.ts +70 -0
- package/src/react/canvas-hud/viewport.ts +77 -0
- package/src/react/components/Popover.tsx +62 -5
- package/src/react/lib/floatingSide.spec.ts +37 -0
- package/src/react/lib/floatingSide.ts +73 -0
|
@@ -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,
|
|
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;
|
|
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
|
-
|
|
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,
|
|
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":";AAAA,OAAO,KAAK,QAAQ,MAAM,yBAAyB,CAAC;AACpD,OAAO,EAAkB,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|