migrate react-grid-layout v1 to v2

- Replace WidthProvider(Responsive) HOC with useContainerWidth hook
- Update types: Layout (single item) → LayoutItem, Layout[] → Layout
- Replace isDraggable/isResizable/resizeHandles with dragConfig/resizeConfig
- Update EventCallback signature for v2 API
- Remove @types/react-grid-layout (v2 includes its own types)
This commit is contained in:
Josh Hawkins 2026-03-04 19:36:59 -06:00
parent 638ee3bd0a
commit 2e66902fe4
3 changed files with 215 additions and 199 deletions

51
web/package-lock.json generated
View File

@ -63,7 +63,7 @@
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-grid-layout": "^1.5.0", "react-grid-layout": "^2.2.2",
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0", "react-i18next": "^15.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@ -96,7 +96,6 @@
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^18.3.2", "@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10", "@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",
@ -5223,15 +5222,6 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-icons": { "node_modules/@types/react-icons": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz",
@ -10959,11 +10949,12 @@
} }
}, },
"node_modules/react-draggable": { "node_modules/react-draggable": {
"version": "4.4.6", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
"license": "MIT",
"dependencies": { "dependencies": {
"clsx": "^1.1.1", "clsx": "^2.1.1",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"peerDependencies": { "peerDependencies": {
@ -10971,14 +10962,6 @@
"react-dom": ">= 16.3.0" "react-dom": ">= 16.3.0"
} }
}, },
"node_modules/react-draggable/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/react-dropzone": { "node_modules/react-dropzone": {
"version": "14.3.8", "version": "14.3.8",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
@ -10997,15 +10980,15 @@
} }
}, },
"node_modules/react-grid-layout": { "node_modules/react-grid-layout": {
"version": "1.5.0", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.0.tgz", "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz",
"integrity": "sha512-WBKX7w/LsTfI99WskSu6nX2nbJAUD7GD6nIXcwYLyPpnslojtmql2oD3I2g5C3AK8hrxIarYT8awhuDIp7iQ5w==", "integrity": "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"clsx": "^2.0.0", "clsx": "^2.1.1",
"fast-equals": "^4.0.3", "fast-equals": "^4.0.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-draggable": "^4.4.5", "react-draggable": "^4.4.6",
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1" "resize-observer-polyfill": "^1.5.1"
}, },
@ -11188,15 +11171,17 @@
} }
}, },
"node_modules/react-resizable": { "node_modules/react-resizable": {
"version": "3.0.5", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz",
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", "integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==",
"license": "MIT",
"dependencies": { "dependencies": {
"prop-types": "15.x", "prop-types": "15.x",
"react-draggable": "^4.0.3" "react-draggable": "^4.5.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">= 16.3" "react": ">= 16.3",
"react-dom": ">= 16.3"
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {

View File

@ -69,7 +69,7 @@
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-grid-layout": "^1.5.0", "react-grid-layout": "^2.2.2",
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0", "react-i18next": "^15.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@ -102,7 +102,6 @@
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^18.3.2", "@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10", "@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",

View File

@ -14,10 +14,10 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { import {
ItemCallback,
Layout, Layout,
LayoutItem,
Responsive, Responsive,
WidthProvider, useContainerWidth,
} from "react-grid-layout"; } from "react-grid-layout";
import "react-grid-layout/css/styles.css"; import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css"; import "react-resizable/css/styles.css";
@ -116,11 +116,8 @@ export default function DraggableGridLayout({
// grid layout // grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const [gridLayout, setGridLayout, isGridLayoutLoaded] =
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
Layout[]
>(`${cameraGroup}-draggable-layout`);
const [group] = useUserPersistedOverlayState( const [group] = useUserPersistedOverlayState(
"cameraGroup", "cameraGroup",
@ -158,11 +155,11 @@ export default function DraggableGridLayout({
const [currentIncludeBirdseye, setCurrentIncludeBirdseye] = const [currentIncludeBirdseye, setCurrentIncludeBirdseye] =
useState<boolean>(); useState<boolean>();
const [currentGridLayout, setCurrentGridLayout] = useState< const [currentGridLayout, setCurrentGridLayout] = useState<
Layout[] | undefined Layout | undefined
>(); >();
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(currentLayout: Layout[]) => { (currentLayout: Layout) => {
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
return; return;
} }
@ -174,7 +171,7 @@ export default function DraggableGridLayout({
); );
const generateLayout = useCallback( const generateLayout = useCallback(
(baseLayout: Layout[] | undefined) => { (baseLayout: Layout | undefined) => {
if (!isGridLayoutLoaded) { if (!isGridLayoutLoaded) {
return; return;
} }
@ -184,7 +181,7 @@ export default function DraggableGridLayout({
? ["birdseye", ...cameras.map((camera) => camera?.name || "")] ? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || ""); : cameras.map((camera) => camera?.name || "");
const optionsMap: Layout[] = baseLayout const optionsMap: LayoutItem[] = baseLayout
? baseLayout.filter((layout) => cameraNames?.includes(layout.i)) ? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
: []; : [];
@ -325,6 +322,24 @@ export default function DraggableGridLayout({
const gridContainerRef = useRef<HTMLDivElement>(null); const gridContainerRef = useRef<HTMLDivElement>(null);
const {
width: gridWidth,
containerRef: gridWidthRef,
mounted: gridMounted,
} = useContainerWidth();
// Combine gridContainerRef and gridWidthRef into a single callback ref
const combinedGridRef = useCallback(
(node: HTMLDivElement | null) => {
(
gridContainerRef as React.MutableRefObject<HTMLDivElement | null>
).current = node;
(gridWidthRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
},
[gridWidthRef],
);
const [{ width: containerWidth, height: containerHeight }] = const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(gridContainerRef); useResizeObserver(gridContainerRef);
@ -363,12 +378,14 @@ export default function DraggableGridLayout({
); );
}, [availableWidth, marginValue]); }, [availableWidth, marginValue]);
const handleResize: ItemCallback = ( const handleResize = (
_: Layout[], _layout: Layout,
oldLayoutItem: Layout, oldLayoutItem: LayoutItem | null,
layoutItem: Layout, layoutItem: LayoutItem | null,
placeholder: Layout, placeholder: LayoutItem | null,
) => { ) => {
if (!oldLayoutItem || !layoutItem || !placeholder) return;
const heightDiff = layoutItem.h - oldLayoutItem.h; const heightDiff = layoutItem.h - oldLayoutItem.h;
const widthDiff = layoutItem.w - oldLayoutItem.w; const widthDiff = layoutItem.w - oldLayoutItem.w;
const changeCoef = oldLayoutItem.w / oldLayoutItem.h; const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
@ -529,7 +546,7 @@ export default function DraggableGridLayout({
) : ( ) : (
<div <div
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8" className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
ref={gridContainerRef} ref={combinedGridRef}
> >
<EditGroupDialog <EditGroupDialog
open={editGroup} open={editGroup}
@ -537,155 +554,170 @@ export default function DraggableGridLayout({
currentGroups={groups} currentGroups={groups}
activeGroup={group} activeGroup={group}
/> />
<ResponsiveGridLayout {gridMounted && (
className="grid-layout" <Responsive
layouts={{ className="grid-layout"
lg: currentGridLayout, width={gridWidth}
md: currentGridLayout, layouts={{
sm: currentGridLayout, lg: currentGridLayout,
xs: currentGridLayout, md: currentGridLayout,
xxs: currentGridLayout, sm: currentGridLayout,
}} xs: currentGridLayout,
rowHeight={cellHeight} xxs: currentGridLayout,
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} }}
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }} rowHeight={cellHeight}
margin={[marginValue, marginValue]} breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
containerPadding={[0, isEditMode ? 6 : 3]} cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
resizeHandles={isEditMode ? ["sw", "nw", "se", "ne"] : []} margin={[marginValue, marginValue]}
onDragStop={handleLayoutChange} containerPadding={[0, isEditMode ? 6 : 3]}
onResize={handleResize} resizeConfig={{
onResizeStart={() => setShowCircles(false)} enabled: isEditMode,
onResizeStop={handleLayoutChange} handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
isDraggable={isEditMode} }}
isResizable={isEditMode} dragConfig={{
> enabled: isEditMode,
{includeBirdseye && birdseyeConfig?.enabled && ( }}
<BirdseyeLivePlayerGridItem onDragStop={handleLayoutChange}
key="birdseye" onResize={handleResize}
className={cn( onResizeStart={() => setShowCircles(false)}
isEditMode && onResizeStop={handleLayoutChange}
showCircles && >
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", {includeBirdseye && birdseyeConfig?.enabled && (
)} <BirdseyeLivePlayerGridItem
birdseyeConfig={birdseyeConfig} key="birdseye"
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"} className={cn(
onClick={() => onSelectCamera("birdseye")} isEditMode &&
> showCircles &&
{isEditMode && showCircles && <CornerCircles />} "outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
</BirdseyeLivePlayerGridItem> )}
)} birdseyeConfig={birdseyeConfig}
{cameras.map((camera) => { liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
let grow; onClick={() => onSelectCamera("birdseye")}
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
grow = `aspect-wide w-full`;
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
grow = `aspect-tall h-full`;
} else {
grow = "aspect-video";
}
const availableStreams = camera.live.streams || {};
const firstStreamEntry = Object.values(availableStreams)[0] || "";
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(
streamNameFromSettings,
);
const streamName = streamExists
? streamNameFromSettings
: firstStreamEntry;
const streamType =
currentGroupStreamingSettings?.[camera.name]?.streamType;
const autoLive =
streamType !== undefined
? streamType !== "no-streaming"
: undefined;
const showStillWithoutActivity =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"continuous";
const useWebGL =
currentGroupStreamingSettings?.[camera.name]
?.compatibilityMode || false;
return (
<GridLiveContextMenu
className={grow}
key={camera.name}
camera={camera.name}
streamName={streamName}
cameraGroup={cameraGroup}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[streamName]?.supportsAudio ??
false
}
audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]}
setVolumeState={(value) =>
setVolumeStates({
[camera.name]: value,
})
}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
config={config}
streamMetadata={streamMetadata}
> >
<LivePlayer
key={camera.name}
streamName={streamName}
autoLive={autoLive ?? globalAutoLive}
showStillWithoutActivity={showStillWithoutActivity ?? true}
alwaysShowCameraName={displayCameraNames}
useWebGL={useWebGL}
cameraRef={cameraRef}
className={cn(
"rounded-lg bg-black md:rounded-2xl",
grow,
isEditMode &&
showCircles &&
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
playInBackground={false}
showStats={statsStates[camera.name]}
onClick={() => {
!isEditMode && onSelectCamera(camera.name);
}}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]}
/>
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu> </BirdseyeLivePlayerGridItem>
); )}
})} {cameras.map((camera) => {
</ResponsiveGridLayout> let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
grow = `aspect-wide w-full`;
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
grow = `aspect-tall h-full`;
} else {
grow = "aspect-video";
}
const availableStreams = camera.live.streams || {};
const firstStreamEntry =
Object.values(availableStreams)[0] || "";
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName ||
"";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(
streamNameFromSettings,
);
const streamName = streamExists
? streamNameFromSettings
: firstStreamEntry;
const streamType =
currentGroupStreamingSettings?.[camera.name]?.streamType;
const autoLive =
streamType !== undefined
? streamType !== "no-streaming"
: undefined;
const showStillWithoutActivity =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"continuous";
const useWebGL =
currentGroupStreamingSettings?.[camera.name]
?.compatibilityMode || false;
return (
<GridLiveContextMenu
className={grow}
key={camera.name}
camera={camera.name}
streamName={streamName}
cameraGroup={cameraGroup}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[streamName]?.supportsAudio ??
false
}
audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]}
setVolumeState={(value) =>
setVolumeStates({
[camera.name]: value,
})
}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
config={config}
streamMetadata={streamMetadata}
>
<LivePlayer
key={camera.name}
streamName={streamName}
autoLive={autoLive ?? globalAutoLive}
showStillWithoutActivity={
showStillWithoutActivity ?? true
}
alwaysShowCameraName={displayCameraNames}
useWebGL={useWebGL}
cameraRef={cameraRef}
className={cn(
"rounded-lg bg-black md:rounded-2xl",
grow,
isEditMode &&
showCircles &&
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={
preferredLiveModes[camera.name] ?? "mse"
}
playInBackground={false}
showStats={statsStates[camera.name]}
onClick={() => {
!isEditMode && onSelectCamera(camera.name);
}}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
onResetLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]}
/>
{isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu>
);
})}
</Responsive>
)}
{isDesktop && ( {isDesktop && (
<div <div
className={cn( className={cn(