Add Tab Preview Functionality and Enhance Dashboard Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new DashboardTabPreview component for displaying tab previews, improving user interaction and visual feedback. - Implemented tab preview capture logic using html2canvas-pro, allowing for dynamic rendering of tab content. - Enhanced CSS styles for dashboard tab panes and previews, ensuring better layout management and visual consistency. - Updated NavigationTabsContext to manage tab pane registration and preview capture layout, streamlining the tab interaction process. - Refactored DashboardTabPanes and DashboardTabs components to integrate preview functionality and improve overall code structure.
This commit is contained in:
parent
f56646f906
commit
fcad667516
@ -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 {
|
||||
|
||||
11
bun.lock
11
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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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 <Outlet />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='dashboard-tab-panes'>
|
||||
{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 (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={
|
||||
isActive
|
||||
? 'dashboard-tab-pane dashboard-tab-pane-active'
|
||||
: 'dashboard-tab-pane dashboard-tab-pane-inactive'
|
||||
ref={(element) => 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}
|
||||
>
|
||||
<NavigationTabActiveContext.Provider value={isActive}>
|
||||
<UNSAFE_LocationContext.Provider
|
||||
|
||||
28
src/components/Dashboard/common/DashboardTabPreview.jsx
Normal file
28
src/components/Dashboard/common/DashboardTabPreview.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const DEFAULT_HEIGHT = 180
|
||||
|
||||
const DashboardTabPreview = ({ src, height = DEFAULT_HEIGHT }) => {
|
||||
const previewHeight = typeof height === 'number' ? `${height}px` : height
|
||||
|
||||
if (!src) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='dashboard-tab-preview'
|
||||
style={{
|
||||
height: previewHeight,
|
||||
'--dashboard-tab-preview-height': previewHeight
|
||||
}}
|
||||
>
|
||||
<img src={src} alt='' className='dashboard-tab-preview-image' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
DashboardTabPreview.propTypes = {
|
||||
src: PropTypes.string,
|
||||
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string])
|
||||
}
|
||||
|
||||
export default DashboardTabPreview
|
||||
@ -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 = (
|
||||
<button
|
||||
ref={previewTargetRef}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={isSelected}
|
||||
@ -104,6 +169,8 @@ const DashboardTabItem = ({
|
||||
onKeyDown={(event) => onKeyDown(event, tabIndex)}
|
||||
onDragOver={(event) => onDragOver(event, tab)}
|
||||
onDrop={(event) => onDrop(event, tab)}
|
||||
onMouseEnter={startPreviewCapture}
|
||||
onMouseLeave={handlePreviewMouseLeave}
|
||||
>
|
||||
<DashboardTabChrome />
|
||||
<span className='dashboard-tab-item-body'>
|
||||
@ -114,6 +181,28 @@ const DashboardTabItem = ({
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
return tabButton
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger='hover'
|
||||
placement='bottom'
|
||||
arrow={false}
|
||||
mouseEnterDelay={0}
|
||||
mouseLeaveDelay={0}
|
||||
destroyOnHidden
|
||||
open={isDragging ? false : previewOpen}
|
||||
onOpenChange={handlePreviewOpenChange}
|
||||
content={previewSrc ? <DashboardTabPreview src={previewSrc} /> : null}
|
||||
classNames={{ root: 'dashboard-tab-preview-popover' }}
|
||||
styles={{ body: { padding: 0, overflow: 'hidden' } }}
|
||||
>
|
||||
{tabButton}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
DashboardTabItem.propTypes = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
370
src/components/Dashboard/context/tabPreviewCapture.js
Normal file
370
src/components/Dashboard/context/tabPreviewCapture.js
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
38
src/components/Dashboard/context/tabPreviewWorker.js
Normal file
38
src/components/Dashboard/context/tabPreviewWorker.js
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user