diff --git a/.gitignore b/.gitignore index c1d0c94..9dfabde 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ apps/docs/.vitepress/cache/ .artifacts/ auth-state.json /.obsidian +/.reasonix +/.worktrees +/reasonix.toml diff --git a/apps/website/src/components/MarkdownEditor.vue b/apps/website/src/components/MarkdownEditor.vue index 6e40d79..c5fd3e4 100644 --- a/apps/website/src/components/MarkdownEditor.vue +++ b/apps/website/src/components/MarkdownEditor.vue @@ -1,4 +1,5 @@ + + + + + + {{ group.label }} + + + + + + + + + + + + + diff --git a/apps/website/src/components/markdown-editor/emoji-data.ts b/apps/website/src/components/markdown-editor/emoji-data.ts new file mode 100644 index 0000000..6282890 --- /dev/null +++ b/apps/website/src/components/markdown-editor/emoji-data.ts @@ -0,0 +1,36 @@ +import type { UbbEmotionDescriptor, UbbEmotionFamily } from "@cc98/ubb"; + +/** + * 表情面板的分类配置。表情数据(编号、资源地址、展示名)来自 packages/ubb, + * 这里只描述 UI 呈现:tab 顺序、tab 文案、格子边长(按各分类图片实际尺寸取)。 + */ +interface EmotionGroup { + family: UbbEmotionFamily; + label: string; + cell: string; +} + +export const emotionGroups: EmotionGroup[] = [ + { family: "cc98", label: "CC98", cell: "4rem" }, + { family: "ac", label: "AC娘", cell: "4.75rem" }, + { family: "mahjong-animal", label: "麻将 动物", cell: "2.5rem" }, + { family: "mahjong-cartoon", label: "麻将 卡通", cell: "2.5rem" }, + { family: "mahjong-face", label: "麻将 脸", cell: "2.5rem" }, + { family: "tb", label: "贴吧", cell: "3rem" }, + { family: "ms", label: "雀魂", cell: "4rem" }, + { family: "em", label: "经典", cell: "3rem" }, +]; + +/** 表情按钮图标,取自 heroicons:face-smile-solid,风格与 Crepe 内置图标一致。 */ +export const emojiButtonIcon = ``; + +/** + * 按当前主题解析 AC 娘资源的显示/插入地址:暗色模式使用 ac-dark 目录。 + * 面板展示与编辑器插入共用,保证所见即所得;插入内容即当前主题版本。 + */ +export function resolveEmotionDisplaySrc(emotion: UbbEmotionDescriptor, isDark: boolean): string { + if (emotion.family === "ac" && isDark) { + return emotion.src.replace("/ac/", "/ac-dark/"); + } + return emotion.src; +} diff --git a/apps/website/src/components/rich-content/markdown/MarkdownRenderer.vue b/apps/website/src/components/rich-content/markdown/MarkdownRenderer.vue index 993304b..806dd4c 100644 --- a/apps/website/src/components/rich-content/markdown/MarkdownRenderer.vue +++ b/apps/website/src/components/rich-content/markdown/MarkdownRenderer.vue @@ -2,6 +2,7 @@ import { defineComponent, h, type PropType } from "vue"; import type { RichContentOptions } from "../types"; import UniverseRoot from "../universe/UniverseRoot.vue"; +import { useThemeStore } from "../../../stores/theme"; import { parseMarkdown } from "./remark"; import { renderMarkdownRoot } from "./renderMarkdownNode"; @@ -18,9 +19,14 @@ export default defineComponent({ }, }, setup(props) { + const theme = useThemeStore(); return () => h(UniverseRoot, { contentType: "markdown", preserveWhitespace: false }, () => - renderMarkdownRoot(parseMarkdown(props.content), props.options), + renderMarkdownRoot( + parseMarkdown(props.content), + props.options, + theme.effectiveMode === "dark", + ), ); }, }); diff --git a/apps/website/src/components/rich-content/markdown/renderMarkdownNode.ts b/apps/website/src/components/rich-content/markdown/renderMarkdownNode.ts index fe4bf13..d03ea34 100644 --- a/apps/website/src/components/rich-content/markdown/renderMarkdownNode.ts +++ b/apps/website/src/components/rich-content/markdown/renderMarkdownNode.ts @@ -22,6 +22,8 @@ interface MarkdownRenderContext { footnotes: ReadonlyMap; footnoteNumbers: ReadonlyMap; options: Readonly; + /** 当前主题是否为暗色,用于 AC 娘表情资源的主题适配。 */ + isDark: boolean; } function normalizedIdentifier(identifier: string): string { @@ -44,13 +46,27 @@ function renderLink( : h(Fragment, null, children); } +/** CC98 官方 AC 娘资源路径,按主题在 ac 与 ac-dark 目录间切换,与 UBB 渲染端 UbbEmotion 一致。 */ +const AC_EMOTION_PATH = "/static/images/ac/"; +const AC_EMOTION_DARK_PATH = "/static/images/ac-dark/"; + +function themeImageSource(source: string, isDark: boolean): string { + if (isDark && source.includes(AC_EMOTION_PATH)) { + return source.replace(AC_EMOTION_PATH, AC_EMOTION_DARK_PATH); + } + if (!isDark && source.includes(AC_EMOTION_DARK_PATH)) { + return source.replace(AC_EMOTION_DARK_PATH, AC_EMOTION_PATH); + } + return source; +} + function renderImage( source: string, alt: string | null | undefined, title: string | null | undefined, context: MarkdownRenderContext, ): VNodeChild { - const src = sanitizeImageUrl(source, context.options); + const src = sanitizeImageUrl(themeImageSource(source, context.isDark), context.options); if (!src) return alt || source; return h(UniverseImage, { src, @@ -213,7 +229,11 @@ function renderMarkdownNode(node: Nodes, context: MarkdownRenderContext): VNodeC } } -export function renderMarkdownRoot(root: Root, options: Readonly): VNodeChild { +export function renderMarkdownRoot( + root: Root, + options: Readonly, + isDark = false, +): VNodeChild { const definitions = new Map(); const footnotes = new Map(); const footnoteNumbers = new Map(); @@ -238,5 +258,11 @@ export function renderMarkdownRoot(root: Root, options: Readonly { + test("tab 覆盖 ubb 的全部表情分类,且每个分类都有表情可选", () => { + expect(emotionGroups.map((group) => group.family).sort()).toEqual( + [...UBB_EMOTION_FAMILIES].sort(), + ); + for (const group of emotionGroups) { + expect(listUbbEmotions(group.family).length, `${group.label} 分类为空`).toBeGreaterThan(0); + } + }); + + test("AC 娘显示地址随主题切换,其余分类不受影响", () => { + const ac = resolveUbbEmotionTag("ac01")!; + const cc98 = resolveUbbEmotionTag("cc9801")!; + + expect(resolveEmotionDisplaySrc(ac, false)).toBe( + "https://www.cc98.org/static/images/ac/01.png", + ); + expect(resolveEmotionDisplaySrc(ac, true)).toBe( + "https://www.cc98.org/static/images/ac-dark/01.png", + ); + expect(resolveEmotionDisplaySrc(cc98, true)).toBe( + "https://www.cc98.org/static/images/CC98/CC9801.gif", + ); + }); +}); diff --git a/apps/website/tests/rich-content/content-renderer.test.ts b/apps/website/tests/rich-content/content-renderer.test.ts index 6471e07..9c1c29c 100644 --- a/apps/website/tests/rich-content/content-renderer.test.ts +++ b/apps/website/tests/rich-content/content-renderer.test.ts @@ -146,4 +146,30 @@ describe("ContentRenderer", () => { expect(html).not.toContain('src="data:'); expect(html).toContain("<script>"); }); + + test("Markdown 中 AC 娘图片暗色模式替换为 ac-dark 资源", async () => { + const content = ""; + const light = await renderContent(content, "markdown", {}, "light"); + expect(light).toContain("/static/images/ac/01.png"); + expect(light).not.toContain("ac-dark"); + const dark = await renderContent(content, "markdown", {}, "dark"); + expect(dark).toContain("/static/images/ac-dark/01.png"); + expect(dark).not.toContain("/static/images/ac/01.png"); + }); + + test("Markdown 中非 AC 娘图片暗色模式不替换", async () => { + const content = ""; + const dark = await renderContent(content, "markdown", {}, "dark"); + expect(dark).toContain("/static/images/CC98/CC9801.gif"); + expect(dark).not.toContain("ac-dark"); + }); + + test("Markdown 中 ac-dark 资源在亮色模式还原为白天版", async () => { + const content = ""; + const light = await renderContent(content, "markdown", {}, "light"); + expect(light).toContain("/static/images/ac/01.png"); + expect(light).not.toContain("ac-dark"); + const dark = await renderContent(content, "markdown", {}, "dark"); + expect(dark).toContain("/static/images/ac-dark/01.png"); + }); }); diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md index 7483d4c..7a2830f 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -33,6 +33,7 @@ | 执行计划 | 说明 | | ------------------------------------------------------------------- | ------------------------------------------------------ | +| `completed/2026-08-04-markdown-editor-emoji.md` | 编辑器表情面板已落地,表情目录收归 `packages/ubb` | | `completed/2026-07-25-color-token-audit.md` | 全站颜色字面量迁移、自动检查和亮暗模式回归已完成 | | `completed/2026-07-25-installable-pwa.md` | 可安装 PWA、可靠首页应用外壳和访问后路由缓存已完成 | | `completed/2026-07-24-seasonal-dark-themes.md` | 春季、夏季和秋季暗色调色板与视觉回归已完成 | diff --git a/docs/exec-plans/completed/2026-08-04-markdown-editor-emoji.md b/docs/exec-plans/completed/2026-08-04-markdown-editor-emoji.md new file mode 100644 index 0000000..f9083ea --- /dev/null +++ b/docs/exec-plans/completed/2026-08-04-markdown-editor-emoji.md @@ -0,0 +1,108 @@ +# Markdown 编辑器表情输入 + +## 背景 + +`apps/website` 的 Markdown 编辑器是 `components/MarkdownEditor.vue`,基于 Milkdown Crepe 7.21.3 封装,发帖、回帖、编辑三处共用。当前工具栏只有 Crepe 内置的 topBar 按钮(标题选择 + 加粗/斜体/删除线/列表/链接/表格/代码块/公式/引用/分隔线)和选中文本时的浮动工具栏,没有表情入口;编辑器下方只有"上传图片 / 上传附件"两个按钮。 + +论坛表情的资源与 Markdown 形态已有事实源: + +- `packages/ubb/src/emotion.ts` 的 `resolveUbbEmotionTag` 定义了六类表情(em / ac / ms / cc98 / tb / mahjong)的编号合法范围与图片 URL 规则,资源根为 `https://www.cc98.org/static/images`。 +- `packages/ubb/src/to-markdown.ts` 已把 UBB 表情转成标准 Markdown 图片 ``,渲染端 `components/rich-content/markdown/MarkdownRenderer.vue`(remark)直接支持该语法。 + +已确认 Crepe 的 topBar feature 提供官方钩子 `TopBarFeatureConfig.buildTopBar(builder: GroupBuilder)`,可以在工具栏末尾追加自定义按钮,按钮由 `icon`(内联 SVG 字符串)+ `onRun(ctx)` 定义,与现有按钮同构。 + +已与用户确认的方向:表情面板只放论坛图片表情;入口放在 topBar 工具栏;按钮图标从现有图标库(`@iconify-json/fa`、`@iconify-json/heroicons`)取,风格与 Crepe 内置图标相近。 + +## 目标 + +Markdown 编辑器工具栏末尾增加"表情"按钮,点击弹出分类面板(CC98 / AC娘 / 麻将脸 / 贴吧 / 雀魂 / 经典),选择表情后在光标处插入对应 Markdown 图片,插入结果与 UBB→Markdown 迁移输出一致。 + +## 非目标 + +- 不加 Unicode emoji 面板(二期再议,另行计划)。 +- ~~不改 `packages/ubb` 的公共 API:表情枚举数据放 website 侧,编号规则与 `emotion.ts` 对齐并用 `resolveUbbEmotionTag` 校验。~~ 2026-08-05 撤销,表情目录改由 ubb 导出,见决策记录。 +- 默认不替换 Crepe 全部内置图标,除非视觉验收发现风格不协调(见方案,预计不需要)。 +- 不涉及 UBB 内容编辑(本项目没有 UBB 编辑器)。 + +## 方案 + +### 图标 + +用 `heroicons:face-smile-solid`(`@iconify-json/heroicons` 已在 devDependencies):24×24 viewBox、`fill="currentColor"` 的实心几何风格,与 Crepe 内置图标(24×24 fill path,Material 风格)一致,不需要替换其他内置图标。 + +Crepe 的 `Icon` 组件通过 `innerHTML` 注入内联 SVG 字符串(DOMPurify 过滤),不接受 iconify 类名。因此把该图标的 SVG path 提取为字符串常量(代码注释标注来源 `heroicons:face-smile-solid`),通过 `buildTopBar` 的 `icon` 字段传入。 + +### 表情按钮与面板 + +`MarkdownEditor.vue` 的 `topBar` feature 配置增加 `buildTopBar`: + +- `builder.addGroup("emoji", "表情").addItem("emoji", { icon: 笑脸SVG, active: () => false, onRun: (ctx) => 打开面板 })`。 +- `onRun` 保存 `ctx` 供面板插入时使用,并把 `emojiPanelOpen` 置为 `true`。 + +新增 `components/markdown-editor/EmojiPanel.vue`:分类 tab + 表情网格,浮层渲染在 `markdown-editor__shell` 内(topBar 下方),点击外部关闭;样式用 DESIGN.md 的 token(`--cc98-color-*`)+ UnoCSS;编辑器 `disabled` 时不渲染。 + +新增 `components/markdown-editor/emoji-data.ts`:只放面板的 UI 配置(分类 tab 顺序、tab 文案、格子边长)与笑脸图标 SVG 常量,表情条目由 `packages/ubb` 的 `listUbbEmotions(family)` 提供,展示名由 `ubbEmotionDisplayName` 提供(`CC98 01`、`AC娘 01`、`麻将脸 动物 001` 等),与 `to-markdown.ts` 输出同源。 + +### 插入 + +点击表情后执行 `editor.value.action((ctx) => …)`: + +- `const view = ctx.get(editorViewCtx)`,先 `view.focus()`(点击 topBar 按钮和面板时编辑器可能失焦,selection 保留在 state 中)。 +- 用 `schema.nodes.image.createAndFill({ src, alt })` 创建节点,`view.dispatch(view.state.tr.replaceSelectionWith(node))` 替换当前 selection(与现有 `insertImageCommand` 语义一致),输出 Markdown 即 ``。 + +`modelValue` 的同步不需要额外处理:Milkdown 的 `markdownUpdated` 监听已存在,插入会自然触发 `update:modelValue`。 + +### 表情枚举范围(`packages/ubb/src/emotion.ts` 的 `FAMILY_SPECS`) + +- em:`em00`–`em91`(`em\d{2}`,数值 ≤ 91)。 +- ac:`ac01`–`ac54`、`ac1001`–`ac1040`、`ac2001`–`ac2055`。 +- ms:`ms01`–`ms54`。 +- cc98:`cc9801`–`cc9837`(15–30、36–37 用 png,其余 gif)。 +- tb:`tb01`–`tb33`。 +- mahjong:animal `a:001`–`a:016`;cartoon 共 10 个(003/018/019/046/049/059/096/134/189/217,其中 018/049/096 为 gif);face `f:001`–`f:208`(13 个 gif 编号 004/009/056/061/062/087/115/120/137/168/169/175/206)。 + +## 实施步骤 + +1. 新增 `components/markdown-editor/emoji-data.ts`:表情枚举生成 + `resolveUbbEmotionTag` 校验 + 笑脸图标 SVG 常量。✅ +2. 新增 `components/markdown-editor/EmojiPanel.vue`:面板 UI 与交互。✅ +3. 修改 `components/MarkdownEditor.vue`:`buildTopBar` 追加按钮、面板挂载与状态、`insertEmoji` 插入函数、按钮 aria-label(沿用 `labelCrepeControls` 模式)。✅ +4. 新增单测:枚举与 `resolveUbbEmotionTag`/`to-markdown` 输出一致;插入函数的 markdown 输出。✅ +5. 验证:`vp check`、`vp run -r test`、agent-browser 走查面板与插入。✅ +6. 2026-08-05 简化重构:表情目录收归 `packages/ubb`,面板与测试按单一事实源收敛。✅ + +## 验证 + +- `vp run ready` 全绿:format + lint + typecheck + `check:colors` + `knip --include files,exports,types` + `vp run -r build` + `vp run -r test`(website 296、ubb 190、api 21 用例)。 +- 浏览器走查(临时挂载页 + agent-browser,走查后删除挂载页):表情按钮弹出面板,8 个分类 tab 与 CC98 37 格图片正常加载;点击 CC98 01 插入 `` 并自动关闭面板;按钮二次点击关闭、点击面板外关闭;暗色模式下 AC 娘格子与插入结果均为 `/ac-dark/01.png`。 +- 待用户在真实页面确认的剩余项:发帖页、回帖页、编辑页三处入口的实际观感,以及只读态无表情按钮。 + +## 进展与调整 + +- 2026-08-04:计划评审通过后开始实施,代码与单测完成。 +- 实现中根据 review 结论把"面板外部点击关闭"的监听从面板组件移到 `MarkdownEditor.vue`,用同步标记区分按钮触发与外部点击,避免依赖 Vue 响应式更新时序的隐式前提。 +- `vp install` 曾自动升级 catalog 依赖(milkdown 7.21.3→7.22.0 等),与本次任务无关,已还原 `package.json` 与 `pnpm-workspace.yaml`,环境保持 7.21.3 与 lock 一致。 +- 2026-08-04 用户走查后反馈三个美观问题,已修复并用 agent-browser 实测验证: + 1. 亮色模式激活 tab 文字与背景同色(`--cc98-blue-soft` 在亮色下等于 `--cc98-blue-strong`),文字被遮住。改为 `background: primary-soft` + `color: on-primary`(与 SigninView 的标签用法一致),亮暗模式均为白字蓝底。 + 2. 暗色模式下 AC 娘应使用暗色资源。面板渲染时按 `useThemeStore().effectiveMode` 把 `/ac/` 替换为 `/ac-dark/`,与 UBB 渲染端 `UbbEmotion.vue` 的规则一致;实测暗色下缩略图与预览窗均加载 `ac-dark` 资源。 + 3. 表情网格过密、图太小。格子放大到 2.75rem、间距 0.375rem;点击缩略图弹出方形放大预览窗(约 144px,显示图与名称),点击预览窗插入正文。 +- 2026-08-04 第二轮走查反馈后再次调整(均已实测): + 1. hover 激活 tab 时 `:hover` 特异性高于 `--active`,白字被浅灰底覆盖。补 `.emoji-panel__tab--active:hover` 保持蓝底白字,非激活 tab hover 文字加深为 `text`。 + 2. 去掉两步预览窗,恢复点击表情直接插入正文。 + 3. 候选区按原 UBB 编辑器习惯分尺寸:AC 娘 76×66px(原项目 75×65)、麻将脸 2.5rem、其余分类 3rem。 +- 2026-08-04 第三轮走查反馈后调整(均已实测): + 1. CC98、雀魂格子加大到 4rem(图片实际 130×130 / 150×150),贴吧保持 3rem(图片实际仅 30×30),经典 em 3rem(72×72)。 + 2. 面板不再铺满编辑器宽度,改为右上角表情按钮下方弹出的小框(宽 22rem、右对齐),带 160ms 淡入 + 上移 + 缩放动画(CSS animation,`transform-origin: top right`)。不用 Vue `` 组件(oxfmt 解析该模板结构失败,改用 CSS animation 达到同样弹出效果)。 +- 2026-08-04 插入策略按用户确认调整为所见即所得:编辑器插入面板当前显示版本(暗色模式插入 ac-dark、亮色插入 ac),渲染端改为双向适配(暗色 `ac→ac-dark`、亮色 `ac-dark→ac`),任何主题下显示正确;提取公共函数 `resolveEmotionDisplaySrc` 供面板与编辑器共用。 +- 遗留:~~插入正文的表情 src 为白天版(与 UBB→Markdown 迁移输出一致);Markdown 渲染端(`MarkdownRenderer.vue`)暂无 UBB 渲染那样的 ac-dark 主题替换,暗色模式下已发布的 Markdown 帖中 AC 娘仍显示白天版,属渲染端一致性问题,待确认是否纳入二期。~~ 已随所见即所得方案一并处理(插入随主题 + 渲染端双向适配),不再遗留。 +- 2026-08-05 用户 review 后做简化重构(`vp run ready` 全绿,浏览器实测通过,净 −214 行): + 1. 表情合法编号区间此前在 ubb 与面板各存一份,面板还把拼出的 tag 反解一遍来自检。现在区间集中到 `emotion.ts` 的 `FAMILY_SPECS`,导出 `listUbbEmotions`、`UBB_EMOTION_FAMILIES`、`ubbEmotionDisplayName`;`to-markdown.ts` 删掉 `emotionMarkdownAlt` switch 改调同一函数,展示名单一来源。 + 2. `emoji-data.ts` 从 124 行降到 36 行,只剩 UI 配置与图标常量。 + 3. 麻将脸的 `mahjongSubgroups` 派生结构删除,animal / cartoon / face 直接作为三个 tab,面板只剩一条渲染路径。 + 4. 手写的 pointerdown 闩锁(模块级标记 + `useEventListener(window, …)`)换成 VueUse 的 `onClickOutside(…, { ignore: [".milkdown-top-bar"] })`,按钮开关与外部关闭均实测正常。 + 5. 测试收敛为两个契约:面板分类列表与 ubb 权威列表一致(ubb 新增分类即失败)、AC 娘明暗资源解析;新增 `packages/ubb/tests/emotion.test.ts` 守 `listUbbEmotions` 与 `resolveUbbEmotionTag` 的一致性。 + +## 决策记录 + +- 图标用 `heroicons:face-smile-solid` 提取的 SVG 常量,默认不替换 Crepe 内置图标。若走查发现风格不协调,替换全部内置图标作为后续选项。 +- 2026-08-05 推翻"表情数据放 website 侧":表情编号区间是 ubb 解析器的固有知识,前端再抄一份就有两个事实源,且要靠反解自检来维持一致。改为 ubb 导出 `listUbbEmotions` / `ubbEmotionDisplayName`,website 只描述 UI 呈现。ubb 仍不含 UI 状态,只是从"只读解析"扩展为"表情目录 + 解析"。 +- 用 image 节点插入而非字符串拼接:与 Crepe 现有图片插入语义一致,避免手写 Markdown 与节点解析的差异。 diff --git a/packages/ubb/src/emotion.ts b/packages/ubb/src/emotion.ts index 62b063a..5eea6ec 100644 --- a/packages/ubb/src/emotion.ts +++ b/packages/ubb/src/emotion.ts @@ -1,101 +1,140 @@ +export type UbbEmotionFamily = + | "em" + | "ac" + | "ms" + | "cc98" + | "tb" + | "mahjong-animal" + | "mahjong-cartoon" + | "mahjong-face"; + export interface UbbEmotionDescriptor { - family: - | "em" - | "ac" - | "ms" - | "cc98" - | "tb" - | "mahjong-animal" - | "mahjong-cartoon" - | "mahjong-face"; + family: UbbEmotionFamily; code: string; src: string; alt: string; } -const CARTOON_CODES = new Set([3, 18, 19, 46, 49, 59, 96, 134, 189, 217]); +const ASSET_BASE = "https://www.cc98.org/static/images"; const CARTOON_GIFS = new Set([18, 49, 96]); const FACE_GIFS = new Set([4, 9, 56, 61, 62, 87, 115, 120, 137, 168, 169, 175, 206]); -const EMOTION_ASSET_BASE = "https://www.cc98.org/static/images"; -function descriptor( - family: UbbEmotionDescriptor["family"], - code: string, - src: string, -): UbbEmotionDescriptor { - return { family, code, src, alt: `[${family}:${code}]` }; +interface EmotionFamilySpec { + /** UBB 标签前缀:`[ac01]` 的 `ac`、`[a:001]` 的 `a:`。 */ + tagPrefix: string; + /** 中文展示名,与编号拼成 Markdown 图片 alt。 */ + label: string; + /** 全部合法编号,顺序即官方表情面板顺序。 */ + codes: string[]; + src: (code: string) => string; } -export function resolveUbbEmotionTag(tag: string): UbbEmotionDescriptor | null { - let match = tag.match(/^em(\d{2})$/); - if (match) { - const value = Number(match[1]); - return value <= 91 - ? descriptor("em", match[1], `${EMOTION_ASSET_BASE}/em/em${match[1]}.gif`) - : null; - } +function codeRange(start: number, end: number, width: number): string[] { + return Array.from({ length: end - start + 1 }, (_, index) => + String(start + index).padStart(width, "0"), + ); +} - match = tag.match(/^ac(\d{2}|\d{4})$/); - if (match) { - const value = Number(match[1]); - const valid = - (value >= 1 && value <= 54) || - (value >= 1001 && value <= 1040) || - (value >= 2001 && value <= 2055); - return valid ? descriptor("ac", match[1], `${EMOTION_ASSET_BASE}/ac/${match[1]}.png`) : null; - } +/** 表情编号范围与资源规则的唯一事实源:解析标签与枚举面板都从这里派生。 */ +const FAMILY_SPECS: Record = { + em: { + tagPrefix: "em", + label: "经典表情", + codes: codeRange(0, 91, 2), + src: (code) => `${ASSET_BASE}/em/em${code}.gif`, + }, + ac: { + tagPrefix: "ac", + label: "AC娘", + codes: [...codeRange(1, 54, 2), ...codeRange(1001, 1040, 4), ...codeRange(2001, 2055, 4)], + src: (code) => `${ASSET_BASE}/ac/${code}.png`, + }, + ms: { + tagPrefix: "ms", + label: "雀魂", + codes: codeRange(1, 54, 2), + src: (code) => `${ASSET_BASE}/ms/ms${code}.png`, + }, + cc98: { + tagPrefix: "cc98", + label: "CC98", + codes: codeRange(1, 37, 2), + src: (code) => { + const value = Number(code); + const extension = (value >= 15 && value <= 30) || value >= 36 ? "png" : "gif"; + return `${ASSET_BASE}/CC98/CC98${code}.${extension}`; + }, + }, + tb: { + tagPrefix: "tb", + label: "贴吧", + codes: codeRange(1, 33, 2), + src: (code) => `${ASSET_BASE}/tb/tb${code}.png`, + }, + "mahjong-animal": { + tagPrefix: "a:", + label: "麻将脸 动物", + codes: codeRange(1, 16, 3), + src: (code) => `${ASSET_BASE}/mahjong/animal2017/${code}.png`, + }, + "mahjong-cartoon": { + tagPrefix: "c:", + label: "麻将脸 卡通", + codes: [3, 18, 19, 46, 49, 59, 96, 134, 189, 217].map((value) => + String(value).padStart(3, "0"), + ), + src: (code) => + `${ASSET_BASE}/mahjong/carton2017/${code}.${CARTOON_GIFS.has(Number(code)) ? "gif" : "png"}`, + }, + "mahjong-face": { + tagPrefix: "f:", + label: "麻将脸", + codes: codeRange(1, 208, 3), + src: (code) => + `${ASSET_BASE}/mahjong/face2017/${code}.${FACE_GIFS.has(Number(code)) ? "gif" : "png"}`, + }, +}; - match = tag.match(/^ms(\d{2})$/); - if (match) { - const value = Number(match[1]); - return value >= 1 && value <= 54 - ? descriptor("ms", match[1], `${EMOTION_ASSET_BASE}/ms/ms${match[1]}.png`) - : null; - } +interface EmotionIndex { + byTag: Map; + byFamily: Map; +} - match = tag.match(/^cc98(\d{2})$/); - if (match) { - const value = Number(match[1]); - if (value < 1 || value > 37) return null; - const extension = (value >= 15 && value <= 30) || value >= 36 ? "png" : "gif"; - return descriptor("cc98", match[1], `${EMOTION_ASSET_BASE}/CC98/CC98${match[1]}.${extension}`); - } +let cachedIndex: EmotionIndex | null = null; - match = tag.match(/^tb(\d{2})$/); - if (match) { - const value = Number(match[1]); - return value >= 1 && value <= 33 - ? descriptor("tb", match[1], `${EMOTION_ASSET_BASE}/tb/tb${match[1]}.png`) - : null; - } - - match = tag.match(/^([acf]):(\d{3})$/); - if (!match) return null; - const type = match[1]; - const code = match[2]; - const value = Number(code); - if (type === "a" && value >= 1 && value <= 16) { - return descriptor( - "mahjong-animal", - code, - `${EMOTION_ASSET_BASE}/mahjong/animal2017/${code}.png`, - ); - } - if (type === "c" && CARTOON_CODES.has(value)) { - const extension = CARTOON_GIFS.has(value) ? "gif" : "png"; - return descriptor( - "mahjong-cartoon", - code, - `${EMOTION_ASSET_BASE}/mahjong/carton2017/${code}.${extension}`, - ); - } - if (type === "f" && value >= 1 && value <= 208) { - const extension = FACE_GIFS.has(value) ? "gif" : "png"; - return descriptor( - "mahjong-face", +/** 表情表体积固定(599 项),首次解析或枚举时构建,避免仅导入模块就分配。 */ +function emotionIndex(): EmotionIndex { + if (cachedIndex) return cachedIndex; + const byTag = new Map(); + const byFamily = new Map(); + const specs = Object.entries(FAMILY_SPECS) as [UbbEmotionFamily, EmotionFamilySpec][]; + for (const [family, spec] of specs) { + const emotions = spec.codes.map((code) => ({ + family, code, - `${EMOTION_ASSET_BASE}/mahjong/face2017/${code}.${extension}`, - ); + src: spec.src(code), + alt: `[${family}:${code}]`, + })); + byFamily.set(family, emotions); + for (const emotion of emotions) byTag.set(`${spec.tagPrefix}${emotion.code}`, emotion); } - return null; + cachedIndex = { byTag, byFamily }; + return cachedIndex; +} + +export function resolveUbbEmotionTag(tag: string): UbbEmotionDescriptor | null { + return emotionIndex().byTag.get(tag) ?? null; +} + +/** 全部表情分类,顺序即 FAMILY_SPECS 的声明顺序,供 UI 枚举 tab 时对齐。 */ +export const UBB_EMOTION_FAMILIES = Object.keys(FAMILY_SPECS) as UbbEmotionFamily[]; + +/** 按分类列出全部表情,顺序即官方面板顺序,供编辑器表情面板枚举。 */ +export function listUbbEmotions(family: UbbEmotionFamily): UbbEmotionDescriptor[] { + return emotionIndex().byFamily.get(family) ?? []; +} + +/** 表情的中文展示名(如 `AC娘 01`),UBB→Markdown 的图片 alt 与编辑器面板共用。 */ +export function ubbEmotionDisplayName(emotion: UbbEmotionDescriptor): string { + return `${FAMILY_SPECS[emotion.family].label} ${emotion.code}`; } diff --git a/packages/ubb/src/index.ts b/packages/ubb/src/index.ts index ed1269e..68766c1 100644 --- a/packages/ubb/src/index.ts +++ b/packages/ubb/src/index.ts @@ -2,8 +2,13 @@ export const UBB_VERSION = "0.0.0"; export type { UbbNode, UbbTextNode, UbbTagNode, UbbAttrs } from "./types.ts"; -export { resolveUbbEmotionTag } from "./emotion.ts"; -export type { UbbEmotionDescriptor } from "./emotion.ts"; +export { + listUbbEmotions, + resolveUbbEmotionTag, + ubbEmotionDisplayName, + UBB_EMOTION_FAMILIES, +} from "./emotion.ts"; +export type { UbbEmotionDescriptor, UbbEmotionFamily } from "./emotion.ts"; export { parseUbb } from "./parser.ts"; export { UBB_REGEX_TAG_FAMILIES, diff --git a/packages/ubb/src/to-markdown.ts b/packages/ubb/src/to-markdown.ts index 16f423a..298ed73 100644 --- a/packages/ubb/src/to-markdown.ts +++ b/packages/ubb/src/to-markdown.ts @@ -10,7 +10,7 @@ * - 表情标签:转为 CC98 官方资源的标准 Markdown 图片;权限标签剥除为空字符串。 * - math/m/noubb/md:保留原文或转义后输出。 */ -import { resolveUbbEmotionTag, type UbbEmotionDescriptor } from "./emotion.ts"; +import { resolveUbbEmotionTag, ubbEmotionDisplayName } from "./emotion.ts"; import { parseUbb } from "./parser.ts"; import { getTagMode, matchUbbRegexTagFamily } from "./tags.ts"; import type { UbbNode } from "./types.ts"; @@ -116,7 +116,7 @@ function nodeToMarkdown(node: UbbNode): string { if (tag === "table") return tableToMarkdown(children); const emotion = resolveUbbEmotionTag(tag); - if (emotion) return markdownImage(emotionMarkdownAlt(emotion), emotion.src); + if (emotion) return markdownImage(ubbEmotionDisplayName(emotion), emotion.src); // 能识别标签族但编号无效时保留原始 UBB,避免迁移时静默丢内容 if (matchUbbRegexTagFamily(tag)) return `[${tag}]`; @@ -133,27 +133,6 @@ function markdownImage(alt: string, source: string): string { return ``; } -function emotionMarkdownAlt(emotion: UbbEmotionDescriptor): string { - switch (emotion.family) { - case "em": - return `经典表情 ${emotion.code}`; - case "ac": - return `AC娘 ${emotion.code}`; - case "ms": - return `雀魂 ${emotion.code}`; - case "cc98": - return `CC98 ${emotion.code}`; - case "tb": - return `贴吧 ${emotion.code}`; - case "mahjong-animal": - return `麻将脸 动物 ${emotion.code}`; - case "mahjong-cartoon": - return `麻将脸 卡通 ${emotion.code}`; - case "mahjong-face": - return `麻将脸 ${emotion.code}`; - } -} - /** * 把 table 的子节点(tr/td/th)转成 Markdown 表格。 */ diff --git a/packages/ubb/tests/emotion.test.ts b/packages/ubb/tests/emotion.test.ts new file mode 100644 index 0000000..c4f895d --- /dev/null +++ b/packages/ubb/tests/emotion.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + listUbbEmotions, + resolveUbbEmotionTag, + UBB_EMOTION_FAMILIES, + ubbEmotionDisplayName, +} from "../src/emotion.ts"; + +describe("表情表", () => { + test("各分类的表情数量与 CC98 官方资源范围一致", () => { + const counts = Object.fromEntries( + UBB_EMOTION_FAMILIES.map((family) => [family, listUbbEmotions(family).length]), + ); + expect(counts).toEqual({ + em: 92, + ac: 149, + ms: 54, + cc98: 37, + tb: 33, + "mahjong-animal": 16, + "mahjong-cartoon": 10, + "mahjong-face": 208, + }); + }); + + test("枚举出的表情与标签解析结果是同一份数据", () => { + const ac01 = listUbbEmotions("ac")[0]; + expect(ac01).toBe(resolveUbbEmotionTag("ac01")); + expect(ac01.src).toBe("https://www.cc98.org/static/images/ac/01.png"); + expect(ubbEmotionDisplayName(ac01)).toBe("AC娘 01"); + + const cartoon = listUbbEmotions("mahjong-cartoon")[0]; + expect(cartoon).toBe(resolveUbbEmotionTag("c:003")); + expect(ubbEmotionDisplayName(cartoon)).toBe("麻将脸 卡通 003"); + }); + + test("范围外或补零形式的编号不成立", () => { + expect(resolveUbbEmotionTag("ac00")).toBeNull(); + expect(resolveUbbEmotionTag("ac55")).toBeNull(); + // AC 娘四位编号只有 1001-1040 / 2001-2055,官方没有 0001.png 这类资源 + expect(resolveUbbEmotionTag("ac0001")).toBeNull(); + expect(resolveUbbEmotionTag("em92")).toBeNull(); + expect(resolveUbbEmotionTag("c:020")).toBeNull(); + expect(resolveUbbEmotionTag("f:209")).toBeNull(); + expect(resolveUbbEmotionTag("ac1")).toBeNull(); + }); +});