Files
ark-stronghold-protocol/public/js/ui/toasts.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

137 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Global toast notifications (top-centre stack).
//
// Imperative API usable from anywhere: `toast(text, kind)`, `toastError(err)`; render <ToastHost/>
// once near the app root. Identical messages shown within a short window are merged (with a ×N
// counter) instead of stacking, and the stack is capped so an error storm cannot flood the screen.
import { h } from '../../vendor/preact.module.js';
import { useEffect, useReducer } from '../../vendor/hooks.module.js';
import htm from '../../vendor/htm.module.js';
import { ERR_TEXT } from '../../../shared/constants.js';
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
function hFresh(type, props, ...children) {
if (this && typeof this === 'object') this[0] = 3;
return h(type, props, ...children);
}
const html = htm.bind(hFresh);
export const TOAST_KINDS = ['info', 'success', 'warn', 'error'];
const MAX_TOASTS = 4;
const DEFAULT_MS = { info: 2600, success: 2400, warn: 3200, error: 3600 };
const MERGE_WINDOW_MS = 2500;
let seq = 0;
/** @type {{id:number, text:string, kind:string, count:number, at:number, ttl:number, leaving:boolean}[]} */
let toasts = [];
const listeners = new Set();
const timers = new Map();
const emit = () => { for (const fn of [...listeners]) { try { fn(toasts); } catch (err) { console.error(err); } } };
function schedule(t) {
clearTimeout(timers.get(t.id));
timers.set(t.id, setTimeout(() => dismissToast(t.id), t.ttl));
}
/**
* Show a toast.
* @param {string} text message (plain text)
* @param {'info'|'success'|'warn'|'error'} [kind]
* @param {{ ttl?: number }} [opts]
* @returns {number} toast id
*/
export function toast(text, kind = 'info', opts = {}) {
const k = TOAST_KINDS.includes(kind) ? kind : 'info';
const msg = String(text ?? '').slice(0, 200).trim();
if (!msg) return -1;
const now = Date.now();
const ttl = Number.isFinite(opts.ttl) && opts.ttl > 0 ? opts.ttl : DEFAULT_MS[k];
const same = toasts.find((t) => !t.leaving && t.text === msg && t.kind === k && now - t.at < MERGE_WINDOW_MS + t.ttl);
if (same) {
toasts = toasts.map((t) => (t === same ? { ...t, count: t.count + 1, at: now, ttl } : t));
schedule(toasts.find((t) => t.id === same.id));
emit();
return same.id;
}
const t = { id: ++seq, text: msg, kind: k, count: 1, at: now, ttl, leaving: false };
toasts = [...toasts, t];
// Cap the stack: drop the oldest immediately.
while (toasts.length > MAX_TOASTS) {
const old = toasts[0];
clearTimeout(timers.get(old.id));
timers.delete(old.id);
toasts = toasts.slice(1);
}
schedule(t);
emit();
return t.id;
}
/**
* Dismiss a toast (plays the exit animation, then removes it).
* @param {number} id
*/
export function dismissToast(id) {
const t = toasts.find((x) => x.id === id);
if (!t) return;
clearTimeout(timers.get(id));
if (t.leaving) return;
toasts = toasts.map((x) => (x.id === id ? { ...x, leaving: true } : x));
emit();
timers.set(id, setTimeout(() => {
timers.delete(id);
toasts = toasts.filter((x) => x.id !== id);
emit();
}, 200));
}
/**
* Text for an error-ish value: NetError / {code,msg} / Error / string.
* @param {any} err
* @returns {string}
*/
export function describeError(err) {
if (!err) return '发生未知错误';
if (typeof err === 'string') return ERR_TEXT[err] || err;
if (err.code && ERR_TEXT[err.code]) return ERR_TEXT[err.code];
if (typeof err.message === 'string' && err.message) return err.message;
if (typeof err.msg === 'string' && err.msg) return err.msg;
return '发生未知错误';
}
/**
* Show an error toast for a failed request / error push.
* @param {any} err
* @returns {number}
*/
export function toastError(err) {
return toast(describeError(err), 'error');
}
const ICONS = {
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z',
success: 'M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6L20.1 8.4 18.7 7z',
warn: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z',
error: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm5 13.6L15.6 17 12 13.4 8.4 17 7 15.6 10.6 12 7 8.4 8.4 7 12 10.6 15.6 7 17 8.4 13.4 12 17 15.6Z',
};
/** Renders the toast stack. Mount once. */
export function ToastHost() {
const [, force] = useReducer((c) => c + 1, 0);
useEffect(() => {
listeners.add(force);
return () => listeners.delete(force);
}, []);
return html`
<div class="toast-host" role="status" aria-live="polite">
${toasts.map((t) => html`
<div key=${t.id} class=${`toast toast--${t.kind}${t.leaving ? ' is-leaving' : ''}`}
onClick=${() => dismissToast(t.id)}>
<svg class="toast__icon" viewBox="0 0 24 24" aria-hidden="true"><path d=${ICONS[t.kind]} /></svg>
<span class="toast__text">${t.text}</span>
${t.count > 1 ? html`<span class="toast__count">×${t.count}</span>` : null}
</div>`)}
</div>`;
}