@camstack/ui-library 1.2.177 → 1.2.179

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.
@@ -72,3 +72,36 @@ export declare function clampNormalizedRect(rect: GridNormalizedRect): GridNorma
72
72
  export declare function moveNormalizedRect(rect: GridNormalizedRect, dx: number, dy: number): GridNormalizedRect;
73
73
  /** Resize from the bottom-right corner: the origin stays put. */
74
74
  export declare function resizeNormalizedRect(rect: GridNormalizedRect, dw: number, dh: number): GridNormalizedRect;
75
+ /** The eight grips on a rectangle. A corner moves two edges, an edge one. */
76
+ export type GridResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
77
+ /**
78
+ * Resize from ANY handle: the opposite side is the anchor.
79
+ *
80
+ * The editor had one grip, in the bottom-right, which meant a cell could only
81
+ * grow away from its origin — to move its top edge an operator had to drag the
82
+ * whole cell and then resize it back. The rectangle is expressed as its two
83
+ * edges here rather than origin+extent, because that is what makes "the
84
+ * opposite edge does not move" fall out instead of being arithmetic to get
85
+ * wrong. `clampNormalizedRect` still has the last word: the addon THROWS on a
86
+ * rectangle outside the unit square.
87
+ */
88
+ export declare function resizeNormalizedRectFrom(rect: GridNormalizedRect, handle: GridResizeHandle, dx: number, dy: number): GridNormalizedRect;
89
+ /**
90
+ * Tile the whole canvas with `count` cells, leaving no gap.
91
+ *
92
+ * Autofill exists because the useful answer to "put these four cameras up" is
93
+ * almost never the one hand-dragging produces: rectangles land a pixel apart
94
+ * and the composition shows black seams the operator cannot see in the editor
95
+ * but will see in the stream.
96
+ *
97
+ * The shape is the near-square one — `ceil(sqrt(n))` columns — and a short last
98
+ * row STRETCHES to the full width rather than leaving a hole. Three cameras
99
+ * become two over one, not a 2x2 with an empty quarter, which is precisely the
100
+ * layout autofill is asked to avoid.
101
+ *
102
+ * Aspect is deliberately not considered: these are CANVAS fractions, and what a
103
+ * source shows inside its cell is the crop's business (the source rectangle),
104
+ * not the placement's. Mixing the two here would silently re-crop every cell
105
+ * whenever the canvas was resized.
106
+ */
107
+ export declare function autofillCellRects(count: number): readonly GridNormalizedRect[];
package/dist/index.cjs CHANGED
@@ -2365,6 +2365,46 @@ var Laugh = createLucideIcon("laugh", [
2365
2365
  * This source code is licensed under the ISC license.
2366
2366
  * See the LICENSE file in the root directory of this source tree.
2367
2367
  */
2368
+ var LayoutGrid = createLucideIcon("layout-grid", [
2369
+ ["rect", {
2370
+ width: "7",
2371
+ height: "7",
2372
+ x: "3",
2373
+ y: "3",
2374
+ rx: "1",
2375
+ key: "1g98yp"
2376
+ }],
2377
+ ["rect", {
2378
+ width: "7",
2379
+ height: "7",
2380
+ x: "14",
2381
+ y: "3",
2382
+ rx: "1",
2383
+ key: "6d4xhi"
2384
+ }],
2385
+ ["rect", {
2386
+ width: "7",
2387
+ height: "7",
2388
+ x: "14",
2389
+ y: "14",
2390
+ rx: "1",
2391
+ key: "nxv5o0"
2392
+ }],
2393
+ ["rect", {
2394
+ width: "7",
2395
+ height: "7",
2396
+ x: "3",
2397
+ y: "14",
2398
+ rx: "1",
2399
+ key: "1bb6yr"
2400
+ }]
2401
+ ]);
2402
+ /**
2403
+ * @license lucide-react v0.576.0 - ISC
2404
+ *
2405
+ * This source code is licensed under the ISC license.
2406
+ * See the LICENSE file in the root directory of this source tree.
2407
+ */
2368
2408
  var Leaf = createLucideIcon("leaf", [["path", {
2369
2409
  d: "M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z",
2370
2410
  key: "nnexq3"
@@ -16664,13 +16704,75 @@ function moveNormalizedRect(rect, dx, dy) {
16664
16704
  y: rect.y + dy
16665
16705
  });
16666
16706
  }
16667
- /** Resize from the bottom-right corner: the origin stays put. */
16668
- function resizeNormalizedRect(rect, dw, dh) {
16707
+ /**
16708
+ * Resize from ANY handle: the opposite side is the anchor.
16709
+ *
16710
+ * The editor had one grip, in the bottom-right, which meant a cell could only
16711
+ * grow away from its origin — to move its top edge an operator had to drag the
16712
+ * whole cell and then resize it back. The rectangle is expressed as its two
16713
+ * edges here rather than origin+extent, because that is what makes "the
16714
+ * opposite edge does not move" fall out instead of being arithmetic to get
16715
+ * wrong. `clampNormalizedRect` still has the last word: the addon THROWS on a
16716
+ * rectangle outside the unit square.
16717
+ */
16718
+ function resizeNormalizedRectFrom(rect, handle, dx, dy) {
16719
+ const west = handle === "nw" || handle === "w" || handle === "sw";
16720
+ const east = handle === "ne" || handle === "e" || handle === "se";
16721
+ const north = handle === "nw" || handle === "n" || handle === "ne";
16722
+ const south = handle === "sw" || handle === "s" || handle === "se";
16723
+ let left = rect.x;
16724
+ let right = rect.x + rect.width;
16725
+ let top = rect.y;
16726
+ let bottom = rect.y + rect.height;
16727
+ if (west) left = Math.min(right - MIN_EXTENT, Math.max(0, left + dx));
16728
+ if (east) right = Math.max(left + MIN_EXTENT, Math.min(1, right + dx));
16729
+ if (north) top = Math.min(bottom - MIN_EXTENT, Math.max(0, top + dy));
16730
+ if (south) bottom = Math.max(top + MIN_EXTENT, Math.min(1, bottom + dy));
16669
16731
  return clampNormalizedRect({
16670
- ...rect,
16671
- width: rect.width + dw,
16672
- height: rect.height + dh
16673
- });
16732
+ x: left,
16733
+ y: top,
16734
+ width: right - left,
16735
+ height: bottom - top
16736
+ });
16737
+ }
16738
+ /**
16739
+ * Tile the whole canvas with `count` cells, leaving no gap.
16740
+ *
16741
+ * Autofill exists because the useful answer to "put these four cameras up" is
16742
+ * almost never the one hand-dragging produces: rectangles land a pixel apart
16743
+ * and the composition shows black seams the operator cannot see in the editor
16744
+ * but will see in the stream.
16745
+ *
16746
+ * The shape is the near-square one — `ceil(sqrt(n))` columns — and a short last
16747
+ * row STRETCHES to the full width rather than leaving a hole. Three cameras
16748
+ * become two over one, not a 2x2 with an empty quarter, which is precisely the
16749
+ * layout autofill is asked to avoid.
16750
+ *
16751
+ * Aspect is deliberately not considered: these are CANVAS fractions, and what a
16752
+ * source shows inside its cell is the crop's business (the source rectangle),
16753
+ * not the placement's. Mixing the two here would silently re-crop every cell
16754
+ * whenever the canvas was resized.
16755
+ */
16756
+ function autofillCellRects(count) {
16757
+ if (!Number.isFinite(count) || count <= 0) return [];
16758
+ const cells = Math.floor(count);
16759
+ const columns = Math.ceil(Math.sqrt(cells));
16760
+ const rows = Math.ceil(cells / columns);
16761
+ const rects = [];
16762
+ let placed = 0;
16763
+ for (let row = 0; row < rows; row++) {
16764
+ const inRow = Math.min(columns, cells - placed);
16765
+ const height = 1 / rows;
16766
+ const width = 1 / inRow;
16767
+ for (let column = 0; column < inRow; column++) rects.push({
16768
+ x: column * width,
16769
+ y: row * height,
16770
+ width,
16771
+ height
16772
+ });
16773
+ placed += inRow;
16774
+ }
16775
+ return rects;
16674
16776
  }
16675
16777
  //#endregion
16676
16778
  //#region src/composites/cap-settings/CameraGridLayoutEditor.tsx
@@ -16704,6 +16806,61 @@ var FULL_FRAME = {
16704
16806
  var MAX_CELLS = 16;
16705
16807
  /** Hoisted so `useDevices` does not re-subscribe on every render. */
16706
16808
  var CAMERA_FILTER = { type: _camstack_types.DeviceType.Camera };
16809
+ /**
16810
+ * A rectangle being dragged must not also PAN THE PAGE.
16811
+ *
16812
+ * On a phone a pointerdown on a `div` is a scroll gesture until the browser is
16813
+ * told otherwise, and `preventDefault` in the handler is too late — the browser
16814
+ * has already decided. `touch-action: none` is the only thing that makes a drag
16815
+ * work with a finger, and every surface that starts one carries it.
16816
+ */
16817
+ var DRAG_SURFACE = { touchAction: "none" };
16818
+ /**
16819
+ * Finger-sized hit area around a grip.
16820
+ *
16821
+ * The visible square stays small (it sits ON the picture being edited and a big
16822
+ * one hides what it is positioning); the TOUCHABLE box around it is 24px, which
16823
+ * is the smallest target size that does not need a fingernail. The two are
16824
+ * different boxes on purpose — making the dot itself 24px would cover a quarter
16825
+ * of a small cell.
16826
+ */
16827
+ var HANDLE_HIT = "absolute flex h-6 w-6 items-center justify-center";
16828
+ /** The eight grips, in the order they are drawn. */
16829
+ var HANDLES = [
16830
+ "nw",
16831
+ "n",
16832
+ "ne",
16833
+ "e",
16834
+ "se",
16835
+ "s",
16836
+ "sw",
16837
+ "w"
16838
+ ];
16839
+ var HANDLE_STYLE = {
16840
+ nw: "-top-3 -left-3 cursor-nw-resize",
16841
+ n: "-top-3 left-1/2 -translate-x-1/2 cursor-n-resize",
16842
+ ne: "-top-3 -right-3 cursor-ne-resize",
16843
+ e: "top-1/2 -right-3 -translate-y-1/2 cursor-e-resize",
16844
+ se: "-bottom-3 -right-3 cursor-se-resize",
16845
+ s: "-bottom-3 left-1/2 -translate-x-1/2 cursor-s-resize",
16846
+ sw: "-bottom-3 -left-3 cursor-sw-resize",
16847
+ w: "top-1/2 -left-3 -translate-y-1/2 cursor-w-resize"
16848
+ };
16849
+ /**
16850
+ * Start a drag and KEEP it, even when the finger leaves the rectangle.
16851
+ *
16852
+ * Without pointer capture a drag ends the moment the pointer crosses the box it
16853
+ * started in — which on a phone is immediately, because the box is small and
16854
+ * the finger is not. With capture the events keep coming to the element that
16855
+ * was grabbed and still bubble to the canvas that does the arithmetic.
16856
+ */
16857
+ function capturePointer(event) {
16858
+ event.currentTarget.setPointerCapture(event.pointerId);
16859
+ }
16860
+ /** Move or resize, from one drag and one delta. */
16861
+ function applyDrag(drag, dx, dy) {
16862
+ return drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRectFrom(drag.origin, drag.mode, dx, dy);
16863
+ }
16707
16864
  function CameraGridLayoutEditor({ deviceId }) {
16708
16865
  const system = useSystem();
16709
16866
  const cameras = (0, react$1.useMemo)(() => system.listDeviceInfos(CAMERA_FILTER), [system, useDevices(CAMERA_FILTER)]);
@@ -16792,19 +16949,39 @@ function CameraGridLayoutEditor({ deviceId }) {
16792
16949
  if (drag === null) return;
16793
16950
  const bounds = event.currentTarget.getBoundingClientRect();
16794
16951
  if (bounds.width === 0 || bounds.height === 0) return;
16952
+ if (drag.target !== "cell") return;
16795
16953
  const dx = (event.clientX - drag.startX) / bounds.width;
16796
16954
  const dy = (event.clientY - drag.startY) / bounds.height;
16797
- patchCell(drag.index, { cell: drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRect(drag.origin, dx, dy) });
16955
+ patchCell(drag.index, { cell: applyDrag(drag, dx, dy) });
16798
16956
  };
16799
16957
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetPanel, {
16800
16958
  title: "Grid layout",
16801
16959
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Grid2x2, { className: "h-3.5 w-3.5" }),
16802
- headerRight: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16803
- type: "button",
16804
- disabled: saving,
16805
- onClick: () => void save(),
16806
- className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16807
- children: [saving ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Save, { className: "h-3 w-3" }), "Save"]
16960
+ headerRight: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16961
+ className: "flex items-center gap-1",
16962
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16963
+ type: "button",
16964
+ disabled: draft.cells.length === 0,
16965
+ title: "Tile every cell over the whole canvas, leaving no gap",
16966
+ onClick: () => {
16967
+ const rects = autofillCellRects(draft.cells.length);
16968
+ setDraft({
16969
+ ...draft,
16970
+ cells: draft.cells.map((cell, index) => ({
16971
+ ...cell,
16972
+ cell: rects[index] ?? cell.cell
16973
+ }))
16974
+ });
16975
+ },
16976
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16977
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LayoutGrid, { className: "h-3 w-3" }), " Autofill"]
16978
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16979
+ type: "button",
16980
+ disabled: saving,
16981
+ onClick: () => void save(),
16982
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16983
+ children: [saving ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Save, { className: "h-3 w-3" }), "Save"]
16984
+ })]
16808
16985
  }),
16809
16986
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16810
16987
  className: "flex flex-col gap-3",
@@ -16819,12 +16996,15 @@ function CameraGridLayoutEditor({ deviceId }) {
16819
16996
  }),
16820
16997
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16821
16998
  className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
16822
- style: { aspectRatio: String(canvasAspect) },
16999
+ style: {
17000
+ aspectRatio: String(canvasAspect),
17001
+ ...DRAG_SURFACE
17002
+ },
16823
17003
  onPointerMove,
16824
17004
  onPointerUp: () => {
16825
17005
  setDrag(null);
16826
17006
  },
16827
- onPointerLeave: () => {
17007
+ onPointerCancel: () => {
16828
17008
  setDrag(null);
16829
17009
  },
16830
17010
  children: [draft.cells.map((cell, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GridCellBox, {
@@ -16837,6 +17017,7 @@ function CameraGridLayoutEditor({ deviceId }) {
16837
17017
  setSelected(index);
16838
17018
  setDrag({
16839
17019
  index,
17020
+ target: "cell",
16840
17021
  mode,
16841
17022
  startX: event.clientX,
16842
17023
  startY: event.clientY,
@@ -16969,10 +17150,12 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16969
17150
  role: "presentation",
16970
17151
  onPointerDown: (event) => {
16971
17152
  onSelect();
17153
+ capturePointer(event);
16972
17154
  onDragStart("move", event);
16973
17155
  },
16974
17156
  className: `absolute cursor-move border ${selected ? "border-accent" : "border-border/60"}`,
16975
17157
  style: {
17158
+ ...DRAG_SURFACE,
16976
17159
  left: `${String(cell.cell.x * 100)}%`,
16977
17160
  top: `${String(cell.cell.y * 100)}%`,
16978
17161
  width: `${String(cell.cell.width * 100)}%`,
@@ -16982,14 +17165,17 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16982
17165
  backgroundPosition,
16983
17166
  backgroundRepeat: "no-repeat"
16984
17167
  },
16985
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17168
+ children: selected && HANDLES.map((handle) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16986
17169
  role: "presentation",
16987
17170
  onPointerDown: (event) => {
16988
17171
  event.stopPropagation();
16989
- onDragStart("resize", event);
17172
+ capturePointer(event);
17173
+ onDragStart(handle, event);
16990
17174
  },
16991
- className: "absolute -bottom-1 -right-1 h-2.5 w-2.5 cursor-se-resize bg-accent"
16992
- })
17175
+ className: `${HANDLE_HIT} ${HANDLE_STYLE[handle]}`,
17176
+ style: DRAG_SURFACE,
17177
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-2.5 w-2.5 bg-accent" })
17178
+ }, handle))
16993
17179
  });
16994
17180
  }
16995
17181
  function CellInspector({ cell, index, cameras, onChange, onRemove }) {
@@ -17025,17 +17211,23 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17025
17211
  }, camera.id))
17026
17212
  })]
17027
17213
  }),
17214
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SourceCropEditor, {
17215
+ deviceId: cell.deviceId,
17216
+ source: cell.source,
17217
+ onChange: (source) => {
17218
+ onChange({ source });
17219
+ }
17220
+ }),
17028
17221
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17029
- className: "grid grid-cols-4 gap-2",
17222
+ className: "grid grid-cols-2 gap-x-3 gap-y-1.5",
17030
17223
  children: [
17031
17224
  "x",
17032
17225
  "y",
17033
17226
  "width",
17034
17227
  "height"
17035
- ].map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
17228
+ ].map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CropSlider, {
17036
17229
  label: `Crop ${key}`,
17037
- value: Math.round(cell.source[key] * 100) / 100,
17038
- step: .01,
17230
+ value: cell.source[key],
17039
17231
  onChange: (next) => {
17040
17232
  onChange({ source: clampNormalizedRect({
17041
17233
  ...cell.source,
@@ -17047,6 +17239,165 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17047
17239
  ]
17048
17240
  });
17049
17241
  }
17242
+ /**
17243
+ * The crop, on the source camera's own frame.
17244
+ *
17245
+ * The whole snapshot is drawn at full opacity behind a dimmed overlay, and the
17246
+ * selected rectangle is the hole in it: what is INSIDE is what the grid will
17247
+ * show. Drawing it the other way round — bright rectangle on a dark picture —
17248
+ * reads as "this part is highlighted", which is not the same claim.
17249
+ *
17250
+ * Its drag state is local. The canvas above owns cell placement and this owns
17251
+ * the crop; sharing one drag state would mean every pointer move on either
17252
+ * canvas consulted a `target` discriminator to decide whether it was for the
17253
+ * other one.
17254
+ */
17255
+ function SourceCropEditor({ deviceId, source, onChange }) {
17256
+ const { src } = useDeviceSnapshotImage(deviceId, { width: 640 });
17257
+ const [drag, setDrag] = (0, react$1.useState)(null);
17258
+ const [sourceAspect, setSourceAspect] = (0, react$1.useState)(null);
17259
+ const onPointerMove = (event) => {
17260
+ if (drag === null) return;
17261
+ const bounds = event.currentTarget.getBoundingClientRect();
17262
+ if (bounds.width === 0 || bounds.height === 0) return;
17263
+ onChange(applyDrag({
17264
+ ...drag,
17265
+ index: 0,
17266
+ target: "source"
17267
+ }, (event.clientX - drag.startX) / bounds.width, (event.clientY - drag.startY) / bounds.height));
17268
+ };
17269
+ const full = source.x === 0 && source.y === 0 && source.width === 1 && source.height === 1;
17270
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17271
+ className: "flex flex-col gap-1",
17272
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17273
+ className: "flex items-center justify-between",
17274
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17275
+ className: "uppercase text-foreground-subtle",
17276
+ children: "Visible part of the camera"
17277
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17278
+ type: "button",
17279
+ disabled: full,
17280
+ onClick: () => {
17281
+ onChange(FULL_FRAME);
17282
+ },
17283
+ className: "flex items-center gap-1 rounded border border-border px-1.5 py-0.5 uppercase disabled:opacity-40",
17284
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Maximize2, { className: "h-3 w-3" }), " Whole frame"]
17285
+ })]
17286
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17287
+ className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
17288
+ style: {
17289
+ aspectRatio: String(sourceAspect ?? 16 / 9),
17290
+ ...DRAG_SURFACE
17291
+ },
17292
+ onPointerMove,
17293
+ onPointerUp: () => {
17294
+ setDrag(null);
17295
+ },
17296
+ onPointerCancel: () => {
17297
+ setDrag(null);
17298
+ },
17299
+ children: [
17300
+ src === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17301
+ className: "absolute inset-0 flex items-center justify-center text-[10px] text-foreground-subtle",
17302
+ children: "No snapshot from this camera yet — the numbers below still apply."
17303
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
17304
+ src,
17305
+ alt: "",
17306
+ className: "absolute inset-0 h-full w-full",
17307
+ onLoad: (event) => {
17308
+ const { naturalWidth, naturalHeight } = event.currentTarget;
17309
+ if (naturalWidth > 0 && naturalHeight > 0) setSourceAspect(naturalWidth / naturalHeight);
17310
+ }
17311
+ }),
17312
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "pointer-events-none absolute inset-0 bg-black/55" }),
17313
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17314
+ role: "presentation",
17315
+ onPointerDown: (event) => {
17316
+ capturePointer(event);
17317
+ setDrag({
17318
+ mode: "move",
17319
+ startX: event.clientX,
17320
+ startY: event.clientY,
17321
+ origin: source
17322
+ });
17323
+ },
17324
+ className: "absolute cursor-move border border-accent",
17325
+ style: {
17326
+ ...DRAG_SURFACE,
17327
+ left: `${String(source.x * 100)}%`,
17328
+ top: `${String(source.y * 100)}%`,
17329
+ width: `${String(source.width * 100)}%`,
17330
+ height: `${String(source.height * 100)}%`,
17331
+ backgroundImage: src === null ? void 0 : `url(${src})`,
17332
+ backgroundSize: `${String(100 / source.width)}% ${String(100 / source.height)}%`,
17333
+ backgroundPosition: source.width >= 1 && source.height >= 1 ? "center" : `${String(source.x / Math.max(1e-6, 1 - source.width) * 100)}% ${String(source.y / Math.max(1e-6, 1 - source.height) * 100)}%`
17334
+ },
17335
+ children: HANDLES.map((handle) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17336
+ role: "presentation",
17337
+ onPointerDown: (event) => {
17338
+ event.stopPropagation();
17339
+ capturePointer(event);
17340
+ setDrag({
17341
+ mode: handle,
17342
+ startX: event.clientX,
17343
+ startY: event.clientY,
17344
+ origin: source
17345
+ });
17346
+ },
17347
+ className: `${HANDLE_HIT} ${HANDLE_STYLE[handle]}`,
17348
+ style: DRAG_SURFACE,
17349
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-2.5 w-2.5 bg-accent" })
17350
+ }, handle))
17351
+ })
17352
+ ]
17353
+ })]
17354
+ });
17355
+ }
17356
+ /**
17357
+ * One crop number, as a slider AND as a number.
17358
+ *
17359
+ * The slider is what makes this usable with a thumb — a `number` input on a
17360
+ * phone opens a keyboard, and adjusting a crop by typing 0.37 then 0.39 then
17361
+ * 0.38 is not adjusting, it is guessing. The typed field stays because a slider
17362
+ * cannot express "exactly a third" and because a value you can read is how you
17363
+ * copy a crop from one cell to another.
17364
+ *
17365
+ * Both write through `clampNormalizedRect` upstream, so neither can hand the
17366
+ * addon a rectangle that leaves the frame — it THROWS on one, by design.
17367
+ */
17368
+ function CropSlider({ label, value, onChange }) {
17369
+ const rounded = Math.round(value * 100) / 100;
17370
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17371
+ className: "flex flex-col gap-0.5",
17372
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
17373
+ className: "flex items-baseline justify-between text-foreground-subtle",
17374
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17375
+ type: "number",
17376
+ min: 0,
17377
+ max: 1,
17378
+ step: .01,
17379
+ value: rounded,
17380
+ onChange: (event) => {
17381
+ const next = Number(event.target.value);
17382
+ if (Number.isFinite(next)) onChange(next);
17383
+ },
17384
+ className: "w-12 rounded border border-border bg-transparent px-1 py-0.5 text-right"
17385
+ })]
17386
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17387
+ type: "range",
17388
+ min: 0,
17389
+ max: 1,
17390
+ step: .005,
17391
+ value,
17392
+ onChange: (event) => {
17393
+ const next = Number(event.target.value);
17394
+ if (Number.isFinite(next)) onChange(next);
17395
+ },
17396
+ style: DRAG_SURFACE,
17397
+ className: "h-5 w-full accent-accent"
17398
+ })]
17399
+ });
17400
+ }
17050
17401
  function LabelledNumber({ label, value, step = 1, onChange }) {
17051
17402
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17052
17403
  className: "flex flex-col gap-0.5",
package/dist/index.js CHANGED
@@ -2341,6 +2341,46 @@ var Laugh = createLucideIcon("laugh", [
2341
2341
  * This source code is licensed under the ISC license.
2342
2342
  * See the LICENSE file in the root directory of this source tree.
2343
2343
  */
2344
+ var LayoutGrid = createLucideIcon("layout-grid", [
2345
+ ["rect", {
2346
+ width: "7",
2347
+ height: "7",
2348
+ x: "3",
2349
+ y: "3",
2350
+ rx: "1",
2351
+ key: "1g98yp"
2352
+ }],
2353
+ ["rect", {
2354
+ width: "7",
2355
+ height: "7",
2356
+ x: "14",
2357
+ y: "3",
2358
+ rx: "1",
2359
+ key: "6d4xhi"
2360
+ }],
2361
+ ["rect", {
2362
+ width: "7",
2363
+ height: "7",
2364
+ x: "14",
2365
+ y: "14",
2366
+ rx: "1",
2367
+ key: "nxv5o0"
2368
+ }],
2369
+ ["rect", {
2370
+ width: "7",
2371
+ height: "7",
2372
+ x: "3",
2373
+ y: "14",
2374
+ rx: "1",
2375
+ key: "1bb6yr"
2376
+ }]
2377
+ ]);
2378
+ /**
2379
+ * @license lucide-react v0.576.0 - ISC
2380
+ *
2381
+ * This source code is licensed under the ISC license.
2382
+ * See the LICENSE file in the root directory of this source tree.
2383
+ */
2344
2384
  var Leaf = createLucideIcon("leaf", [["path", {
2345
2385
  d: "M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z",
2346
2386
  key: "nnexq3"
@@ -16640,13 +16680,75 @@ function moveNormalizedRect(rect, dx, dy) {
16640
16680
  y: rect.y + dy
16641
16681
  });
16642
16682
  }
16643
- /** Resize from the bottom-right corner: the origin stays put. */
16644
- function resizeNormalizedRect(rect, dw, dh) {
16683
+ /**
16684
+ * Resize from ANY handle: the opposite side is the anchor.
16685
+ *
16686
+ * The editor had one grip, in the bottom-right, which meant a cell could only
16687
+ * grow away from its origin — to move its top edge an operator had to drag the
16688
+ * whole cell and then resize it back. The rectangle is expressed as its two
16689
+ * edges here rather than origin+extent, because that is what makes "the
16690
+ * opposite edge does not move" fall out instead of being arithmetic to get
16691
+ * wrong. `clampNormalizedRect` still has the last word: the addon THROWS on a
16692
+ * rectangle outside the unit square.
16693
+ */
16694
+ function resizeNormalizedRectFrom(rect, handle, dx, dy) {
16695
+ const west = handle === "nw" || handle === "w" || handle === "sw";
16696
+ const east = handle === "ne" || handle === "e" || handle === "se";
16697
+ const north = handle === "nw" || handle === "n" || handle === "ne";
16698
+ const south = handle === "sw" || handle === "s" || handle === "se";
16699
+ let left = rect.x;
16700
+ let right = rect.x + rect.width;
16701
+ let top = rect.y;
16702
+ let bottom = rect.y + rect.height;
16703
+ if (west) left = Math.min(right - MIN_EXTENT, Math.max(0, left + dx));
16704
+ if (east) right = Math.max(left + MIN_EXTENT, Math.min(1, right + dx));
16705
+ if (north) top = Math.min(bottom - MIN_EXTENT, Math.max(0, top + dy));
16706
+ if (south) bottom = Math.max(top + MIN_EXTENT, Math.min(1, bottom + dy));
16645
16707
  return clampNormalizedRect({
16646
- ...rect,
16647
- width: rect.width + dw,
16648
- height: rect.height + dh
16649
- });
16708
+ x: left,
16709
+ y: top,
16710
+ width: right - left,
16711
+ height: bottom - top
16712
+ });
16713
+ }
16714
+ /**
16715
+ * Tile the whole canvas with `count` cells, leaving no gap.
16716
+ *
16717
+ * Autofill exists because the useful answer to "put these four cameras up" is
16718
+ * almost never the one hand-dragging produces: rectangles land a pixel apart
16719
+ * and the composition shows black seams the operator cannot see in the editor
16720
+ * but will see in the stream.
16721
+ *
16722
+ * The shape is the near-square one — `ceil(sqrt(n))` columns — and a short last
16723
+ * row STRETCHES to the full width rather than leaving a hole. Three cameras
16724
+ * become two over one, not a 2x2 with an empty quarter, which is precisely the
16725
+ * layout autofill is asked to avoid.
16726
+ *
16727
+ * Aspect is deliberately not considered: these are CANVAS fractions, and what a
16728
+ * source shows inside its cell is the crop's business (the source rectangle),
16729
+ * not the placement's. Mixing the two here would silently re-crop every cell
16730
+ * whenever the canvas was resized.
16731
+ */
16732
+ function autofillCellRects(count) {
16733
+ if (!Number.isFinite(count) || count <= 0) return [];
16734
+ const cells = Math.floor(count);
16735
+ const columns = Math.ceil(Math.sqrt(cells));
16736
+ const rows = Math.ceil(cells / columns);
16737
+ const rects = [];
16738
+ let placed = 0;
16739
+ for (let row = 0; row < rows; row++) {
16740
+ const inRow = Math.min(columns, cells - placed);
16741
+ const height = 1 / rows;
16742
+ const width = 1 / inRow;
16743
+ for (let column = 0; column < inRow; column++) rects.push({
16744
+ x: column * width,
16745
+ y: row * height,
16746
+ width,
16747
+ height
16748
+ });
16749
+ placed += inRow;
16750
+ }
16751
+ return rects;
16650
16752
  }
16651
16753
  //#endregion
16652
16754
  //#region src/composites/cap-settings/CameraGridLayoutEditor.tsx
@@ -16680,6 +16782,61 @@ var FULL_FRAME = {
16680
16782
  var MAX_CELLS = 16;
16681
16783
  /** Hoisted so `useDevices` does not re-subscribe on every render. */
16682
16784
  var CAMERA_FILTER = { type: DeviceType.Camera };
16785
+ /**
16786
+ * A rectangle being dragged must not also PAN THE PAGE.
16787
+ *
16788
+ * On a phone a pointerdown on a `div` is a scroll gesture until the browser is
16789
+ * told otherwise, and `preventDefault` in the handler is too late — the browser
16790
+ * has already decided. `touch-action: none` is the only thing that makes a drag
16791
+ * work with a finger, and every surface that starts one carries it.
16792
+ */
16793
+ var DRAG_SURFACE = { touchAction: "none" };
16794
+ /**
16795
+ * Finger-sized hit area around a grip.
16796
+ *
16797
+ * The visible square stays small (it sits ON the picture being edited and a big
16798
+ * one hides what it is positioning); the TOUCHABLE box around it is 24px, which
16799
+ * is the smallest target size that does not need a fingernail. The two are
16800
+ * different boxes on purpose — making the dot itself 24px would cover a quarter
16801
+ * of a small cell.
16802
+ */
16803
+ var HANDLE_HIT = "absolute flex h-6 w-6 items-center justify-center";
16804
+ /** The eight grips, in the order they are drawn. */
16805
+ var HANDLES = [
16806
+ "nw",
16807
+ "n",
16808
+ "ne",
16809
+ "e",
16810
+ "se",
16811
+ "s",
16812
+ "sw",
16813
+ "w"
16814
+ ];
16815
+ var HANDLE_STYLE = {
16816
+ nw: "-top-3 -left-3 cursor-nw-resize",
16817
+ n: "-top-3 left-1/2 -translate-x-1/2 cursor-n-resize",
16818
+ ne: "-top-3 -right-3 cursor-ne-resize",
16819
+ e: "top-1/2 -right-3 -translate-y-1/2 cursor-e-resize",
16820
+ se: "-bottom-3 -right-3 cursor-se-resize",
16821
+ s: "-bottom-3 left-1/2 -translate-x-1/2 cursor-s-resize",
16822
+ sw: "-bottom-3 -left-3 cursor-sw-resize",
16823
+ w: "top-1/2 -left-3 -translate-y-1/2 cursor-w-resize"
16824
+ };
16825
+ /**
16826
+ * Start a drag and KEEP it, even when the finger leaves the rectangle.
16827
+ *
16828
+ * Without pointer capture a drag ends the moment the pointer crosses the box it
16829
+ * started in — which on a phone is immediately, because the box is small and
16830
+ * the finger is not. With capture the events keep coming to the element that
16831
+ * was grabbed and still bubble to the canvas that does the arithmetic.
16832
+ */
16833
+ function capturePointer(event) {
16834
+ event.currentTarget.setPointerCapture(event.pointerId);
16835
+ }
16836
+ /** Move or resize, from one drag and one delta. */
16837
+ function applyDrag(drag, dx, dy) {
16838
+ return drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRectFrom(drag.origin, drag.mode, dx, dy);
16839
+ }
16683
16840
  function CameraGridLayoutEditor({ deviceId }) {
16684
16841
  const system = useSystem();
16685
16842
  const cameras = useMemo(() => system.listDeviceInfos(CAMERA_FILTER), [system, useDevices(CAMERA_FILTER)]);
@@ -16768,19 +16925,39 @@ function CameraGridLayoutEditor({ deviceId }) {
16768
16925
  if (drag === null) return;
16769
16926
  const bounds = event.currentTarget.getBoundingClientRect();
16770
16927
  if (bounds.width === 0 || bounds.height === 0) return;
16928
+ if (drag.target !== "cell") return;
16771
16929
  const dx = (event.clientX - drag.startX) / bounds.width;
16772
16930
  const dy = (event.clientY - drag.startY) / bounds.height;
16773
- patchCell(drag.index, { cell: drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRect(drag.origin, dx, dy) });
16931
+ patchCell(drag.index, { cell: applyDrag(drag, dx, dy) });
16774
16932
  };
16775
16933
  return /* @__PURE__ */ jsx(WidgetPanel, {
16776
16934
  title: "Grid layout",
16777
16935
  icon: /* @__PURE__ */ jsx(Grid2x2, { className: "h-3.5 w-3.5" }),
16778
- headerRight: /* @__PURE__ */ jsxs("button", {
16779
- type: "button",
16780
- disabled: saving,
16781
- onClick: () => void save(),
16782
- className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16783
- children: [saving ? /* @__PURE__ */ jsx(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsx(Save, { className: "h-3 w-3" }), "Save"]
16936
+ headerRight: /* @__PURE__ */ jsxs("div", {
16937
+ className: "flex items-center gap-1",
16938
+ children: [/* @__PURE__ */ jsxs("button", {
16939
+ type: "button",
16940
+ disabled: draft.cells.length === 0,
16941
+ title: "Tile every cell over the whole canvas, leaving no gap",
16942
+ onClick: () => {
16943
+ const rects = autofillCellRects(draft.cells.length);
16944
+ setDraft({
16945
+ ...draft,
16946
+ cells: draft.cells.map((cell, index) => ({
16947
+ ...cell,
16948
+ cell: rects[index] ?? cell.cell
16949
+ }))
16950
+ });
16951
+ },
16952
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16953
+ children: [/* @__PURE__ */ jsx(LayoutGrid, { className: "h-3 w-3" }), " Autofill"]
16954
+ }), /* @__PURE__ */ jsxs("button", {
16955
+ type: "button",
16956
+ disabled: saving,
16957
+ onClick: () => void save(),
16958
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16959
+ children: [saving ? /* @__PURE__ */ jsx(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsx(Save, { className: "h-3 w-3" }), "Save"]
16960
+ })]
16784
16961
  }),
16785
16962
  children: /* @__PURE__ */ jsxs("div", {
16786
16963
  className: "flex flex-col gap-3",
@@ -16795,12 +16972,15 @@ function CameraGridLayoutEditor({ deviceId }) {
16795
16972
  }),
16796
16973
  /* @__PURE__ */ jsxs("div", {
16797
16974
  className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
16798
- style: { aspectRatio: String(canvasAspect) },
16975
+ style: {
16976
+ aspectRatio: String(canvasAspect),
16977
+ ...DRAG_SURFACE
16978
+ },
16799
16979
  onPointerMove,
16800
16980
  onPointerUp: () => {
16801
16981
  setDrag(null);
16802
16982
  },
16803
- onPointerLeave: () => {
16983
+ onPointerCancel: () => {
16804
16984
  setDrag(null);
16805
16985
  },
16806
16986
  children: [draft.cells.map((cell, index) => /* @__PURE__ */ jsx(GridCellBox, {
@@ -16813,6 +16993,7 @@ function CameraGridLayoutEditor({ deviceId }) {
16813
16993
  setSelected(index);
16814
16994
  setDrag({
16815
16995
  index,
16996
+ target: "cell",
16816
16997
  mode,
16817
16998
  startX: event.clientX,
16818
16999
  startY: event.clientY,
@@ -16945,10 +17126,12 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16945
17126
  role: "presentation",
16946
17127
  onPointerDown: (event) => {
16947
17128
  onSelect();
17129
+ capturePointer(event);
16948
17130
  onDragStart("move", event);
16949
17131
  },
16950
17132
  className: `absolute cursor-move border ${selected ? "border-accent" : "border-border/60"}`,
16951
17133
  style: {
17134
+ ...DRAG_SURFACE,
16952
17135
  left: `${String(cell.cell.x * 100)}%`,
16953
17136
  top: `${String(cell.cell.y * 100)}%`,
16954
17137
  width: `${String(cell.cell.width * 100)}%`,
@@ -16958,14 +17141,17 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16958
17141
  backgroundPosition,
16959
17142
  backgroundRepeat: "no-repeat"
16960
17143
  },
16961
- children: /* @__PURE__ */ jsx("div", {
17144
+ children: selected && HANDLES.map((handle) => /* @__PURE__ */ jsx("div", {
16962
17145
  role: "presentation",
16963
17146
  onPointerDown: (event) => {
16964
17147
  event.stopPropagation();
16965
- onDragStart("resize", event);
17148
+ capturePointer(event);
17149
+ onDragStart(handle, event);
16966
17150
  },
16967
- className: "absolute -bottom-1 -right-1 h-2.5 w-2.5 cursor-se-resize bg-accent"
16968
- })
17151
+ className: `${HANDLE_HIT} ${HANDLE_STYLE[handle]}`,
17152
+ style: DRAG_SURFACE,
17153
+ children: /* @__PURE__ */ jsx("span", { className: "h-2.5 w-2.5 bg-accent" })
17154
+ }, handle))
16969
17155
  });
16970
17156
  }
16971
17157
  function CellInspector({ cell, index, cameras, onChange, onRemove }) {
@@ -17001,17 +17187,23 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17001
17187
  }, camera.id))
17002
17188
  })]
17003
17189
  }),
17190
+ /* @__PURE__ */ jsx(SourceCropEditor, {
17191
+ deviceId: cell.deviceId,
17192
+ source: cell.source,
17193
+ onChange: (source) => {
17194
+ onChange({ source });
17195
+ }
17196
+ }),
17004
17197
  /* @__PURE__ */ jsx("div", {
17005
- className: "grid grid-cols-4 gap-2",
17198
+ className: "grid grid-cols-2 gap-x-3 gap-y-1.5",
17006
17199
  children: [
17007
17200
  "x",
17008
17201
  "y",
17009
17202
  "width",
17010
17203
  "height"
17011
- ].map((key) => /* @__PURE__ */ jsx(LabelledNumber, {
17204
+ ].map((key) => /* @__PURE__ */ jsx(CropSlider, {
17012
17205
  label: `Crop ${key}`,
17013
- value: Math.round(cell.source[key] * 100) / 100,
17014
- step: .01,
17206
+ value: cell.source[key],
17015
17207
  onChange: (next) => {
17016
17208
  onChange({ source: clampNormalizedRect({
17017
17209
  ...cell.source,
@@ -17023,6 +17215,165 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17023
17215
  ]
17024
17216
  });
17025
17217
  }
17218
+ /**
17219
+ * The crop, on the source camera's own frame.
17220
+ *
17221
+ * The whole snapshot is drawn at full opacity behind a dimmed overlay, and the
17222
+ * selected rectangle is the hole in it: what is INSIDE is what the grid will
17223
+ * show. Drawing it the other way round — bright rectangle on a dark picture —
17224
+ * reads as "this part is highlighted", which is not the same claim.
17225
+ *
17226
+ * Its drag state is local. The canvas above owns cell placement and this owns
17227
+ * the crop; sharing one drag state would mean every pointer move on either
17228
+ * canvas consulted a `target` discriminator to decide whether it was for the
17229
+ * other one.
17230
+ */
17231
+ function SourceCropEditor({ deviceId, source, onChange }) {
17232
+ const { src } = useDeviceSnapshotImage(deviceId, { width: 640 });
17233
+ const [drag, setDrag] = useState(null);
17234
+ const [sourceAspect, setSourceAspect] = useState(null);
17235
+ const onPointerMove = (event) => {
17236
+ if (drag === null) return;
17237
+ const bounds = event.currentTarget.getBoundingClientRect();
17238
+ if (bounds.width === 0 || bounds.height === 0) return;
17239
+ onChange(applyDrag({
17240
+ ...drag,
17241
+ index: 0,
17242
+ target: "source"
17243
+ }, (event.clientX - drag.startX) / bounds.width, (event.clientY - drag.startY) / bounds.height));
17244
+ };
17245
+ const full = source.x === 0 && source.y === 0 && source.width === 1 && source.height === 1;
17246
+ return /* @__PURE__ */ jsxs("div", {
17247
+ className: "flex flex-col gap-1",
17248
+ children: [/* @__PURE__ */ jsxs("div", {
17249
+ className: "flex items-center justify-between",
17250
+ children: [/* @__PURE__ */ jsx("span", {
17251
+ className: "uppercase text-foreground-subtle",
17252
+ children: "Visible part of the camera"
17253
+ }), /* @__PURE__ */ jsxs("button", {
17254
+ type: "button",
17255
+ disabled: full,
17256
+ onClick: () => {
17257
+ onChange(FULL_FRAME);
17258
+ },
17259
+ className: "flex items-center gap-1 rounded border border-border px-1.5 py-0.5 uppercase disabled:opacity-40",
17260
+ children: [/* @__PURE__ */ jsx(Maximize2, { className: "h-3 w-3" }), " Whole frame"]
17261
+ })]
17262
+ }), /* @__PURE__ */ jsxs("div", {
17263
+ className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
17264
+ style: {
17265
+ aspectRatio: String(sourceAspect ?? 16 / 9),
17266
+ ...DRAG_SURFACE
17267
+ },
17268
+ onPointerMove,
17269
+ onPointerUp: () => {
17270
+ setDrag(null);
17271
+ },
17272
+ onPointerCancel: () => {
17273
+ setDrag(null);
17274
+ },
17275
+ children: [
17276
+ src === null ? /* @__PURE__ */ jsx("div", {
17277
+ className: "absolute inset-0 flex items-center justify-center text-[10px] text-foreground-subtle",
17278
+ children: "No snapshot from this camera yet — the numbers below still apply."
17279
+ }) : /* @__PURE__ */ jsx("img", {
17280
+ src,
17281
+ alt: "",
17282
+ className: "absolute inset-0 h-full w-full",
17283
+ onLoad: (event) => {
17284
+ const { naturalWidth, naturalHeight } = event.currentTarget;
17285
+ if (naturalWidth > 0 && naturalHeight > 0) setSourceAspect(naturalWidth / naturalHeight);
17286
+ }
17287
+ }),
17288
+ /* @__PURE__ */ jsx("div", { className: "pointer-events-none absolute inset-0 bg-black/55" }),
17289
+ /* @__PURE__ */ jsx("div", {
17290
+ role: "presentation",
17291
+ onPointerDown: (event) => {
17292
+ capturePointer(event);
17293
+ setDrag({
17294
+ mode: "move",
17295
+ startX: event.clientX,
17296
+ startY: event.clientY,
17297
+ origin: source
17298
+ });
17299
+ },
17300
+ className: "absolute cursor-move border border-accent",
17301
+ style: {
17302
+ ...DRAG_SURFACE,
17303
+ left: `${String(source.x * 100)}%`,
17304
+ top: `${String(source.y * 100)}%`,
17305
+ width: `${String(source.width * 100)}%`,
17306
+ height: `${String(source.height * 100)}%`,
17307
+ backgroundImage: src === null ? void 0 : `url(${src})`,
17308
+ backgroundSize: `${String(100 / source.width)}% ${String(100 / source.height)}%`,
17309
+ backgroundPosition: source.width >= 1 && source.height >= 1 ? "center" : `${String(source.x / Math.max(1e-6, 1 - source.width) * 100)}% ${String(source.y / Math.max(1e-6, 1 - source.height) * 100)}%`
17310
+ },
17311
+ children: HANDLES.map((handle) => /* @__PURE__ */ jsx("div", {
17312
+ role: "presentation",
17313
+ onPointerDown: (event) => {
17314
+ event.stopPropagation();
17315
+ capturePointer(event);
17316
+ setDrag({
17317
+ mode: handle,
17318
+ startX: event.clientX,
17319
+ startY: event.clientY,
17320
+ origin: source
17321
+ });
17322
+ },
17323
+ className: `${HANDLE_HIT} ${HANDLE_STYLE[handle]}`,
17324
+ style: DRAG_SURFACE,
17325
+ children: /* @__PURE__ */ jsx("span", { className: "h-2.5 w-2.5 bg-accent" })
17326
+ }, handle))
17327
+ })
17328
+ ]
17329
+ })]
17330
+ });
17331
+ }
17332
+ /**
17333
+ * One crop number, as a slider AND as a number.
17334
+ *
17335
+ * The slider is what makes this usable with a thumb — a `number` input on a
17336
+ * phone opens a keyboard, and adjusting a crop by typing 0.37 then 0.39 then
17337
+ * 0.38 is not adjusting, it is guessing. The typed field stays because a slider
17338
+ * cannot express "exactly a third" and because a value you can read is how you
17339
+ * copy a crop from one cell to another.
17340
+ *
17341
+ * Both write through `clampNormalizedRect` upstream, so neither can hand the
17342
+ * addon a rectangle that leaves the frame — it THROWS on one, by design.
17343
+ */
17344
+ function CropSlider({ label, value, onChange }) {
17345
+ const rounded = Math.round(value * 100) / 100;
17346
+ return /* @__PURE__ */ jsxs("label", {
17347
+ className: "flex flex-col gap-0.5",
17348
+ children: [/* @__PURE__ */ jsxs("span", {
17349
+ className: "flex items-baseline justify-between text-foreground-subtle",
17350
+ children: [/* @__PURE__ */ jsx("span", { children: label }), /* @__PURE__ */ jsx("input", {
17351
+ type: "number",
17352
+ min: 0,
17353
+ max: 1,
17354
+ step: .01,
17355
+ value: rounded,
17356
+ onChange: (event) => {
17357
+ const next = Number(event.target.value);
17358
+ if (Number.isFinite(next)) onChange(next);
17359
+ },
17360
+ className: "w-12 rounded border border-border bg-transparent px-1 py-0.5 text-right"
17361
+ })]
17362
+ }), /* @__PURE__ */ jsx("input", {
17363
+ type: "range",
17364
+ min: 0,
17365
+ max: 1,
17366
+ step: .005,
17367
+ value,
17368
+ onChange: (event) => {
17369
+ const next = Number(event.target.value);
17370
+ if (Number.isFinite(next)) onChange(next);
17371
+ },
17372
+ style: DRAG_SURFACE,
17373
+ className: "h-5 w-full accent-accent"
17374
+ })]
17375
+ });
17376
+ }
17026
17377
  function LabelledNumber({ label, value, step = 1, onChange }) {
17027
17378
  return /* @__PURE__ */ jsxs("label", {
17028
17379
  className: "flex flex-col gap-0.5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/ui-library",
3
- "version": "1.2.177",
3
+ "version": "1.2.179",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",