diablo2-web/src/client/toolbar/toolbar.ts

899 lines
29 KiB
TypeScript

import {
getGameLang,
onLangChange,
setLang,
tToolbar,
type GameLang,
} from '../i18n/lang.ts'
import { resolveLocalizedText } from '../plugin-host/api.ts'
import type { ToolbarContributionSpec } from '../plugin-host/plugin-host.ts'
import {
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost,
type AutomapRevealMode,
type ClientLightingPreset,
type ClientSettings,
type ServerHostValidationResult,
type ViewportResolution,
} from '../settings/client-settings.ts'
import { getGlobalSettingsStore, type SettingsStore } from '../settings/settings-store.ts'
import {
clearClientAssetCache,
formatCacheBytesMb,
} from '../sw/register.ts'
import {
NATIVE_113C_VIEWPORT_RESOLUTIONS,
formatViewportLabel,
getViewportProfile,
type ViewportProfile,
} from '../view/viewport-profile.ts'
export type ToolbarConnectionPhase =
| 'disconnected'
| 'connecting'
| 'bncs'
| 'mcp'
| 'd2gs'
| 'error'
export type ToolbarDiagnosticKind =
| 'none'
| 'quota_exceeded'
| 'tls_error'
| 'bare_ip_on_https'
| 'invalid_host'
| 'custom'
export interface ToolbarConnectionState {
readonly phase: ToolbarConnectionPhase
readonly rttMs: number | null
readonly diagnosticKind?: ToolbarDiagnosticKind | undefined
readonly diagnosticMessage?: string | undefined
}
export interface ToolbarCallbacks {
readonly onServerHostReconnect?: ((newHost: string, prevHost: string) => void) | undefined
readonly onLangChange?: ((lang: GameLang) => void) | undefined
readonly onLightingChange?: ((preset: ClientLightingPreset) => void) | undefined
readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined
readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined
readonly onPluginsToggle?: (() => void) | undefined
readonly onClearCache?: (() => void | Promise<void>) | undefined
}
export interface ToolbarOptions {
readonly store?: SettingsStore | undefined
readonly container?: HTMLElement | null | undefined
readonly pageProtocol?: string | undefined
readonly allowedViewports?: readonly ViewportResolution[] | undefined
readonly confirmReconnect?: ((message: string) => boolean) | undefined
readonly callbacks?: ToolbarCallbacks | undefined
}
export interface ToolbarSnapshot {
readonly connection: ToolbarConnectionState
readonly settings: ClientSettings
readonly viewportProfile: ViewportProfile
readonly allowedViewports: readonly ViewportResolution[]
readonly statusLabel: string
readonly rttLabel: string
readonly diagnosticBannerText: string | null
readonly serverInputValue: string
readonly serverInputValid: boolean
}
interface ContributionEntry {
readonly spec: ToolbarContributionSpec
readonly el: HTMLElement | null
readonly labelEl: HTMLLabelElement | null
readonly selectEl: HTMLSelectElement | null
}
/**
* Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with primary controls:
* 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Plugins modal button (`#d2-toolbar-plugins-btn`) + Dynamic Plugin Contribution Slot (`#d2-toolbar-plugin-slot`)
* 2. Live Language Toggle (`中` / `EN`)
* 3. Viewport Size Selector (`#d2-toolbar-viewport-select`, native 1.13c `640x480 | 800x600` by default, extensible via `extended-viewports` plugin) + Asset Cache Clear button
*/
export class Toolbar {
private readonly store: SettingsStore
private readonly pageProtocol: string
private readonly confirmReconnectFn: (message: string) => boolean
private readonly callbacks: ToolbarCallbacks
private connection: ToolbarConnectionState = {
phase: 'disconnected',
rttMs: null,
diagnosticKind: 'none',
}
private allowedViewports: readonly ViewportResolution[]
private serverInputValue: string
private serverInputValid = true
private cacheUsageBytes = 0
private cacheClearedFlash = false
private pluginsReady = false
private pluginsHasWarning = false
private pluginsMenuOpen = false
private readonly contributions = new Map<string, ContributionEntry>()
private unsubscribeStore: (() => void) | null = null
private unsubscribeLang: (() => void) | null = null
// Optional real DOM elements when mounted in browser
private rootEl: HTMLElement | null = null
private statusDotEl: HTMLElement | null = null
private statusTextEl: HTMLElement | null = null
private rttTextEl: HTMLElement | null = null
private langBtnZhEl: HTMLButtonElement | null = null
private langBtnEnEl: HTMLButtonElement | null = null
private viewportLabelEl: HTMLElement | null = null
private viewportSelectEl: HTMLSelectElement | null = null
private pluginsBtnEl: HTMLButtonElement | null = null
private pluginSlotEl: HTMLElement | null = null
private cacheClearBtnEl: HTMLButtonElement | null = null
private diagBannerEl: HTMLElement | null = null
constructor(options?: ToolbarOptions) {
this.store = options?.store ?? getGlobalSettingsStore()
this.pageProtocol =
options?.pageProtocol ??
(typeof window !== 'undefined' && window.location ? window.location.protocol : 'https:')
this.allowedViewports =
options?.allowedViewports && options.allowedViewports.length > 0
? [...options.allowedViewports]
: [...NATIVE_113C_VIEWPORT_RESOLUTIONS]
this.confirmReconnectFn =
options?.confirmReconnect ??
((msg: string) =>
typeof window !== 'undefined' && typeof window.confirm === 'function'
? window.confirm(msg)
: true)
this.callbacks = options?.callbacks ?? {}
this.serverInputValue = this.store.get().serverHost
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
this.mountDom(options?.container ?? null)
}
this.unsubscribeStore = this.store.subscribe((next, prev, changedKeys) => {
if (changedKeys.includes('serverHost')) {
this.serverInputValue = next.serverHost
this.serverInputValid = true
}
if (changedKeys.includes('lang') && next.lang !== prev.lang) {
this.callbacks.onLangChange?.(next.lang)
}
if (changedKeys.includes('lightingPreset') && next.lightingPreset !== prev.lightingPreset) {
this.callbacks.onLightingChange?.(next.lightingPreset)
}
if (changedKeys.includes('automapReveal') && next.automapReveal !== prev.automapReveal) {
this.callbacks.onAutomapRevealChange?.(next.automapReveal)
}
if (changedKeys.includes('viewport') && next.viewport !== prev.viewport) {
this.callbacks.onViewportChange?.(getViewportProfile(next.viewport))
}
this.syncDom()
})
this.unsubscribeLang = onLangChange(() => {
this.syncDom()
})
}
getElement(): HTMLElement | null {
return this.rootEl
}
getSettings(): ClientSettings {
return this.store.get()
}
getConnectionState(): ToolbarConnectionState {
return this.connection
}
isActiveSession(): boolean {
const p = this.connection.phase
return p === 'connecting' || p === 'bncs' || p === 'mcp' || p === 'd2gs'
}
setConnectionState(
next: Partial<ToolbarConnectionState> & { readonly phase: ToolbarConnectionPhase },
): void {
this.connection = {
phase: next.phase,
rttMs: next.rttMs !== undefined ? next.rttMs : this.connection.rttMs,
diagnosticKind:
next.diagnosticKind ??
(next.phase === 'error' ? this.connection.diagnosticKind ?? 'custom' : 'none'),
diagnosticMessage: next.diagnosticMessage,
}
this.syncDom()
}
setRttMs(rttMs: number | null): void {
this.connection = {
...this.connection,
rttMs: rttMs !== null && Number.isFinite(rttMs) ? Math.max(0, Math.round(rttMs)) : null,
}
this.syncDom()
}
reportDiagnostic(kind: ToolbarDiagnosticKind, customMessage?: string): void {
this.connection = {
...this.connection,
phase: kind === 'none' ? this.connection.phase : 'error',
diagnosticKind: kind,
diagnosticMessage: customMessage,
}
this.syncDom()
}
clearDiagnostic(): void {
this.connection = {
...this.connection,
diagnosticKind: 'none',
diagnosticMessage: undefined,
}
this.syncDom()
}
/**
* Attempt to apply a new `serverHost`.
* - Validates against `validateServerHost` (rejects bare IP on `https:`).
* - If currently in an active session (`connecting | bncs | mcp | d2gs`), asks for confirmation before disconnecting & reconnecting.
*/
submitServerHost(rawHost: string): ServerHostValidationResult & { readonly applied: boolean } {
this.serverInputValue = rawHost
const check = validateServerHost(rawHost, this.pageProtocol)
if (!check.valid) {
this.serverInputValid = false
const lang = this.store.get().lang
const msg = lang === 'en' ? check.errorMessage : (check.errorMessageZh ?? check.errorMessage)
this.connection = {
...this.connection,
diagnosticKind: check.reason === 'bare_ip_on_https' ? 'bare_ip_on_https' : 'invalid_host',
diagnosticMessage: msg,
}
this.syncDom()
return { ...check, applied: false }
}
const prevHost = this.store.get().serverHost
if (check.normalizedHost === prevHost) {
this.serverInputValue = check.normalizedHost
this.serverInputValid = true
if (
this.connection.diagnosticKind === 'bare_ip_on_https' ||
this.connection.diagnosticKind === 'invalid_host'
) {
this.clearDiagnostic()
} else {
this.syncDom()
}
return { ...check, applied: true }
}
if (this.isActiveSession()) {
const confirmMsg = tToolbar('toolbar.server.confirmReconnect', this.store.get().lang)
if (!this.confirmReconnectFn(confirmMsg)) {
this.serverInputValue = prevHost
this.serverInputValid = true
this.syncDom()
return { ...check, applied: false }
}
}
this.serverInputValid = true
this.serverInputValue = check.normalizedHost
if (
this.connection.diagnosticKind === 'bare_ip_on_https' ||
this.connection.diagnosticKind === 'invalid_host'
) {
this.connection = {
...this.connection,
diagnosticKind: 'none',
diagnosticMessage: undefined,
}
}
this.store.setServerHost(check.normalizedHost, this.pageProtocol)
this.callbacks.onServerHostReconnect?.(check.normalizedHost, prevHost)
this.syncDom()
return { ...check, applied: true }
}
setLanguage(lang: GameLang): void {
setLang(lang)
this.store.set({ lang })
this.syncDom()
}
toggleLanguage(): GameLang {
const next: GameLang = this.store.get().lang === 'en' ? 'zh' : 'en'
this.setLanguage(next)
return next
}
setLightingPreset(preset: ClientLightingPreset): void {
this.store.set({ lightingPreset: preset })
this.syncDom()
}
setAutomapReveal(mode: AutomapRevealMode): void {
this.store.set({ automapReveal: mode })
this.syncDom()
}
getAllowedViewports(): readonly ViewportResolution[] {
return this.allowedViewports
}
setAllowedViewports(resolutions: readonly ViewportResolution[]): void {
const valid = resolutions.filter((r): r is ViewportResolution =>
(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(r),
)
this.allowedViewports =
valid.length > 0 ? valid : [...NATIVE_113C_VIEWPORT_RESOLUTIONS]
const current = this.store.get().viewport
if (!this.allowedViewports.includes(current)) {
const fallback: ViewportResolution = this.allowedViewports.includes('800x600')
? '800x600'
: this.allowedViewports[0]!
this.store.set({ viewport: fallback })
}
this.syncDom()
}
setViewport(viewport: ViewportResolution): ViewportProfile {
const target: ViewportResolution = this.allowedViewports.includes(viewport)
? viewport
: this.allowedViewports.includes('800x600')
? '800x600'
: this.allowedViewports[0]!
this.store.set({ viewport: target })
this.syncDom()
return getViewportProfile(target)
}
/** Advance to the next resolution in `allowedViewports` order, wrapping back to the first. */
toggleViewport(): ViewportProfile {
const order = this.allowedViewports
const currentIdx = order.indexOf(this.store.get().viewport)
const next = order[(currentIdx + 1) % order.length]!
return this.setViewport(next)
}
setPluginsButtonState(state: {
readonly ready: boolean
readonly hasWarning: boolean
readonly open?: boolean | undefined
}): void {
this.pluginsReady = state.ready
this.pluginsHasWarning = state.hasWarning
if (state.open !== undefined) {
this.pluginsMenuOpen = state.open
}
this.syncDom()
}
addContribution(spec: ToolbarContributionSpec): () => void {
const existing = this.contributions.get(spec.domId)
if (existing) {
existing.el?.remove()
this.contributions.delete(spec.domId)
}
let el: HTMLElement | null = null
let labelEl: HTMLLabelElement | null = null
let selectEl: HTMLSelectElement | null = null
if (typeof document !== 'undefined' && this.pluginSlotEl) {
const lang = this.store.get().lang || getGameLang()
const label = resolveLocalizedText(spec.label, lang)
if (spec.kind === 'link') {
const link = document.createElement('a')
link.id = spec.domId
link.className = 'd2-toolbar__btn'
link.href = spec.href
link.target = '_blank'
link.rel = 'noopener'
link.textContent = label
link.title = label
el = link
} else if (spec.kind === 'button') {
const btn = document.createElement('button')
btn.type = 'button'
btn.id = spec.domId
btn.className = 'd2-toolbar__btn'
btn.textContent = label
btn.title = label
btn.addEventListener('click', () => {
spec.onClick()
})
el = btn
} else {
const group = document.createElement('div')
group.id = spec.domId
group.className = 'd2-toolbar__group'
const lbl = document.createElement('label')
lbl.className = 'd2-toolbar__label'
lbl.htmlFor = spec.selectDomId
lbl.textContent = label
const sel = document.createElement('select')
sel.id = spec.selectDomId
sel.className = 'd2-toolbar__select'
for (const optSpec of spec.options) {
const opt = document.createElement('option')
opt.value = optSpec.value
opt.textContent = resolveLocalizedText(optSpec.label, lang)
sel.appendChild(opt)
}
sel.value = spec.getValue()
sel.addEventListener('change', () => {
spec.onChange(sel.value)
})
group.appendChild(lbl)
group.appendChild(sel)
el = group
labelEl = lbl
selectEl = sel
}
this.pluginSlotEl.appendChild(el)
}
const entry: ContributionEntry = { spec, el, labelEl, selectEl }
this.contributions.set(spec.domId, entry)
return () => {
const current = this.contributions.get(spec.domId)
if (current === entry) {
current.el?.remove()
this.contributions.delete(spec.domId)
}
}
}
setAssetCacheUsageBytes(bytes: number): void {
this.cacheUsageBytes = Number.isFinite(bytes) && bytes > 0 ? bytes : 0
this.syncDom()
}
async clearAssetCache(): Promise<number> {
const res = await clearClientAssetCache()
this.cacheUsageBytes = 0
this.cacheClearedFlash = true
this.syncDom()
await this.callbacks.onClearCache?.()
setTimeout(() => {
this.cacheClearedFlash = false
this.syncDom()
}, 1500)
return res.deletedEntries
}
getSnapshot(): ToolbarSnapshot {
const settings = this.store.get()
const lang = settings.lang || getGameLang()
const statusKey = `toolbar.status.${this.connection.phase}` as const
const statusLabel = tToolbar(statusKey, lang)
const rttLabel =
this.connection.rttMs !== null ? `${this.connection.rttMs} ms` : '-- ms'
let diagnosticBannerText: string | null = null
const kind = this.connection.diagnosticKind ?? 'none'
if (kind === 'quota_exceeded') {
diagnosticBannerText = tToolbar('toolbar.diag.quotaExceeded', lang)
} else if (kind === 'tls_error') {
diagnosticBannerText = tToolbar('toolbar.diag.tlsError', lang)
} else if (kind === 'bare_ip_on_https') {
diagnosticBannerText =
this.connection.diagnosticMessage ?? tToolbar('toolbar.diag.bareIpHttps', lang)
} else if (kind === 'invalid_host' || kind === 'custom') {
diagnosticBannerText = this.connection.diagnosticMessage ?? null
}
return {
connection: this.connection,
settings,
viewportProfile: getViewportProfile(settings.viewport),
allowedViewports: this.allowedViewports,
statusLabel,
rttLabel,
diagnosticBannerText,
serverInputValue: this.serverInputValue,
serverInputValid: this.serverInputValid,
}
}
destroy(): void {
this.unsubscribeStore?.()
this.unsubscribeStore = null
this.unsubscribeLang?.()
this.unsubscribeLang = null
this.contributions.clear()
if (this.rootEl && this.rootEl.parentNode) {
this.rootEl.parentNode.removeChild(this.rootEl)
}
this.rootEl = null
}
private ensureStyles(doc: Document): void {
if (!doc.head || doc.getElementById('d2-toolbar-styles')) return
const styleEl = doc.createElement('style')
styleEl.id = 'd2-toolbar-styles'
styleEl.textContent = `
#d2-toolbar,
.d2-toolbar {
position: relative;
z-index: 1000;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
height: 32px;
min-height: 32px;
max-height: 32px;
padding: 0 12px;
box-sizing: border-box;
background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%);
border-bottom: 1px solid #4a3c24;
color: #d6c496;
font-family: var(--font-display, "D2Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif);
font-size: 12px;
line-height: 1;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
user-select: none;
}
.d2-toolbar__group {
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
flex-shrink: 0;
gap: 6px;
white-space: nowrap;
}
.d2-toolbar__divider {
width: 1px;
height: 18px;
background: #3a2f1d;
}
.d2-toolbar__status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 3px;
background: #14110d;
border: 1px solid #352b1b;
}
.d2-toolbar__status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background: #6b6b6b;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
.d2-toolbar__status-dot--disconnected { background: #767676; }
.d2-toolbar__status-dot--connecting { background: #e6a822; box-shadow: 0 0 6px #e6a822; }
.d2-toolbar__status-dot--bncs { background: #4495e8; box-shadow: 0 0 6px #4495e8; }
.d2-toolbar__status-dot--mcp { background: #36c5d6; box-shadow: 0 0 6px #36c5d6; }
.d2-toolbar__status-dot--d2gs { background: #2ecc71; box-shadow: 0 0 6px #2ecc71; }
.d2-toolbar__status-dot--error { background: #e74c3c; box-shadow: 0 0 6px #e74c3c; }
.d2-toolbar__rtt {
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
color: #a89974;
}
.d2-toolbar__label {
color: #b59e68;
font-size: 11px;
letter-spacing: 0.03em;
}
.d2-toolbar__select {
height: 22px;
padding: 0 6px;
background: #120f0b;
border: 1px solid #4a3c24;
border-radius: 2px;
color: #e2d1a0;
font-size: 11px;
cursor: pointer;
outline: none;
}
.d2-toolbar__select:hover,
.d2-toolbar__btn:hover {
border-color: #c7b377;
color: #fff2c6;
}
.d2-toolbar__btn,
a.d2-toolbar__btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
padding: 0 8px;
box-sizing: border-box;
background: linear-gradient(180deg, #2b2316 0%, #17120b 100%);
border: 1px solid #574629;
border-radius: 2px;
color: #dfc991;
font-size: 11px;
text-decoration: none;
cursor: pointer;
}
.d2-toolbar__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.d2-toolbar__btn--active {
background: linear-gradient(180deg, #524020 0%, #2e2310 100%);
border-color: #d4b46a;
color: #fff5d6;
}
.d2-toolbar__btn--warn {
border-color: #d4882a;
color: #ffb84d;
}
.d2-toolbar__diag {
position: absolute;
top: 32px;
left: 12px;
right: 12px;
z-index: 1001;
display: none;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: rgba(38, 12, 10, 0.96);
border: 1px solid #a93226;
border-top: none;
border-radius: 0 0 4px 4px;
color: #ffd6d1;
font-size: 12px;
}
.d2-toolbar__diag--visible {
display: flex;
}
`
doc.head.appendChild(styleEl)
}
private mountDom(container: HTMLElement | null): void {
const doc = document
this.ensureStyles(doc)
const root = doc.createElement('header')
root.id = 'd2-toolbar'
root.className = 'd2-toolbar'
// Group 1: Connection Status + RTT + Plugins button + Plugin Contribution Slot
const statusGroup = doc.createElement('div')
statusGroup.className = 'd2-toolbar__group'
const statusBadge = doc.createElement('div')
statusBadge.className = 'd2-toolbar__status'
const dot = doc.createElement('span')
dot.id = 'd2-toolbar-status-dot'
dot.className = 'd2-toolbar__status-dot d2-toolbar__status-dot--disconnected'
const statusText = doc.createElement('span')
statusText.id = 'd2-toolbar-status-text'
const rttText = doc.createElement('span')
rttText.id = 'd2-toolbar-rtt'
rttText.className = 'd2-toolbar__rtt'
statusBadge.appendChild(dot)
statusBadge.appendChild(statusText)
statusBadge.appendChild(rttText)
const pluginsBtn = doc.createElement('button')
pluginsBtn.type = 'button'
pluginsBtn.id = 'd2-toolbar-plugins-btn'
pluginsBtn.className = 'd2-toolbar__btn'
pluginsBtn.disabled = !this.pluginsReady
pluginsBtn.addEventListener('click', () => {
if (!this.pluginsReady) return
this.callbacks.onPluginsToggle?.()
})
const pluginSlot = doc.createElement('div')
pluginSlot.id = 'd2-toolbar-plugin-slot'
pluginSlot.className = 'd2-toolbar__group'
statusGroup.appendChild(statusBadge)
statusGroup.appendChild(pluginsBtn)
statusGroup.appendChild(pluginSlot)
// Group 2: Language Toggle (中 / EN)
const langGroup = doc.createElement('div')
langGroup.className = 'd2-toolbar__group'
const langBtnZh = doc.createElement('button')
langBtnZh.type = 'button'
langBtnZh.id = 'd2-toolbar-lang-zh'
langBtnZh.className = 'd2-toolbar__btn'
langBtnZh.textContent = '中'
langBtnZh.addEventListener('click', () => this.setLanguage('zh'))
const langBtnEn = doc.createElement('button')
langBtnEn.type = 'button'
langBtnEn.id = 'd2-toolbar-lang-en'
langBtnEn.className = 'd2-toolbar__btn'
langBtnEn.textContent = 'EN'
langBtnEn.addEventListener('click', () => this.setLanguage('en'))
langGroup.appendChild(langBtnZh)
langGroup.appendChild(langBtnEn)
// Group 3: Viewport Size Selector + Asset Cache Clear button
const viewportGroup = doc.createElement('div')
viewportGroup.className = 'd2-toolbar__group'
const viewportLabel = doc.createElement('label')
viewportLabel.className = 'd2-toolbar__label'
viewportLabel.htmlFor = 'd2-toolbar-viewport-select'
const viewportSelect = doc.createElement('select')
viewportSelect.id = 'd2-toolbar-viewport-select'
viewportSelect.className = 'd2-toolbar__select'
viewportSelect.addEventListener('change', () => {
this.setViewport(viewportSelect.value as ViewportResolution)
})
const cacheClearBtn = doc.createElement('button')
cacheClearBtn.type = 'button'
cacheClearBtn.id = 'd2-toolbar-cache-clear-btn'
cacheClearBtn.className = 'd2-toolbar__btn'
cacheClearBtn.addEventListener('click', () => {
void this.clearAssetCache()
})
viewportGroup.appendChild(viewportLabel)
viewportGroup.appendChild(viewportSelect)
viewportGroup.appendChild(cacheClearBtn)
// Diagnostic Banner
const diagBanner = doc.createElement('div')
diagBanner.id = 'd2-toolbar-diag-banner'
diagBanner.className = 'd2-toolbar__diag'
root.appendChild(statusGroup)
root.appendChild(langGroup)
root.appendChild(viewportGroup)
root.appendChild(diagBanner)
if (container) {
container.appendChild(root)
}
this.rootEl = root
this.statusDotEl = dot
this.statusTextEl = statusText
this.rttTextEl = rttText
this.langBtnZhEl = langBtnZh
this.langBtnEnEl = langBtnEn
this.viewportLabelEl = viewportLabel
this.viewportSelectEl = viewportSelect
this.pluginsBtnEl = pluginsBtn
this.pluginSlotEl = pluginSlot
this.cacheClearBtnEl = cacheClearBtn
this.diagBannerEl = diagBanner
this.syncDom()
}
private syncDom(): void {
if (!this.rootEl) return
const snap = this.getSnapshot()
const lang = snap.settings.lang
if (this.statusDotEl) {
this.statusDotEl.className = `d2-toolbar__status-dot d2-toolbar__status-dot--${snap.connection.phase}`
}
if (this.statusTextEl) {
this.statusTextEl.textContent = snap.statusLabel
}
if (this.rttTextEl) {
this.rttTextEl.textContent = snap.rttLabel
}
if (this.pluginsBtnEl) {
const baseLabel = tToolbar('toolbar.plugins.open', lang)
this.pluginsBtnEl.disabled = !this.pluginsReady
this.pluginsBtnEl.textContent = this.pluginsHasWarning ? `${baseLabel} ⚠` : baseLabel
this.pluginsBtnEl.title = baseLabel
this.pluginsBtnEl.setAttribute('aria-expanded', this.pluginsMenuOpen ? 'true' : 'false')
this.pluginsBtnEl.setAttribute('aria-haspopup', 'dialog')
let cls = 'd2-toolbar__btn'
if (this.pluginsMenuOpen) cls += ' d2-toolbar__btn--active'
if (this.pluginsHasWarning) cls += ' d2-toolbar__btn--warn'
this.pluginsBtnEl.className = cls
}
for (const entry of this.contributions.values()) {
if (!entry.el) continue
const spec = entry.spec
const label = resolveLocalizedText(spec.label, lang)
if (spec.kind === 'select') {
if (entry.labelEl) {
entry.labelEl.textContent = label
}
if (entry.selectEl && typeof document !== 'undefined') {
const currentOpts = Array.from(entry.selectEl.options)
if (
currentOpts.length !== spec.options.length ||
currentOpts.some((o, idx) => o.value !== spec.options[idx]?.value)
) {
entry.selectEl.innerHTML = ''
for (const optSpec of spec.options) {
const opt = document.createElement('option')
opt.value = optSpec.value
opt.textContent = resolveLocalizedText(optSpec.label, lang)
entry.selectEl.appendChild(opt)
}
} else {
for (let idx = 0; idx < spec.options.length; idx += 1) {
const optSpec = spec.options[idx]!
const optEl = currentOpts[idx]
if (optEl) {
optEl.textContent = resolveLocalizedText(optSpec.label, lang)
}
}
}
entry.selectEl.value = spec.getValue()
}
} else {
entry.el.textContent = label
entry.el.title = label
}
}
if (this.langBtnZhEl) {
this.langBtnZhEl.className =
lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'
}
if (this.langBtnEnEl) {
this.langBtnEnEl.className =
lang === 'en' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'
}
if (this.viewportLabelEl) {
this.viewportLabelEl.textContent = tToolbar('toolbar.viewport.label', lang)
}
if (this.viewportSelectEl && typeof document !== 'undefined') {
const existingValues = Array.from(this.viewportSelectEl.options).map((o) => o.value)
const needsRebuild =
existingValues.length !== this.allowedViewports.length ||
existingValues.some((v, idx) => v !== this.allowedViewports[idx])
if (needsRebuild) {
this.viewportSelectEl.innerHTML = ''
for (const vp of this.allowedViewports) {
const opt = document.createElement('option')
opt.value = vp
opt.textContent = formatViewportLabel(vp)
this.viewportSelectEl.appendChild(opt)
}
}
this.viewportSelectEl.value = snap.settings.viewport
}
if (this.cacheClearBtnEl) {
if (this.cacheClearedFlash) {
this.cacheClearBtnEl.textContent = tToolbar('toolbar.cache.cleared', lang)
} else {
const baseLabel = tToolbar('toolbar.cache.clear', lang)
this.cacheClearBtnEl.textContent =
this.cacheUsageBytes > 0
? `${baseLabel} (${formatCacheBytesMb(this.cacheUsageBytes)})`
: baseLabel
}
}
if (this.diagBannerEl) {
if (snap.diagnosticBannerText) {
this.diagBannerEl.textContent = snap.diagnosticBannerText
this.diagBannerEl.className = 'd2-toolbar__diag d2-toolbar__diag--visible'
} else {
this.diagBannerEl.textContent = ''
this.diagBannerEl.className = 'd2-toolbar__diag'
}
}
}
}