@camstack/ui-library 1.2.177 → 1.2.178

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,31 @@ 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
+ /** The eight grips, in the order they are drawn. */
16810
+ var HANDLES = [
16811
+ "nw",
16812
+ "n",
16813
+ "ne",
16814
+ "e",
16815
+ "se",
16816
+ "s",
16817
+ "sw",
16818
+ "w"
16819
+ ];
16820
+ var HANDLE_STYLE = {
16821
+ nw: "-top-1 -left-1 cursor-nw-resize",
16822
+ n: "-top-1 left-1/2 -translate-x-1/2 cursor-n-resize",
16823
+ ne: "-top-1 -right-1 cursor-ne-resize",
16824
+ e: "top-1/2 -right-1 -translate-y-1/2 cursor-e-resize",
16825
+ se: "-bottom-1 -right-1 cursor-se-resize",
16826
+ s: "-bottom-1 left-1/2 -translate-x-1/2 cursor-s-resize",
16827
+ sw: "-bottom-1 -left-1 cursor-sw-resize",
16828
+ w: "top-1/2 -left-1 -translate-y-1/2 cursor-w-resize"
16829
+ };
16830
+ /** Move or resize, from one drag and one delta. */
16831
+ function applyDrag(drag, dx, dy) {
16832
+ return drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRectFrom(drag.origin, drag.mode, dx, dy);
16833
+ }
16707
16834
  function CameraGridLayoutEditor({ deviceId }) {
16708
16835
  const system = useSystem();
16709
16836
  const cameras = (0, react$1.useMemo)(() => system.listDeviceInfos(CAMERA_FILTER), [system, useDevices(CAMERA_FILTER)]);
@@ -16792,19 +16919,39 @@ function CameraGridLayoutEditor({ deviceId }) {
16792
16919
  if (drag === null) return;
16793
16920
  const bounds = event.currentTarget.getBoundingClientRect();
16794
16921
  if (bounds.width === 0 || bounds.height === 0) return;
16922
+ if (drag.target !== "cell") return;
16795
16923
  const dx = (event.clientX - drag.startX) / bounds.width;
16796
16924
  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) });
16925
+ patchCell(drag.index, { cell: applyDrag(drag, dx, dy) });
16798
16926
  };
16799
16927
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetPanel, {
16800
16928
  title: "Grid layout",
16801
16929
  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"]
16930
+ headerRight: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16931
+ className: "flex items-center gap-1",
16932
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16933
+ type: "button",
16934
+ disabled: draft.cells.length === 0,
16935
+ title: "Tile every cell over the whole canvas, leaving no gap",
16936
+ onClick: () => {
16937
+ const rects = autofillCellRects(draft.cells.length);
16938
+ setDraft({
16939
+ ...draft,
16940
+ cells: draft.cells.map((cell, index) => ({
16941
+ ...cell,
16942
+ cell: rects[index] ?? cell.cell
16943
+ }))
16944
+ });
16945
+ },
16946
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16947
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LayoutGrid, { className: "h-3 w-3" }), " Autofill"]
16948
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16949
+ type: "button",
16950
+ disabled: saving,
16951
+ onClick: () => void save(),
16952
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16953
+ 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"]
16954
+ })]
16808
16955
  }),
16809
16956
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16810
16957
  className: "flex flex-col gap-3",
@@ -16837,6 +16984,7 @@ function CameraGridLayoutEditor({ deviceId }) {
16837
16984
  setSelected(index);
16838
16985
  setDrag({
16839
16986
  index,
16987
+ target: "cell",
16840
16988
  mode,
16841
16989
  startX: event.clientX,
16842
16990
  startY: event.clientY,
@@ -16982,14 +17130,14 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16982
17130
  backgroundPosition,
16983
17131
  backgroundRepeat: "no-repeat"
16984
17132
  },
16985
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17133
+ children: selected && HANDLES.map((handle) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16986
17134
  role: "presentation",
16987
17135
  onPointerDown: (event) => {
16988
17136
  event.stopPropagation();
16989
- onDragStart("resize", event);
17137
+ onDragStart(handle, event);
16990
17138
  },
16991
- className: "absolute -bottom-1 -right-1 h-2.5 w-2.5 cursor-se-resize bg-accent"
16992
- })
17139
+ className: `absolute h-2.5 w-2.5 bg-accent ${HANDLE_STYLE[handle]}`
17140
+ }, handle))
16993
17141
  });
16994
17142
  }
16995
17143
  function CellInspector({ cell, index, cameras, onChange, onRemove }) {
@@ -17025,6 +17173,13 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17025
17173
  }, camera.id))
17026
17174
  })]
17027
17175
  }),
17176
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SourceCropEditor, {
17177
+ deviceId: cell.deviceId,
17178
+ source: cell.source,
17179
+ onChange: (source) => {
17180
+ onChange({ source });
17181
+ }
17182
+ }),
17028
17183
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17029
17184
  className: "grid grid-cols-4 gap-2",
17030
17185
  children: [
@@ -17047,6 +17202,107 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17047
17202
  ]
17048
17203
  });
17049
17204
  }
17205
+ /**
17206
+ * The crop, on the source camera's own frame.
17207
+ *
17208
+ * The whole snapshot is drawn at full opacity behind a dimmed overlay, and the
17209
+ * selected rectangle is the hole in it: what is INSIDE is what the grid will
17210
+ * show. Drawing it the other way round — bright rectangle on a dark picture —
17211
+ * reads as "this part is highlighted", which is not the same claim.
17212
+ *
17213
+ * Its drag state is local. The canvas above owns cell placement and this owns
17214
+ * the crop; sharing one drag state would mean every pointer move on either
17215
+ * canvas consulted a `target` discriminator to decide whether it was for the
17216
+ * other one.
17217
+ */
17218
+ function SourceCropEditor({ deviceId, source, onChange }) {
17219
+ const { src } = useDeviceSnapshotImage(deviceId, { width: 640 });
17220
+ const [drag, setDrag] = (0, react$1.useState)(null);
17221
+ const onPointerMove = (event) => {
17222
+ if (drag === null) return;
17223
+ const bounds = event.currentTarget.getBoundingClientRect();
17224
+ if (bounds.width === 0 || bounds.height === 0) return;
17225
+ onChange(applyDrag({
17226
+ ...drag,
17227
+ index: 0,
17228
+ target: "source"
17229
+ }, (event.clientX - drag.startX) / bounds.width, (event.clientY - drag.startY) / bounds.height));
17230
+ };
17231
+ const full = source.x === 0 && source.y === 0 && source.width === 1 && source.height === 1;
17232
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17233
+ className: "flex flex-col gap-1",
17234
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17235
+ className: "flex items-center justify-between",
17236
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17237
+ className: "uppercase text-foreground-subtle",
17238
+ children: "Visible part of the camera"
17239
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17240
+ type: "button",
17241
+ disabled: full,
17242
+ onClick: () => {
17243
+ onChange(FULL_FRAME);
17244
+ },
17245
+ className: "flex items-center gap-1 rounded border border-border px-1.5 py-0.5 uppercase disabled:opacity-40",
17246
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Maximize2, { className: "h-3 w-3" }), " Whole frame"]
17247
+ })]
17248
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17249
+ className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
17250
+ style: { aspectRatio: "16 / 9" },
17251
+ onPointerMove,
17252
+ onPointerUp: () => {
17253
+ setDrag(null);
17254
+ },
17255
+ onPointerLeave: () => {
17256
+ setDrag(null);
17257
+ },
17258
+ children: [
17259
+ src === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17260
+ className: "absolute inset-0 flex items-center justify-center text-[10px] text-foreground-subtle",
17261
+ children: "No snapshot from this camera yet — the numbers below still apply."
17262
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
17263
+ src,
17264
+ alt: "",
17265
+ className: "absolute inset-0 h-full w-full object-cover"
17266
+ }),
17267
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "pointer-events-none absolute inset-0 bg-black/55" }),
17268
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17269
+ role: "presentation",
17270
+ onPointerDown: (event) => {
17271
+ setDrag({
17272
+ mode: "move",
17273
+ startX: event.clientX,
17274
+ startY: event.clientY,
17275
+ origin: source
17276
+ });
17277
+ },
17278
+ className: "absolute cursor-move border border-accent",
17279
+ style: {
17280
+ left: `${String(source.x * 100)}%`,
17281
+ top: `${String(source.y * 100)}%`,
17282
+ width: `${String(source.width * 100)}%`,
17283
+ height: `${String(source.height * 100)}%`,
17284
+ backgroundImage: src === null ? void 0 : `url(${src})`,
17285
+ backgroundSize: `${String(100 / source.width)}% ${String(100 / source.height)}%`,
17286
+ 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)}%`
17287
+ },
17288
+ children: HANDLES.map((handle) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17289
+ role: "presentation",
17290
+ onPointerDown: (event) => {
17291
+ event.stopPropagation();
17292
+ setDrag({
17293
+ mode: handle,
17294
+ startX: event.clientX,
17295
+ startY: event.clientY,
17296
+ origin: source
17297
+ });
17298
+ },
17299
+ className: `absolute h-2.5 w-2.5 bg-accent ${HANDLE_STYLE[handle]}`
17300
+ }, handle))
17301
+ })
17302
+ ]
17303
+ })]
17304
+ });
17305
+ }
17050
17306
  function LabelledNumber({ label, value, step = 1, onChange }) {
17051
17307
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17052
17308
  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,31 @@ 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
+ /** The eight grips, in the order they are drawn. */
16786
+ var HANDLES = [
16787
+ "nw",
16788
+ "n",
16789
+ "ne",
16790
+ "e",
16791
+ "se",
16792
+ "s",
16793
+ "sw",
16794
+ "w"
16795
+ ];
16796
+ var HANDLE_STYLE = {
16797
+ nw: "-top-1 -left-1 cursor-nw-resize",
16798
+ n: "-top-1 left-1/2 -translate-x-1/2 cursor-n-resize",
16799
+ ne: "-top-1 -right-1 cursor-ne-resize",
16800
+ e: "top-1/2 -right-1 -translate-y-1/2 cursor-e-resize",
16801
+ se: "-bottom-1 -right-1 cursor-se-resize",
16802
+ s: "-bottom-1 left-1/2 -translate-x-1/2 cursor-s-resize",
16803
+ sw: "-bottom-1 -left-1 cursor-sw-resize",
16804
+ w: "top-1/2 -left-1 -translate-y-1/2 cursor-w-resize"
16805
+ };
16806
+ /** Move or resize, from one drag and one delta. */
16807
+ function applyDrag(drag, dx, dy) {
16808
+ return drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRectFrom(drag.origin, drag.mode, dx, dy);
16809
+ }
16683
16810
  function CameraGridLayoutEditor({ deviceId }) {
16684
16811
  const system = useSystem();
16685
16812
  const cameras = useMemo(() => system.listDeviceInfos(CAMERA_FILTER), [system, useDevices(CAMERA_FILTER)]);
@@ -16768,19 +16895,39 @@ function CameraGridLayoutEditor({ deviceId }) {
16768
16895
  if (drag === null) return;
16769
16896
  const bounds = event.currentTarget.getBoundingClientRect();
16770
16897
  if (bounds.width === 0 || bounds.height === 0) return;
16898
+ if (drag.target !== "cell") return;
16771
16899
  const dx = (event.clientX - drag.startX) / bounds.width;
16772
16900
  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) });
16901
+ patchCell(drag.index, { cell: applyDrag(drag, dx, dy) });
16774
16902
  };
16775
16903
  return /* @__PURE__ */ jsx(WidgetPanel, {
16776
16904
  title: "Grid layout",
16777
16905
  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"]
16906
+ headerRight: /* @__PURE__ */ jsxs("div", {
16907
+ className: "flex items-center gap-1",
16908
+ children: [/* @__PURE__ */ jsxs("button", {
16909
+ type: "button",
16910
+ disabled: draft.cells.length === 0,
16911
+ title: "Tile every cell over the whole canvas, leaving no gap",
16912
+ onClick: () => {
16913
+ const rects = autofillCellRects(draft.cells.length);
16914
+ setDraft({
16915
+ ...draft,
16916
+ cells: draft.cells.map((cell, index) => ({
16917
+ ...cell,
16918
+ cell: rects[index] ?? cell.cell
16919
+ }))
16920
+ });
16921
+ },
16922
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16923
+ children: [/* @__PURE__ */ jsx(LayoutGrid, { className: "h-3 w-3" }), " Autofill"]
16924
+ }), /* @__PURE__ */ jsxs("button", {
16925
+ type: "button",
16926
+ disabled: saving,
16927
+ onClick: () => void save(),
16928
+ className: "flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] uppercase",
16929
+ children: [saving ? /* @__PURE__ */ jsx(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsx(Save, { className: "h-3 w-3" }), "Save"]
16930
+ })]
16784
16931
  }),
16785
16932
  children: /* @__PURE__ */ jsxs("div", {
16786
16933
  className: "flex flex-col gap-3",
@@ -16813,6 +16960,7 @@ function CameraGridLayoutEditor({ deviceId }) {
16813
16960
  setSelected(index);
16814
16961
  setDrag({
16815
16962
  index,
16963
+ target: "cell",
16816
16964
  mode,
16817
16965
  startX: event.clientX,
16818
16966
  startY: event.clientY,
@@ -16958,14 +17106,14 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16958
17106
  backgroundPosition,
16959
17107
  backgroundRepeat: "no-repeat"
16960
17108
  },
16961
- children: /* @__PURE__ */ jsx("div", {
17109
+ children: selected && HANDLES.map((handle) => /* @__PURE__ */ jsx("div", {
16962
17110
  role: "presentation",
16963
17111
  onPointerDown: (event) => {
16964
17112
  event.stopPropagation();
16965
- onDragStart("resize", event);
17113
+ onDragStart(handle, event);
16966
17114
  },
16967
- className: "absolute -bottom-1 -right-1 h-2.5 w-2.5 cursor-se-resize bg-accent"
16968
- })
17115
+ className: `absolute h-2.5 w-2.5 bg-accent ${HANDLE_STYLE[handle]}`
17116
+ }, handle))
16969
17117
  });
16970
17118
  }
16971
17119
  function CellInspector({ cell, index, cameras, onChange, onRemove }) {
@@ -17001,6 +17149,13 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17001
17149
  }, camera.id))
17002
17150
  })]
17003
17151
  }),
17152
+ /* @__PURE__ */ jsx(SourceCropEditor, {
17153
+ deviceId: cell.deviceId,
17154
+ source: cell.source,
17155
+ onChange: (source) => {
17156
+ onChange({ source });
17157
+ }
17158
+ }),
17004
17159
  /* @__PURE__ */ jsx("div", {
17005
17160
  className: "grid grid-cols-4 gap-2",
17006
17161
  children: [
@@ -17023,6 +17178,107 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17023
17178
  ]
17024
17179
  });
17025
17180
  }
17181
+ /**
17182
+ * The crop, on the source camera's own frame.
17183
+ *
17184
+ * The whole snapshot is drawn at full opacity behind a dimmed overlay, and the
17185
+ * selected rectangle is the hole in it: what is INSIDE is what the grid will
17186
+ * show. Drawing it the other way round — bright rectangle on a dark picture —
17187
+ * reads as "this part is highlighted", which is not the same claim.
17188
+ *
17189
+ * Its drag state is local. The canvas above owns cell placement and this owns
17190
+ * the crop; sharing one drag state would mean every pointer move on either
17191
+ * canvas consulted a `target` discriminator to decide whether it was for the
17192
+ * other one.
17193
+ */
17194
+ function SourceCropEditor({ deviceId, source, onChange }) {
17195
+ const { src } = useDeviceSnapshotImage(deviceId, { width: 640 });
17196
+ const [drag, setDrag] = useState(null);
17197
+ const onPointerMove = (event) => {
17198
+ if (drag === null) return;
17199
+ const bounds = event.currentTarget.getBoundingClientRect();
17200
+ if (bounds.width === 0 || bounds.height === 0) return;
17201
+ onChange(applyDrag({
17202
+ ...drag,
17203
+ index: 0,
17204
+ target: "source"
17205
+ }, (event.clientX - drag.startX) / bounds.width, (event.clientY - drag.startY) / bounds.height));
17206
+ };
17207
+ const full = source.x === 0 && source.y === 0 && source.width === 1 && source.height === 1;
17208
+ return /* @__PURE__ */ jsxs("div", {
17209
+ className: "flex flex-col gap-1",
17210
+ children: [/* @__PURE__ */ jsxs("div", {
17211
+ className: "flex items-center justify-between",
17212
+ children: [/* @__PURE__ */ jsx("span", {
17213
+ className: "uppercase text-foreground-subtle",
17214
+ children: "Visible part of the camera"
17215
+ }), /* @__PURE__ */ jsxs("button", {
17216
+ type: "button",
17217
+ disabled: full,
17218
+ onClick: () => {
17219
+ onChange(FULL_FRAME);
17220
+ },
17221
+ className: "flex items-center gap-1 rounded border border-border px-1.5 py-0.5 uppercase disabled:opacity-40",
17222
+ children: [/* @__PURE__ */ jsx(Maximize2, { className: "h-3 w-3" }), " Whole frame"]
17223
+ })]
17224
+ }), /* @__PURE__ */ jsxs("div", {
17225
+ className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
17226
+ style: { aspectRatio: "16 / 9" },
17227
+ onPointerMove,
17228
+ onPointerUp: () => {
17229
+ setDrag(null);
17230
+ },
17231
+ onPointerLeave: () => {
17232
+ setDrag(null);
17233
+ },
17234
+ children: [
17235
+ src === null ? /* @__PURE__ */ jsx("div", {
17236
+ className: "absolute inset-0 flex items-center justify-center text-[10px] text-foreground-subtle",
17237
+ children: "No snapshot from this camera yet — the numbers below still apply."
17238
+ }) : /* @__PURE__ */ jsx("img", {
17239
+ src,
17240
+ alt: "",
17241
+ className: "absolute inset-0 h-full w-full object-cover"
17242
+ }),
17243
+ /* @__PURE__ */ jsx("div", { className: "pointer-events-none absolute inset-0 bg-black/55" }),
17244
+ /* @__PURE__ */ jsx("div", {
17245
+ role: "presentation",
17246
+ onPointerDown: (event) => {
17247
+ setDrag({
17248
+ mode: "move",
17249
+ startX: event.clientX,
17250
+ startY: event.clientY,
17251
+ origin: source
17252
+ });
17253
+ },
17254
+ className: "absolute cursor-move border border-accent",
17255
+ style: {
17256
+ left: `${String(source.x * 100)}%`,
17257
+ top: `${String(source.y * 100)}%`,
17258
+ width: `${String(source.width * 100)}%`,
17259
+ height: `${String(source.height * 100)}%`,
17260
+ backgroundImage: src === null ? void 0 : `url(${src})`,
17261
+ backgroundSize: `${String(100 / source.width)}% ${String(100 / source.height)}%`,
17262
+ 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)}%`
17263
+ },
17264
+ children: HANDLES.map((handle) => /* @__PURE__ */ jsx("div", {
17265
+ role: "presentation",
17266
+ onPointerDown: (event) => {
17267
+ event.stopPropagation();
17268
+ setDrag({
17269
+ mode: handle,
17270
+ startX: event.clientX,
17271
+ startY: event.clientY,
17272
+ origin: source
17273
+ });
17274
+ },
17275
+ className: `absolute h-2.5 w-2.5 bg-accent ${HANDLE_STYLE[handle]}`
17276
+ }, handle))
17277
+ })
17278
+ ]
17279
+ })]
17280
+ });
17281
+ }
17026
17282
  function LabelledNumber({ label, value, step = 1, onChange }) {
17027
17283
  return /* @__PURE__ */ jsxs("label", {
17028
17284
  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.178",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",