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)
+ })
+ }
+}