Add Live Favicon Support and Update Live Activities Component
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 functionality to dynamically update the favicon based on live activity status. - Added CSS styles for the live activity favicon to enhance visual representation. - Refactored LiveActivities component to capture and apply favicon updates, improving user experience with real-time notifications. - Implemented utility functions for favicon management, ensuring efficient handling of favicon updates and restoration.
This commit is contained in:
parent
f0b0ad41a6
commit
7574e1ef5b
@ -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 {
|
||||
|
||||
@ -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 (
|
||||
<button
|
||||
type='button'
|
||||
className='live-activity'
|
||||
aria-label={label}
|
||||
aria-hidden={onClick ? undefined : true}
|
||||
tabIndex={onClick ? tabIndex : -1}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
'--live-activity-color': color
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
activity.percent == null
|
||||
? 'live-activity-flash'
|
||||
: 'live-activity-progress'
|
||||
}
|
||||
style={{
|
||||
background: activity.percent == null ? color : undefined,
|
||||
width: activity.percent == null ? undefined : `${activity.percent}%`
|
||||
}}
|
||||
>
|
||||
{activity.percent == null ? null : (
|
||||
<>
|
||||
<span
|
||||
className='live-activity-progress-fill'
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span className='live-activity-progress-wave' />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className='live-activity-icon'>
|
||||
{createElement(activity.model.icon, {
|
||||
style: { fontSize: iconSize }
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const readState = (object) => {
|
||||
const raw = object?.state ?? object?.status
|
||||
@ -340,10 +463,89 @@ 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
|
||||
|
||||
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('|')
|
||||
: ''
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={`live-activities${activitiesOpen ? ' live-activities-open' : ''}`}
|
||||
aria-hidden={activitiesOpen ? undefined : true}
|
||||
@ -357,14 +559,13 @@ const LiveActivities = () => {
|
||||
>
|
||||
<div className='live-activities-row'>
|
||||
{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
|
||||
if (!activity.open || typeof activity.model.url !== 'string')
|
||||
return
|
||||
navigate(
|
||||
`${activity.model.url}/info?${activity.model.name}Id=${activity.id}`
|
||||
)
|
||||
@ -396,54 +597,38 @@ const LiveActivities = () => {
|
||||
)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='live-activity'
|
||||
aria-label={tooltip}
|
||||
tabIndex={activity.open ? 0 : -1}
|
||||
onClick={open}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
'--live-activity-color': color
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
activity.percent == null
|
||||
? 'live-activity-flash'
|
||||
: 'live-activity-progress'
|
||||
}
|
||||
style={{
|
||||
background: activity.percent == null ? color : undefined,
|
||||
width:
|
||||
activity.percent == null
|
||||
? undefined
|
||||
: `${activity.percent}%`
|
||||
}}
|
||||
>
|
||||
{activity.percent == null ? null : (
|
||||
<>
|
||||
<span
|
||||
className='live-activity-progress-fill'
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span className='live-activity-progress-wave' />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className='live-activity-icon'>
|
||||
{createElement(activity.model.icon, {
|
||||
style: { fontSize: iconSize }
|
||||
{renderLiveActivityFace({
|
||||
activity,
|
||||
size,
|
||||
iconSize,
|
||||
label: tooltip,
|
||||
tabIndex: activity.open ? 0 : -1,
|
||||
onClick: open
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{faviconActivity
|
||||
? createPortal(
|
||||
<div
|
||||
className='live-activity-favicon-source'
|
||||
aria-hidden='true'
|
||||
ref={faviconSourceRef}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{renderLiveActivityFace({
|
||||
activity: faviconActivity,
|
||||
size,
|
||||
iconSize: faviconIconSize
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user