diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 8572f9db536..ee6e0c1d2dc 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,15 +2,15 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 811509, + "brotliBytes": 811580, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 809751, + "gzipBytes": 809812, "imageRawBytes": 156376, - "initialRawBytes": 1871594, + "initialRawBytes": 1872342, "largestAssetRawBytes": 746428, "otherAssetRawBytes": 0, - "ownedRawBytes": 34456, + "ownedRawBytes": 34486, "requestCount": 32, "scriptRequestCount": 16, "sourceOwners": [ @@ -248,15 +248,15 @@ ] }, "limits": { - "brotliBytes": 819625, + "brotliBytes": 819696, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 817849, + "gzipBytes": 817911, "imageRawBytes": 157940, - "initialRawBytes": 1890310, + "initialRawBytes": 1891066, "largestAssetRawBytes": 753893, "otherAssetRawBytes": 0, - "ownedRawBytes": 34456, + "ownedRawBytes": 34486, "requestCount": 32, "scriptRequestCount": 16 }, @@ -266,17 +266,17 @@ }, "editor": { "baseline": { - "brotliBytes": 1002549, + "brotliBytes": 1004921, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 998126, + "gzipBytes": 1000551, "imageRawBytes": 156376, - "initialRawBytes": 2453021, + "initialRawBytes": 2458459, "largestAssetRawBytes": 746428, "otherAssetRawBytes": 0, - "ownedRawBytes": 113668, - "requestCount": 75, - "scriptRequestCount": 59, + "ownedRawBytes": 114810, + "requestCount": 76, + "scriptRequestCount": 60, "sourceOwners": [ "package:@ark-ui/react", "package:@chakra-ui/react", @@ -639,6 +639,7 @@ "source:src/workbench/diagnostics/logger.ts", "source:src/workbench/extensions/contributionSource.ts", "source:src/workbench/extensions/extensionRegistry.ts", + "source:src/workbench/floatingWindows.ts", "source:src/workbench/focusRegions.tsx", "source:src/workbench/graphPreviewInvocation.ts", "source:src/workbench/graphSurfaces.ts", @@ -712,6 +713,7 @@ "source:src/workbench/shell/topbar/useTopbarShortcut.ts", "source:src/workbench/useNotify.ts", "source:src/workbench/useOpenWorkbenchWidget.ts", + "source:src/workbench/widget-frame/FloatingWidgetLayer.tsx", "source:src/workbench/widget-frame/WidgetActionsMenu.tsx", "source:src/workbench/widget-frame/WidgetBar.tsx", "source:src/workbench/widget-frame/WidgetEnableMenu.tsx", @@ -761,17 +763,17 @@ ] }, "limits": { - "brotliBytes": 1012575, + "brotliBytes": 1014971, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 1008108, + "gzipBytes": 1010557, "imageRawBytes": 157940, - "initialRawBytes": 2477552, + "initialRawBytes": 2483044, "largestAssetRawBytes": 753893, "otherAssetRawBytes": 0, - "ownedRawBytes": 113668, - "requestCount": 75, - "scriptRequestCount": 59 + "ownedRawBytes": 114810, + "requestCount": 76, + "scriptRequestCount": 60 }, "owner": "workbench", "remediationTicket": "deepen-widget-registry-loading", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 6e3abdff562..7c52833cfbb 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -25,11 +25,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 90.19999980926514, + "domContentLoadedMedianMs": 90.5, "id": "launchpad", "layoutSwitchMedianMs": 0, - "loadMedianMs": 90.19999980926514, - "longestTaskMaxMs": 59, + "loadMedianMs": 90.5, + "longestTaskMaxMs": 56, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -39,24 +39,24 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, + "otherRawBytes": 122334, "requestCount": 20, - "scriptRawBytes": 1493579, + "scriptRawBytes": 1494327, "scriptRequestCount": 16, - "totalRawBytes": 1641501 + "totalRawBytes": 1642484 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, + "otherRawBytes": 123558, "requestCount": 20, - "scriptRawBytes": 1508515, + "scriptRawBytes": 1509271, "scriptRequestCount": 16, - "totalRawBytes": 1657917 + "totalRawBytes": 1658909 }, - "routeReadyMedianMs": 191.7999997138977, + "routeReadyMedianMs": 194.69999980926514, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -83,11 +83,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 89.69999980926514, + "domContentLoadedMedianMs": 89.19999980926514, "id": "launchpad", "layoutSwitchMedianMs": 0, - "loadMedianMs": 89.7999997138977, - "longestTaskMaxMs": 55, + "loadMedianMs": 89.19999980926514, + "longestTaskMaxMs": 58, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -97,24 +97,24 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, + "otherRawBytes": 122334, "requestCount": 20, - "scriptRawBytes": 1493579, + "scriptRawBytes": 1494327, "scriptRequestCount": 16, - "totalRawBytes": 1641501 + "totalRawBytes": 1642484 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, + "otherRawBytes": 123558, "requestCount": 20, - "scriptRawBytes": 1508515, + "scriptRawBytes": 1509271, "scriptRequestCount": 16, - "totalRawBytes": 1657917 + "totalRawBytes": 1658909 }, - "routeReadyMedianMs": 233.09999990463257, + "routeReadyMedianMs": 241, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -141,13 +141,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 88.7999997138977, + "domContentLoadedMedianMs": 92, "id": "editor-minimal", "layoutSwitchMedianMs": 0, - "loadMedianMs": 88.7999997138977, - "longestTaskMaxMs": 82, + "loadMedianMs": 92, + "longestTaskMaxMs": 80, "owner": "workbench", - "projectSwitchMedianMs": 61.90444500000012, + "projectSwitchMedianMs": 62.09378900000047, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -155,24 +155,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, - "requestCount": 99, - "scriptRawBytes": 3230460, - "scriptRequestCount": 93, - "totalRawBytes": 3550171 + "otherRawBytes": 122334, + "requestCount": 100, + "scriptRawBytes": 3235928, + "scriptRequestCount": 94, + "totalRawBytes": 3555874 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, - "requestCount": 99, - "scriptRawBytes": 3262765, - "scriptRequestCount": 93, - "totalRawBytes": 3585673 + "otherRawBytes": 123558, + "requestCount": 100, + "scriptRawBytes": 3268288, + "scriptRequestCount": 94, + "totalRawBytes": 3591433 }, - "routeReadyMedianMs": 575.3000001907349, + "routeReadyMedianMs": 576, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -199,13 +199,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 87.09999990463257, + "domContentLoadedMedianMs": 90.5, "id": "editor-minimal", "layoutSwitchMedianMs": 0, - "loadMedianMs": 87.09999990463257, - "longestTaskMaxMs": 73, + "loadMedianMs": 90.5, + "longestTaskMaxMs": 77, "owner": "workbench", - "projectSwitchMedianMs": 76.04111999999805, + "projectSwitchMedianMs": 76.8406619999987, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -213,24 +213,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, - "requestCount": 99, - "scriptRawBytes": 3230460, - "scriptRequestCount": 93, - "totalRawBytes": 3550171 + "otherRawBytes": 122334, + "requestCount": 100, + "scriptRawBytes": 3235928, + "scriptRequestCount": 94, + "totalRawBytes": 3555874 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, - "requestCount": 99, - "scriptRawBytes": 3262765, - "scriptRequestCount": 93, - "totalRawBytes": 3585673 + "otherRawBytes": 123558, + "requestCount": 100, + "scriptRawBytes": 3268288, + "scriptRequestCount": 94, + "totalRawBytes": 3591433 }, - "routeReadyMedianMs": 586.6999998092651, + "routeReadyMedianMs": 602.1999998092651, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -239,29 +239,29 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 135411, + "largestAssetRawBytes": 135412, "otherRawBytes": 0, "requestCount": 4, - "scriptRawBytes": 277689, + "scriptRawBytes": 277677, "scriptRequestCount": 4, - "totalRawBytes": 277689 + "totalRawBytes": 277677 }, "activatedResourceLimits": { "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 136766, + "largestAssetRawBytes": 136767, "otherRawBytes": 0, "requestCount": 4, - "scriptRawBytes": 280466, + "scriptRawBytes": 280454, "scriptRequestCount": 4, - "totalRawBytes": 280466 + "totalRawBytes": 280454 }, - "domContentLoadedMedianMs": 94.19999980926514, + "domContentLoadedMedianMs": 93.19999980926514, "id": "editor-canvas", - "layoutSwitchMedianMs": 53.40000009536743, - "loadMedianMs": 94.19999980926514, - "longestTaskMaxMs": 75, + "layoutSwitchMedianMs": 49.80000019073486, + "loadMedianMs": 93.19999980926514, + "longestTaskMaxMs": 79, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -271,24 +271,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, - "requestCount": 103, - "scriptRawBytes": 3508149, - "scriptRequestCount": 97, - "totalRawBytes": 3827860 + "otherRawBytes": 122334, + "requestCount": 104, + "scriptRawBytes": 3513605, + "scriptRequestCount": 98, + "totalRawBytes": 3833551 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, - "requestCount": 103, - "scriptRawBytes": 3543231, - "scriptRequestCount": 97, - "totalRawBytes": 3866139 + "otherRawBytes": 123558, + "requestCount": 104, + "scriptRawBytes": 3548742, + "scriptRequestCount": 98, + "totalRawBytes": 3871887 }, - "routeReadyMedianMs": 998.5, + "routeReadyMedianMs": 992.3000001907349, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -297,29 +297,29 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 135411, + "largestAssetRawBytes": 135412, "otherRawBytes": 0, "requestCount": 4, - "scriptRawBytes": 277689, + "scriptRawBytes": 277677, "scriptRequestCount": 4, - "totalRawBytes": 277689 + "totalRawBytes": 277677 }, "activatedResourceLimits": { "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 136766, + "largestAssetRawBytes": 136767, "otherRawBytes": 0, "requestCount": 4, - "scriptRawBytes": 280466, + "scriptRawBytes": 280454, "scriptRequestCount": 4, - "totalRawBytes": 280466 + "totalRawBytes": 280454 }, - "domContentLoadedMedianMs": 85.39999961853027, + "domContentLoadedMedianMs": 84.7999997138977, "id": "editor-canvas", - "layoutSwitchMedianMs": 129.39999961853027, - "loadMedianMs": 85.39999961853027, - "longestTaskMaxMs": 265, + "layoutSwitchMedianMs": 130.40000009536743, + "loadMedianMs": 84.7999997138977, + "longestTaskMaxMs": 264, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -329,24 +329,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, - "requestCount": 103, - "scriptRawBytes": 3508149, - "scriptRequestCount": 97, - "totalRawBytes": 3827860 + "otherRawBytes": 122334, + "requestCount": 104, + "scriptRawBytes": 3513605, + "scriptRequestCount": 98, + "totalRawBytes": 3833551 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, - "requestCount": 103, - "scriptRawBytes": 3543231, - "scriptRequestCount": 97, - "totalRawBytes": 3866139 + "otherRawBytes": 123558, + "requestCount": 104, + "scriptRawBytes": 3548742, + "scriptRequestCount": 98, + "totalRawBytes": 3871887 }, - "routeReadyMedianMs": 1088.6999998092651, + "routeReadyMedianMs": 1096.1999998092651, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -373,11 +373,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 96.40000009536743, + "domContentLoadedMedianMs": 92.5, "id": "editor-workflow", - "layoutSwitchMedianMs": 52.59999990463257, - "loadMedianMs": 96.5, - "longestTaskMaxMs": 74, + "layoutSwitchMedianMs": 50.69999980926514, + "loadMedianMs": 92.5, + "longestTaskMaxMs": 77, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -387,24 +387,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, - "requestCount": 99, - "scriptRawBytes": 3230460, - "scriptRequestCount": 93, - "totalRawBytes": 3550171 + "otherRawBytes": 122334, + "requestCount": 100, + "scriptRawBytes": 3235928, + "scriptRequestCount": 94, + "totalRawBytes": 3555874 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, - "requestCount": 99, - "scriptRawBytes": 3262765, - "scriptRequestCount": 93, - "totalRawBytes": 3585673 + "otherRawBytes": 123558, + "requestCount": 100, + "scriptRawBytes": 3268288, + "scriptRequestCount": 94, + "totalRawBytes": 3591433 }, - "routeReadyMedianMs": 996.1999998092651, + "routeReadyMedianMs": 985.7999997138977, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -431,11 +431,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 89.09999990463257, + "domContentLoadedMedianMs": 86.59999990463257, "id": "editor-workflow", - "layoutSwitchMedianMs": 80.90000009536743, - "loadMedianMs": 89.09999990463257, - "longestTaskMaxMs": 282, + "layoutSwitchMedianMs": 79.09999990463257, + "loadMedianMs": 86.59999990463257, + "longestTaskMaxMs": 261, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -445,24 +445,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, - "requestCount": 99, - "scriptRawBytes": 3230460, - "scriptRequestCount": 93, - "totalRawBytes": 3550171 + "otherRawBytes": 122334, + "requestCount": 100, + "scriptRawBytes": 3235928, + "scriptRequestCount": 94, + "totalRawBytes": 3555874 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, - "requestCount": 99, - "scriptRawBytes": 3262765, - "scriptRequestCount": 93, - "totalRawBytes": 3585673 + "otherRawBytes": 123558, + "requestCount": 100, + "scriptRawBytes": 3268288, + "scriptRequestCount": 94, + "totalRawBytes": 3591433 }, - "routeReadyMedianMs": 1062.4000000953674, + "routeReadyMedianMs": 1046.1000003814697, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -489,10 +489,10 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 86.2000002861023, + "domContentLoadedMedianMs": 90.2999997138977, "id": "editor-gallery", - "layoutSwitchMedianMs": 27.899999618530273, - "loadMedianMs": 86.30000019073486, + "layoutSwitchMedianMs": 27.799999713897705, + "loadMedianMs": 90.2999997138977, "longestTaskMaxMs": 77, "owner": "gallery", "projectSwitchMedianMs": 0, @@ -503,24 +503,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, - "requestCount": 99, - "scriptRawBytes": 3230460, - "scriptRequestCount": 93, - "totalRawBytes": 3550171 + "otherRawBytes": 122334, + "requestCount": 100, + "scriptRawBytes": 3235928, + "scriptRequestCount": 94, + "totalRawBytes": 3555874 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, - "requestCount": 99, - "scriptRawBytes": 3262765, - "scriptRequestCount": 93, - "totalRawBytes": 3585673 + "otherRawBytes": 123558, + "requestCount": 100, + "scriptRawBytes": 3268288, + "scriptRequestCount": 94, + "totalRawBytes": 3591433 }, - "routeReadyMedianMs": 1233.4000000953674, + "routeReadyMedianMs": 1235, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -547,11 +547,11 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 88.5, + "domContentLoadedMedianMs": 89.5, "id": "editor-gallery", - "layoutSwitchMedianMs": 44.39999961853027, - "loadMedianMs": 88.5, - "longestTaskMaxMs": 88, + "layoutSwitchMedianMs": 46.80000019073486, + "loadMedianMs": 89.59999990463257, + "longestTaskMaxMs": 91, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -561,24 +561,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746428, - "otherRawBytes": 122099, - "requestCount": 99, - "scriptRawBytes": 3230460, - "scriptRequestCount": 93, - "totalRawBytes": 3550171 + "otherRawBytes": 122334, + "requestCount": 100, + "scriptRawBytes": 3235928, + "scriptRequestCount": 94, + "totalRawBytes": 3555874 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753893, - "otherRawBytes": 123320, - "requestCount": 99, - "scriptRawBytes": 3262765, - "scriptRequestCount": 93, - "totalRawBytes": 3585673 + "otherRawBytes": 123558, + "requestCount": 100, + "scriptRawBytes": 3268288, + "scriptRequestCount": 94, + "totalRawBytes": 3591433 }, - "routeReadyMedianMs": 1277.6000003814697, + "routeReadyMedianMs": 1316, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -1536,6 +1536,7 @@ "source:src/workbench/diagnostics/logger.ts", "source:src/workbench/extensions/contributionSource.ts", "source:src/workbench/extensions/extensionRegistry.ts", + "source:src/workbench/floatingWindows.ts", "source:src/workbench/focusRegions.tsx", "source:src/workbench/graphPreviewInvocation.ts", "source:src/workbench/graphSurfaces.ts", @@ -1632,6 +1633,7 @@ "source:src/workbench/shell/topbar/useTopbarShortcut.ts", "source:src/workbench/useNotify.ts", "source:src/workbench/useOpenWorkbenchWidget.ts", + "source:src/workbench/widget-frame/FloatingWidgetLayer.tsx", "source:src/workbench/widget-frame/WidgetActionsMenu.tsx", "source:src/workbench/widget-frame/WidgetBar.tsx", "source:src/workbench/widget-frame/WidgetEnableMenu.tsx", @@ -2433,6 +2435,7 @@ "source:src/workbench/diagnostics/logger.ts", "source:src/workbench/extensions/contributionSource.ts", "source:src/workbench/extensions/extensionRegistry.ts", + "source:src/workbench/floatingWindows.ts", "source:src/workbench/focusRegions.tsx", "source:src/workbench/graphPreviewInvocation.ts", "source:src/workbench/graphSurfaces.ts", @@ -2530,6 +2533,7 @@ "source:src/workbench/useCanvasProjectMutationDispatch.ts", "source:src/workbench/useNotify.ts", "source:src/workbench/useOpenWorkbenchWidget.ts", + "source:src/workbench/widget-frame/FloatingWidgetLayer.tsx", "source:src/workbench/widget-frame/WidgetActionsMenu.tsx", "source:src/workbench/widget-frame/WidgetBar.tsx", "source:src/workbench/widget-frame/WidgetEnableMenu.tsx", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 53f228586f7..7b6fbfc7180 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1130,6 +1130,15 @@ "retry": "Retry", "title": "{{label}} failed" }, + "floating": { + "dock": "Dock to panel", + "floatWindow": "Float Window", + "maximize": "Maximize", + "resize": "Resize window", + "restore": "Restore", + "shade": "Shade", + "unshade": "Unshade" + }, "loadingLabel": "Loading {{label}}", "removeWidget": "Remove {{label}}", "panelLabel": "{{region}} widget panel", diff --git a/invokeai/frontend/webv2/src/architecture/workbenchOwnershipManifest.json b/invokeai/frontend/webv2/src/architecture/workbenchOwnershipManifest.json index 5a447e52a47..451be645088 100644 --- a/invokeai/frontend/webv2/src/architecture/workbenchOwnershipManifest.json +++ b/invokeai/frontend/webv2/src/architecture/workbenchOwnershipManifest.json @@ -95,6 +95,7 @@ "canvasStagingView.ts": "workbench", "controlAdapters.ts": "workbench", "controlLayerChecks.ts": "workbench", + "floatingWindows.ts": "workbench", "focusRegions.tsx": "workbench", "generationSettingsOrchestration.ts": "workbench", "graphContracts.ts": "workbench", diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx index 4faaca4b66a..fd5fb96b19e 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryUiContext.tsx @@ -77,7 +77,7 @@ export interface GalleryWidgetRuntime { export interface GalleryWidgetProps { presentation?: 'compact' | 'expanded' | 'tooltip'; - region: 'bottom' | 'center' | 'dialog' | 'left' | 'popover' | 'right'; + region: 'bottom' | 'center' | 'dialog' | 'floating' | 'left' | 'popover' | 'right'; runtime: GalleryWidgetRuntime; } diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueWidgetView.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueWidgetView.tsx index ee10142df79..97ed2940330 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueWidgetView.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueWidgetView.tsx @@ -25,7 +25,7 @@ export const QueueWidgetView = ({ region, }: { presentation?: 'compact' | 'expanded' | 'tooltip'; - region: 'bottom' | 'center' | 'dialog' | 'left' | 'popover' | 'right'; + region: 'bottom' | 'center' | 'dialog' | 'floating' | 'left' | 'popover' | 'right'; }) => { const { t } = useTranslation(); const counts = useQueueCounts(); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts b/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts index 93bed4ad8d6..501b5d9d57c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts @@ -1,7 +1,7 @@ import type { ProjectGraphAction } from '@features/workflow/core/document'; import type { ProjectGraphState } from '@features/workflow/core/types'; -export type WorkflowRegion = 'left' | 'right' | 'bottom' | 'center' | 'dialog' | 'popover'; +export type WorkflowRegion = 'left' | 'right' | 'bottom' | 'center' | 'dialog' | 'popover' | 'floating'; /** * Panel regions workflow surfaces may open/select widgets in. Structural diff --git a/invokeai/frontend/webv2/src/workbench/floatingWidgets.test.ts b/invokeai/frontend/webv2/src/workbench/floatingWidgets.test.ts new file mode 100644 index 00000000000..e3cdf64e09a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/floatingWidgets.test.ts @@ -0,0 +1,212 @@ +import type { Project, WorkbenchState } from '@workbench/projectContracts'; + +import { describe, expect, it } from 'vitest'; + +import { + cascadeDefaultGeometry, + clampSizeToMinimum, + clampWindowToViewport, + FLOATING_MIN_HEIGHT_PX, + FLOATING_MIN_WIDTH_PX, + nextStackOrder, +} from './floatingWindows'; +import { createInitialWorkbenchState, workbenchReducer } from './workbenchState.testing'; + +const getActiveProject = (state: WorkbenchState): Project => { + const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); + + if (!project) { + throw new Error('missing active project'); + } + + return project; +}; + +const floatGallery = (state = createInitialWorkbenchState()): WorkbenchState => + workbenchReducer(state, { instanceId: 'gallery', type: 'floatWidget' }); + +describe('floatWidget', () => { + it('detaches the instance from its region and repairs the active instance', () => { + const initial = createInitialWorkbenchState(); + const before = getActiveProject(initial).widgetRegions.right; + expect(before.instanceIds).toContain('gallery'); + expect(before.activeInstanceId).toBe('gallery'); + + const state = floatGallery(initial); + const project = getActiveProject(state); + + expect(project.widgetRegions.right.instanceIds).not.toContain('gallery'); + expect(project.widgetRegions.right.activeInstanceId).not.toBe('gallery'); + expect(project.widgetRegions.right.instanceIds).toContain(project.widgetRegions.right.activeInstanceId); + expect(project.floatingWidgets?.gallery).toMatchObject({ mode: 'windowed', returnRegion: 'right', stackOrder: 1 }); + }); + + it('is a no-op for unknown or already-floating instances', () => { + const floated = floatGallery(); + + expect(workbenchReducer(floated, { instanceId: 'gallery', type: 'floatWidget' })).toBe(floated); + expect(workbenchReducer(floated, { instanceId: 'no-such-widget', type: 'floatWidget' })).toBe(floated); + }); + + it('cascades geometry and stacks successive windows', () => { + let state = floatGallery(); + state = workbenchReducer(state, { instanceId: 'queue', type: 'floatWidget' }); + const floating = getActiveProject(state).floatingWidgets; + + expect(floating?.queue.stackOrder).toBe(2); + expect(floating?.queue.x).toBeGreaterThan(floating?.gallery.x ?? 0); + }); +}); + +describe('dockFloatingWidget', () => { + it('returns the instance to its origin region as the active widget', () => { + const state = workbenchReducer(floatGallery(), { instanceId: 'gallery', type: 'dockFloatingWidget' }); + const project = getActiveProject(state); + + expect(project.floatingWidgets?.gallery).toBeUndefined(); + expect(project.widgetRegions.right.instanceIds).toContain('gallery'); + expect(project.widgetRegions.right.activeInstanceId).toBe('gallery'); + expect(project.layout.panels.isRightOpen).toBe(true); + expect(project.widgetRegions.right.isCollapsed).toBe(false); + }); + + it('float -> dock -> float round-trips', () => { + let state = floatGallery(); + state = workbenchReducer(state, { instanceId: 'gallery', type: 'dockFloatingWidget' }); + state = workbenchReducer(state, { instanceId: 'gallery', type: 'floatWidget' }); + + expect(getActiveProject(state).floatingWidgets?.gallery).toBeDefined(); + expect(getActiveProject(state).widgetRegions.right.instanceIds).not.toContain('gallery'); + }); + + it('is a no-op when nothing is floating', () => { + const initial = createInitialWorkbenchState(); + + expect(workbenchReducer(initial, { instanceId: 'gallery', type: 'dockFloatingWidget' })).toBe(initial); + }); +}); + +describe('geometry, mode, and stacking actions', () => { + it('clamps committed sizes to the minimums', () => { + const state = workbenchReducer(floatGallery(), { + heightPx: 10, + instanceId: 'gallery', + type: 'setFloatingWidgetGeometry', + widthPx: 10, + x: 5, + y: 7, + }); + const floating = getActiveProject(state).floatingWidgets?.gallery; + + expect(floating).toMatchObject({ heightPx: FLOATING_MIN_HEIGHT_PX, widthPx: FLOATING_MIN_WIDTH_PX, x: 5, y: 7 }); + }); + + it('switches modes and ignores repeats', () => { + const state = workbenchReducer(floatGallery(), { + instanceId: 'gallery', + mode: 'maximized', + type: 'setFloatingWidgetMode', + }); + + expect(getActiveProject(state).floatingWidgets?.gallery.mode).toBe('maximized'); + const repeat = workbenchReducer(state, { instanceId: 'gallery', mode: 'maximized', type: 'setFloatingWidgetMode' }); + expect(repeat).toBe(state); + }); + + it('focus raises a window to the top and no-ops when already topmost', () => { + let state = floatGallery(); + state = workbenchReducer(state, { instanceId: 'queue', type: 'floatWidget' }); + + state = workbenchReducer(state, { instanceId: 'gallery', type: 'focusFloatingWidget' }); + const floating = getActiveProject(state).floatingWidgets; + expect(floating?.gallery.stackOrder).toBeGreaterThan(floating?.queue.stackOrder ?? 0); + + expect(workbenchReducer(state, { instanceId: 'gallery', type: 'focusFloatingWidget' })).toBe(state); + }); +}); + +describe('interaction with region placement', () => { + it('openRegionWidget docks a floating instance instead of double-rendering it', () => { + const floated = floatGallery(); + const state = workbenchReducer(floated, { region: 'right', type: 'openRegionWidget', widgetId: 'gallery' }); + const project = getActiveProject(state); + + expect(project.floatingWidgets?.gallery).toBeUndefined(); + expect(project.widgetRegions.right.instanceIds).toContain('gallery'); + }); +}); + +describe('pure helpers', () => { + it('nextStackOrder starts at 1 and increments past the max', () => { + expect(nextStackOrder(undefined)).toBe(1); + expect( + nextStackOrder({ + a: { heightPx: 1, mode: 'windowed', returnRegion: 'right', stackOrder: 4, widthPx: 1, x: 0, y: 0 }, + }) + ).toBe(5); + }); + + it('cascadeDefaultGeometry offsets successive windows', () => { + expect(cascadeDefaultGeometry(1).x).toBe(cascadeDefaultGeometry(0).x + 32); + }); + + it('clampWindowToViewport keeps a grabbable sliver on screen', () => { + const clamped = clampWindowToViewport( + { heightPx: 300, widthPx: 400, x: 5000, y: 5000 }, + { height: 800, width: 1200 } + ); + expect(clamped.x).toBeLessThanOrEqual(1200 - 48); + expect(clamped.y).toBeLessThanOrEqual(800 - 48); + + const negative = clampWindowToViewport( + { heightPx: 300, widthPx: 400, x: -5000, y: -50 }, + { height: 800, width: 1200 } + ); + expect(negative.x).toBeGreaterThanOrEqual(48 - 400); + expect(negative.y).toBe(0); + }); + + it('clampSizeToMinimum enforces the floor without moving the window', () => { + expect(clampSizeToMinimum({ heightPx: 1, widthPx: 1, x: 9, y: 9 })).toEqual({ + heightPx: FLOATING_MIN_HEIGHT_PX, + widthPx: FLOATING_MIN_WIDTH_PX, + x: 9, + y: 9, + }); + }); +}); + +describe('interaction with presets and undo', () => { + it('applying a preset docks all floating widgets (no double render)', () => { + const floated = floatGallery(); + const state = workbenchReducer(floated, { presetId: 'compose', type: 'applyPreset' }); + const project = getActiveProject(state); + + expect(project.floatingWidgets ?? {}).toEqual({}); + expect(project.widgetRegions.right.instanceIds).toContain('gallery'); + }); + + it('undo restores floating state together with the regions', () => { + // Snapshot A: gallery docked. The undoable action (apply preset) captures it. + let state = createInitialWorkbenchState(); + state = workbenchReducer(state, { presetId: 'compose', type: 'applyPreset' }); + // Float after the snapshot, then undo: gallery must return to docked-only. + state = workbenchReducer(state, { instanceId: 'gallery', type: 'floatWidget' }); + state = workbenchReducer(state, { type: 'undoProjectChange' }); + let project = getActiveProject(state); + + expect(project.floatingWidgets ?? {}).toEqual({}); + expect(project.widgetRegions.right.instanceIds).toContain('gallery'); + + // Mirror case: float, take a snapshot, dock, undo -> floating state returns + // WITH the matching regions; the widget is exactly one of docked/floating. + state = workbenchReducer(state, { instanceId: 'gallery', type: 'floatWidget' }); + state = workbenchReducer(state, { presetId: 'compose', type: 'applyPreset' }); + state = workbenchReducer(state, { type: 'undoProjectChange' }); + project = getActiveProject(state); + + const isFloating = Boolean(project.floatingWidgets?.gallery); + const isDocked = Object.values(project.widgetRegions).some((region) => region.instanceIds.includes('gallery')); + expect(isFloating !== isDocked).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/floatingWindows.ts b/invokeai/frontend/webv2/src/workbench/floatingWindows.ts new file mode 100644 index 00000000000..526b10e43da --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/floatingWindows.ts @@ -0,0 +1,60 @@ +import type { FloatingWidgetState } from '@workbench/layoutContracts'; +import type { WidgetInstanceId } from '@workbench/widgetContracts'; + +/** + * Pure geometry and stacking helpers for floating widget windows. The reducer + * owns the state; components clamp against the live viewport (the reducer + * never reads window dimensions). + */ + +export const FLOATING_DEFAULT_WIDTH_PX = 520; +export const FLOATING_DEFAULT_HEIGHT_PX = 440; +export const FLOATING_MIN_WIDTH_PX = 280; +export const FLOATING_MIN_HEIGHT_PX = 200; + +/** Minimum sliver of a window that must stay reachable inside the viewport. */ +const VIEWPORT_MARGIN_PX = 48; +const CASCADE_ORIGIN_PX = 96; +const CASCADE_STEP_PX = 32; +const CASCADE_WRAP = 8; + +export interface FloatingGeometry { + x: number; + y: number; + widthPx: number; + heightPx: number; +} + +export const nextStackOrder = (floatingWidgets: Record | undefined): number => + Object.values(floatingWidgets ?? {}).reduce((max, state) => Math.max(max, state.stackOrder), 0) + 1; + +/** Default placement for the Nth window: a classic cascading offset. */ +export const cascadeDefaultGeometry = (existingCount: number): FloatingGeometry => { + const step = (existingCount % CASCADE_WRAP) * CASCADE_STEP_PX; + + return { + heightPx: FLOATING_DEFAULT_HEIGHT_PX, + widthPx: FLOATING_DEFAULT_WIDTH_PX, + x: CASCADE_ORIGIN_PX + step, + y: CASCADE_ORIGIN_PX + step, + }; +}; + +export const clampSizeToMinimum = (geometry: FloatingGeometry): FloatingGeometry => ({ + ...geometry, + heightPx: Math.max(FLOATING_MIN_HEIGHT_PX, geometry.heightPx), + widthPx: Math.max(FLOATING_MIN_WIDTH_PX, geometry.widthPx), +}); + +/** + * Keep at least a grabbable corner of the window inside the viewport so a + * drag (or a shrunk browser window) can never strand it off-screen. + */ +export const clampWindowToViewport = ( + geometry: FloatingGeometry, + viewport: { width: number; height: number } +): FloatingGeometry => ({ + ...geometry, + x: Math.min(Math.max(geometry.x, VIEWPORT_MARGIN_PX - geometry.widthPx), viewport.width - VIEWPORT_MARGIN_PX), + y: Math.min(Math.max(geometry.y, 0), Math.max(0, viewport.height - VIEWPORT_MARGIN_PX)), +}); diff --git a/invokeai/frontend/webv2/src/workbench/layoutContracts.ts b/invokeai/frontend/webv2/src/workbench/layoutContracts.ts index 32b63d5e538..2ba3ac6b552 100644 --- a/invokeai/frontend/webv2/src/workbench/layoutContracts.ts +++ b/invokeai/frontend/webv2/src/workbench/layoutContracts.ts @@ -20,6 +20,21 @@ export interface PanelState { export type WidgetRegion = 'left' | 'right' | 'bottom' | 'center'; +export type FloatingWidgetMode = 'windowed' | 'maximized' | 'shaded'; + +/** Geometry + stacking for a widget instance detached into a floating window. */ +export interface FloatingWidgetState { + x: number; + y: number; + widthPx: number; + heightPx: number; + mode: FloatingWidgetMode; + /** The dockable region this window returns to when docked. */ + returnRegion: WidgetRegion; + /** Z-order within the floating layer; higher renders on top. */ + stackOrder: number; +} + export interface WidgetRegionState { activeInstanceId: WidgetInstanceId; instanceIds: WidgetInstanceId[]; diff --git a/invokeai/frontend/webv2/src/workbench/projectContracts.ts b/invokeai/frontend/webv2/src/workbench/projectContracts.ts index 68cca864185..fababe3b940 100644 --- a/invokeai/frontend/webv2/src/workbench/projectContracts.ts +++ b/invokeai/frontend/webv2/src/workbench/projectContracts.ts @@ -5,6 +5,7 @@ import type { CanvasStateContractV2 } from './canvas-engine/api'; import type { GraphContract } from './graphContracts'; import type { InvocationControllerState } from './invocationContracts'; import type { + FloatingWidgetState, LayoutPreset, LayoutPresetId, LayoutPresetOverrides, @@ -36,6 +37,13 @@ export interface Project { projectGraph: ProjectGraphState; widgetInstances: Record; widgetRegions: Record; + /** + * Widget instances detached into floating windows. Optional and additive: + * projects persisted before this field existed hydrate with no floating + * windows. A floated instance is removed from its region's instanceIds + * while it floats. + */ + floatingWidgets?: Record; widgetGraphs: Partial>; canvas: CanvasStateContractV2; graphHistory: GraphHistorySnapshot[]; @@ -112,6 +120,8 @@ export interface ProjectUndoSnapshot { projectGraph: ProjectGraphState; widgetInstances: Record; widgetRegions: Record; + /** Captured with widgetRegions: regions and floating windows are one placement fact. */ + floatingWidgets?: Record; widgetGraphs: Partial>; } diff --git a/invokeai/frontend/webv2/src/workbench/shell/WorkbenchShell.tsx b/invokeai/frontend/webv2/src/workbench/shell/WorkbenchShell.tsx index 7d076fd85de..aa7175d51bd 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/WorkbenchShell.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/WorkbenchShell.tsx @@ -16,6 +16,7 @@ import { FocusRegionProvider } from '@workbench/focusRegions'; import { WidgetIcon } from '@workbench/iconResolver'; import { PROJECT_CONTENT_PANEL_ID } from '@workbench/projects/projectTabsA11y'; import { WidgetBar } from '@workbench/widget-frame'; +import { FloatingWidgetLayer } from '@workbench/widget-frame/FloatingWidgetLayer'; import { getRegionDropState, isWidgetDndData, @@ -272,6 +273,7 @@ export const WorkbenchShell = () => { + {activeDrag ? : null} diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/FloatingWidgetLayer.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/FloatingWidgetLayer.tsx new file mode 100644 index 00000000000..118226ecb33 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/FloatingWidgetLayer.tsx @@ -0,0 +1,40 @@ +import type { FloatingWidgetState } from '@workbench/layoutContracts'; +import type { WidgetInstanceId } from '@workbench/widgetContracts'; + +import { shallowEqual, useActiveProjectSelector } from '@workbench/WorkbenchContext'; +import { lazy, Suspense } from 'react'; + +// The window chrome loads only once a widget actually floats, keeping it out +// of the shell's eager bundle — nothing renders while no widget floats. +const FloatingWidgetWindow = lazy(() => + import('./FloatingWidgetWindow').then((module) => ({ default: module.FloatingWidgetWindow })) +); + +const EMPTY_FLOATING: Record = {}; + +/** + * Renders every floated widget instance of the active project as a fixed + * window. Mounted once in the shell; nothing renders while no widget floats. + * z-order derives from the RANK of each window's stackOrder, not its raw + * value — stackOrder grows monotonically forever (it is persisted), and raw + * values would eventually climb past the UI library's layer tokens. + */ +export const FloatingWidgetLayer = () => { + const floatingWidgets = useActiveProjectSelector( + (project) => project.floatingWidgets ?? EMPTY_FLOATING, + shallowEqual + ); + const entries = Object.entries(floatingWidgets).sort(([, left], [, right]) => left.stackOrder - right.stackOrder); + + if (entries.length === 0) { + return null; + } + + return ( + + {entries.map(([instanceId, state], stackRank) => ( + + ))} + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/FloatingWidgetWindow.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/FloatingWidgetWindow.tsx new file mode 100644 index 00000000000..87b305f83a3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/FloatingWidgetWindow.tsx @@ -0,0 +1,253 @@ +import type { FloatingWidgetState } from '@workbench/layoutContracts'; +import type { WidgetInstanceId } from '@workbench/widgetContracts'; + +import { Box, Flex, HStack, Icon, Text } from '@chakra-ui/react'; +import { IconButton, Tooltip } from '@platform/ui'; +import { + clampWindowToViewport, + FLOATING_MIN_HEIGHT_PX, + FLOATING_MIN_WIDTH_PX, + type FloatingGeometry, +} from '@workbench/floatingWindows'; +import { WidgetIcon } from '@workbench/iconResolver'; +import { resolveWidgetInstanceLabel } from '@workbench/widgetLabels'; +import { useActiveProjectSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext'; +import { useWorkbenchWidgetRegistry } from '@workbench/WorkbenchWidgetRegistryContext'; +import { ChevronsDownUpIcon, ChevronsUpDownIcon, Maximize2Icon, Minimize2Icon, PanelRightIcon } from 'lucide-react'; +import { + useCallback, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import { WidgetRendererById } from './WidgetRenderer'; +import { areWidgetRenderInstancesEqual } from './widgetRenderInstance'; + +/** Below Chakra dialogs/popovers/toasts; above the docked shell. */ +const FLOATING_BASE_Z_INDEX = 800; + +/** + * One detached widget window: fixed-position chrome with a draggable title + * bar, a corner resize handle, shade/maximize/dock controls, and the standard + * widget renderer as its body. Drag and resize use the same raw-pointer + * pattern as the panel resize handles — transient local px state, one commit + * to the reducer on release. + */ +export const FloatingWidgetWindow = ({ + instanceId, + stackRank, + state, +}: { + instanceId: WidgetInstanceId; + /** 0-based position in the layer's stacking order (0 = bottom window). */ + stackRank: number; + state: FloatingWidgetState; +}) => { + const { t } = useTranslation(); + const { widgets } = useWorkbenchCommands(); + const { getWidgetById } = useWorkbenchWidgetRegistry(); + const instance = useActiveProjectSelector( + (project) => project.widgetInstances[instanceId], + areWidgetRenderInstancesEqual + ); + const [dragGeometry, setDragGeometry] = useState(null); + + const widget = instance ? getWidgetById(instance.typeId) : undefined; + + const commitGeometry = useCallback( + (geometry: FloatingGeometry) => { + const clamped = clampWindowToViewport(geometry, { height: window.innerHeight, width: window.innerWidth }); + widgets.setFloatingGeometry(instanceId, clamped); + }, + [instanceId, widgets] + ); + + const beginPointerOperation = useCallback( + ( + event: ReactPointerEvent, + apply: (deltaX: number, deltaY: number, start: FloatingGeometry) => FloatingGeometry + ) => { + event.preventDefault(); + + const startX = event.clientX; + const startY = event.clientY; + const start: FloatingGeometry = { heightPx: state.heightPx, widthPx: state.widthPx, x: state.x, y: state.y }; + let next = start; + const pointerSession = new AbortController(); + + const handlePointerMove = (moveEvent: PointerEvent) => { + next = apply(moveEvent.clientX - startX, moveEvent.clientY - startY, start); + setDragGeometry(next); + }; + + const handlePointerUp = () => { + pointerSession.abort(); + setDragGeometry(null); + commitGeometry(next); + }; + + window.addEventListener('pointermove', handlePointerMove, { signal: pointerSession.signal }); + window.addEventListener('pointerup', handlePointerUp, { signal: pointerSession.signal }); + window.addEventListener('pointercancel', handlePointerUp, { signal: pointerSession.signal }); + }, + [commitGeometry, state.heightPx, state.widthPx, state.x, state.y] + ); + + const handleTitlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0 || state.mode === 'maximized' || (event.target as HTMLElement).closest('button')) { + return; + } + + beginPointerOperation(event, (deltaX, deltaY, start) => ({ ...start, x: start.x + deltaX, y: start.y + deltaY })); + }, + [beginPointerOperation, state.mode] + ); + + const handleResizePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) { + return; + } + + beginPointerOperation(event, (deltaX, deltaY, start) => ({ + ...start, + heightPx: Math.max(FLOATING_MIN_HEIGHT_PX, start.heightPx + deltaY), + widthPx: Math.max(FLOATING_MIN_WIDTH_PX, start.widthPx + deltaX), + })); + }, + [beginPointerOperation] + ); + + const handleFocus = useCallback(() => widgets.focusFloating(instanceId), [instanceId, widgets]); + const handleDock = useCallback(() => widgets.dockFloating(instanceId), [instanceId, widgets]); + const handleToggleShade = useCallback( + () => widgets.setFloatingMode(instanceId, state.mode === 'shaded' ? 'windowed' : 'shaded'), + [instanceId, state.mode, widgets] + ); + const handleToggleMaximize = useCallback( + () => widgets.setFloatingMode(instanceId, state.mode === 'maximized' ? 'windowed' : 'maximized'), + [instanceId, state.mode, widgets] + ); + const handleTitleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + // A double-click on a title-bar button (e.g. Maximize) must not also + // shade the window. + if (state.mode !== 'maximized' && !(event.target as HTMLElement).closest('button')) { + handleToggleShade(); + } + }, + [handleToggleShade, state.mode] + ); + + if (!instance || !widget || widget.status !== 'enabled') { + return null; + } + + const label = resolveWidgetInstanceLabel(instance, widget.manifest, t); + const geometry = dragGeometry ?? state; + const isMaximized = state.mode === 'maximized'; + const isShaded = state.mode === 'shaded'; + // CSS clamp keeps a grabbable sliver on-screen even for geometry persisted + // on a larger display (or after the browser window shrinks) — the commit + // clamp only covers drags on the current viewport. + const positionProps = isMaximized + ? { h: '100vh', left: 0, top: 0, w: '100vw' } + : { + h: isShaded ? 'auto' : `${geometry.heightPx}px`, + left: `clamp(${48 - geometry.widthPx}px, ${geometry.x}px, calc(100vw - 48px))`, + top: `clamp(0px, ${geometry.y}px, calc(100vh - 48px))`, + w: `${geometry.widthPx}px`, + }; + + return ( + + + + + + {label} + + + + + + + + + + + + + + + + + + + + + {isShaded ? null : ( + + + + )} + {isShaded || isMaximized ? null : ( + + )} + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetActionsMenu.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetActionsMenu.tsx index ec2473f9349..6e78028135c 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetActionsMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetActionsMenu.tsx @@ -15,7 +15,7 @@ import { IconButton } from '@platform/ui'; import { createGraphBearingSurface } from '@workbench/graphSurfaces'; import { resolveWidgetLabel } from '@workbench/widgetLabels'; import { shallowEqual, useActiveProjectSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext'; -import { GitBranchIcon, MoreHorizontalIcon, TargetIcon } from 'lucide-react'; +import { GitBranchIcon, MoreHorizontalIcon, PictureInPicture2Icon, TargetIcon } from 'lucide-react'; import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -118,6 +118,7 @@ export const WidgetActionsMenu = ({ (project) => ({ projectGraph: project.projectGraph, widgetGraphs: project.widgetGraphs }), shallowEqual ) as Project; + const { widgets } = useWorkbenchCommands(); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [previewGraph, setPreviewGraph] = useState(null); const label = resolveWidgetLabel(manifest, t); @@ -126,6 +127,10 @@ export const WidgetActionsMenu = ({ manifest.graphBearing?.surfaces.includes(region) ? createGraphBearingSurface(manifest, region, label) : null, [label, manifest, region] ); + // Floating is offered only from dockable regions; the floating window's own + // chrome carries the dock control. + const canFloat = Boolean(manifest.allowFloating) && region !== 'floating'; + const handleFloat = useCallback(() => widgets.float(instance.id), [instance.id, widgets]); const surfaceSourceId = surface?.sourceId; const handlePreview = useCallback(async () => { flushWorkbenchDrafts(); @@ -139,7 +144,7 @@ export const WidgetActionsMenu = ({ ? Object.fromEntries(activeProject.projectGraph.nodes.map((node) => [node.id, node.position])) : undefined; - if (!surface && !HeaderMenu) { + if (!surface && !HeaderMenu && !canFloat) { return null; } @@ -157,7 +162,14 @@ export const WidgetActionsMenu = ({ {surface ? : null} - {surface && HeaderMenu ? : null} + {surface && (HeaderMenu || canFloat) ? : null} + {canFloat ? ( + + + {t('widgets.floating.floatWindow')} + + ) : null} + {canFloat && HeaderMenu ? : null} {HeaderMenu ? ( ) : null} diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetRenderer.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetRenderer.tsx index 96c34c98723..5285fdd1128 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetRenderer.tsx +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetRenderer.tsx @@ -229,7 +229,9 @@ const WidgetShellFrame = ({ return {safeContent}; } - if (region === 'popover' || region === 'dialog' || presentation === 'compact') { + // Floating windows provide their own chrome (FloatingWidgetWindow), so the + // view renders bare, like popovers and dialogs. + if (region === 'popover' || region === 'dialog' || region === 'floating' || presentation === 'compact') { return safeContent; } diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts index 028df9c212d..1c2e7feeb98 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts @@ -53,6 +53,9 @@ const createDispatch = () => { }; const widgets: WorkbenchWidgetCommands = { + dockFloating: (instanceId) => dispatch({ instanceId, type: 'dockFloatingWidget' }), + float: (instanceId) => dispatch({ instanceId, type: 'floatWidget' }), + focusFloating: (instanceId) => dispatch({ instanceId, type: 'focusFloatingWidget' }), move: (options) => dispatch({ ...options, type: 'moveWidgetInstance' }), open: (options) => dispatch({ ...options, type: 'openRegionWidget' }), patchInstanceValues: (instanceId, values, projectId) => @@ -60,6 +63,9 @@ const createDispatch = () => { patchValues: (widgetId, values, projectId) => dispatch({ projectId, type: 'patchWidgetValues', values, widgetId }), reorder: (options) => dispatch({ ...options, type: 'reorderWidgetInstances' }), select: (options) => dispatch({ ...options, type: 'selectRegionWidget' }), + setFloatingGeometry: (instanceId, geometry) => + dispatch({ instanceId, type: 'setFloatingWidgetGeometry', ...geometry }), + setFloatingMode: (instanceId, mode) => dispatch({ instanceId, mode, type: 'setFloatingWidgetMode' }), setInstanceValues: (instanceId, values, projectId) => dispatch({ instanceId, projectId, type: 'setWidgetInstanceValues', values }), toggle: (options) => dispatch({ ...options, type: 'toggleRegionWidget' }), diff --git a/invokeai/frontend/webv2/src/workbench/widgetContracts.ts b/invokeai/frontend/webv2/src/workbench/widgetContracts.ts index 7de6d45d693..64a8666a09b 100644 --- a/invokeai/frontend/webv2/src/workbench/widgetContracts.ts +++ b/invokeai/frontend/webv2/src/workbench/widgetContracts.ts @@ -31,7 +31,7 @@ export type WidgetInstanceId = string; export type WidgetId = WidgetTypeId; -export type WorkbenchRegion = 'left' | 'right' | 'center' | 'bottom' | 'dialog' | 'popover'; +export type WorkbenchRegion = 'left' | 'right' | 'center' | 'bottom' | 'dialog' | 'popover' | 'floating'; export interface WidgetStateContract { id: WidgetTypeId; @@ -300,6 +300,8 @@ export interface WidgetManifest { icon: WidgetIconComponent; bottomPanel?: 'expandable' | 'tooltip'; centerPlacement?: 'toolbar' | 'view'; + /** Opt-in: the widget can be detached into a movable floating window. */ + allowFloating?: boolean; /** Only offered while an admin is signed in to a multi-user backend. */ requiresAdmin?: boolean; chrome?: { diff --git a/invokeai/frontend/webv2/src/workbench/widgetPlacementCommands.test.ts b/invokeai/frontend/webv2/src/workbench/widgetPlacementCommands.test.ts index 3cf6827e612..af7487c2239 100644 --- a/invokeai/frontend/webv2/src/workbench/widgetPlacementCommands.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgetPlacementCommands.test.ts @@ -59,6 +59,9 @@ const applyCommand = (state: WorkbenchState, command: (widgets: WorkbenchWidgetC }; const createWidgetCommands = (dispatch: (action: WorkbenchAction) => void): WorkbenchWidgetCommands => ({ + dockFloating: (instanceId) => dispatch({ instanceId, type: 'dockFloatingWidget' }), + float: (instanceId) => dispatch({ instanceId, type: 'floatWidget' }), + focusFloating: (instanceId) => dispatch({ instanceId, type: 'focusFloatingWidget' }), move: (options) => dispatch({ ...options, type: 'moveWidgetInstance' }), open: (options) => dispatch({ ...options, type: 'openRegionWidget' }), patchInstanceValues: (instanceId, values, projectId) => @@ -66,6 +69,9 @@ const createWidgetCommands = (dispatch: (action: WorkbenchAction) => void): Work patchValues: (widgetId, values, projectId) => dispatch({ projectId, type: 'patchWidgetValues', values, widgetId }), reorder: (options) => dispatch({ ...options, type: 'reorderWidgetInstances' }), select: (options) => dispatch({ ...options, type: 'selectRegionWidget' }), + setFloatingGeometry: (instanceId, geometry) => + dispatch({ instanceId, type: 'setFloatingWidgetGeometry', ...geometry }), + setFloatingMode: (instanceId, mode) => dispatch({ instanceId, mode, type: 'setFloatingWidgetMode' }), setInstanceValues: (instanceId, values, projectId) => dispatch({ instanceId, projectId, type: 'setWidgetInstanceValues', values }), toggle: (options) => dispatch({ ...options, type: 'toggleRegionWidget' }), diff --git a/invokeai/frontend/webv2/src/workbench/widgets/image-map/manifest.ts b/invokeai/frontend/webv2/src/workbench/widgets/image-map/manifest.ts index d47c340db07..b2d85ed6613 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/image-map/manifest.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/image-map/manifest.ts @@ -3,6 +3,7 @@ import type { WidgetManifest } from '@workbench/widgetContracts'; import { MapIcon } from 'lucide-react'; export const imageMapWidgetManifest: WidgetManifest = { + allowFloating: true, allowMultiple: false, allowedRegions: ['left', 'right', 'center'], failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' }, diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 24b3fe397c2..fb419c4b056 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -13,6 +13,8 @@ import type { GraphContract } from '@workbench/graphContracts'; import type { InvocationRoute, InvocationSourceId, ResultDestination } from '@workbench/invocationContracts'; import type { CenterViewId, + FloatingWidgetMode, + FloatingWidgetState, LayoutPreset, LayoutPresetId, LayoutPresetOverrides, @@ -121,6 +123,7 @@ import { getCanvasStagingSlots, getFirstCanvasPlaceholderSlotIndex, } from './canvasStagingView'; +import { cascadeDefaultGeometry, clampSizeToMinimum, nextStackOrder } from './floatingWindows'; import { defaultInvocationRoute, isInvocationRouteValid, @@ -188,6 +191,18 @@ type WorkbenchReducerAction = } | { type: 'setRegionWidgetCollapsed'; region: WidgetRegion; isCollapsed: boolean } | { type: 'setRegionWidgetSize'; region: WidgetRegion; sizePx: number } + | { type: 'floatWidget'; instanceId: WidgetInstanceId } + | { type: 'dockFloatingWidget'; instanceId: WidgetInstanceId } + | { + type: 'setFloatingWidgetGeometry'; + instanceId: WidgetInstanceId; + x: number; + y: number; + widthPx: number; + heightPx: number; + } + | { type: 'setFloatingWidgetMode'; instanceId: WidgetInstanceId; mode: FloatingWidgetMode } + | { type: 'focusFloatingWidget'; instanceId: WidgetInstanceId } | { type: 'setGenerateSettings'; values: GenerateWidgetValues; projectId?: string; origin?: WorkbenchActionOrigin } | { type: 'patchGenerateSettings'; @@ -916,6 +931,7 @@ const createUndoSnapshot = ( project: Project, projectGraph = cloneProjectGraph(project.projectGraph) ): ProjectUndoSnapshot => ({ + floatingWidgets: project.floatingWidgets ? { ...project.floatingWidgets } : undefined, invocation: { ...project.invocation }, layout: { ...project.layout, panels: { ...project.layout.panels } }, projectGraph, @@ -926,6 +942,9 @@ const createUndoSnapshot = ( const restoreUndoSnapshot = (project: Project, snapshot: ProjectUndoSnapshot): Project => ({ ...project, + // Restored WITH widgetRegions — they are one placement fact, and restoring + // one without the other can double-render or orphan a floated instance. + floatingWidgets: snapshot.floatingWidgets ? { ...snapshot.floatingWidgets } : undefined, invocation: { ...snapshot.invocation }, layout: { ...snapshot.layout, panels: { ...snapshot.layout.panels } }, projectGraph: cloneProjectGraph(normalizeProjectGraph(snapshot.projectGraph)), @@ -1580,6 +1599,9 @@ const applyLayoutPresetToProject = (project: Project, preset: LayoutPreset): Pro return { ...project, + // A preset is a full placement reset: floating windows dock implicitly, + // otherwise an instance the preset places in a region would render twice. + floatingWidgets: undefined, layout: { ...snapshot.layout, panels: { ...snapshot.layout.panels }, @@ -2652,9 +2674,13 @@ export const __workbenchReducerInternal = ( ...project.widgetInstances, [instanceId]: createWidgetInstance(action.widgetId, instanceId, action.initialValues), }; + // Placing an instance into a region implicitly docks it: an instance + // must never render in a panel and a floating window at once. + const { [instanceId]: _floated, ...floatingWidgets } = project.floatingWidgets ?? {}; return { ...project, + floatingWidgets, layout: openPanelForRegion(project.layout, action.region), widgetInstances, widgetRegions: { @@ -2718,6 +2744,130 @@ export const __workbenchReducerInternal = ( }) ); } + case 'floatWidget': { + return updateActiveProject(state, (project) => { + if (project.floatingWidgets?.[action.instanceId]) { + return project; + } + + const hostEntry = (Object.entries(project.widgetRegions) as [WidgetRegion, WidgetRegionState][]).find( + ([, region]) => region.instanceIds.includes(action.instanceId) + ); + + if (!hostEntry || !project.widgetInstances[action.instanceId]) { + return project; + } + + const [hostRegionId, hostRegion] = hostEntry; + const instanceIds = hostRegion.instanceIds.filter((instanceId) => instanceId !== action.instanceId); + const fallbackInstanceId = getNextInstanceId(hostRegion, action.instanceId); + const floating: FloatingWidgetState = { + ...cascadeDefaultGeometry(Object.keys(project.floatingWidgets ?? {}).length), + mode: 'windowed', + returnRegion: hostRegionId, + stackOrder: nextStackOrder(project.floatingWidgets), + }; + + return { + ...project, + floatingWidgets: { ...project.floatingWidgets, [action.instanceId]: floating }, + widgetRegions: { + ...project.widgetRegions, + [hostRegionId]: { + ...hostRegion, + activeInstanceId: + hostRegion.activeInstanceId === action.instanceId && fallbackInstanceId + ? fallbackInstanceId + : hostRegion.activeInstanceId, + instanceIds, + }, + }, + }; + }); + } + case 'dockFloatingWidget': { + return updateActiveProject(state, (project) => { + const floating = project.floatingWidgets?.[action.instanceId]; + + if (!floating) { + return project; + } + + const { [action.instanceId]: _docked, ...remaining } = project.floatingWidgets ?? {}; + const region = project.widgetRegions[floating.returnRegion]; + const instanceIds = region.instanceIds.includes(action.instanceId) + ? region.instanceIds + : [...region.instanceIds, action.instanceId]; + + return { + ...project, + floatingWidgets: remaining, + layout: openPanelForRegion(project.layout, floating.returnRegion), + widgetRegions: { + ...project.widgetRegions, + [floating.returnRegion]: { + ...region, + activeInstanceId: action.instanceId, + instanceIds, + isCollapsed: false, + }, + }, + }; + }); + } + case 'setFloatingWidgetGeometry': { + return updateActiveProject(state, (project) => { + const floating = project.floatingWidgets?.[action.instanceId]; + + if (!floating) { + return project; + } + + const geometry = clampSizeToMinimum({ + heightPx: action.heightPx, + widthPx: action.widthPx, + x: action.x, + y: action.y, + }); + + return { + ...project, + floatingWidgets: { ...project.floatingWidgets, [action.instanceId]: { ...floating, ...geometry } }, + }; + }); + } + case 'setFloatingWidgetMode': { + return updateActiveProject(state, (project) => { + const floating = project.floatingWidgets?.[action.instanceId]; + + if (!floating || floating.mode === action.mode) { + return project; + } + + return { + ...project, + floatingWidgets: { ...project.floatingWidgets, [action.instanceId]: { ...floating, mode: action.mode } }, + }; + }); + } + case 'focusFloatingWidget': { + return updateActiveProject(state, (project) => { + const floating = project.floatingWidgets?.[action.instanceId]; + const topOrder = nextStackOrder(project.floatingWidgets) - 1; + + if (!floating || floating.stackOrder === topOrder) { + return project; + } + + return { + ...project, + floatingWidgets: { + ...project.floatingWidgets, + [action.instanceId]: { ...floating, stackOrder: topOrder + 1 }, + }, + }; + }); + } case 'moveWidgetInstance': { return updateActiveProject(state, (project) => { const fromRegion = project.widgetRegions[action.fromRegion]; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts index 23eccefbdda..cf1f74943d2 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts @@ -354,6 +354,9 @@ const createCommands = (dispatch: WorkbenchDispatch, getState: () => WorkbenchSt setStatus: command('setQueueItemStatus'), }, widgets: { + dockFloating: command('dockFloatingWidget', (instanceId: string) => ({ instanceId })), + float: command('floatWidget', (instanceId: string) => ({ instanceId })), + focusFloating: command('focusFloatingWidget', (instanceId: string) => ({ instanceId })), move: command('moveWidgetInstance'), open: command('openRegionWidget'), patchInstanceValues: command( @@ -380,6 +383,17 @@ const createCommands = (dispatch: WorkbenchDispatch, getState: () => WorkbenchSt ), reorder: command('reorderWidgetInstances'), select: command('selectRegionWidget'), + setFloatingGeometry: command( + 'setFloatingWidgetGeometry', + (instanceId: string, geometry: { x: number; y: number; widthPx: number; heightPx: number }) => ({ + instanceId, + ...geometry, + }) + ), + setFloatingMode: command( + 'setFloatingWidgetMode', + (instanceId: string, mode: ActionPayload<'setFloatingWidgetMode'>['mode']) => ({ instanceId, mode }) + ), setInstanceValues: command( 'setWidgetInstanceValues', (instanceId: string, values: Record, projectId?: string) => ({