From 969f5fece22ee178a5fbdb550df7333d103fa73e Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Fri, 18 Sep 2026 21:57:35 +0100 Subject: [PATCH] Add SnapDOM Integration and Refactor Dashboard Tab Functionality - Introduced @zumer/snapdom for enhanced tab preview capture, replacing html2canvas-pro for improved performance and reliability. - Refactored DashboardTabPanes and DashboardTabs components to utilize the new tab preview logic, streamlining the rendering process. - Updated CSS styles for dashboard tab previews, enhancing visual feedback and layout consistency. - Removed deprecated preview capture logic and associated worker, simplifying the codebase. - Enhanced NavigationTabsContext to manage tab previews and capturing state more effectively. --- assets/stylesheets/App.css | 88 +++-- bun.lock | 14 +- package.json | 2 +- pnpm-lock.yaml | 47 +-- .../Dashboard/common/DashboardTabPanes.jsx | 151 ++++---- .../Dashboard/common/DashboardTabPreview.jsx | 71 +++- .../Dashboard/common/DashboardTabs.jsx | 71 +--- .../context/NavigationTabsContext.jsx | 132 ++++--- .../Dashboard/context/tabPreviewCapture.js | 348 ++++-------------- .../Dashboard/context/tabPreviewWorker.js | 38 -- 10 files changed, 366 insertions(+), 596 deletions(-) delete mode 100644 src/components/Dashboard/context/tabPreviewWorker.js diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index fcc41c20..43452994 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -4022,43 +4022,36 @@ body.objectKanbanColumnResizing * { } .dashboard-tab-pane { + position: absolute; + top: 0; + left: 0; display: flex; flex-direction: column; - height: 100%; + box-sizing: border-box; width: 100%; + height: 100%; + min-width: 0; min-height: 0; } .dashboard-tab-pane-active { - position: relative; - left: 0; z-index: 1; + pointer-events: auto; + transform: none; } .dashboard-tab-pane-inactive { - position: absolute; - top: 0; - left: -100000px; z-index: 0; + overflow: hidden; pointer-events: none; + visibility: hidden; + transform: translate(-100%, 0); } .dashboard-tab-pane-inactive.dashboard-tab-pane-capturing { - position: fixed; - top: 0; - left: calc(-1 * var(--dashboard-tab-capture-width) - 48px); - z-index: 2; - box-sizing: border-box; - width: var(--dashboard-tab-capture-width); - height: var(--dashboard-tab-capture-height); - min-width: var(--dashboard-tab-capture-width); - min-height: var(--dashboard-tab-capture-height); - max-width: var(--dashboard-tab-capture-width); - max-height: var(--dashboard-tab-capture-height); - overflow: hidden; + /* Stay translated off-screen so capture cannot cover the active tab. + Layout size stays 100% of the host for SnapDOM. */ visibility: visible; - opacity: 1; - pointer-events: none; } .dashboard-tab-preview-popover .ant-popover-inner { @@ -4069,18 +4062,69 @@ body.objectKanbanColumnResizing * { .dashboard-tab-preview { position: relative; width: 320px; - height: var(--dashboard-tab-preview-height, 180px); background: var(--tab-preview-render-background); overflow: hidden; padding: 8px; } -.dashboard-tab-preview-image { +.dashboard-tab-preview-stack { + position: relative; + width: 100%; + overflow: hidden; +} + +.dashboard-tab-preview-svg { display: block; width: 100%; + height: auto; + pointer-events: none; +} + +.dashboard-tab-preview-svg-current { + position: relative; + z-index: 0; +} + +.dashboard-tab-preview-svg-previous { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; height: 100%; object-fit: cover; object-position: top center; + filter: blur(8px); + opacity: 1; + transition: + opacity 280ms ease, + filter 280ms ease; +} + +.dashboard-tab-preview-stack:has(.dashboard-tab-preview-loading):not( + :has(.dashboard-tab-preview-svg-current) + ) { + min-height: 120px; +} + +.dashboard-tab-preview-svg-previous.is-revealing { + opacity: 0; + filter: blur(0); +} + +.dashboard-tab-preview-loading { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.dashboard-tab-preview-loading-icon { + font-size: 16px; + color: var(--color-text-secondary, rgba(255, 255, 255, 0.92)); + filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.45)); } .dashboard-tab-preview-placeholder { diff --git a/bun.lock b/bun.lock index d9445e5c..93303303 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,7 @@ "@tsparticles/slim": "^3.9.1", "@uiw/react-codemirror": "^4.25.1", "@vscode/sudo-prompt": "^9.3.2", + "@zumer/snapdom": "^3.0.0", "antd": "^5.27.1", "antd-style": "^3.7.1", "axios": "^1.11.0", @@ -57,7 +58,6 @@ "echarts-for-react": "^3.0.6", "electron-store": "^11.0.2", "gcode-preview": "3.0.0-alpha.6", - "html2canvas-pro": "^2.4.3", "json-schema-traverse": "^1.0.0", "keycloak-js": "^26.2.0", "lodash": "^4.17.23", @@ -951,6 +951,8 @@ "@zeit/schemas": ["@zeit/schemas@2.36.0", "", {}, "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg=="], + "@zumer/snapdom": ["@zumer/snapdom@3.0.0", "", {}, "sha512-O+gqKYz+7545JNgG4Hzdy9/i+7/H38bDpF6tUOCJBqAUTm9npbaE3ZxuevFBcRi6cBLpvMqVYIjtywAZIP6OFQ=="], + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.2", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -1039,8 +1041,6 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.4", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ=="], @@ -1201,8 +1201,6 @@ "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], - "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], - "css-mediaquery": ["css-mediaquery@0.1.2", "", {}, "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "1.0.0", "css-what": "6.2.2", "domhandler": "5.0.3", "domutils": "3.2.2", "nth-check": "2.1.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], @@ -1579,8 +1577,6 @@ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], - "html2canvas-pro": ["html2canvas-pro@2.4.3", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-ov+EmxDc2abD2uKYpZdFWHkQkl3pu2fH0N98khT1Ojfqr3DJOuGyFIFTSfSyaWxddK9jSJu8sM7aUATxM26mDg=="], - "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.2", "toidentifier": "1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -2527,8 +2523,6 @@ "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "0.3.11", "acorn": "8.17.0", "commander": "2.20.3", "source-map-support": "0.5.21" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], - "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], - "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], "three": ["three@0.179.1", "", {}, "sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw=="], @@ -2637,8 +2631,6 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], - "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], diff --git a/package.json b/package.json index 95e281ea..08bbde66 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@tsparticles/slim": "^3.9.1", "@uiw/react-codemirror": "^4.25.1", "@vscode/sudo-prompt": "^9.3.2", + "@zumer/snapdom": "^3.0.0", "antd": "^5.27.1", "antd-style": "^3.7.1", "axios": "^1.11.0", @@ -61,7 +62,6 @@ "echarts-for-react": "^3.0.6", "electron-store": "^11.0.2", "gcode-preview": "3.0.0-alpha.6", - "html2canvas-pro": "^2.4.3", "json-schema-traverse": "^1.0.0", "keycloak-js": "^26.2.0", "lodash": "^4.17.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 982bbe58..ee9b4738 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: '@vscode/sudo-prompt': specifier: ^9.3.2 version: 9.3.2 + '@zumer/snapdom': + specifier: ^3.0.0 + version: 3.0.0 antd: specifier: ^5.27.1 version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -172,9 +175,6 @@ importers: gcode-preview: specifier: 3.0.0-alpha.6 version: 3.0.0-alpha.6 - html2canvas-pro: - specifier: ^2.4.3 - version: 2.4.3 json-schema-traverse: specifier: ^1.0.0 version: 1.0.0 @@ -2418,6 +2418,9 @@ packages: '@zeit/schemas@2.36.0': resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} + '@zumer/snapdom@3.0.0': + resolution: {integrity: sha512-O+gqKYz+7545JNgG4Hzdy9/i+7/H38bDpF6tUOCJBqAUTm9npbaE3ZxuevFBcRi6cBLpvMqVYIjtywAZIP6OFQ==} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2638,10 +2641,6 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - base64-arraybuffer@1.0.2: - resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} - engines: {node: '>= 0.6.0'} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3017,9 +3016,6 @@ packages: resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} engines: {node: '>=4'} - css-line-break@2.1.0: - resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} - css-mediaquery@0.1.2: resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} @@ -3969,10 +3965,6 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - html2canvas-pro@2.4.3: - resolution: {integrity: sha512-ov+EmxDc2abD2uKYpZdFWHkQkl3pu2fH0N98khT1Ojfqr3DJOuGyFIFTSfSyaWxddK9jSJu8sM7aUATxM26mDg==} - engines: {node: '>=16.0.0'} - http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -6145,9 +6137,6 @@ packages: engines: {node: '>=10'} hasBin: true - text-segmentation@1.0.3: - resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} - text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -6380,9 +6369,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utrie@1.0.2: - resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} - validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -9014,6 +9000,8 @@ snapshots: '@zeit/schemas@2.36.0': {} + '@zumer/snapdom@3.0.0': {} + abbrev@3.0.1: {} accepts@2.0.0: @@ -9337,8 +9325,6 @@ snapshots: balanced-match@1.0.2: {} - base64-arraybuffer@1.0.2: {} - base64-js@1.5.1: {} baseline-browser-mapping@2.11.4: {} @@ -9754,10 +9740,6 @@ snapshots: css-color-keywords@1.0.0: {} - css-line-break@2.1.0: - dependencies: - utrie: 1.0.2 - css-mediaquery@0.1.2: {} css-select@5.2.2: @@ -11092,11 +11074,6 @@ snapshots: html-url-attributes@3.0.1: {} - html2canvas-pro@2.4.3: - dependencies: - css-line-break: 2.1.0 - text-segmentation: 1.0.3 - http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -13839,10 +13816,6 @@ snapshots: source-map-support: 0.5.21 optional: true - text-segmentation@1.0.3: - dependencies: - utrie: 1.0.2 - text-table@0.2.0: {} three@0.165.0: {} @@ -14096,10 +14069,6 @@ snapshots: util-deprecate@1.0.2: {} - utrie@1.0.2: - dependencies: - base64-arraybuffer: 1.0.2 - validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/src/components/Dashboard/common/DashboardTabPanes.jsx b/src/components/Dashboard/common/DashboardTabPanes.jsx index 941b1582..18314d44 100644 --- a/src/components/Dashboard/common/DashboardTabPanes.jsx +++ b/src/components/Dashboard/common/DashboardTabPanes.jsx @@ -1,4 +1,5 @@ -import { useLayoutEffect, useRef } from 'react' +import { memo, useLayoutEffect, useMemo, useRef } from 'react' +import PropTypes from 'prop-types' import classNames from 'classnames' import { Outlet, Routes, UNSAFE_LocationContext, useLocation } from 'react-router-dom' import { TableStateProvider } from '../context/TableStateContext' @@ -40,16 +41,65 @@ const CachedTabRouteLayout = () => ( ) +const arePanePropsEqual = (prev, next) => + prev.tabId === next.tabId && + prev.isActive === next.isActive && + prev.registerTabPane === next.registerTabPane && + prev.loc === next.loc + +const DashboardTabPane = memo(function DashboardTabPane({ + tabId, + isActive, + loc, + registerTabPane +}) { + const tabRoutes = useMemo( + () => wrapDashboardChildRoutes(), + [] + ) + const locationContextValue = useMemo( + () => ({ location: loc, navigationType: 'POP' }), + [loc] + ) + + return ( +
registerTabPane(tabId, element)} + data-tab-id={tabId} + className={classNames('dashboard-tab-pane', { + 'dashboard-tab-pane-active': isActive, + 'dashboard-tab-pane-inactive': !isActive + })} + aria-hidden={!isActive} + inert={!isActive} + > + + + {tabRoutes} + + +
+ ) +}, arePanePropsEqual) + +DashboardTabPane.displayName = 'DashboardTabPane' + +DashboardTabPane.propTypes = { + tabId: PropTypes.string.isRequired, + isActive: PropTypes.bool, + loc: PropTypes.shape({ + pathname: PropTypes.string, + search: PropTypes.string, + hash: PropTypes.string, + key: PropTypes.string, + state: PropTypes.any + }).isRequired, + registerTabPane: PropTypes.func.isRequired +} + const DashboardTabPanes = () => { const location = useLocation() - const { - tabs, - activeTabId, - isElectron, - registerTabPane, - previewCaptureLayout, - reportPreviewCaptureLayout - } = useNavigationTabs() + const { tabs, activeTabId, isElectron, registerTabPane } = useNavigationTabs() const frozenRef = useRef(new Map()) const prevActiveIdRef = useRef(activeTabId) const lastLocationRef = useRef(location) @@ -80,35 +130,6 @@ const DashboardTabPanes = () => { window.dispatchEvent(new Event('resize')) }, [activeTabId, isElectron]) - useLayoutEffect(() => { - if (!previewCaptureLayout?.tabId) return undefined - - const pane = - document.querySelector( - `.dashboard-tab-pane[data-tab-id="${CSS.escape(previewCaptureLayout.tabId)}"]` - ) - if (!pane) return undefined - - void pane.offsetHeight - window.dispatchEvent(new Event('resize')) - - let cancelled = false - let secondFrame = 0 - const firstFrame = window.requestAnimationFrame(() => { - secondFrame = window.requestAnimationFrame(() => { - if (cancelled) return - void pane.offsetHeight - reportPreviewCaptureLayout(previewCaptureLayout.tabId, pane) - }) - }) - - return () => { - cancelled = true - window.cancelAnimationFrame(firstFrame) - if (secondFrame) window.cancelAnimationFrame(secondFrame) - } - }, [previewCaptureLayout, reportPreviewCaptureLayout]) - prevActiveIdRef.current = activeTabId lastLocationRef.current = location @@ -116,58 +137,32 @@ const DashboardTabPanes = () => { return } - const capturingTabId = previewCaptureLayout?.tabId - const capturingIndex = capturingTabId - ? tabs.findIndex((tab) => tab.id === capturingTabId) - : -1 - const renderTabs = - capturingIndex >= 0 && capturingIndex < tabs.length - 1 - ? [...tabs.filter((tab) => tab.id !== capturingTabId), tabs[capturingIndex]] - : tabs + const stackedTabs = activeTab + ? [...tabs.filter((tab) => tab.id !== activeTabId), activeTab] + : tabs return (
- {renderTabs.map((tab) => { + {stackedTabs.map((tab) => { const isActive = tab.id === activeTabId - const isCapturing = - !isActive && previewCaptureLayout?.tabId === tab.id const loc = isActive && !pendingRestoreRef.current ? location : frozenRef.current.get(tab.id) || entryToLocation(getTabCurrentEntry(tab), location, tab.id) + if (!isActive && !frozenRef.current.has(tab.id)) { + frozenRef.current.set(tab.id, loc) + } + return ( -
registerTabPane(tab.id, element)} - data-tab-id={tab.id} - className={classNames('dashboard-tab-pane', { - 'dashboard-tab-pane-active': isActive, - 'dashboard-tab-pane-inactive': !isActive, - 'dashboard-tab-pane-capturing': isCapturing - })} - style={ - isCapturing - ? { - '--dashboard-tab-capture-width': `${previewCaptureLayout.width}px`, - '--dashboard-tab-capture-height': `${previewCaptureLayout.height}px` - } - : undefined - } - aria-hidden={!isActive} - inert={!isActive && !isCapturing} - > - - - - {wrapDashboardChildRoutes()} - - - -
+ tabId={tab.id} + isActive={isActive} + loc={loc} + registerTabPane={registerTabPane} + /> ) })}
diff --git a/src/components/Dashboard/common/DashboardTabPreview.jsx b/src/components/Dashboard/common/DashboardTabPreview.jsx index 694eeb22..0d01b0f3 100644 --- a/src/components/Dashboard/common/DashboardTabPreview.jsx +++ b/src/components/Dashboard/common/DashboardTabPreview.jsx @@ -1,28 +1,73 @@ +import { useLayoutEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' +import classNames from 'classnames' +import { LoadingOutlined } from '@ant-design/icons' -const DEFAULT_HEIGHT = 180 +const DashboardTabPreview = ({ src, loading = false }) => { + const lastSrcRef = useRef(src || null) + const [overlaySrc, setOverlaySrc] = useState(src || null) + const [revealing, setRevealing] = useState(false) -const DashboardTabPreview = ({ src, height = DEFAULT_HEIGHT }) => { - const previewHeight = typeof height === 'number' ? `${height}px` : height + useLayoutEffect(() => { + if (loading) { + const locked = lastSrcRef.current || src || null + lastSrcRef.current = locked + setOverlaySrc(locked) + setRevealing(false) + return + } - if (!src) return null + if (src) { + lastSrcRef.current = src + } + + setRevealing(true) + }, [loading, src]) + + const handleOverlayTransitionEnd = (event) => { + if (event.propertyName !== 'opacity' || !revealing) return + setOverlaySrc(null) + setRevealing(false) + } return ( -
- +
+
+ {src ? ( + + ) : null} + {overlaySrc ? ( + + ) : null} + {loading ? ( +
+ +
+ ) : null} +
) } DashboardTabPreview.propTypes = { src: PropTypes.string, - height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + loading: PropTypes.bool } export default DashboardTabPreview diff --git a/src/components/Dashboard/common/DashboardTabs.jsx b/src/components/Dashboard/common/DashboardTabs.jsx index 8411faa8..6a74f6c1 100644 --- a/src/components/Dashboard/common/DashboardTabs.jsx +++ b/src/components/Dashboard/common/DashboardTabs.jsx @@ -8,7 +8,7 @@ import PlusIcon from '../../Icons/PlusIcon' import XMarkIcon from '../../Icons/XMarkIcon' import HomeIcon from '../../Icons/HomeIcon' import { getModelByName } from '../../../database/ObjectModels' -import { useNavigationTabs } from '../context/NavigationTabsContext' +import { useNavigationTabs, useTabPreview } from '../context/NavigationTabsContext' import { getDesktopWindowId } from '../../../electrobun-bridge.js' import { hasExternalTabDrag, writeTabDragData } from './tabDrag' @@ -80,70 +80,31 @@ const DashboardTabItem = ({ onClose }) => { const { captureTabPreview } = useNavigationTabs() + const { src: previewSrc, capturing: previewCapturing } = useTabPreview(tab.id) const [previewOpen, setPreviewOpen] = useState(false) - const [previewSrc, setPreviewSrc] = useState(null) - const previewHoverRef = useRef(false) - const previewOpenRef = useRef(false) - const previewRequestRef = useRef(0) const previewTargetRef = useRef(null) const model = tab.modelName ? getModelByName(tab.modelName) : null const Icon = model?.icon || HomeIcon - const closePreview = useCallback(() => { - previewHoverRef.current = false - previewOpenRef.current = false - previewRequestRef.current += 1 - setPreviewOpen(false) - setPreviewSrc(null) - }, []) - - const startPreviewCapture = useCallback(() => { - if (isSelected || isDragging) return - - previewHoverRef.current = true - const requestId = previewRequestRef.current + 1 - previewRequestRef.current = requestId - previewOpenRef.current = false - setPreviewOpen(false) - setPreviewSrc(null) - - void captureTabPreview(tab.id).then((dataUrl) => { - if ( - !previewHoverRef.current || - previewRequestRef.current !== requestId || - !dataUrl - ) { - return - } - if ( - previewTargetRef.current && - !previewTargetRef.current.matches(':hover') - ) { - return - } - setPreviewSrc(dataUrl) - previewOpenRef.current = true - setPreviewOpen(true) - }) - }, [captureTabPreview, isDragging, isSelected, tab.id]) - const handlePreviewOpenChange = useCallback( (nextOpen) => { - if (!nextOpen) closePreview() + if (isSelected || isDragging) { + setPreviewOpen(false) + return + } + setPreviewOpen(nextOpen) + if (nextOpen) { + void captureTabPreview(tab.id) + } }, - [closePreview] + [captureTabPreview, isDragging, isSelected, tab.id] ) - const handlePreviewMouseLeave = useCallback(() => { - if (previewOpenRef.current) return - closePreview() - }, [closePreview]) - useEffect(() => { if (isSelected || isDragging) { - closePreview() + setPreviewOpen(false) } - }, [closePreview, isDragging, isSelected]) + }, [isDragging, isSelected]) const tabButton = (