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-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
|
.printerQueue.ant-steps.ant-steps-vertical
|
||||||
> .ant-steps-item:last-child
|
> .ant-steps-item:last-child
|
||||||
.ant-steps-item-content {
|
.ant-steps-item-content {
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { snapdom } from '@zumer/snapdom'
|
||||||
import { theme, Popover } from 'antd'
|
import { theme, Popover } from 'antd'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
@ -61,6 +63,127 @@ const STATUS_COLOR_VAR = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LIVE_ACTIVITY_TRANSITION_MS = 300
|
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 readState = (object) => {
|
||||||
const raw = object?.state ?? object?.status
|
const raw = object?.state ?? object?.status
|
||||||
@ -340,10 +463,89 @@ const LiveActivities = () => {
|
|||||||
|
|
||||||
const size = token.controlHeight
|
const size = token.controlHeight
|
||||||
const iconSize = Math.max(12, Math.round(size * 0.5))
|
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 openCount = slots.filter((slot) => slot.open).length
|
||||||
const activitiesOpen = openCount > 0
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={`live-activities${activitiesOpen ? ' live-activities-open' : ''}`}
|
className={`live-activities${activitiesOpen ? ' live-activities-open' : ''}`}
|
||||||
aria-hidden={activitiesOpen ? undefined : true}
|
aria-hidden={activitiesOpen ? undefined : true}
|
||||||
@ -357,14 +559,13 @@ const LiveActivities = () => {
|
|||||||
>
|
>
|
||||||
<div className='live-activities-row'>
|
<div className='live-activities-row'>
|
||||||
{slots.map((activity) => {
|
{slots.map((activity) => {
|
||||||
const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info'
|
|
||||||
const color = `var(${colorVar})`
|
|
||||||
const tooltip =
|
const tooltip =
|
||||||
activity.percent == null
|
activity.percent == null
|
||||||
? `${activity.name} · ${activity.text}`
|
? `${activity.name} · ${activity.text}`
|
||||||
: `${activity.name} · ${activity.text} ${activity.percent}%`
|
: `${activity.name} · ${activity.text} ${activity.percent}%`
|
||||||
const open = () => {
|
const open = () => {
|
||||||
if (!activity.open || typeof activity.model.url !== 'string') return
|
if (!activity.open || typeof activity.model.url !== 'string')
|
||||||
|
return
|
||||||
navigate(
|
navigate(
|
||||||
`${activity.model.url}/info?${activity.model.name}Id=${activity.id}`
|
`${activity.model.url}/info?${activity.model.name}Id=${activity.id}`
|
||||||
)
|
)
|
||||||
@ -396,54 +597,38 @@ const LiveActivities = () => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{renderLiveActivityFace({
|
||||||
type='button'
|
activity,
|
||||||
className='live-activity'
|
size,
|
||||||
aria-label={tooltip}
|
iconSize,
|
||||||
tabIndex={activity.open ? 0 : -1}
|
label: tooltip,
|
||||||
onClick={open}
|
tabIndex: activity.open ? 0 : -1,
|
||||||
style={{
|
onClick: open
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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