feat(banner): 首页常驻滚动通知与管理配置及类型样式支持
This commit is contained in:
181
web/src/lib/announcements/banner-notice.ts
Normal file
181
web/src/lib/announcements/banner-notice.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 首页常驻通知条的「通知类型」与「标题图标素材」模型。
|
||||
*
|
||||
* 通知类型决定通知条底色(后台与前台共用同一套 token)。
|
||||
* 图标素材是 emoji 字符:它就是普通文本,随 TitleRuns 的文本分段一起存储与渲染,
|
||||
* 因此可以插到标题任意位置、跟随分段样式、参与关键字检索,后端不需要单独字段或白名单。
|
||||
*
|
||||
* 本模块只放纯数据与纯函数,不 import 任何组件。
|
||||
*/
|
||||
|
||||
export type BannerNoticeType = "notice" | "activity" | "update" | "warning";
|
||||
|
||||
/** 未指定类型时的兜底:平台公告。 */
|
||||
export const BANNER_NOTICE_DEFAULT_TYPE: BannerNoticeType = "notice";
|
||||
|
||||
export const BANNER_NOTICE_TYPES: {
|
||||
key: BannerNoticeType;
|
||||
label: string;
|
||||
hint: string;
|
||||
/** 该类型的语义底色 token;由 globals.css 按明暗主题分别取值。 */
|
||||
surfaceVar: string;
|
||||
}[] = [
|
||||
{ key: "notice", label: "公告", hint: "平台公告、规则与流程变更", surfaceVar: "--notice-banner-surface-notice" },
|
||||
{ key: "activity", label: "活动", hint: "运营活动、限时促销与福利", surfaceVar: "--notice-banner-surface-activity" },
|
||||
{ key: "update", label: "更新", hint: "版本发布、功能上新与改版", surfaceVar: "--notice-banner-surface-update" },
|
||||
{ key: "warning", label: "警告", hint: "维护窗口、风险与服务异常", surfaceVar: "--notice-banner-surface-warning" },
|
||||
];
|
||||
|
||||
const BANNER_NOTICE_TYPE_KEYS = new Set<string>(BANNER_NOTICE_TYPES.map((item) => item.key));
|
||||
|
||||
/** 未知 / 空类型一律回落到默认类型,通知条不会因为脏数据失去底色。 */
|
||||
export function normalizeBannerNoticeType(value?: string | null): BannerNoticeType {
|
||||
const raw = (value || "").trim().toLowerCase();
|
||||
return BANNER_NOTICE_TYPE_KEYS.has(raw) ? (raw as BannerNoticeType) : BANNER_NOTICE_DEFAULT_TYPE;
|
||||
}
|
||||
|
||||
export function bannerNoticeTypeMeta(value?: string | null) {
|
||||
const key = normalizeBannerNoticeType(value);
|
||||
return BANNER_NOTICE_TYPES.find((item) => item.key === key) as (typeof BANNER_NOTICE_TYPES)[number];
|
||||
}
|
||||
|
||||
export function bannerNoticeTypeLabel(value?: string | null) {
|
||||
return bannerNoticeTypeMeta(value).label;
|
||||
}
|
||||
|
||||
export function bannerNoticeTypeHint(value?: string | null) {
|
||||
return bannerNoticeTypeMeta(value).hint;
|
||||
}
|
||||
|
||||
/** 该类型底色的 CSS 引用(含 var());scope 内可被预览容器覆盖成明暗指定档。 */
|
||||
export function bannerNoticeSurfaceCSS(value?: string | null) {
|
||||
return `var(${bannerNoticeTypeMeta(value).surfaceVar})`;
|
||||
}
|
||||
|
||||
/** 合法类型键,供后端白名单与前端一致性测试使用。 */
|
||||
export const BANNER_NOTICE_TYPE_VALUES = BANNER_NOTICE_TYPES.map((item) => item.key);
|
||||
|
||||
/**
|
||||
* 标题可插入的 emoji 图标素材,按用途分组。
|
||||
* 每项的 char 会作为普通文本插进标题(跟随光标处的字号 / 颜色样式),label 供搜索与 aria 标注。
|
||||
*/
|
||||
export const BANNER_NOTICE_EMOJI_GROUPS: { key: string; label: string; emoji: { char: string; label: string }[] }[] = [
|
||||
{
|
||||
key: "notice",
|
||||
label: "通知",
|
||||
emoji: [
|
||||
{ char: "📢", label: "喇叭" },
|
||||
{ char: "📣", label: "扩音" },
|
||||
{ char: "🔔", label: "铃铛" },
|
||||
{ char: "📮", label: "邮筒" },
|
||||
{ char: "📌", label: "图钉" },
|
||||
{ char: "📄", label: "文件" },
|
||||
{ char: "📝", label: "备注" },
|
||||
{ char: "📰", label: "快讯" },
|
||||
{ char: "🗓️", label: "日历" },
|
||||
{ char: "⏰", label: "闹钟" },
|
||||
{ char: "🕐", label: "时钟" },
|
||||
{ char: "🎯", label: "目标" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "celebrate",
|
||||
label: "庆祝",
|
||||
emoji: [
|
||||
{ char: "🎉", label: "撒花" },
|
||||
{ char: "🎊", label: "礼花" },
|
||||
{ char: "🎈", label: "气球" },
|
||||
{ char: "🎁", label: "礼物" },
|
||||
{ char: "🎂", label: "蛋糕" },
|
||||
{ char: "🥳", label: "狂欢" },
|
||||
{ char: "🏆", label: "奖杯" },
|
||||
{ char: "🥇", label: "金牌" },
|
||||
{ char: "👑", label: "皇冠" },
|
||||
{ char: "💎", label: "宝石" },
|
||||
{ char: "✨", label: "闪光" },
|
||||
{ char: "⭐", label: "星星" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "hot",
|
||||
label: "热门",
|
||||
emoji: [
|
||||
{ char: "🔥", label: "热门" },
|
||||
{ char: "🚀", label: "火箭" },
|
||||
{ char: "⚡", label: "闪电" },
|
||||
{ char: "💥", label: "爆炸" },
|
||||
{ char: "🌟", label: "亮星" },
|
||||
{ char: "💫", label: "星晕" },
|
||||
{ char: "🌈", label: "彩虹" },
|
||||
{ char: "☀️", label: "太阳" },
|
||||
{ char: "🌙", label: "月亮" },
|
||||
{ char: "❤️", label: "红心" },
|
||||
{ char: "💯", label: "满分" },
|
||||
{ char: "👍", label: "点赞" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "commerce",
|
||||
label: "商业",
|
||||
emoji: [
|
||||
{ char: "💰", label: "钱袋" },
|
||||
{ char: "💸", label: "飞钱" },
|
||||
{ char: "💳", label: "银行卡" },
|
||||
{ char: "🏦", label: "银行" },
|
||||
{ char: "🛒", label: "购物车" },
|
||||
{ char: "🛍️", label: "购物袋" },
|
||||
{ char: "🏷️", label: "价签" },
|
||||
{ char: "💼", label: "公文包" },
|
||||
{ char: "📈", label: "上涨" },
|
||||
{ char: "📉", label: "下跌" },
|
||||
{ char: "🎫", label: "票券" },
|
||||
{ char: "🎟️", label: "门票" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
emoji: [
|
||||
{ char: "✅", label: "完成" },
|
||||
{ char: "❌", label: "取消" },
|
||||
{ char: "⚠️", label: "警告" },
|
||||
{ char: "⛔", label: "禁止" },
|
||||
{ char: "🚫", label: "禁用" },
|
||||
{ char: "❓", label: "疑问" },
|
||||
{ char: "❗", label: "感叹" },
|
||||
{ char: "⏳", label: "等待" },
|
||||
{ char: "🔄", label: "刷新" },
|
||||
{ char: "🔧", label: "扳手" },
|
||||
{ char: "🔒", label: "上锁" },
|
||||
{ char: "🐞", label: "缺陷" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 扁平化的 emoji 清单,顺序与分组一致。 */
|
||||
export const BANNER_NOTICE_EMOJIS = BANNER_NOTICE_EMOJI_GROUPS.flatMap((group) =>
|
||||
group.emoji.map((item) => ({ ...item, group: group.key, groupLabel: group.label })),
|
||||
);
|
||||
|
||||
/** 按关键字过滤(匹配中文名、分组名或字符本身),空关键字返回全量。 */
|
||||
export function searchBannerNoticeEmojis(keyword: string) {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
if (!query) return BANNER_NOTICE_EMOJIS;
|
||||
return BANNER_NOTICE_EMOJIS.filter((item) => item.char.includes(query) || item.label.includes(query) || item.groupLabel.includes(query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取指定类型在当前主题下的通知条实底色,避免把设计 token 的色值复制到 TS 里。
|
||||
* scope 传后台预览容器可在该容器强制指定的明 / 暗档里取值。
|
||||
*/
|
||||
export function readBannerNoticeSurface(noticeType?: string | null, scope?: HTMLElement | null) {
|
||||
if (typeof window === "undefined") return "";
|
||||
const host = scope || window.document.body || window.document.documentElement;
|
||||
if (!host) return "";
|
||||
const probe = window.document.createElement("span");
|
||||
probe.style.cssText = `position:absolute;left:-9999px;top:-9999px;background-color:${bannerNoticeSurfaceCSS(noticeType)}`;
|
||||
host.appendChild(probe);
|
||||
const resolved = window.getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
return resolved;
|
||||
}
|
||||
217
web/src/lib/announcements/banner-title-rich-text.ts
Normal file
217
web/src/lib/announcements/banner-title-rich-text.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 通知标题富文本编辑与通知条渲染之间的桥接层。
|
||||
*
|
||||
* 编辑器用 Tiptap 提供选区能力,但存储与渲染都只认 `BannerTitleRun[]`:
|
||||
* - 只保留一个 paragraph 与行内 `textStyle` 标记,标题永远是单段、单行语义。
|
||||
* - 字号 / 字体只接受白名单档位(其余值在转换时丢弃),因此不会把任意 CSS 写进通知条。
|
||||
*/
|
||||
|
||||
import { Extension, type Editor, type JSONContent } from "@tiptap/core";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
|
||||
import {
|
||||
BANNER_TITLE_FONT_FAMILIES,
|
||||
BANNER_TITLE_FONT_SIZES,
|
||||
BANNER_TITLE_FONT_WEIGHTS,
|
||||
bannerTitleFontFamilyCSS,
|
||||
bannerTitleFontFamilyKey,
|
||||
normalizeBannerTitleHex,
|
||||
normalizeBannerTitleRuns,
|
||||
type BannerTitleFontFamilyKey,
|
||||
type BannerTitleRun,
|
||||
} from "./banner-title";
|
||||
|
||||
export const BANNER_TITLE_TEXT_STYLE = "textStyle";
|
||||
|
||||
/**
|
||||
* 字重没有官方扩展,按 FontSize 的写法把 fontWeight 挂到 textStyle 标记上。
|
||||
* 只声明属性,不下发命令:样式统一走 applyBannerTitleStyle,保证每个文本节点只留一个标记。
|
||||
*/
|
||||
export const BannerTitleFontWeight = Extension.create<{ types: string[] }>({
|
||||
name: "bannerTitleFontWeight",
|
||||
addOptions() {
|
||||
return { types: [BANNER_TITLE_TEXT_STYLE] };
|
||||
},
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
fontWeight: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.style.fontWeight || null,
|
||||
renderHTML: (attributes) => (attributes.fontWeight ? { style: `font-weight: ${attributes.fontWeight}` } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export function createBannerTitleExtensions(placeholder?: string) {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
bold: false,
|
||||
blockquote: false,
|
||||
bulletList: false,
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
dropcursor: false,
|
||||
gapcursor: false,
|
||||
hardBreak: false,
|
||||
heading: false,
|
||||
horizontalRule: false,
|
||||
italic: false,
|
||||
link: false,
|
||||
listItem: false,
|
||||
listKeymap: false,
|
||||
orderedList: false,
|
||||
strike: false,
|
||||
trailingNode: false,
|
||||
underline: false,
|
||||
}),
|
||||
TextStyle,
|
||||
FontSize,
|
||||
FontFamily,
|
||||
Color,
|
||||
BannerTitleFontWeight,
|
||||
CharacterCount,
|
||||
...(placeholder ? [Placeholder.configure({ placeholder })] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export type BannerTitleStylePatch = {
|
||||
fontSize?: number | null;
|
||||
fontWeight?: number | null;
|
||||
fontFamily?: BannerTitleFontFamilyKey | null;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type BannerTitleSelectionStyle = {
|
||||
fontSize?: number;
|
||||
fontWeight?: number;
|
||||
fontFamily?: BannerTitleFontFamilyKey;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export function bannerTitleRunsToDoc(runs: BannerTitleRun[]): JSONContent {
|
||||
const content = [];
|
||||
for (const run of normalizeBannerTitleRuns(runs)) {
|
||||
const attrs = bannerTitleMarkAttrs(run);
|
||||
const node: JSONContent = { type: "text", text: run.text };
|
||||
if (Object.values(attrs).some((value) => value)) {
|
||||
node.marks = [{ type: BANNER_TITLE_TEXT_STYLE, attrs }];
|
||||
}
|
||||
content.push(node);
|
||||
}
|
||||
return { type: "doc", content: [{ type: "paragraph", content }] };
|
||||
}
|
||||
|
||||
export function bannerDocToRuns(doc?: JSONContent | null): BannerTitleRun[] {
|
||||
const paragraph = (doc?.content || []).find((node) => node.type === "paragraph");
|
||||
const runs: BannerTitleRun[] = [];
|
||||
for (const node of paragraph?.content || []) {
|
||||
if (node.type !== "text" || !node.text) continue;
|
||||
const attrs = textStyleAttributes(node);
|
||||
const run: BannerTitleRun = { text: node.text };
|
||||
const size = Number(attrs.fontSize ? String(attrs.fontSize).replace(/[^\d]/g, "") : "");
|
||||
if (BANNER_TITLE_FONT_SIZES.includes(size)) run.fontSize = size;
|
||||
const weight = Number(String(attrs.fontWeight || "").replace(/[^\d]/g, ""));
|
||||
if (BANNER_TITLE_FONT_WEIGHTS.some((item) => item.value === weight)) run.fontWeight = weight;
|
||||
const family = bannerTitleFontFamilyKey(typeof attrs.fontFamily === "string" ? attrs.fontFamily : "");
|
||||
if (family && family !== "sans") run.fontFamily = family;
|
||||
const color = normalizeBannerTitleHex(typeof attrs.color === "string" ? attrs.color : "");
|
||||
if (color) run.color = color;
|
||||
runs.push(run);
|
||||
}
|
||||
return normalizeBannerTitleRuns(runs);
|
||||
}
|
||||
|
||||
export function bannerTitleSelectionStyle(editor: Editor): BannerTitleSelectionStyle {
|
||||
const attrs = editor.getAttributes(BANNER_TITLE_TEXT_STYLE) as Record<string, unknown>;
|
||||
const selection: BannerTitleSelectionStyle = {};
|
||||
const size = Number(attrs.fontSize ? String(attrs.fontSize).replace(/[^\d]/g, "") : "");
|
||||
if (BANNER_TITLE_FONT_SIZES.includes(size)) selection.fontSize = size;
|
||||
const weight = Number(String(attrs.fontWeight || "").replace(/[^\d]/g, ""));
|
||||
if (BANNER_TITLE_FONT_WEIGHTS.some((item) => item.value === weight)) selection.fontWeight = weight;
|
||||
const family = bannerTitleFontFamilyKey(typeof attrs.fontFamily === "string" ? attrs.fontFamily : "");
|
||||
if (family && family !== "sans") selection.fontFamily = family;
|
||||
const color = normalizeBannerTitleHex(typeof attrs.color === "string" ? attrs.color : "");
|
||||
if (color) selection.color = color;
|
||||
return selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把样式片段应用到当前选区。
|
||||
*
|
||||
* 没有用 Tiptap 的 `setMark`:同一文本节点上它会叠加多个 textStyle 标记(属性不合并),
|
||||
* 反复调整会留下旧值。这里按节点先移除旧标记再写入合并后的属性,保证每个节点只有一个标记。
|
||||
*/
|
||||
export function applyBannerTitleStyle(editor: Editor, patch: BannerTitleStylePatch) {
|
||||
const textStyle = editor.state.schema.marks[BANNER_TITLE_TEXT_STYLE];
|
||||
if (!textStyle) return;
|
||||
const markPatch: Record<string, string | null> = {};
|
||||
if (patch.fontSize !== undefined) markPatch.fontSize = patch.fontSize ? `${patch.fontSize}px` : null;
|
||||
if (patch.fontWeight !== undefined) markPatch.fontWeight = patch.fontWeight ? String(patch.fontWeight) : null;
|
||||
if (patch.fontFamily !== undefined) markPatch.fontFamily = patch.fontFamily ? bannerTitleFontFamilyCSS(patch.fontFamily) || null : null;
|
||||
if (patch.color !== undefined) markPatch.color = patch.color ? normalizeBannerTitleHex(patch.color) || null : null;
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
const transaction = editor.state.tr;
|
||||
if (empty) {
|
||||
transaction.addStoredMark(textStyle.create({ ...(editor.getAttributes(BANNER_TITLE_TEXT_STYLE) as Record<string, unknown>), ...markPatch }));
|
||||
} else {
|
||||
editor.state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (!node.isText) return;
|
||||
const start = Math.max(pos, from);
|
||||
const end = Math.min(pos + node.nodeSize, to);
|
||||
if (start >= end) return;
|
||||
const attrs = textStyleAttributes(node);
|
||||
transaction.removeMark(start, end, textStyle);
|
||||
transaction.addMark(start, end, textStyle.create({ ...attrs, ...markPatch }));
|
||||
});
|
||||
}
|
||||
editor.view.dispatch(transaction);
|
||||
}
|
||||
|
||||
export function clearBannerTitleStyle(editor: Editor) {
|
||||
const textStyle = editor.state.schema.marks[BANNER_TITLE_TEXT_STYLE];
|
||||
if (!textStyle) return;
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
const transaction = editor.state.tr;
|
||||
if (empty) {
|
||||
transaction.removeStoredMark(textStyle);
|
||||
} else {
|
||||
transaction.removeMark(from, to, textStyle);
|
||||
}
|
||||
editor.view.dispatch(transaction);
|
||||
}
|
||||
|
||||
function bannerTitleMarkAttrs(run: BannerTitleRun) {
|
||||
return {
|
||||
fontSize: run.fontSize ? `${run.fontSize}px` : null,
|
||||
fontWeight: run.fontWeight ? String(run.fontWeight) : null,
|
||||
fontFamily: bannerTitleFontFamilyCSS(run.fontFamily) || null,
|
||||
color: run.color || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一文本节点可能带有多个 textStyle 标记,按出现顺序合并,后写入的取最终值。
|
||||
* 标记来自两处:编辑器里的 ProseMirror 节点(`mark.type` 是 MarkType 对象)与 JSON 文档(`mark.type` 是字符串)。
|
||||
*/
|
||||
function textStyleAttributes(node: { marks?: readonly { type?: string | { name?: string }; attrs?: Record<string, unknown> }[] }) {
|
||||
const attrs: Record<string, unknown> = {};
|
||||
for (const mark of node.marks || []) {
|
||||
const name = typeof mark.type === "string" ? mark.type : mark.type?.name;
|
||||
if (name !== BANNER_TITLE_TEXT_STYLE) continue;
|
||||
Object.assign(attrs, mark.attrs || {});
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
export { BANNER_TITLE_FONT_FAMILIES };
|
||||
167
web/src/lib/announcements/banner-title.ts
Normal file
167
web/src/lib/announcements/banner-title.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 首页常驻通知条的标题样式模型。
|
||||
*
|
||||
* 标题按「样式分段」存储:每段是一串连续文本加可选的字号 / 字重 / 字体 / 颜色覆盖。
|
||||
* 后端只接受这里的白名单取值(字号 10-20、字重 400/500/600/700、字体 sans|serif|mono、颜色 #RRGGBB),
|
||||
* 所以通知条渲染时不会出现任意 CSS 值。
|
||||
*/
|
||||
|
||||
export type BannerTitleFontFamilyKey = "sans" | "serif" | "mono";
|
||||
|
||||
export type BannerTitleRun = {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: number;
|
||||
fontFamily?: BannerTitleFontFamilyKey;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export const BANNER_TITLE_MAX_CHARS = 120;
|
||||
|
||||
export const BANNER_TITLE_MIN_FONT_SIZE = 10;
|
||||
export const BANNER_TITLE_MAX_FONT_SIZE = 20;
|
||||
|
||||
/** 通知条标题默认字号,与前台 `text-xs` 一致。 */
|
||||
export const BANNER_TITLE_DEFAULT_FONT_SIZE = 12;
|
||||
/** 通知条标题默认字重,与前台 `font-semibold` 一致。 */
|
||||
export const BANNER_TITLE_DEFAULT_FONT_WEIGHT = 600;
|
||||
|
||||
export const BANNER_TITLE_FONT_SIZES = [10, 11, 12, 13, 14, 15, 16, 18, 20];
|
||||
|
||||
export const BANNER_TITLE_FONT_WEIGHTS = [
|
||||
{ value: 400, label: "常规" },
|
||||
{ value: 500, label: "中等" },
|
||||
{ value: 600, label: "半粗" },
|
||||
{ value: 700, label: "粗体" },
|
||||
];
|
||||
|
||||
export const BANNER_TITLE_FONT_FAMILIES: { key: BannerTitleFontFamilyKey; label: string; css: string }[] = [
|
||||
{ key: "sans", label: "默认", css: "" },
|
||||
{ key: "serif", label: "衬线", css: 'Georgia, "Songti SC", "SimSun", serif' },
|
||||
{ key: "mono", label: "等宽", css: "var(--font-mono)" },
|
||||
];
|
||||
|
||||
export function bannerTitleFontFamilyCSS(key?: BannerTitleFontFamilyKey) {
|
||||
if (!key) return "";
|
||||
return BANNER_TITLE_FONT_FAMILIES.find((item) => item.key === key)?.css || "";
|
||||
}
|
||||
|
||||
/** 由 CSS 值反查字体档位;未知字体返回空,调用方按未设置处理。 */
|
||||
export function bannerTitleFontFamilyKey(css?: string) {
|
||||
const value = (css || "").trim();
|
||||
if (!value) return "" as const;
|
||||
return BANNER_TITLE_FONT_FAMILIES.find((item) => item.css === value)?.key || ("" as const);
|
||||
}
|
||||
|
||||
/** 清洗单个分段:非法取值一律丢弃,不调用方兜底。 */
|
||||
export function normalizeBannerTitleRun(run: BannerTitleRun): BannerTitleRun | null {
|
||||
const text = typeof run.text === "string" ? run.text : "";
|
||||
if (!text) return null;
|
||||
const normalized: BannerTitleRun = { text };
|
||||
const size = Number.isInteger(run.fontSize) ? (run.fontSize as number) : undefined;
|
||||
if (size && size >= BANNER_TITLE_MIN_FONT_SIZE && size <= BANNER_TITLE_MAX_FONT_SIZE) normalized.fontSize = size;
|
||||
const weight = BANNER_TITLE_FONT_WEIGHTS.find((item) => item.value === run.fontWeight)?.value;
|
||||
if (weight) normalized.fontWeight = weight;
|
||||
const family = BANNER_TITLE_FONT_FAMILIES.find((item) => item.key === run.fontFamily)?.key;
|
||||
if (family && family !== "sans") normalized.fontFamily = family;
|
||||
const color = normalizeBannerTitleHex(run.color);
|
||||
if (color) normalized.color = color;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** 清洗整段标题并合并相邻同样式分段,与后端 normalizeBannerTitleRuns 保持同一套规则。 */
|
||||
export function normalizeBannerTitleRuns(runs?: BannerTitleRun[] | null): BannerTitleRun[] {
|
||||
const normalized: BannerTitleRun[] = [];
|
||||
for (const run of runs || []) {
|
||||
const next = normalizeBannerTitleRun(run);
|
||||
if (!next) continue;
|
||||
const previous = normalized[normalized.length - 1];
|
||||
if (previous && sameBannerTitleRunStyle(previous, next)) {
|
||||
previous.text += next.text;
|
||||
continue;
|
||||
}
|
||||
normalized.push(next);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function sameBannerTitleRunStyle(left: BannerTitleRun, right: BannerTitleRun) {
|
||||
return left.fontSize === right.fontSize && left.fontWeight === right.fontWeight && left.fontFamily === right.fontFamily && left.color === right.color;
|
||||
}
|
||||
|
||||
export function bannerTitlePlainText(runs?: BannerTitleRun[] | null) {
|
||||
return (runs || []).map((run) => run.text).join("");
|
||||
}
|
||||
|
||||
/** 按码点计数,与后端 utf8.RuneCountInString 一致(emoji 等代理对按 1 个字符算)。 */
|
||||
export function bannerTitleCharCount(runs?: BannerTitleRun[] | null) {
|
||||
return Array.from(bannerTitlePlainText(runs)).length;
|
||||
}
|
||||
|
||||
export function normalizeBannerTitleHex(value?: string) {
|
||||
const raw = (value || "").trim().toUpperCase();
|
||||
if (!raw) return "";
|
||||
const hex = raw.startsWith("#") ? raw.slice(1) : raw;
|
||||
if (/^[0-9A-F]{3}$/.test(hex)) {
|
||||
return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`;
|
||||
}
|
||||
return /^[0-9A-F]{6}$/.test(hex) ? `#${hex}` : "";
|
||||
}
|
||||
|
||||
/** 分段对应的内联样式;未设置的维度交给外层类名(通知条默认白色文字、12px)。 */
|
||||
export function bannerTitleRunStyle(run: BannerTitleRun) {
|
||||
const style: Record<string, string | number> = {};
|
||||
if (run.fontSize) {
|
||||
style.fontSize = `${run.fontSize}px`;
|
||||
}
|
||||
if (run.fontWeight) style.fontWeight = run.fontWeight;
|
||||
const family = bannerTitleFontFamilyCSS(run.fontFamily);
|
||||
if (family) style.fontFamily = family;
|
||||
if (run.color) style.color = run.color;
|
||||
return style;
|
||||
}
|
||||
|
||||
/** 解析 #RGB / #RRGGBB / rgb() 为 0-255 三元组;无法解析时返回 null。 */
|
||||
export function parseBannerTitleColor(value?: string): [number, number, number] | null {
|
||||
const raw = (value || "").trim();
|
||||
if (!raw) return null;
|
||||
const hex = normalizeBannerTitleHex(raw);
|
||||
if (hex) {
|
||||
return [Number.parseInt(hex.slice(1, 3), 16), Number.parseInt(hex.slice(3, 5), 16), Number.parseInt(hex.slice(5, 7), 16)];
|
||||
}
|
||||
const rgb = raw.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i);
|
||||
if (!rgb) return null;
|
||||
return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
||||
}
|
||||
|
||||
/** WCAG 对比度;任一颜色无法解析时返回 null。 */
|
||||
export function bannerTitleContrastRatio(foreground?: string, background?: string) {
|
||||
const front = parseBannerTitleColor(foreground);
|
||||
const back = parseBannerTitleColor(background);
|
||||
if (!front || !back) return null;
|
||||
const frontLuminance = relativeLuminance(front);
|
||||
const backLuminance = relativeLuminance(back);
|
||||
const lighter = Math.max(frontLuminance, backLuminance);
|
||||
const darker = Math.min(frontLuminance, backLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function relativeLuminance([red, green, blue]: [number, number, number]) {
|
||||
const channels = [red, green, blue].map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
/** 返回该主题下对比度不足 4.5:1 的文字颜色(去重),用于编辑时的可读性提示。 */
|
||||
export function lowContrastBannerTitleColors(runs: BannerTitleRun[] | null | undefined, background: string) {
|
||||
if (!background) return [];
|
||||
const failing = new Set<string>();
|
||||
for (const run of runs || []) {
|
||||
if (!run.color) continue;
|
||||
const ratio = bannerTitleContrastRatio(run.color, background);
|
||||
if (ratio !== null && ratio < 4.5) failing.add(run.color);
|
||||
}
|
||||
return Array.from(failing);
|
||||
}
|
||||
Reference in New Issue
Block a user