Files
trade-message-center/apps/chrome-extension/src/onetalk/main-page/dom/action-status-tooltip.ts
T

193 lines
6.9 KiB
TypeScript

// 管理 OneTalk 页面进行中动作提示的 DOM 边界。
import { isObjectRecord } from "../../../lib/guards.ts";
export type TooltipColor = "neutral" | "info" | "warning" | "error";
export type OneTalkActionStatusTooltip = {
start: (id: string, text: string, color?: TooltipColor) => void;
update: (id: string, text: string, color?: TooltipColor) => boolean;
close: (id: string) => boolean;
};
type OneTalkActionStatusTooltipWindow = Pick<Window, "document"> & {
__tradeMessageCenterOneTalk?: unknown;
};
type ActionStatus = {
color: TooltipColor;
row: HTMLDivElement;
};
const STATUS_CONTAINER_ATTRIBUTE = "data-tmc-action-status-tooltip";
const STATUS_ROW_ATTRIBUTE = "data-tmc-action-status-row";
const STATUS_COLOR_ATTRIBUTE = "data-tmc-action-status-color";
const DEFAULT_TOOLTIP_COLOR: TooltipColor = "neutral";
const tooltipRefreshes = new WeakMap<OneTalkActionStatusTooltipWindow, () => void>();
const TOOLTIP_COLORS: Readonly<
Record<TooltipColor, { background: string; border: string; text: string }>
> = {
neutral: { background: "#f5f5f5", border: "#d9d9d9", text: "#595959" },
info: { background: "#e6f4ff", border: "#91caff", text: "#1677ff" },
warning: { background: "#fffbe6", border: "#ffe58f", text: "#d48806" },
error: { background: "#fff2f0", border: "#ffccc7", text: "#cf1322" },
};
const isTooltipColor = (value: unknown): value is TooltipColor => {
return typeof value === "string" && Object.hasOwn(TOOLTIP_COLORS, value);
};
const requireTooltipColor = (value: unknown): TooltipColor => {
if (isTooltipColor(value)) return value;
throw new TypeError("onetalk_action_status_tooltip_color_invalid");
};
const statusRowStyles = (color: TooltipColor): string => {
const theme = TOOLTIP_COLORS[color];
return [
"box-sizing: border-box",
"width: 100%",
"padding: 8px 12px",
"border: 1px solid " + theme.border,
"border-radius: 6px",
"background: " + theme.background,
"color: " + theme.text,
"font-size: 13px",
"line-height: 20px",
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
"box-shadow: 0 2px 8px rgb(0 0 0 / 12%)",
].join(";");
};
const updateStatusRow = (status: ActionStatus, text: string, color: TooltipColor): void => {
status.color = color;
status.row.textContent = text;
status.row.setAttribute(STATUS_COLOR_ATTRIBUTE, color);
status.row.style.cssText = statusRowStyles(color);
};
const createStatusRow = (
document: Document,
id: string,
text: string,
color: TooltipColor,
): ActionStatus => {
const row = document.createElement("div");
row.setAttribute(STATUS_ROW_ATTRIBUTE, "");
row.dataset.tmcActionStatusId = id;
const status = { color, row };
updateStatusRow(status, text, color);
return status;
};
const createStatusContainer = (document: Document): HTMLDivElement => {
const container = document.createElement("div");
container.setAttribute(STATUS_CONTAINER_ATTRIBUTE, "");
container.setAttribute("aria-live", "polite");
container.setAttribute("role", "status");
container.style.cssText = [
"position: fixed",
"top: 0",
"left: 50%",
"transform: translateX(-50%)",
"z-index: 2147483647",
"display: flex",
"flex-direction: column",
"gap: 8px",
"width: min(600px, calc(100vw - 96px))",
"pointer-events: none",
].join(";");
return container;
};
const findStatusContainer = (document: Document): HTMLDivElement | null => {
return document.querySelector<HTMLDivElement>(`div[${STATUS_CONTAINER_ATTRIBUTE}]`);
};
const ensureStatusContainer = (
document: Document,
statuses: ReadonlyMap<string, ActionStatus>,
): HTMLDivElement => {
const existing = findStatusContainer(document);
const container = existing ?? createStatusContainer(document);
if (!existing) {
if (!document.body)
throw new Error("onetalk_action_status_tooltip_document_body_unavailable");
document.body.append(container);
}
for (const status of statuses.values()) container.append(status.row);
return container;
};
const isTooltipFacade = (value: unknown): value is OneTalkActionStatusTooltip => {
return (
isObjectRecord(value) &&
typeof value.start === "function" &&
typeof value.update === "function" &&
typeof value.close === "function"
);
};
const createTooltipFacade = (
pageWindow: OneTalkActionStatusTooltipWindow,
): OneTalkActionStatusTooltip => {
const statuses = new Map<string, ActionStatus>();
const refresh = (): void => {
if (statuses.size > 0) ensureStatusContainer(pageWindow.document, statuses);
};
tooltipRefreshes.set(pageWindow, refresh);
const start = (id: string, text: string, color: TooltipColor = DEFAULT_TOOLTIP_COLOR): void => {
if (statuses.has(id)) {
refresh();
return;
}
const resolvedColor = requireTooltipColor(color);
const status = createStatusRow(pageWindow.document, id, text, resolvedColor);
statuses.set(id, status);
const container = ensureStatusContainer(pageWindow.document, statuses);
if (status.row.parentElement !== container) container.append(status.row);
};
const update = (id: string, text: string, color?: TooltipColor): boolean => {
const status = statuses.get(id);
if (!status) return false;
const resolvedColor = color === undefined ? status.color : requireTooltipColor(color);
updateStatusRow(status, text, resolvedColor);
ensureStatusContainer(pageWindow.document, statuses);
return true;
};
const close = (id: string): boolean => {
const status = statuses.get(id);
if (!status) return false;
statuses.delete(id);
status.row.remove();
if (statuses.size === 0) findStatusContainer(pageWindow.document)?.remove();
return true;
};
return { close, start, update };
};
/** 读取已安装的页面动作提示 facade。 */
export const readOneTalkActionStatusTooltip = (
pageWindow: OneTalkActionStatusTooltipWindow,
): OneTalkActionStatusTooltip | null => {
const namespace = pageWindow.__tradeMessageCenterOneTalk;
return isObjectRecord(namespace) && isTooltipFacade(namespace.tooltip)
? namespace.tooltip
: null;
};
/** 将进行中动作提示注册到 OneTalk 页面命名空间。 */
export const installOneTalkActionStatusTooltip = (
pageWindow: OneTalkActionStatusTooltipWindow,
): void => {
const existing = pageWindow.__tradeMessageCenterOneTalk;
const namespace: Record<string, unknown> = isObjectRecord(existing) ? existing : {};
if (isTooltipFacade(namespace.tooltip)) {
tooltipRefreshes.get(pageWindow)?.();
return;
}
namespace.tooltip = createTooltipFacade(pageWindow);
pageWindow.__tradeMessageCenterOneTalk = namespace;
};