@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.
- package/dist/composites/cap-settings/camera-grid-layout.d.ts +33 -0
- package/dist/index.cjs +374 -23
- package/dist/index.js +374 -23
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
16668
|
-
|
|
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
|
-
|
|
16671
|
-
|
|
16672
|
-
|
|
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:
|
|
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)("
|
|
16803
|
-
|
|
16804
|
-
|
|
16805
|
-
|
|
16806
|
-
|
|
16807
|
-
|
|
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: {
|
|
16999
|
+
style: {
|
|
17000
|
+
aspectRatio: String(canvasAspect),
|
|
17001
|
+
...DRAG_SURFACE
|
|
17002
|
+
},
|
|
16823
17003
|
onPointerMove,
|
|
16824
17004
|
onPointerUp: () => {
|
|
16825
17005
|
setDrag(null);
|
|
16826
17006
|
},
|
|
16827
|
-
|
|
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
|
-
|
|
17172
|
+
capturePointer(event);
|
|
17173
|
+
onDragStart(handle, event);
|
|
16990
17174
|
},
|
|
16991
|
-
className:
|
|
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-
|
|
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)(
|
|
17228
|
+
].map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CropSlider, {
|
|
17036
17229
|
label: `Crop ${key}`,
|
|
17037
|
-
value:
|
|
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
|
-
/**
|
|
16644
|
-
|
|
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
|
-
|
|
16647
|
-
|
|
16648
|
-
|
|
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:
|
|
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("
|
|
16779
|
-
|
|
16780
|
-
|
|
16781
|
-
|
|
16782
|
-
|
|
16783
|
-
|
|
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: {
|
|
16975
|
+
style: {
|
|
16976
|
+
aspectRatio: String(canvasAspect),
|
|
16977
|
+
...DRAG_SURFACE
|
|
16978
|
+
},
|
|
16799
16979
|
onPointerMove,
|
|
16800
16980
|
onPointerUp: () => {
|
|
16801
16981
|
setDrag(null);
|
|
16802
16982
|
},
|
|
16803
|
-
|
|
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
|
-
|
|
17148
|
+
capturePointer(event);
|
|
17149
|
+
onDragStart(handle, event);
|
|
16966
17150
|
},
|
|
16967
|
-
className:
|
|
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-
|
|
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(
|
|
17204
|
+
].map((key) => /* @__PURE__ */ jsx(CropSlider, {
|
|
17012
17205
|
label: `Crop ${key}`,
|
|
17013
|
-
value:
|
|
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",
|