diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 3df85f1d..63a5cd7a 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -4349,6 +4349,48 @@ body.objectKanbanColumnResizing * { --live-activity-flash-to: 0.45; } +.live-activity-favicon-source { + position: fixed; + top: 0; + left: 0; + margin: 0; + padding: 0; + border: 0; + border-radius: 8px; + overflow: hidden; + pointer-events: none; + transform: translateX(-120vw); + z-index: -1; +} + +.live-activity-favicon-source .live-activity { + margin: 0; + padding: 0; + border: 1px solid var(--live-activity-color); + border-radius: 8px; + box-sizing: border-box; + box-shadow: none; + cursor: default; + background: color-mix( + in srgb, + var(--live-activity-color) 45%, + var(--color-background) + ); +} + +.live-activity-favicon-source .live-activity-progress-fill { + opacity: 0.92; +} + +.live-activity-favicon-source .live-activity-flash { + --live-activity-flash-from: 0.55; + --live-activity-flash-to: 0.92; +} + +.live-activity-favicon-source .live-activity-progress-wave { + display: none; +} + .printerQueue.ant-steps.ant-steps-vertical > .ant-steps-item:last-child .ant-steps-item-content { diff --git a/src/components/Dashboard/common/LiveActivities.jsx b/src/components/Dashboard/common/LiveActivities.jsx index 9a5c98a1..ee49df54 100644 --- a/src/components/Dashboard/common/LiveActivities.jsx +++ b/src/components/Dashboard/common/LiveActivities.jsx @@ -7,6 +7,8 @@ import { useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { snapdom } from '@zumer/snapdom' import { theme, Popover } from 'antd' import { useNavigate } from 'react-router-dom' import { ApiServerContext } from '../context/ApiServerContext' @@ -61,6 +63,127 @@ const STATUS_COLOR_VAR = { } const LIVE_ACTIVITY_TRANSITION_MS = 300 +const FAVICON_PX = 32 + +let faviconOriginalHref +let faviconOriginalType +let faviconAppliedHref +let faviconObjectUrl +let faviconCaptureChain = Promise.resolve() + +const faviconLink = () => document.querySelector('link[rel="icon"]') + +const tightenFaviconSvg = (src) => { + const comma = src.indexOf(',') + if (comma < 0 || !src.slice(0, comma).includes('svg')) return src + let svg = decodeURIComponent(src.slice(comma + 1)) + svg = svg.replace(/viewBox="0 0 ([\d.]+) ([\d.]+)"/, (match, w, h) => { + const width = Number(w) + const height = Number(h) + if (!Number.isFinite(width) || width < 4 || width !== height) return match + return `viewBox="1 1 ${width - 2} ${height - 2}"` + }) + return `${src.slice(0, comma)},${encodeURIComponent(svg)}` +} + +const applyLiveFavicon = (href) => { + const tightened = href ? tightenFaviconSvg(href) : href + if (!tightened || tightened === faviconAppliedHref) return + const current = faviconLink() + if (!current) return + if (faviconOriginalHref === undefined) { + faviconOriginalHref = current.getAttribute('href') + faviconOriginalType = current.getAttribute('type') + } + const next = document.createElement('link') + next.rel = 'icon' + next.type = 'image/svg+xml' + next.href = tightened + current.replaceWith(next) + if (faviconObjectUrl && faviconObjectUrl !== tightened) { + URL.revokeObjectURL(faviconObjectUrl) + } + faviconObjectUrl = tightened.startsWith('blob:') ? tightened : null + faviconAppliedHref = tightened +} + +const restoreLiveFavicon = () => { + if (faviconAppliedHref == null) return + const current = faviconLink() + faviconAppliedHref = null + if (faviconObjectUrl) { + URL.revokeObjectURL(faviconObjectUrl) + faviconObjectUrl = null + } + if (!current || faviconOriginalHref == null) return + const next = document.createElement('link') + next.rel = 'icon' + if (faviconOriginalType) next.type = faviconOriginalType + next.href = faviconOriginalHref + current.replaceWith(next) +} + +const waitForPaint = () => + new Promise((resolve) => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(resolve) + }) + }) + +const renderLiveActivityFace = ({ + activity, + size, + iconSize, + label, + tabIndex = 0, + onClick +}) => { + const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info' + const color = `var(${colorVar})` + + return ( + + ) +} const readState = (object) => { const raw = object?.state ?? object?.status @@ -340,110 +463,172 @@ const LiveActivities = () => { const size = token.controlHeight const iconSize = Math.max(12, Math.round(size * 0.5)) + const faviconIconSize = Math.round(size * 0.66) const openCount = slots.filter((slot) => slot.open).length const activitiesOpen = openCount > 0 - return ( -
-
- {slots.map((activity) => { - const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info' - const color = `var(${colorVar})` - const tooltip = - activity.percent == null - ? `${activity.name} · ${activity.text}` - : `${activity.name} · ${activity.text} ${activity.percent}%` - const open = () => { - if (!activity.open || typeof activity.model.url !== 'string') return - navigate( - `${activity.model.url}/info?${activity.model.name}Id=${activity.id}` - ) - } + const faviconActivity = useMemo( + () => + activities.reduce((best, activity) => { + if (activity.percent == null) return best + if (!best || activity.percent > best.percent) return activity + return best + }, null), + [activities] + ) + const faviconSourceRef = useRef(null) + const faviconCaptureSeq = useRef(0) + const faviconSignature = faviconActivity + ? [ + faviconActivity.key, + faviconActivity.percent, + faviconActivity.status, + size, + faviconIconSize, + FAVICON_PX, + token.colorText, + token.colorInfo, + token.colorSuccess, + token.colorWarning, + token.colorError + ].join('|') + : '' - return ( - - } - trigger='hover' - placement='bottomRight' - arrow={false} - style={{ padding: 0 }} - > -
{ - if (event.target !== event.currentTarget) return - if (event.propertyName !== 'width') return - if (activity.open) return - setSlots((current) => - current.filter((slot) => slot.key !== activity.key) - ) - }} + useEffect(() => () => restoreLiveFavicon(), []) + + useEffect(() => { + const seq = ++faviconCaptureSeq.current + let cancelled = false + + faviconCaptureChain = faviconCaptureChain + .catch(() => undefined) + .then(async () => { + if (cancelled || seq !== faviconCaptureSeq.current) return + await waitForPaint() + if (cancelled || seq !== faviconCaptureSeq.current) return + + const face = faviconSourceRef.current?.querySelector('.live-activity') + if (!face) { + restoreLiveFavicon() + return + } + + try { + const image = await snapdom.toSvg(face, { + width: FAVICON_PX, + height: FAVICON_PX, + embedFonts: false, + backgroundColor: null, + dpr: 1, + scale: 1, + outerTransforms: false, + invalidate: true + }) + if ( + cancelled || + seq !== faviconCaptureSeq.current || + !image?.src + ) { + return + } + applyLiveFavicon(image.src) + } catch (error) { + if (!cancelled && seq === faviconCaptureSeq.current) { + console.error('Failed to capture live activity favicon', error) + } + } + }) + + return () => { + cancelled = true + } + }, [faviconSignature]) + + return ( + <> +
+
+ {slots.map((activity) => { + const tooltip = + activity.percent == null + ? `${activity.name} · ${activity.text}` + : `${activity.name} · ${activity.text} ${activity.percent}%` + const open = () => { + if (!activity.open || typeof activity.model.url !== 'string') + return + navigate( + `${activity.model.url}/info?${activity.model.name}Id=${activity.id}` + ) + } + + return ( + + } + trigger='hover' + placement='bottomRight' + arrow={false} + style={{ padding: 0 }} > - -
- - ) - })} + {renderLiveActivityFace({ + activity, + size, + iconSize, + label: tooltip, + tabIndex: activity.open ? 0 : -1, + onClick: open + })} +
+ + ) + })} +
-
+ {faviconActivity + ? createPortal( + , + document.body + ) + : null} + ) }