diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index b2e6e70b..fcc41c20 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -4018,20 +4018,78 @@ body.objectKanbanColumnResizing * { flex: 1 1 auto; min-height: 0; height: 100%; + overflow: hidden; } .dashboard-tab-pane { + display: flex; + flex-direction: column; height: 100%; + width: 100%; min-height: 0; } .dashboard-tab-pane-active { - display: flex; - flex-direction: column; + position: relative; + left: 0; + z-index: 1; } .dashboard-tab-pane-inactive { - display: none; + position: absolute; + top: 0; + left: -100000px; + z-index: 0; + pointer-events: none; +} + +.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; + visibility: visible; + opacity: 1; + pointer-events: none; +} + +.dashboard-tab-preview-popover .ant-popover-inner { + padding: 0; + overflow: hidden; +} + +.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 { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: top center; +} + +.dashboard-tab-preview-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(128, 128, 128, 0.18); } .state-tag-loading svg path { diff --git a/bun.lock b/bun.lock index 4a87bd3d..d9445e5c 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,7 @@ "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", @@ -1038,6 +1039,8 @@ "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=="], @@ -1198,6 +1201,8 @@ "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=="], @@ -1574,6 +1579,8 @@ "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=="], @@ -2520,6 +2527,8 @@ "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=="], @@ -2628,6 +2637,8 @@ "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 9b11c850..95e281ea 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "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 b593e65d..982bbe58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ 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 @@ -2635,6 +2638,10 @@ 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==} @@ -3010,6 +3017,9 @@ 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==} @@ -3959,6 +3969,10 @@ 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==} @@ -6131,6 +6145,9 @@ 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==} @@ -6363,6 +6380,9 @@ 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==} @@ -9317,6 +9337,8 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.11.4: {} @@ -9732,6 +9754,10 @@ 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: @@ -11066,6 +11092,11 @@ 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: @@ -13808,6 +13839,10 @@ 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: {} @@ -14061,6 +14096,10 @@ 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 ae9d6697..941b1582 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 classNames from 'classnames' import { Outlet, Routes, UNSAFE_LocationContext, useLocation } from 'react-router-dom' import { TableStateProvider } from '../context/TableStateContext' import { @@ -41,8 +42,14 @@ const CachedTabRouteLayout = () => ( const DashboardTabPanes = () => { const location = useLocation() - const { tabs, activeTabId, isElectron } = useNavigationTabs() - const visitedRef = useRef(new Set()) + const { + tabs, + activeTabId, + isElectron, + registerTabPane, + previewCaptureLayout, + reportPreviewCaptureLayout + } = useNavigationTabs() const frozenRef = useRef(new Map()) const prevActiveIdRef = useRef(activeTabId) const lastLocationRef = useRef(location) @@ -62,13 +69,8 @@ const DashboardTabPanes = () => { frozenRef.current.set(activeTabId, location) } - if (activeTabId) { - visitedRef.current.add(activeTabId) - } - - for (const tabId of [...visitedRef.current]) { + for (const tabId of [...frozenRef.current.keys()]) { if (!tabs.some((tab) => tab.id === tabId)) { - visitedRef.current.delete(tabId) frozenRef.current.delete(tabId) } } @@ -78,19 +80,57 @@ const DashboardTabPanes = () => { window.dispatchEvent(new Event('resize')) }, [activeTabId, isElectron]) - const visibleTabs = tabs.filter((tab) => visitedRef.current.has(tab.id)) + 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 - if (visibleTabs.length === 0) { + if (tabs.length === 0) { 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 + return (
- {visibleTabs.map((tab) => { + {renderTabs.map((tab) => { const isActive = tab.id === activeTabId + const isCapturing = + !isActive && previewCaptureLayout?.tabId === tab.id const loc = isActive && !pendingRestoreRef.current ? location @@ -100,12 +140,23 @@ const DashboardTabPanes = () => { 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} > { + const previewHeight = typeof height === 'number' ? `${height}px` : height + + if (!src) return null + + return ( +
+ +
+ ) +} + +DashboardTabPreview.propTypes = { + src: PropTypes.string, + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) +} + +export default DashboardTabPreview diff --git a/src/components/Dashboard/common/DashboardTabs.jsx b/src/components/Dashboard/common/DashboardTabs.jsx index fadabad2..8411faa8 100644 --- a/src/components/Dashboard/common/DashboardTabs.jsx +++ b/src/components/Dashboard/common/DashboardTabs.jsx @@ -1,6 +1,7 @@ import PropTypes from 'prop-types' import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Button, Flex, Typography } from 'antd' +import { Button, Flex, Popover, Typography } from 'antd' +import DashboardTabPreview from './DashboardTabPreview' import classNames from 'classnames' import ScrollBox from './ScrollBox' import PlusIcon from '../../Icons/PlusIcon' @@ -78,11 +79,75 @@ const DashboardTabItem = ({ onDrop, onClose }) => { + const { captureTabPreview } = useNavigationTabs() + 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 - return ( + 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() + }, + [closePreview] + ) + + const handlePreviewMouseLeave = useCallback(() => { + if (previewOpenRef.current) return + closePreview() + }, [closePreview]) + + useEffect(() => { + if (isSelected || isDragging) { + closePreview() + } + }, [closePreview, isDragging, isSelected]) + + const tabButton = ( ) + + if (isSelected) { + return tabButton + } + + return ( + : null} + classNames={{ root: 'dashboard-tab-preview-popover' }} + styles={{ body: { padding: 0, overflow: 'hidden' } }} + > + {tabButton} + + ) } DashboardTabItem.propTypes = { diff --git a/src/components/Dashboard/context/NavigationTabsContext.jsx b/src/components/Dashboard/context/NavigationTabsContext.jsx index 5779440b..df9995c1 100644 --- a/src/components/Dashboard/context/NavigationTabsContext.jsx +++ b/src/components/Dashboard/context/NavigationTabsContext.jsx @@ -11,6 +11,7 @@ import PropTypes from 'prop-types' import { useLocation, useNavigate } from 'react-router-dom' import { ElectronContext } from './ElectronContext' import { getDesktopWindowId } from '../../../electrobun-bridge.js' +import { captureTabPaneImage } from './tabPreviewCapture' const NavigationTabsContext = createContext() @@ -74,9 +75,14 @@ export const NavigationTabsProvider = ({ children }) => { const [tabs, setTabs] = useState([]) const [activeTabId, setActiveTabId] = useState(null) const [hydrated, setHydrated] = useState(!isElectron) + const [previewCaptureLayout, setPreviewCaptureLayout] = useState(null) const tabsRef = useRef(tabs) const activeTabIdRef = useRef(activeTabId) const isRestoringRef = useRef(false) + const tabPaneElsRef = useRef(new Map()) + const captureQueueRef = useRef(Promise.resolve()) + const captureInFlightRef = useRef(new Map()) + const previewCaptureWaiterRef = useRef(null) tabsRef.current = tabs activeTabIdRef.current = activeTabId @@ -241,6 +247,103 @@ export const NavigationTabsProvider = ({ children }) => { }) }, []) + const registerTabPane = useCallback((tabId, element) => { + if (!tabId) return + if (!element) { + tabPaneElsRef.current.delete(tabId) + return + } + tabPaneElsRef.current.set(tabId, element) + }, []) + + const reportPreviewCaptureLayout = useCallback((tabId, pane) => { + const waiter = previewCaptureWaiterRef.current + if (!waiter || waiter.tabId !== tabId) return + previewCaptureWaiterRef.current = null + waiter.resolve(pane) + }, []) + + const captureTabPreview = useCallback((tabId, { height = 180, width = 320 } = {}) => { + if (!tabId) return Promise.resolve(null) + + const inFlight = captureInFlightRef.current.get(tabId) + if (inFlight) return inFlight + + const run = async () => { + if (tabId === activeTabIdRef.current) { + return null + } + + const pane = + tabPaneElsRef.current.get(tabId) || + document.querySelector( + `.dashboard-tab-pane[data-tab-id="${CSS.escape(tabId)}"]` + ) + if (!pane) return null + + const activePane = + tabPaneElsRef.current.get(activeTabIdRef.current) || + document.querySelector('.dashboard-tab-pane-active') + const host = pane.parentElement + const layoutWidth = Math.round( + activePane?.clientWidth || host?.clientWidth || pane.clientWidth || 0 + ) + const layoutHeight = Math.round( + activePane?.clientHeight || host?.clientHeight || pane.clientHeight || 0 + ) + + try { + const layoutReady = new Promise((resolve) => { + previewCaptureWaiterRef.current = { tabId, resolve } + setPreviewCaptureLayout({ + tabId, + width: layoutWidth, + height: layoutHeight + }) + }) + const laidOutPane = await Promise.race([ + layoutReady, + new Promise((resolve) => { + window.setTimeout(() => resolve(pane), 500) + }) + ]) + const capturePane = laidOutPane || pane + const dataUrl = await captureTabPaneImage(capturePane, { + height, + width, + layoutWidth, + layoutHeight + }) + if (!dataUrl) return null + + return dataUrl + } catch (error) { + console.error('Failed to capture tab preview', error) + return null + } finally { + if (previewCaptureWaiterRef.current?.tabId === tabId) { + previewCaptureWaiterRef.current = null + } + setPreviewCaptureLayout((current) => + current?.tabId === tabId ? null : current + ) + } + } + + const next = captureQueueRef.current.then(run, run) + captureInFlightRef.current.set(tabId, next) + captureQueueRef.current = next.then( + () => undefined, + () => undefined + ) + next.finally(() => { + if (captureInFlightRef.current.get(tabId) === next) { + captureInFlightRef.current.delete(tabId) + } + }) + return next + }, []) + const setTabPage = useCallback(({ title, modelName } = {}) => { const activeId = activeTabIdRef.current if (!activeId) return @@ -453,7 +556,11 @@ export const NavigationTabsProvider = ({ children }) => { goForward, canGoBack, canGoForward, - createNewWindow + createNewWindow, + registerTabPane, + captureTabPreview, + previewCaptureLayout, + reportPreviewCaptureLayout }), [ activeTab, @@ -461,6 +568,9 @@ export const NavigationTabsProvider = ({ children }) => { addTab, canGoBack, canGoForward, + captureTabPreview, + previewCaptureLayout, + reportPreviewCaptureLayout, closeTab, createNewWindow, goBack, @@ -471,6 +581,7 @@ export const NavigationTabsProvider = ({ children }) => { acceptIncomingTab, hydrated, isElectron, + registerTabPane, reorderTabs, selectTab, setTabPage, diff --git a/src/components/Dashboard/context/ThemeContext.jsx b/src/components/Dashboard/context/ThemeContext.jsx index 795cde92..15840c45 100644 --- a/src/components/Dashboard/context/ThemeContext.jsx +++ b/src/components/Dashboard/context/ThemeContext.jsx @@ -175,6 +175,10 @@ export const ThemeProvider = ({ children }) => { '--color-background', isDarkMode ? '#000000' : '#ffffff' ) + root.style.setProperty( + '--tab-preview-render-background', + isDarkMode ? '#000000' : '#ffffff' + ) root.style.setProperty('--color-text', isDarkMode ? '#ffffff' : '#000000') root.style.setProperty( '--color-descriptions-border', diff --git a/src/components/Dashboard/context/tabPreviewCapture.js b/src/components/Dashboard/context/tabPreviewCapture.js new file mode 100644 index 00000000..bbab29f4 --- /dev/null +++ b/src/components/Dashboard/context/tabPreviewCapture.js @@ -0,0 +1,370 @@ +import html2canvas from 'html2canvas-pro' + +const JPEG_QUALITY = 0.82 +const PREVIEW_WIDTH = 320 +const PREVIEW_RESOLUTION_SCALE = 2 +const LAYOUT_TIMEOUT_MS = 450 +const FONT_TIMEOUT_MS = 180 + +let previewWorker = null +let previewRequestId = 0 +const previewRequests = new Map() + +const waitForPaint = () => + new Promise((resolve) => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(resolve) + }) + }) + +const waitForTimeout = (ms) => + new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) + +const waitWithTimeout = (promise, ms) => + Promise.race([promise, waitForTimeout(ms)]) + +const getCssVariableColor = (name) => + window.getComputedStyle(document.documentElement).getPropertyValue(name).trim() + +const cssColorToRgb = (color, fallback) => { + if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') { + return fallback + } + if ( + color.startsWith('#') || + color.startsWith('rgb(') || + color.startsWith('rgba(') + ) { + return color + } + + const canvas = document.createElement('canvas') + canvas.width = 1 + canvas.height = 1 + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) return fallback + ctx.fillStyle = fallback + ctx.fillRect(0, 0, 1, 1) + ctx.fillStyle = color + ctx.fillRect(0, 0, 1, 1) + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data + if (!a) return fallback + return `rgb(${r}, ${g}, ${b})` +} + +const getPreviewBackgroundColor = (pane) => { + const themeColor = getCssVariableColor('--tab-preview-render-background') + if (themeColor) { + return cssColorToRgb(themeColor, themeColor) + } + + return cssColorToRgb( + (pane && window.getComputedStyle(pane).backgroundColor) || + window.getComputedStyle(document.body).backgroundColor, + getCssVariableColor('--color-background') || '#ffffff' + ) +} + +const isBlankCanvas = (canvas) => { + if (!canvas || canvas.width < 8 || canvas.height < 8) return true + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) return true + + const sample = (x, y, width, height) => { + const { data } = ctx.getImageData(x, y, width, height) + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0 && (data[i - 3] > 2 || data[i - 2] > 2 || data[i - 1] > 2)) { + return false + } + } + return true + } + + const sampleWidth = Math.min(canvas.width, 24) + const sampleHeight = Math.min(canvas.height, 24) + const midX = Math.max(0, Math.floor((canvas.width - sampleWidth) / 2)) + const midY = Math.max(0, Math.floor((canvas.height - sampleHeight) / 2)) + const maxX = Math.max(0, canvas.width - sampleWidth) + const maxY = Math.max(0, canvas.height - sampleHeight) + + return ( + sample(0, 0, sampleWidth, sampleHeight) && + sample(midX, midY, sampleWidth, sampleHeight) && + sample(maxX, 0, sampleWidth, sampleHeight) && + sample(0, maxY, sampleWidth, sampleHeight) + ) +} + +const encodeJpegOnMainThread = (canvas, { targetWidth, targetHeight, quality }) => { + const output = document.createElement('canvas') + output.width = targetWidth + output.height = targetHeight + const ctx = output.getContext('2d') + if (!ctx) return canvas.toDataURL('image/jpeg', quality) + ctx.imageSmoothingEnabled = true + if ('imageSmoothingQuality' in ctx) { + ctx.imageSmoothingQuality = 'medium' + } + ctx.drawImage(canvas, 0, 0, targetWidth, targetHeight) + return output.toDataURL('image/jpeg', quality) +} + +const getPreviewWorker = () => { + if (previewWorker) return previewWorker + if (typeof Worker === 'undefined') return null + + try { + previewWorker = new Worker(new URL('./tabPreviewWorker.js', import.meta.url), { + type: 'module' + }) + } catch (error) { + console.error('Failed to start tab preview worker', error) + return null + } + + previewWorker.onmessage = (event) => { + const { id, dataUrl, error } = event.data || {} + const request = previewRequests.get(id) + if (!request) return + previewRequests.delete(id) + if (error) request.reject(new Error(error)) + else request.resolve(dataUrl) + } + + previewWorker.onerror = (error) => { + console.error('Tab preview worker error', error) + for (const request of previewRequests.values()) { + request.reject(error) + } + previewRequests.clear() + previewWorker?.terminate() + previewWorker = null + } + + return previewWorker +} + +const createPreviewBitmap = async (canvas, targetWidth, targetHeight) => { + if (typeof createImageBitmap !== 'function') return null + + try { + return await createImageBitmap(canvas, { + resizeWidth: targetWidth, + resizeHeight: targetHeight, + resizeQuality: 'medium' + }) + } catch { + return createImageBitmap(canvas) + } +} + +const encodeTabPreviewInWorker = async (canvas, { targetWidth, targetHeight, quality }) => { + const worker = getPreviewWorker() + if (!worker || typeof OffscreenCanvas === 'undefined') { + return encodeJpegOnMainThread(canvas, { targetWidth, targetHeight, quality }) + } + + const bitmap = await createPreviewBitmap(canvas, targetWidth, targetHeight) + if (!bitmap) { + return encodeJpegOnMainThread(canvas, { targetWidth, targetHeight, quality }) + } + + const id = (previewRequestId += 1) + + try { + return await new Promise((resolve, reject) => { + previewRequests.set(id, { resolve, reject }) + worker.postMessage( + { id, bitmap, targetWidth, targetHeight, quality }, + [bitmap] + ) + }) + } catch (error) { + console.error('Failed to encode tab preview in worker', error) + return encodeJpegOnMainThread(canvas, { targetWidth, targetHeight, quality }) + } +} + +const waitForFonts = async () => { + if (!document.fonts?.ready) return + await waitWithTimeout(document.fonts.ready, FONT_TIMEOUT_MS) +} + +const waitForPaneSize = async (pane, width, height) => { + const matches = () => + pane.clientWidth >= Math.max(8, width - 2) && + pane.clientHeight >= Math.max(8, height - 2) + + if (matches()) return true + + return new Promise((resolve) => { + let settled = false + const finish = (ready) => { + if (settled) return + settled = true + observer.disconnect() + window.clearTimeout(timeoutId) + resolve(ready) + } + + const observer = new ResizeObserver(() => { + if (matches()) finish(true) + }) + observer.observe(pane) + const timeoutId = window.setTimeout(() => finish(matches()), LAYOUT_TIMEOUT_MS) + if (matches()) finish(true) + }) +} + +export const waitForTabPreviewLayout = async (pane, width, height) => { + if (!pane) return false + + void pane.offsetHeight + await waitForPaneSize(pane, width, height) + await waitForFonts() + await waitForPaint() + void pane.offsetHeight + await waitForPaint() + + return pane.clientWidth >= 8 && pane.clientHeight >= 8 +} + +const renderTabPaneCanvas = async ( + pane, + { + scale, + backgroundColor, + foreignObjectRendering, + layoutWidth, + layoutHeight, + captureWidth, + captureHeight + } +) => + html2canvas(pane, { + backgroundColor, + scale, + logging: false, + useCORS: true, + imageTimeout: 1500, + foreignObjectRendering, + width: captureWidth, + height: captureHeight, + windowWidth: layoutWidth, + windowHeight: layoutHeight, + x: 0, + y: 0, + scrollX: 0, + scrollY: 0, + onclone: (_document, element) => { + const parent = element.parentElement + if (parent) { + for (const sibling of parent.children) { + if (sibling === element) continue + sibling.style.setProperty('visibility', 'hidden', 'important') + sibling.style.setProperty('opacity', '0', 'important') + sibling.style.setProperty('z-index', '0', 'important') + } + } + element.classList.remove( + 'dashboard-tab-pane-inactive', + 'dashboard-tab-pane-capturing' + ) + element.classList.add('dashboard-tab-pane-active') + element.style.position = 'relative' + element.style.left = '0' + element.style.top = '0' + element.style.right = 'auto' + element.style.bottom = 'auto' + element.style.zIndex = '2' + element.style.width = `${layoutWidth}px` + element.style.height = `${layoutHeight}px` + element.style.minWidth = `${layoutWidth}px` + element.style.minHeight = `${layoutHeight}px` + element.style.maxWidth = `${layoutWidth}px` + element.style.maxHeight = `${layoutHeight}px` + element.style.backgroundColor = backgroundColor + element.style.opacity = '1' + element.style.visibility = 'visible' + element.style.transform = 'none' + element.style.pointerEvents = 'auto' + element.style.overflow = 'hidden' + }, + ignoreElements: (element) => + element.classList?.contains('ant-popover') || + element.classList?.contains('dashboard-tab-preview-popover') || + element.classList?.contains('dashboard-tab-preview') + }) + +export const captureTabPaneImage = async ( + pane, + { + height = 180, + width = PREVIEW_WIDTH, + layoutWidth, + layoutHeight + } = {} +) => { + if (!pane) return null + + try { + const sourceWidth = Math.max( + 8, + Math.round(layoutWidth || pane.clientWidth || 0) + ) + const sourceHeight = Math.max( + 8, + Math.round(layoutHeight || pane.clientHeight || 0) + ) + + const ready = await waitForTabPreviewLayout(pane, sourceWidth, sourceHeight) + const paneWidth = pane.clientWidth || sourceWidth + const paneHeight = pane.clientHeight || sourceHeight + if (!ready && (paneWidth < 8 || paneHeight < 8)) return null + + const displayHeight = Math.max(1, Math.round(height)) + const displayWidth = Math.max(1, Math.round(width || PREVIEW_WIDTH)) + const targetHeight = displayHeight * PREVIEW_RESOLUTION_SCALE + const targetWidth = displayWidth * PREVIEW_RESOLUTION_SCALE + const captureWidth = paneWidth + const captureHeight = Math.min( + paneHeight, + Math.max(1, Math.round(paneWidth * (displayHeight / displayWidth))) + ) + const scale = Math.min( + PREVIEW_RESOLUTION_SCALE, + Math.max(0.28, targetWidth / captureWidth) + ) + const backgroundColor = getPreviewBackgroundColor(pane) + + const attempts = [true, false] + for (const foreignObjectRendering of attempts) { + try { + const canvas = await renderTabPaneCanvas(pane, { + scale, + backgroundColor, + foreignObjectRendering, + layoutWidth: paneWidth, + layoutHeight: paneHeight, + captureWidth, + captureHeight + }) + if (isBlankCanvas(canvas)) continue + return await encodeTabPreviewInWorker(canvas, { + targetWidth, + targetHeight, + quality: JPEG_QUALITY + }) + } catch (error) { + console.error('Failed to capture tab preview', error) + } + } + + return null + } catch (error) { + console.error('Failed to capture tab preview', error) + return null + } +} diff --git a/src/components/Dashboard/context/tabPreviewWorker.js b/src/components/Dashboard/context/tabPreviewWorker.js new file mode 100644 index 00000000..1bded431 --- /dev/null +++ b/src/components/Dashboard/context/tabPreviewWorker.js @@ -0,0 +1,38 @@ +const blobToDataUrl = (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }) + +self.onmessage = async (event) => { + const { id, bitmap, targetWidth, targetHeight, quality } = event.data + + try { + const canvas = new OffscreenCanvas(targetWidth, targetHeight) + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error('OffscreenCanvas 2d context unavailable') + } + + ctx.imageSmoothingEnabled = bitmap.width !== targetWidth || bitmap.height !== targetHeight + if ('imageSmoothingQuality' in ctx) { + ctx.imageSmoothingQuality = 'medium' + } + ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight) + bitmap.close?.() + + const blob = await canvas.convertToBlob({ + type: 'image/jpeg', + quality + }) + const dataUrl = await blobToDataUrl(blob) + self.postMessage({ id, dataUrl }) + } catch (error) { + self.postMessage({ + id, + error: error?.message || String(error) + }) + } +}