@camstack/ui-library 1.2.178 → 1.2.180

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.
@@ -32,13 +32,17 @@ export interface GridProfileOffer {
32
32
  readonly profile: 'high' | 'mid' | 'low';
33
33
  readonly offered: boolean;
34
34
  readonly missingSources: readonly number[];
35
+ /**
36
+ * The canvas this profile composes onto, `WxH`, or empty when it is not
37
+ * offered. DERIVED — the operator no longer types a resolution, so this is
38
+ * the only place the grid's actual size is ever stated.
39
+ */
40
+ readonly canvas: string;
35
41
  }
36
42
  export interface GridLayoutView {
37
43
  readonly instanceId: string;
38
44
  readonly deviceId: number;
39
45
  readonly name: string;
40
- readonly width: number;
41
- readonly height: number;
42
46
  readonly fps: number;
43
47
  readonly cells: readonly GridLayoutCell[];
44
48
  readonly profiles: readonly GridProfileOffer[];
package/dist/index.cjs CHANGED
@@ -16641,7 +16641,8 @@ function readProfileOffer(value) {
16641
16641
  return {
16642
16642
  profile,
16643
16643
  offered,
16644
- missingSources
16644
+ missingSources,
16645
+ canvas: typeof value["canvas"] === "string" ? value["canvas"] : ""
16645
16646
  };
16646
16647
  }
16647
16648
  /**
@@ -16655,8 +16656,8 @@ function readProfileOffer(value) {
16655
16656
  function parseGridView(raw) {
16656
16657
  if (raw === null) return null;
16657
16658
  if (!isRecord(raw)) return "unreadable";
16658
- const { instanceId, deviceId, name, width, height, fps, cells, profiles } = raw;
16659
- if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof width !== "number" || typeof height !== "number" || typeof fps !== "number" || !Array.isArray(cells) || !Array.isArray(profiles)) return "unreadable";
16659
+ const { instanceId, deviceId, name, fps, cells, profiles } = raw;
16660
+ if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof fps !== "number" || !Array.isArray(cells) || !Array.isArray(profiles)) return "unreadable";
16660
16661
  const parsed = [];
16661
16662
  for (const entry of cells) {
16662
16663
  const cell = readCell(entry);
@@ -16673,8 +16674,6 @@ function parseGridView(raw) {
16673
16674
  instanceId,
16674
16675
  deviceId,
16675
16676
  name,
16676
- width,
16677
- height,
16678
16677
  fps,
16679
16678
  cells: parsed,
16680
16679
  profiles: offers
@@ -16806,6 +16805,25 @@ var FULL_FRAME = {
16806
16805
  var MAX_CELLS = 16;
16807
16806
  /** Hoisted so `useDevices` does not re-subscribe on every render. */
16808
16807
  var CAMERA_FILTER = { type: _camstack_types.DeviceType.Camera };
16808
+ /**
16809
+ * A rectangle being dragged must not also PAN THE PAGE.
16810
+ *
16811
+ * On a phone a pointerdown on a `div` is a scroll gesture until the browser is
16812
+ * told otherwise, and `preventDefault` in the handler is too late — the browser
16813
+ * has already decided. `touch-action: none` is the only thing that makes a drag
16814
+ * work with a finger, and every surface that starts one carries it.
16815
+ */
16816
+ var DRAG_SURFACE = { touchAction: "none" };
16817
+ /**
16818
+ * Finger-sized hit area around a grip.
16819
+ *
16820
+ * The visible square stays small (it sits ON the picture being edited and a big
16821
+ * one hides what it is positioning); the TOUCHABLE box around it is 24px, which
16822
+ * is the smallest target size that does not need a fingernail. The two are
16823
+ * different boxes on purpose — making the dot itself 24px would cover a quarter
16824
+ * of a small cell.
16825
+ */
16826
+ var HANDLE_HIT = "absolute flex h-6 w-6 items-center justify-center";
16809
16827
  /** The eight grips, in the order they are drawn. */
16810
16828
  var HANDLES = [
16811
16829
  "nw",
@@ -16818,15 +16836,26 @@ var HANDLES = [
16818
16836
  "w"
16819
16837
  ];
16820
16838
  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"
16839
+ nw: "-top-3 -left-3 cursor-nw-resize",
16840
+ n: "-top-3 left-1/2 -translate-x-1/2 cursor-n-resize",
16841
+ ne: "-top-3 -right-3 cursor-ne-resize",
16842
+ e: "top-1/2 -right-3 -translate-y-1/2 cursor-e-resize",
16843
+ se: "-bottom-3 -right-3 cursor-se-resize",
16844
+ s: "-bottom-3 left-1/2 -translate-x-1/2 cursor-s-resize",
16845
+ sw: "-bottom-3 -left-3 cursor-sw-resize",
16846
+ w: "top-1/2 -left-3 -translate-y-1/2 cursor-w-resize"
16829
16847
  };
16848
+ /**
16849
+ * Start a drag and KEEP it, even when the finger leaves the rectangle.
16850
+ *
16851
+ * Without pointer capture a drag ends the moment the pointer crosses the box it
16852
+ * started in — which on a phone is immediately, because the box is small and
16853
+ * the finger is not. With capture the events keep coming to the element that
16854
+ * was grabbed and still bubble to the canvas that does the arithmetic.
16855
+ */
16856
+ function capturePointer(event) {
16857
+ event.currentTarget.setPointerCapture(event.pointerId);
16858
+ }
16830
16859
  /** Move or resize, from one drag and one delta. */
16831
16860
  function applyDrag(drag, dx, dy) {
16832
16861
  return drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRectFrom(drag.origin, drag.mode, dx, dy);
@@ -16866,8 +16895,6 @@ function CameraGridLayoutEditor({ deviceId }) {
16866
16895
  try {
16867
16896
  const outcome = parseGridView(await layout.saveLayout({
16868
16897
  name: draft.name,
16869
- width: draft.width,
16870
- height: draft.height,
16871
16898
  fps: draft.fps,
16872
16899
  cells: draft.cells.map((cell) => ({ ...cell }))
16873
16900
  }));
@@ -16890,7 +16917,27 @@ function CameraGridLayoutEditor({ deviceId }) {
16890
16917
  } : cell)
16891
16918
  });
16892
16919
  }, []);
16893
- const canvasAspect = (0, react$1.useMemo)(() => draft === null ? 16 / 9 : draft.width / draft.height, [draft]);
16920
+ /**
16921
+ * The shape the grid will actually come out as.
16922
+ *
16923
+ * The canvas is no longer a field: each profile derives its own from the
16924
+ * cells and the sources' dimensions, and the server reports what it came out
16925
+ * as. Drawing at that aspect is the only way the editor shows the truth —
16926
+ * before, it drew at whatever two numbers had been typed, which is exactly
16927
+ * how a layout could look right here and wrong on screen.
16928
+ *
16929
+ * 16:9 while nothing is offered yet: a grid with no cells has no shape, and
16930
+ * an empty editor has to be drawn at something.
16931
+ */
16932
+ const canvasAspect = (0, react$1.useMemo)(() => {
16933
+ const offered = loaded === void 0 || loaded === null || loaded === "unreadable" ? [] : loaded.profiles;
16934
+ for (const row of offered) {
16935
+ if (!row.offered) continue;
16936
+ const [width, height] = row.canvas.split("x").map(Number);
16937
+ if (width !== void 0 && height !== void 0 && width > 0 && height > 0) return width / height;
16938
+ }
16939
+ return 16 / 9;
16940
+ }, [loaded]);
16894
16941
  if (loaded === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetPanel, {
16895
16942
  title: "Grid layout",
16896
16943
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Grid2x2, { className: "h-3.5 w-3.5" }),
@@ -16966,12 +17013,15 @@ function CameraGridLayoutEditor({ deviceId }) {
16966
17013
  }),
16967
17014
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16968
17015
  className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
16969
- style: { aspectRatio: String(canvasAspect) },
17016
+ style: {
17017
+ aspectRatio: String(canvasAspect),
17018
+ ...DRAG_SURFACE
17019
+ },
16970
17020
  onPointerMove,
16971
17021
  onPointerUp: () => {
16972
17022
  setDrag(null);
16973
17023
  },
16974
- onPointerLeave: () => {
17024
+ onPointerCancel: () => {
16975
17025
  setDrag(null);
16976
17026
  },
16977
17027
  children: [draft.cells.map((cell, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GridCellBox, {
@@ -16998,62 +17048,39 @@ function CameraGridLayoutEditor({ deviceId }) {
16998
17048
  }),
16999
17049
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17000
17050
  className: "grid grid-cols-4 gap-2 text-[10.5px]",
17001
- children: [
17002
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
17003
- label: "Width",
17004
- value: draft.width,
17005
- onChange: (width) => {
17006
- setDraft({
17007
- ...draft,
17008
- width
17009
- });
17010
- }
17011
- }),
17012
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
17013
- label: "Height",
17014
- value: draft.height,
17015
- onChange: (height) => {
17016
- setDraft({
17017
- ...draft,
17018
- height
17019
- });
17020
- }
17021
- }),
17022
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
17023
- label: "FPS",
17024
- value: draft.fps,
17025
- onChange: (fps) => {
17026
- setDraft({
17027
- ...draft,
17028
- fps
17029
- });
17030
- }
17031
- }),
17032
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17033
- type: "button",
17034
- disabled: draft.cells.length >= MAX_CELLS,
17035
- onClick: () => {
17036
- const first = cameras.find((camera) => camera.id !== deviceId);
17037
- if (!first) return;
17038
- setDraft({
17039
- ...draft,
17040
- cells: [...draft.cells, {
17041
- deviceId: first.id,
17042
- source: FULL_FRAME,
17043
- cell: clampNormalizedRect({
17044
- x: .1,
17045
- y: .1,
17046
- width: .4,
17047
- height: .4
17048
- })
17049
- }]
17050
- });
17051
- setSelected(draft.cells.length);
17052
- },
17053
- className: "flex items-center justify-center gap-1 self-end rounded border border-border px-2 py-1 uppercase",
17054
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Plus, { className: "h-3 w-3" }), " Cell"]
17055
- })
17056
- ]
17051
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
17052
+ label: "FPS",
17053
+ value: draft.fps,
17054
+ onChange: (fps) => {
17055
+ setDraft({
17056
+ ...draft,
17057
+ fps
17058
+ });
17059
+ }
17060
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17061
+ type: "button",
17062
+ disabled: draft.cells.length >= MAX_CELLS,
17063
+ onClick: () => {
17064
+ const first = cameras.find((camera) => camera.id !== deviceId);
17065
+ if (!first) return;
17066
+ setDraft({
17067
+ ...draft,
17068
+ cells: [...draft.cells, {
17069
+ deviceId: first.id,
17070
+ source: FULL_FRAME,
17071
+ cell: clampNormalizedRect({
17072
+ x: .1,
17073
+ y: .1,
17074
+ width: .4,
17075
+ height: .4
17076
+ })
17077
+ }]
17078
+ });
17079
+ setSelected(draft.cells.length);
17080
+ },
17081
+ className: "flex items-center justify-center gap-1 self-end rounded border border-border px-2 py-1 uppercase",
17082
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Plus, { className: "h-3 w-3" }), " Cell"]
17083
+ })]
17057
17084
  }),
17058
17085
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GridProfileReport, {
17059
17086
  offers: loaded.profiles,
@@ -17101,7 +17128,11 @@ function GridProfileReport({ offers, cameras }) {
17101
17128
  children: offer.profile
17102
17129
  }), offer.offered ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
17103
17130
  className: "text-foreground-subtle",
17104
- children: ["composed from every source’s ", offer.profile]
17131
+ children: [
17132
+ offer.canvas,
17133
+ " — composed from every source’s ",
17134
+ offer.profile
17135
+ ]
17105
17136
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17106
17137
  className: "text-danger",
17107
17138
  children: offer.missingSources.length === 0 ? "not offered — this grid has no cells yet" : `not offered — no ${offer.profile} on ${offer.missingSources.map(nameOf).join(", ")}`
@@ -17117,10 +17148,12 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
17117
17148
  role: "presentation",
17118
17149
  onPointerDown: (event) => {
17119
17150
  onSelect();
17151
+ capturePointer(event);
17120
17152
  onDragStart("move", event);
17121
17153
  },
17122
17154
  className: `absolute cursor-move border ${selected ? "border-accent" : "border-border/60"}`,
17123
17155
  style: {
17156
+ ...DRAG_SURFACE,
17124
17157
  left: `${String(cell.cell.x * 100)}%`,
17125
17158
  top: `${String(cell.cell.y * 100)}%`,
17126
17159
  width: `${String(cell.cell.width * 100)}%`,
@@ -17134,9 +17167,12 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
17134
17167
  role: "presentation",
17135
17168
  onPointerDown: (event) => {
17136
17169
  event.stopPropagation();
17170
+ capturePointer(event);
17137
17171
  onDragStart(handle, event);
17138
17172
  },
17139
- className: `absolute h-2.5 w-2.5 bg-accent ${HANDLE_STYLE[handle]}`
17173
+ className: `${HANDLE_HIT} ${HANDLE_STYLE[handle]}`,
17174
+ style: DRAG_SURFACE,
17175
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-2.5 w-2.5 bg-accent" })
17140
17176
  }, handle))
17141
17177
  });
17142
17178
  }
@@ -17181,16 +17217,15 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17181
17217
  }
17182
17218
  }),
17183
17219
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17184
- className: "grid grid-cols-4 gap-2",
17220
+ className: "grid grid-cols-2 gap-x-3 gap-y-1.5",
17185
17221
  children: [
17186
17222
  "x",
17187
17223
  "y",
17188
17224
  "width",
17189
17225
  "height"
17190
- ].map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelledNumber, {
17226
+ ].map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CropSlider, {
17191
17227
  label: `Crop ${key}`,
17192
- value: Math.round(cell.source[key] * 100) / 100,
17193
- step: .01,
17228
+ value: cell.source[key],
17194
17229
  onChange: (next) => {
17195
17230
  onChange({ source: clampNormalizedRect({
17196
17231
  ...cell.source,
@@ -17218,6 +17253,7 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17218
17253
  function SourceCropEditor({ deviceId, source, onChange }) {
17219
17254
  const { src } = useDeviceSnapshotImage(deviceId, { width: 640 });
17220
17255
  const [drag, setDrag] = (0, react$1.useState)(null);
17256
+ const [sourceAspect, setSourceAspect] = (0, react$1.useState)(null);
17221
17257
  const onPointerMove = (event) => {
17222
17258
  if (drag === null) return;
17223
17259
  const bounds = event.currentTarget.getBoundingClientRect();
@@ -17247,12 +17283,15 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17247
17283
  })]
17248
17284
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17249
17285
  className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
17250
- style: { aspectRatio: "16 / 9" },
17286
+ style: {
17287
+ aspectRatio: String(sourceAspect ?? 16 / 9),
17288
+ ...DRAG_SURFACE
17289
+ },
17251
17290
  onPointerMove,
17252
17291
  onPointerUp: () => {
17253
17292
  setDrag(null);
17254
17293
  },
17255
- onPointerLeave: () => {
17294
+ onPointerCancel: () => {
17256
17295
  setDrag(null);
17257
17296
  },
17258
17297
  children: [
@@ -17262,12 +17301,17 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17262
17301
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
17263
17302
  src,
17264
17303
  alt: "",
17265
- className: "absolute inset-0 h-full w-full object-cover"
17304
+ className: "absolute inset-0 h-full w-full",
17305
+ onLoad: (event) => {
17306
+ const { naturalWidth, naturalHeight } = event.currentTarget;
17307
+ if (naturalWidth > 0 && naturalHeight > 0) setSourceAspect(naturalWidth / naturalHeight);
17308
+ }
17266
17309
  }),
17267
17310
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "pointer-events-none absolute inset-0 bg-black/55" }),
17268
17311
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17269
17312
  role: "presentation",
17270
17313
  onPointerDown: (event) => {
17314
+ capturePointer(event);
17271
17315
  setDrag({
17272
17316
  mode: "move",
17273
17317
  startX: event.clientX,
@@ -17277,6 +17321,7 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17277
17321
  },
17278
17322
  className: "absolute cursor-move border border-accent",
17279
17323
  style: {
17324
+ ...DRAG_SURFACE,
17280
17325
  left: `${String(source.x * 100)}%`,
17281
17326
  top: `${String(source.y * 100)}%`,
17282
17327
  width: `${String(source.width * 100)}%`,
@@ -17289,6 +17334,7 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17289
17334
  role: "presentation",
17290
17335
  onPointerDown: (event) => {
17291
17336
  event.stopPropagation();
17337
+ capturePointer(event);
17292
17338
  setDrag({
17293
17339
  mode: handle,
17294
17340
  startX: event.clientX,
@@ -17296,13 +17342,60 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17296
17342
  origin: source
17297
17343
  });
17298
17344
  },
17299
- className: `absolute h-2.5 w-2.5 bg-accent ${HANDLE_STYLE[handle]}`
17345
+ className: `${HANDLE_HIT} ${HANDLE_STYLE[handle]}`,
17346
+ style: DRAG_SURFACE,
17347
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-2.5 w-2.5 bg-accent" })
17300
17348
  }, handle))
17301
17349
  })
17302
17350
  ]
17303
17351
  })]
17304
17352
  });
17305
17353
  }
17354
+ /**
17355
+ * One crop number, as a slider AND as a number.
17356
+ *
17357
+ * The slider is what makes this usable with a thumb — a `number` input on a
17358
+ * phone opens a keyboard, and adjusting a crop by typing 0.37 then 0.39 then
17359
+ * 0.38 is not adjusting, it is guessing. The typed field stays because a slider
17360
+ * cannot express "exactly a third" and because a value you can read is how you
17361
+ * copy a crop from one cell to another.
17362
+ *
17363
+ * Both write through `clampNormalizedRect` upstream, so neither can hand the
17364
+ * addon a rectangle that leaves the frame — it THROWS on one, by design.
17365
+ */
17366
+ function CropSlider({ label, value, onChange }) {
17367
+ const rounded = Math.round(value * 100) / 100;
17368
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17369
+ className: "flex flex-col gap-0.5",
17370
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
17371
+ className: "flex items-baseline justify-between text-foreground-subtle",
17372
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17373
+ type: "number",
17374
+ min: 0,
17375
+ max: 1,
17376
+ step: .01,
17377
+ value: rounded,
17378
+ onChange: (event) => {
17379
+ const next = Number(event.target.value);
17380
+ if (Number.isFinite(next)) onChange(next);
17381
+ },
17382
+ className: "w-12 rounded border border-border bg-transparent px-1 py-0.5 text-right"
17383
+ })]
17384
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17385
+ type: "range",
17386
+ min: 0,
17387
+ max: 1,
17388
+ step: .005,
17389
+ value,
17390
+ onChange: (event) => {
17391
+ const next = Number(event.target.value);
17392
+ if (Number.isFinite(next)) onChange(next);
17393
+ },
17394
+ style: DRAG_SURFACE,
17395
+ className: "h-5 w-full accent-accent"
17396
+ })]
17397
+ });
17398
+ }
17306
17399
  function LabelledNumber({ label, value, step = 1, onChange }) {
17307
17400
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17308
17401
  className: "flex flex-col gap-0.5",
package/dist/index.js CHANGED
@@ -16617,7 +16617,8 @@ function readProfileOffer(value) {
16617
16617
  return {
16618
16618
  profile,
16619
16619
  offered,
16620
- missingSources
16620
+ missingSources,
16621
+ canvas: typeof value["canvas"] === "string" ? value["canvas"] : ""
16621
16622
  };
16622
16623
  }
16623
16624
  /**
@@ -16631,8 +16632,8 @@ function readProfileOffer(value) {
16631
16632
  function parseGridView(raw) {
16632
16633
  if (raw === null) return null;
16633
16634
  if (!isRecord(raw)) return "unreadable";
16634
- const { instanceId, deviceId, name, width, height, fps, cells, profiles } = raw;
16635
- if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof width !== "number" || typeof height !== "number" || typeof fps !== "number" || !Array.isArray(cells) || !Array.isArray(profiles)) return "unreadable";
16635
+ const { instanceId, deviceId, name, fps, cells, profiles } = raw;
16636
+ if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof fps !== "number" || !Array.isArray(cells) || !Array.isArray(profiles)) return "unreadable";
16636
16637
  const parsed = [];
16637
16638
  for (const entry of cells) {
16638
16639
  const cell = readCell(entry);
@@ -16649,8 +16650,6 @@ function parseGridView(raw) {
16649
16650
  instanceId,
16650
16651
  deviceId,
16651
16652
  name,
16652
- width,
16653
- height,
16654
16653
  fps,
16655
16654
  cells: parsed,
16656
16655
  profiles: offers
@@ -16782,6 +16781,25 @@ var FULL_FRAME = {
16782
16781
  var MAX_CELLS = 16;
16783
16782
  /** Hoisted so `useDevices` does not re-subscribe on every render. */
16784
16783
  var CAMERA_FILTER = { type: DeviceType.Camera };
16784
+ /**
16785
+ * A rectangle being dragged must not also PAN THE PAGE.
16786
+ *
16787
+ * On a phone a pointerdown on a `div` is a scroll gesture until the browser is
16788
+ * told otherwise, and `preventDefault` in the handler is too late — the browser
16789
+ * has already decided. `touch-action: none` is the only thing that makes a drag
16790
+ * work with a finger, and every surface that starts one carries it.
16791
+ */
16792
+ var DRAG_SURFACE = { touchAction: "none" };
16793
+ /**
16794
+ * Finger-sized hit area around a grip.
16795
+ *
16796
+ * The visible square stays small (it sits ON the picture being edited and a big
16797
+ * one hides what it is positioning); the TOUCHABLE box around it is 24px, which
16798
+ * is the smallest target size that does not need a fingernail. The two are
16799
+ * different boxes on purpose — making the dot itself 24px would cover a quarter
16800
+ * of a small cell.
16801
+ */
16802
+ var HANDLE_HIT = "absolute flex h-6 w-6 items-center justify-center";
16785
16803
  /** The eight grips, in the order they are drawn. */
16786
16804
  var HANDLES = [
16787
16805
  "nw",
@@ -16794,15 +16812,26 @@ var HANDLES = [
16794
16812
  "w"
16795
16813
  ];
16796
16814
  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"
16815
+ nw: "-top-3 -left-3 cursor-nw-resize",
16816
+ n: "-top-3 left-1/2 -translate-x-1/2 cursor-n-resize",
16817
+ ne: "-top-3 -right-3 cursor-ne-resize",
16818
+ e: "top-1/2 -right-3 -translate-y-1/2 cursor-e-resize",
16819
+ se: "-bottom-3 -right-3 cursor-se-resize",
16820
+ s: "-bottom-3 left-1/2 -translate-x-1/2 cursor-s-resize",
16821
+ sw: "-bottom-3 -left-3 cursor-sw-resize",
16822
+ w: "top-1/2 -left-3 -translate-y-1/2 cursor-w-resize"
16805
16823
  };
16824
+ /**
16825
+ * Start a drag and KEEP it, even when the finger leaves the rectangle.
16826
+ *
16827
+ * Without pointer capture a drag ends the moment the pointer crosses the box it
16828
+ * started in — which on a phone is immediately, because the box is small and
16829
+ * the finger is not. With capture the events keep coming to the element that
16830
+ * was grabbed and still bubble to the canvas that does the arithmetic.
16831
+ */
16832
+ function capturePointer(event) {
16833
+ event.currentTarget.setPointerCapture(event.pointerId);
16834
+ }
16806
16835
  /** Move or resize, from one drag and one delta. */
16807
16836
  function applyDrag(drag, dx, dy) {
16808
16837
  return drag.mode === "move" ? moveNormalizedRect(drag.origin, dx, dy) : resizeNormalizedRectFrom(drag.origin, drag.mode, dx, dy);
@@ -16842,8 +16871,6 @@ function CameraGridLayoutEditor({ deviceId }) {
16842
16871
  try {
16843
16872
  const outcome = parseGridView(await layout.saveLayout({
16844
16873
  name: draft.name,
16845
- width: draft.width,
16846
- height: draft.height,
16847
16874
  fps: draft.fps,
16848
16875
  cells: draft.cells.map((cell) => ({ ...cell }))
16849
16876
  }));
@@ -16866,7 +16893,27 @@ function CameraGridLayoutEditor({ deviceId }) {
16866
16893
  } : cell)
16867
16894
  });
16868
16895
  }, []);
16869
- const canvasAspect = useMemo(() => draft === null ? 16 / 9 : draft.width / draft.height, [draft]);
16896
+ /**
16897
+ * The shape the grid will actually come out as.
16898
+ *
16899
+ * The canvas is no longer a field: each profile derives its own from the
16900
+ * cells and the sources' dimensions, and the server reports what it came out
16901
+ * as. Drawing at that aspect is the only way the editor shows the truth —
16902
+ * before, it drew at whatever two numbers had been typed, which is exactly
16903
+ * how a layout could look right here and wrong on screen.
16904
+ *
16905
+ * 16:9 while nothing is offered yet: a grid with no cells has no shape, and
16906
+ * an empty editor has to be drawn at something.
16907
+ */
16908
+ const canvasAspect = useMemo(() => {
16909
+ const offered = loaded === void 0 || loaded === null || loaded === "unreadable" ? [] : loaded.profiles;
16910
+ for (const row of offered) {
16911
+ if (!row.offered) continue;
16912
+ const [width, height] = row.canvas.split("x").map(Number);
16913
+ if (width !== void 0 && height !== void 0 && width > 0 && height > 0) return width / height;
16914
+ }
16915
+ return 16 / 9;
16916
+ }, [loaded]);
16870
16917
  if (loaded === void 0) return /* @__PURE__ */ jsx(WidgetPanel, {
16871
16918
  title: "Grid layout",
16872
16919
  icon: /* @__PURE__ */ jsx(Grid2x2, { className: "h-3.5 w-3.5" }),
@@ -16942,12 +16989,15 @@ function CameraGridLayoutEditor({ deviceId }) {
16942
16989
  }),
16943
16990
  /* @__PURE__ */ jsxs("div", {
16944
16991
  className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
16945
- style: { aspectRatio: String(canvasAspect) },
16992
+ style: {
16993
+ aspectRatio: String(canvasAspect),
16994
+ ...DRAG_SURFACE
16995
+ },
16946
16996
  onPointerMove,
16947
16997
  onPointerUp: () => {
16948
16998
  setDrag(null);
16949
16999
  },
16950
- onPointerLeave: () => {
17000
+ onPointerCancel: () => {
16951
17001
  setDrag(null);
16952
17002
  },
16953
17003
  children: [draft.cells.map((cell, index) => /* @__PURE__ */ jsx(GridCellBox, {
@@ -16974,62 +17024,39 @@ function CameraGridLayoutEditor({ deviceId }) {
16974
17024
  }),
16975
17025
  /* @__PURE__ */ jsxs("div", {
16976
17026
  className: "grid grid-cols-4 gap-2 text-[10.5px]",
16977
- children: [
16978
- /* @__PURE__ */ jsx(LabelledNumber, {
16979
- label: "Width",
16980
- value: draft.width,
16981
- onChange: (width) => {
16982
- setDraft({
16983
- ...draft,
16984
- width
16985
- });
16986
- }
16987
- }),
16988
- /* @__PURE__ */ jsx(LabelledNumber, {
16989
- label: "Height",
16990
- value: draft.height,
16991
- onChange: (height) => {
16992
- setDraft({
16993
- ...draft,
16994
- height
16995
- });
16996
- }
16997
- }),
16998
- /* @__PURE__ */ jsx(LabelledNumber, {
16999
- label: "FPS",
17000
- value: draft.fps,
17001
- onChange: (fps) => {
17002
- setDraft({
17003
- ...draft,
17004
- fps
17005
- });
17006
- }
17007
- }),
17008
- /* @__PURE__ */ jsxs("button", {
17009
- type: "button",
17010
- disabled: draft.cells.length >= MAX_CELLS,
17011
- onClick: () => {
17012
- const first = cameras.find((camera) => camera.id !== deviceId);
17013
- if (!first) return;
17014
- setDraft({
17015
- ...draft,
17016
- cells: [...draft.cells, {
17017
- deviceId: first.id,
17018
- source: FULL_FRAME,
17019
- cell: clampNormalizedRect({
17020
- x: .1,
17021
- y: .1,
17022
- width: .4,
17023
- height: .4
17024
- })
17025
- }]
17026
- });
17027
- setSelected(draft.cells.length);
17028
- },
17029
- className: "flex items-center justify-center gap-1 self-end rounded border border-border px-2 py-1 uppercase",
17030
- children: [/* @__PURE__ */ jsx(Plus, { className: "h-3 w-3" }), " Cell"]
17031
- })
17032
- ]
17027
+ children: [/* @__PURE__ */ jsx(LabelledNumber, {
17028
+ label: "FPS",
17029
+ value: draft.fps,
17030
+ onChange: (fps) => {
17031
+ setDraft({
17032
+ ...draft,
17033
+ fps
17034
+ });
17035
+ }
17036
+ }), /* @__PURE__ */ jsxs("button", {
17037
+ type: "button",
17038
+ disabled: draft.cells.length >= MAX_CELLS,
17039
+ onClick: () => {
17040
+ const first = cameras.find((camera) => camera.id !== deviceId);
17041
+ if (!first) return;
17042
+ setDraft({
17043
+ ...draft,
17044
+ cells: [...draft.cells, {
17045
+ deviceId: first.id,
17046
+ source: FULL_FRAME,
17047
+ cell: clampNormalizedRect({
17048
+ x: .1,
17049
+ y: .1,
17050
+ width: .4,
17051
+ height: .4
17052
+ })
17053
+ }]
17054
+ });
17055
+ setSelected(draft.cells.length);
17056
+ },
17057
+ className: "flex items-center justify-center gap-1 self-end rounded border border-border px-2 py-1 uppercase",
17058
+ children: [/* @__PURE__ */ jsx(Plus, { className: "h-3 w-3" }), " Cell"]
17059
+ })]
17033
17060
  }),
17034
17061
  /* @__PURE__ */ jsx(GridProfileReport, {
17035
17062
  offers: loaded.profiles,
@@ -17077,7 +17104,11 @@ function GridProfileReport({ offers, cameras }) {
17077
17104
  children: offer.profile
17078
17105
  }), offer.offered ? /* @__PURE__ */ jsxs("span", {
17079
17106
  className: "text-foreground-subtle",
17080
- children: ["composed from every source’s ", offer.profile]
17107
+ children: [
17108
+ offer.canvas,
17109
+ " — composed from every source’s ",
17110
+ offer.profile
17111
+ ]
17081
17112
  }) : /* @__PURE__ */ jsx("span", {
17082
17113
  className: "text-danger",
17083
17114
  children: offer.missingSources.length === 0 ? "not offered — this grid has no cells yet" : `not offered — no ${offer.profile} on ${offer.missingSources.map(nameOf).join(", ")}`
@@ -17093,10 +17124,12 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
17093
17124
  role: "presentation",
17094
17125
  onPointerDown: (event) => {
17095
17126
  onSelect();
17127
+ capturePointer(event);
17096
17128
  onDragStart("move", event);
17097
17129
  },
17098
17130
  className: `absolute cursor-move border ${selected ? "border-accent" : "border-border/60"}`,
17099
17131
  style: {
17132
+ ...DRAG_SURFACE,
17100
17133
  left: `${String(cell.cell.x * 100)}%`,
17101
17134
  top: `${String(cell.cell.y * 100)}%`,
17102
17135
  width: `${String(cell.cell.width * 100)}%`,
@@ -17110,9 +17143,12 @@ function GridCellBox({ cell, selected, onSelect, onDragStart }) {
17110
17143
  role: "presentation",
17111
17144
  onPointerDown: (event) => {
17112
17145
  event.stopPropagation();
17146
+ capturePointer(event);
17113
17147
  onDragStart(handle, event);
17114
17148
  },
17115
- className: `absolute h-2.5 w-2.5 bg-accent ${HANDLE_STYLE[handle]}`
17149
+ className: `${HANDLE_HIT} ${HANDLE_STYLE[handle]}`,
17150
+ style: DRAG_SURFACE,
17151
+ children: /* @__PURE__ */ jsx("span", { className: "h-2.5 w-2.5 bg-accent" })
17116
17152
  }, handle))
17117
17153
  });
17118
17154
  }
@@ -17157,16 +17193,15 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17157
17193
  }
17158
17194
  }),
17159
17195
  /* @__PURE__ */ jsx("div", {
17160
- className: "grid grid-cols-4 gap-2",
17196
+ className: "grid grid-cols-2 gap-x-3 gap-y-1.5",
17161
17197
  children: [
17162
17198
  "x",
17163
17199
  "y",
17164
17200
  "width",
17165
17201
  "height"
17166
- ].map((key) => /* @__PURE__ */ jsx(LabelledNumber, {
17202
+ ].map((key) => /* @__PURE__ */ jsx(CropSlider, {
17167
17203
  label: `Crop ${key}`,
17168
- value: Math.round(cell.source[key] * 100) / 100,
17169
- step: .01,
17204
+ value: cell.source[key],
17170
17205
  onChange: (next) => {
17171
17206
  onChange({ source: clampNormalizedRect({
17172
17207
  ...cell.source,
@@ -17194,6 +17229,7 @@ function CellInspector({ cell, index, cameras, onChange, onRemove }) {
17194
17229
  function SourceCropEditor({ deviceId, source, onChange }) {
17195
17230
  const { src } = useDeviceSnapshotImage(deviceId, { width: 640 });
17196
17231
  const [drag, setDrag] = useState(null);
17232
+ const [sourceAspect, setSourceAspect] = useState(null);
17197
17233
  const onPointerMove = (event) => {
17198
17234
  if (drag === null) return;
17199
17235
  const bounds = event.currentTarget.getBoundingClientRect();
@@ -17223,12 +17259,15 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17223
17259
  })]
17224
17260
  }), /* @__PURE__ */ jsxs("div", {
17225
17261
  className: "relative w-full select-none overflow-hidden rounded border border-border bg-black",
17226
- style: { aspectRatio: "16 / 9" },
17262
+ style: {
17263
+ aspectRatio: String(sourceAspect ?? 16 / 9),
17264
+ ...DRAG_SURFACE
17265
+ },
17227
17266
  onPointerMove,
17228
17267
  onPointerUp: () => {
17229
17268
  setDrag(null);
17230
17269
  },
17231
- onPointerLeave: () => {
17270
+ onPointerCancel: () => {
17232
17271
  setDrag(null);
17233
17272
  },
17234
17273
  children: [
@@ -17238,12 +17277,17 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17238
17277
  }) : /* @__PURE__ */ jsx("img", {
17239
17278
  src,
17240
17279
  alt: "",
17241
- className: "absolute inset-0 h-full w-full object-cover"
17280
+ className: "absolute inset-0 h-full w-full",
17281
+ onLoad: (event) => {
17282
+ const { naturalWidth, naturalHeight } = event.currentTarget;
17283
+ if (naturalWidth > 0 && naturalHeight > 0) setSourceAspect(naturalWidth / naturalHeight);
17284
+ }
17242
17285
  }),
17243
17286
  /* @__PURE__ */ jsx("div", { className: "pointer-events-none absolute inset-0 bg-black/55" }),
17244
17287
  /* @__PURE__ */ jsx("div", {
17245
17288
  role: "presentation",
17246
17289
  onPointerDown: (event) => {
17290
+ capturePointer(event);
17247
17291
  setDrag({
17248
17292
  mode: "move",
17249
17293
  startX: event.clientX,
@@ -17253,6 +17297,7 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17253
17297
  },
17254
17298
  className: "absolute cursor-move border border-accent",
17255
17299
  style: {
17300
+ ...DRAG_SURFACE,
17256
17301
  left: `${String(source.x * 100)}%`,
17257
17302
  top: `${String(source.y * 100)}%`,
17258
17303
  width: `${String(source.width * 100)}%`,
@@ -17265,6 +17310,7 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17265
17310
  role: "presentation",
17266
17311
  onPointerDown: (event) => {
17267
17312
  event.stopPropagation();
17313
+ capturePointer(event);
17268
17314
  setDrag({
17269
17315
  mode: handle,
17270
17316
  startX: event.clientX,
@@ -17272,13 +17318,60 @@ function SourceCropEditor({ deviceId, source, onChange }) {
17272
17318
  origin: source
17273
17319
  });
17274
17320
  },
17275
- className: `absolute h-2.5 w-2.5 bg-accent ${HANDLE_STYLE[handle]}`
17321
+ className: `${HANDLE_HIT} ${HANDLE_STYLE[handle]}`,
17322
+ style: DRAG_SURFACE,
17323
+ children: /* @__PURE__ */ jsx("span", { className: "h-2.5 w-2.5 bg-accent" })
17276
17324
  }, handle))
17277
17325
  })
17278
17326
  ]
17279
17327
  })]
17280
17328
  });
17281
17329
  }
17330
+ /**
17331
+ * One crop number, as a slider AND as a number.
17332
+ *
17333
+ * The slider is what makes this usable with a thumb — a `number` input on a
17334
+ * phone opens a keyboard, and adjusting a crop by typing 0.37 then 0.39 then
17335
+ * 0.38 is not adjusting, it is guessing. The typed field stays because a slider
17336
+ * cannot express "exactly a third" and because a value you can read is how you
17337
+ * copy a crop from one cell to another.
17338
+ *
17339
+ * Both write through `clampNormalizedRect` upstream, so neither can hand the
17340
+ * addon a rectangle that leaves the frame — it THROWS on one, by design.
17341
+ */
17342
+ function CropSlider({ label, value, onChange }) {
17343
+ const rounded = Math.round(value * 100) / 100;
17344
+ return /* @__PURE__ */ jsxs("label", {
17345
+ className: "flex flex-col gap-0.5",
17346
+ children: [/* @__PURE__ */ jsxs("span", {
17347
+ className: "flex items-baseline justify-between text-foreground-subtle",
17348
+ children: [/* @__PURE__ */ jsx("span", { children: label }), /* @__PURE__ */ jsx("input", {
17349
+ type: "number",
17350
+ min: 0,
17351
+ max: 1,
17352
+ step: .01,
17353
+ value: rounded,
17354
+ onChange: (event) => {
17355
+ const next = Number(event.target.value);
17356
+ if (Number.isFinite(next)) onChange(next);
17357
+ },
17358
+ className: "w-12 rounded border border-border bg-transparent px-1 py-0.5 text-right"
17359
+ })]
17360
+ }), /* @__PURE__ */ jsx("input", {
17361
+ type: "range",
17362
+ min: 0,
17363
+ max: 1,
17364
+ step: .005,
17365
+ value,
17366
+ onChange: (event) => {
17367
+ const next = Number(event.target.value);
17368
+ if (Number.isFinite(next)) onChange(next);
17369
+ },
17370
+ style: DRAG_SURFACE,
17371
+ className: "h-5 w-full accent-accent"
17372
+ })]
17373
+ });
17374
+ }
17282
17375
  function LabelledNumber({ label, value, step = 1, onChange }) {
17283
17376
  return /* @__PURE__ */ jsxs("label", {
17284
17377
  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.178",
3
+ "version": "1.2.180",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",