mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
193 lines
6.9 KiB
TypeScript
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;
|
|
};
|