mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-24 17:04:14 +00:00
fix(site): measure the longest string width instead of estimating it (#2411)
Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
@@ -138,7 +138,7 @@ export function getMonitorColumns(
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[status, name]
|
||||
[status, name, longestSystemName]
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useToast } from "@/components/ui/use-toast"
|
||||
import { isReadOnlyUser, queueUserSettings } from "@/lib/api"
|
||||
import { pb } from "@/lib/api"
|
||||
import { SystemStatus } from "@/lib/enums"
|
||||
import { $allSystemsById, $direction, $userSettings, getUserChartTime } from "@/lib/stores"
|
||||
import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores"
|
||||
import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils"
|
||||
import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types"
|
||||
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
|
||||
@@ -145,6 +145,8 @@ export default function NetworkMonitorsTableNew({
|
||||
[sortSettingsKey, sortStorageKey]
|
||||
)
|
||||
|
||||
// recompute when measured widths are invalidated (e.g. web font finished loading)
|
||||
const textMeasureVersion = useStore($textMeasureVersion)
|
||||
const longestTarget = useMemo(() => {
|
||||
let longestTarget = ""
|
||||
for (const p of monitors) {
|
||||
@@ -153,7 +155,7 @@ export default function NetworkMonitorsTableNew({
|
||||
}
|
||||
}
|
||||
return longestTarget
|
||||
}, [monitors])
|
||||
}, [monitors, textMeasureVersion])
|
||||
|
||||
const runMonitorBatch = useCallback(
|
||||
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
|
||||
|
||||
@@ -93,3 +93,8 @@ export const $direction = atom<"ltr" | "rtl">("ltr")
|
||||
|
||||
/** Longest system name string. Used to reserve width in virtualized tables. */
|
||||
export const $longestSystemName = atom("")
|
||||
|
||||
/** Incremented when measured text widths are invalidated (e.g. web font finished loading).
|
||||
* Anything that caches a comparison from isVisuallyLonger should recompute when this changes.
|
||||
*/
|
||||
export const $textMeasureVersion = atom(0)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
$downSystems,
|
||||
$longestSystemName,
|
||||
$pausedSystems,
|
||||
$textMeasureVersion,
|
||||
$upSystems,
|
||||
} from "@/lib/stores"
|
||||
import { isVisuallyLonger, updateFavicon } from "@/lib/utils"
|
||||
@@ -67,6 +68,11 @@ export function init() {
|
||||
// run things that need to be done when systems change
|
||||
onSystemsChanged(newSystems, newSystem, oldSystem)
|
||||
})
|
||||
|
||||
// widths measured with the fallback font may rank names differently, so recompute once they're invalidated
|
||||
$textMeasureVersion.listen(() => {
|
||||
$longestSystemName.set(findLongestName($allSystemsById.get()))
|
||||
})
|
||||
}
|
||||
|
||||
/** Update the longest system name string and favicon based on system status */
|
||||
@@ -78,13 +84,7 @@ function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: Sys
|
||||
// otherwise, if the changed system's new name is longer than the current longest, update it
|
||||
const longestName = $longestSystemName.get()
|
||||
if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) {
|
||||
let newLongest = ""
|
||||
for (const id in systems) {
|
||||
if (isVisuallyLonger(systems[id].name, newLongest)) {
|
||||
newLongest = systems[id].name
|
||||
}
|
||||
}
|
||||
$longestSystemName.set(newLongest)
|
||||
$longestSystemName.set(findLongestName(systems))
|
||||
} else if (newSystem && newSystem.name !== longestName && isVisuallyLonger(newSystem.name, longestName)) {
|
||||
$longestSystemName.set(newSystem.name)
|
||||
}
|
||||
@@ -92,6 +92,17 @@ function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: Sys
|
||||
updateFavicon(downSystems.length)
|
||||
}
|
||||
|
||||
/** Find the visually longest system name */
|
||||
function findLongestName(systems: Record<string, SystemRecord>): string {
|
||||
let longest = ""
|
||||
for (const id in systems) {
|
||||
if (isVisuallyLonger(systems[id].name, longest)) {
|
||||
longest = systems[id].name
|
||||
}
|
||||
}
|
||||
return longest
|
||||
}
|
||||
|
||||
/** Fetch systems from collection */
|
||||
async function fetchSystems(): Promise<SystemRecord[]> {
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
|
||||
import { HourFormat, Unit } from "./enums"
|
||||
import { $copyContent, $userSettings } from "./stores"
|
||||
import { $copyContent, $textMeasureVersion, $userSettings } from "./stores"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -451,6 +451,45 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
|
||||
|
||||
const visualWidthCache = new Map<string, number>()
|
||||
|
||||
let measureContext: CanvasRenderingContext2D | null | undefined
|
||||
let measureFont = ""
|
||||
|
||||
/** Canvas context for measuring text in the font the app renders with, or null where canvas is unavailable.
|
||||
* Only relative widths matter here, so the font size is arbitrary.
|
||||
*/
|
||||
function getMeasureContext(): CanvasRenderingContext2D | null {
|
||||
if (measureContext === undefined) {
|
||||
measureContext = document.createElement("canvas").getContext("2d")
|
||||
// the fallback font has different metrics, so re-measure whenever a font finishes loading.
|
||||
// loadingdone also covers fonts that start loading after the first measurement,
|
||||
// which fonts.ready does not if it has already resolved.
|
||||
if (measureContext && "fonts" in document) {
|
||||
document.fonts.addEventListener("loadingdone", invalidateVisualWidths)
|
||||
}
|
||||
}
|
||||
if (measureContext) {
|
||||
const { fontFamily, fontWeight } = getComputedStyle(document.body)
|
||||
const font = `${fontWeight} 16px ${fontFamily}`
|
||||
if (font !== measureFont) {
|
||||
const isFirstFont = !measureFont
|
||||
measureFont = font
|
||||
measureContext.font = font
|
||||
visualWidthCache.clear()
|
||||
// defer so stores aren't updated in the middle of a comparison or a render
|
||||
if (!isFirstFont) {
|
||||
queueMicrotask(invalidateVisualWidths)
|
||||
}
|
||||
}
|
||||
}
|
||||
return measureContext
|
||||
}
|
||||
|
||||
/** Drop cached widths and notify anything holding a result from isVisuallyLonger */
|
||||
function invalidateVisualWidths() {
|
||||
visualWidthCache.clear()
|
||||
$textMeasureVersion.set($textMeasureVersion.get() + 1)
|
||||
}
|
||||
|
||||
/** Get the visual width of a string, accounting for full-width and narrow punctuation characters.
|
||||
* Don't use for monospaced fonts, use .length instead
|
||||
*/
|
||||
@@ -459,6 +498,11 @@ function getVisualStringWidth(str: string): number {
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const measured = getMeasureContext()?.measureText(str).width
|
||||
if (measured !== undefined) {
|
||||
visualWidthCache.set(str, measured)
|
||||
return measured
|
||||
}
|
||||
let width = 0
|
||||
for (const char of str) {
|
||||
if (char === ".") {
|
||||
|
||||
Reference in New Issue
Block a user