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

890 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 {
CLIENT_LIGHTING_PRESET_ORDER,
LIGHTING_PRESETS,
} from '../scene/lighting-presets.ts'
import {
VALID_AUTOMAP_REVEAL_MODES,
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 {
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 onInspectorToggle?: ((open: boolean) => 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 confirmReconnect?: ((message: string) => boolean) | undefined
readonly callbacks?: ToolbarCallbacks | undefined
}
export interface ToolbarSnapshot {
readonly connection: ToolbarConnectionState
readonly settings: ClientSettings
readonly viewportProfile: ViewportProfile
readonly statusLabel: string
readonly rttLabel: string
readonly diagnosticBannerText: string | null
readonly serverInputValue: string
readonly serverInputValid: boolean
}
/**
* Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with primary controls:
* 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle + Plugins modal button (`#d2-toolbar-plugins-btn`) + Dynamic Plugin Contribution Slot (`#d2-toolbar-plugin-slot`)
* 2. Live Language Toggle (`中` / `EN`)
* 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`)
* 4. Automap Reveal Selector (`off | level | act`)
* 5. Viewport Size Selector (`800x600 | 1024x768 | 1068x600 | 1280x720`) + 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 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,
{ spec: ToolbarContributionSpec; el: HTMLElement | null }
>()
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 lightingLabelEl: HTMLElement | null = null
private lightingSelectEl: HTMLSelectElement | null = null
private automapLabelEl: HTMLElement | null = null
private automapSelectEl: HTMLSelectElement | null = null
private viewportLabelEl: HTMLElement | null = null
private viewportSelectEl: HTMLSelectElement | null = null
private inspectorBtnEl: HTMLButtonElement | 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.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))
}
if (changedKeys.includes('inspectorOpen') && next.inspectorOpen !== prev.inspectorOpen) {
this.callbacks.onInspectorToggle?.(next.inspectorOpen)
}
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()
}
setViewport(viewport: ViewportResolution): ViewportProfile {
this.store.set({ viewport })
this.syncDom()
return getViewportProfile(viewport)
}
/** Advance to the next resolution in `VALID_VIEWPORT_RESOLUTIONS` order, wrapping back to the first. */
toggleViewport(): ViewportProfile {
const order = VALID_VIEWPORT_RESOLUTIONS
const next = order[(order.indexOf(this.store.get().viewport) + 1) % order.length]
return this.setViewport(next)
}
setInspectorOpen(open: boolean): void {
this.store.set({ inspectorOpen: open })
this.syncDom()
}
toggleInspector(): boolean {
const next = !this.store.get().inspectorOpen
this.setInspectorOpen(next)
return 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
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 {
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
}
this.pluginSlotEl.appendChild(el)
}
const entry = { spec, el }
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),
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 + Packet Inspector button + 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 inspectorBtn = doc.createElement('button')
inspectorBtn.type = 'button'
inspectorBtn.id = 'd2-toolbar-inspector-btn'
inspectorBtn.className = 'd2-toolbar__btn'
inspectorBtn.addEventListener('click', () => {
this.toggleInspector()
})
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(inspectorBtn)
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: Lighting Selector
const lightingGroup = doc.createElement('div')
lightingGroup.className = 'd2-toolbar__group'
const lightingLabel = doc.createElement('label')
lightingLabel.className = 'd2-toolbar__label'
lightingLabel.htmlFor = 'd2-toolbar-lighting-select'
const lightingSelect = doc.createElement('select')
lightingSelect.id = 'd2-toolbar-lighting-select'
lightingSelect.className = 'd2-toolbar__select'
lightingSelect.addEventListener('change', () => {
this.setLightingPreset(lightingSelect.value as ClientLightingPreset)
})
lightingGroup.appendChild(lightingLabel)
lightingGroup.appendChild(lightingSelect)
// Group 4: Automap Reveal Selector (Off / Level / Act)
const automapGroup = doc.createElement('div')
automapGroup.className = 'd2-toolbar__group'
const automapLabel = doc.createElement('label')
automapLabel.className = 'd2-toolbar__label'
automapLabel.htmlFor = 'd2-toolbar-automap-select'
const automapSelect = doc.createElement('select')
automapSelect.id = 'd2-toolbar-automap-select'
automapSelect.className = 'd2-toolbar__select'
automapSelect.addEventListener('change', () => {
this.setAutomapReveal(automapSelect.value as AutomapRevealMode)
})
automapGroup.appendChild(automapLabel)
automapGroup.appendChild(automapSelect)
// Group 5: Viewport Size Selector (every VALID_VIEWPORT_RESOLUTIONS entry) + 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'
for (const vp of VALID_VIEWPORT_RESOLUTIONS) {
const opt = doc.createElement('option')
opt.value = vp
opt.textContent = formatViewportLabel(vp)
viewportSelect.appendChild(opt)
}
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(lightingGroup)
root.appendChild(automapGroup)
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.lightingLabelEl = lightingLabel
this.lightingSelectEl = lightingSelect
this.automapLabelEl = automapLabel
this.automapSelectEl = automapSelect
this.viewportLabelEl = viewportLabel
this.viewportSelectEl = viewportSelect
this.inspectorBtnEl = inspectorBtn
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.inspectorBtnEl) {
this.inspectorBtnEl.textContent = tToolbar('toolbar.inspector.toggle', lang)
this.inspectorBtnEl.className = snap.settings.inspectorOpen
? 'd2-toolbar__btn d2-toolbar__btn--active'
: 'd2-toolbar__btn'
}
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) {
const label = resolveLocalizedText(entry.spec.label, lang)
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.lightingLabelEl) {
this.lightingLabelEl.textContent = tToolbar('toolbar.lighting.label', lang)
}
if (this.lightingSelectEl && typeof document !== 'undefined') {
this.lightingSelectEl.innerHTML = ''
for (const presetId of CLIENT_LIGHTING_PRESET_ORDER) {
const cfg = LIGHTING_PRESETS[presetId]
const opt = document.createElement('option')
opt.value = presetId
opt.textContent = lang === 'en' ? cfg.labelEn : cfg.labelZh
this.lightingSelectEl.appendChild(opt)
}
this.lightingSelectEl.value = snap.settings.lightingPreset
}
if (this.automapLabelEl) {
this.automapLabelEl.textContent = tToolbar('toolbar.automap.label', lang)
}
if (this.automapSelectEl && typeof document !== 'undefined') {
this.automapSelectEl.innerHTML = ''
for (const mode of VALID_AUTOMAP_REVEAL_MODES) {
const opt = document.createElement('option')
opt.value = mode
opt.textContent = tToolbar(`toolbar.automap.${mode}` as const, lang)
this.automapSelectEl.appendChild(opt)
}
this.automapSelectEl.value = snap.settings.automapReveal
}
if (this.viewportLabelEl) {
this.viewportLabelEl.textContent = tToolbar('toolbar.viewport.label', lang)
}
if (this.viewportSelectEl) {
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'
}
}
}
}