From 0bc7f7ad70e7ea8e16a461e578fa3aabef15a18e Mon Sep 17 00:00:00 2001 From: shellRaining Date: Tue, 4 Aug 2026 23:58:59 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(markdown-editor):=20=E8=A1=A8=E6=83=85?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=94=AF=E6=8C=81=E6=8F=92=E5=85=A5=E8=AE=BA?= =?UTF-8?q?=E5=9D=9B=E8=A1=A8=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - topBar 新增表情按钮,弹出分类表情面板(CC98 / AC娘 / 麻将脸 / 贴吧 / 雀魂 / 经典) - 点击表情在光标处插入 Markdown 图片,与 UBB→Markdown 迁移输出一致 - 表情枚举复用 packages/ubb 的 resolveUbbEmotionTag 规则并校验 - 所见即所得:插入面板当前主题版本(暗色插入 ac-dark),渲染端双向适配 ac/ac-dark - 面板为右上角弹出的小框,带动画;格子尺寸沿用原 UBB 编辑器习惯 - 新增 emoji-data 与渲染端单测 --- .gitignore | 3 + .../website/src/components/MarkdownEditor.vue | 62 +++++ .../components/markdown-editor/EmojiPanel.vue | 229 ++++++++++++++++++ .../components/markdown-editor/emoji-data.ts | 126 ++++++++++ .../markdown/MarkdownRenderer.vue | 8 +- .../markdown/renderMarkdownNode.ts | 32 ++- .../tests/markdown-editor-emoji.test.ts | 91 +++++++ .../rich-content/content-renderer.test.ts | 26 ++ docs/exec-plans/README.md | 4 +- .../2026-08-04-markdown-editor-emoji.md | 103 ++++++++ 10 files changed, 679 insertions(+), 5 deletions(-) create mode 100644 apps/website/src/components/markdown-editor/EmojiPanel.vue create mode 100644 apps/website/src/components/markdown-editor/emoji-data.ts create mode 100644 apps/website/tests/markdown-editor-emoji.test.ts create mode 100644 docs/exec-plans/active/2026-08-04-markdown-editor-emoji.md 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..95b7ea4 100644 --- a/apps/website/src/components/MarkdownEditor.vue +++ b/apps/website/src/components/MarkdownEditor.vue @@ -17,10 +17,18 @@ import { clipboard } from "@milkdown/kit/plugin/clipboard"; import { uploadConfig } from "@milkdown/kit/plugin/upload"; import { insertImageCommand, insertImageInputRule } from "@milkdown/kit/preset/commonmark"; import { callCommand, replaceAll } from "@milkdown/kit/utils"; +import { useEventListener } from "@vueuse/core"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { normalizeApiError } from "../lib/api-error"; import { createLogger } from "../lib/logger"; +import { useThemeStore } from "../stores/theme"; import { appendMarkdownBlock, createAttachmentMarkdown } from "./markdown-editor"; +import EmojiPanel from "./markdown-editor/EmojiPanel.vue"; +import { + emojiButtonIcon, + resolveEmotionDisplaySrc, + type EditorEmotion, +} from "./markdown-editor/emoji-data"; import UiButton from "./ui/Button.vue"; const props = withDefaults( @@ -46,6 +54,7 @@ const emit = defineEmits<{ }>(); const logger = createLogger("markdown-editor"); +const theme = useThemeStore(); const editorRoot = ref(null); const imageInput = ref(null); const attachmentInput = ref(null); @@ -56,6 +65,10 @@ const imageUploading = ref(false); const attachmentUploading = ref(false); const uploadError = ref(""); const currentMarkdown = ref(props.modelValue); +const emojiPanelOpen = ref(false); +const emojiPanelRoot = ref(null); +/** 本次 pointerdown 由表情按钮触发时置位,外部点击监听消费后清除。 */ +let emojiButtonPointerDown = false; const overLimit = computed(() => currentMarkdown.value.length > props.maxLength); const topBarLabels = [ "加粗", @@ -71,6 +84,7 @@ const topBarLabels = [ "公式", "引用", "分隔线", + "表情", ] as const; const selectionToolbarLabels = [ "加粗", @@ -107,6 +121,34 @@ function replaceEditorMarkdown(markdown: string) { editor.value?.action(replaceAll(markdown)); } +function insertEmoji(emotion: EditorEmotion) { + emojiPanelOpen.value = false; + if (props.disabled || !editor.value) return; + const src = resolveEmotionDisplaySrc(emotion, theme.effectiveMode === "dark"); + editor.value.action((ctx) => { + const view = ctx.get(editorViewCtx); + const node = view.state.schema.nodes.image?.createAndFill({ + src, + alt: emotion.alt, + }); + if (!node) return; + view.focus(); + view.dispatch(view.state.tr.replaceSelectionWith(node)); + }); +} + +useEventListener(window, "pointerdown", (event) => { + if (emojiButtonPointerDown) { + emojiButtonPointerDown = false; + return; + } + if (!emojiPanelOpen.value) return; + const target = event.target as Node; + if (emojiPanelRoot.value && !emojiPanelRoot.value.contains(target)) { + emojiPanelOpen.value = false; + } +}); + async function uploadImageFiles(files: File[]): Promise { uploadError.value = ""; if (props.disabled) return []; @@ -229,6 +271,17 @@ onMounted(async () => { { label: "标题 2", level: 2 }, { label: "标题 3", level: 3 }, ], + buildTopBar: (builder) => { + builder.addGroup("emoji", "表情").addItem("emoji", { + icon: emojiButtonIcon, + active: () => false, + onRun: () => { + if (props.disabled) return; + emojiButtonPointerDown = true; + emojiPanelOpen.value = !emojiPanelOpen.value; + }, + }); + }, }); instance.setReadonly(props.disabled); @@ -299,6 +352,7 @@ watch( watch( () => props.disabled, (disabled) => { + if (disabled) emojiPanelOpen.value = false; crepe.value?.setReadonly(disabled); }, ); @@ -318,6 +372,10 @@ onBeforeUnmount(() => {

编辑器加载中…

+
+ +
+
{ 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..014bc88 --- /dev/null +++ b/apps/website/src/components/markdown-editor/emoji-data.ts @@ -0,0 +1,126 @@ +import { resolveUbbEmotionTag } from "@cc98/ubb"; +import type { UbbEmotionDescriptor } from "@cc98/ubb"; + +/** + * Markdown 编辑器表情面板使用的表情条目。 + * 数据与 packages/ubb 的 resolveUbbEmotionTag 保持一致,src 即官方表情资源地址。 + */ +export interface EditorEmotion { + family: UbbEmotionDescriptor["family"]; + code: string; + src: string; + /** 展示名与图片 alt,命名对齐 packages/ubb/src/to-markdown.ts 的 emotionMarkdownAlt。 */ + alt: string; +} + +export interface EmotionGroup { + key: string; + label: string; + emotions: EditorEmotion[]; +} + +const FAMILY_LABELS: Record = { + em: "经典表情", + ac: "AC娘", + ms: "雀魂", + cc98: "CC98", + tb: "贴吧", + "mahjong-animal": "麻将脸 动物", + "mahjong-cartoon": "麻将脸 卡通", + "mahjong-face": "麻将脸", +}; + +function pad2(value: number): string { + return value < 10 ? `0${value}` : `${value}`; +} + +function pad3(value: number): string { + return value < 10 ? `00${value}` : value < 100 ? `0${value}` : `${value}`; +} + +/** 由 UBB 表情标签构建表情条目,解析失败返回 null。 */ +function emotionFromTag(tag: string): EditorEmotion | null { + const descriptor = resolveUbbEmotionTag(tag); + if (!descriptor) return null; + return { + family: descriptor.family, + code: descriptor.code, + src: descriptor.src, + alt: `${FAMILY_LABELS[descriptor.family]} ${descriptor.code}`, + }; +} + +function range(start: number, end: number): number[] { + return Array.from({ length: end - start + 1 }, (_, index) => start + index); +} + +function collect(tags: string[]): EditorEmotion[] { + return tags.flatMap((tag) => { + const emotion = emotionFromTag(tag); + return emotion ? [emotion] : []; + }); +} + +function buildGroup(key: string, label: string, tags: string[]): EmotionGroup { + return { key, label, emotions: collect(tags) }; +} + +/** 表情按钮图标,取自 heroicons:face-smile-solid,风格与 Crepe 内置图标一致。 */ +export const emojiButtonIcon = ``; + +/** 表情插入到正文后的 Markdown 形态,与 packages/ubb 的 UBB→Markdown 输出一致。 */ +export function emotionToMarkdown(emotion: EditorEmotion): string { + return `![${emotion.alt}](${emotion.src})`; +} + +/** + * 按当前主题解析 AC 娘资源的显示/插入地址:暗色模式使用 ac-dark 目录。 + * 面板展示与编辑器插入共用,保证所见即所得;插入内容即当前主题版本。 + */ +export function resolveEmotionDisplaySrc(emotion: EditorEmotion, isDark: boolean): string { + if (emotion.family === "ac" && isDark) { + return emotion.src.replace("/ac/", "/ac-dark/"); + } + return emotion.src; +} + +export const emotionGroups: EmotionGroup[] = [ + buildGroup( + "cc98", + "CC98", + range(1, 37).map((value) => `cc98${pad2(value)}`), + ), + buildGroup("ac", "AC娘", [ + ...range(1, 54).map((value) => `ac${pad2(value)}`), + ...range(1001, 1040).map((value) => `ac${value}`), + ...range(2001, 2055).map((value) => `ac${value}`), + ]), + buildGroup("mahjong", "麻将脸", [ + ...range(1, 16).map((value) => `a:${pad3(value)}`), + ...[3, 18, 19, 46, 49, 59, 96, 134, 189, 217].map((value) => `c:${pad3(value)}`), + ...range(1, 208).map((value) => `f:${pad3(value)}`), + ]), + buildGroup( + "tb", + "贴吧", + range(1, 33).map((value) => `tb${pad2(value)}`), + ), + buildGroup( + "ms", + "雀魂", + range(1, 54).map((value) => `ms${pad2(value)}`), + ), + buildGroup( + "em", + "经典", + range(0, 91).map((value) => `em${pad2(value)}`), + ), +]; + +/** 麻将脸在面板内按动物/卡通/脸子分区展示时使用。 */ +const mahjongEmotions = emotionGroups.find((group) => group.key === "mahjong")!.emotions; +export const mahjongSubgroups: { label: string; emotions: EditorEmotion[] }[] = [ + { label: "动物", emotions: mahjongEmotions.filter((e) => e.family === "mahjong-animal") }, + { label: "卡通", emotions: mahjongEmotions.filter((e) => e.family === "mahjong-cartoon") }, + { label: "脸", emotions: mahjongEmotions.filter((e) => e.family === "mahjong-face") }, +]; 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("分组与数量符合 resolveUbbEmotionTag 的合法范围", () => { + const counts = Object.fromEntries( + emotionGroups.map((group) => [group.key, group.emotions.length]), + ); + expect(counts).toEqual({ + cc98: 37, + ac: 149, + mahjong: 234, + tb: 33, + ms: 54, + em: 92, + }); + expect(mahjongSubgroups.map((group) => group.emotions.length)).toEqual([16, 10, 208]); + }); + + test("每个表情都能被 resolveUbbEmotionTag 解析且资源地址一致", () => { + for (const group of emotionGroups) { + for (const emotion of group.emotions) { + const descriptor = resolveUbbEmotionTag(emotionToTag(emotion)); + expect(descriptor, `${emotionToTag(emotion)} 应能被解析`).not.toBeNull(); + expect(descriptor!.src).toBe(emotion.src); + } + } + }); + + test("展示名与 UBB→Markdown 迁移输出一致", () => { + const byFamily = (family: EditorEmotion["family"]) => + emotionGroups + .flatMap((group) => group.emotions) + .find((emotion) => emotion.family === family)!; + + expect(byFamily("cc98").alt).toBe("CC98 01"); + expect(byFamily("ac").alt).toBe("AC娘 01"); + expect(byFamily("ms").alt).toBe("雀魂 01"); + expect(byFamily("tb").alt).toBe("贴吧 01"); + expect(byFamily("em").alt).toBe("经典表情 00"); + expect(byFamily("mahjong-animal").alt).toBe("麻将脸 动物 001"); + expect(byFamily("mahjong-cartoon").alt).toBe("麻将脸 卡通 003"); + expect(byFamily("mahjong-face").alt).toBe("麻将脸 001"); + }); + + test("插入后的 Markdown 为标准图片语法", () => { + const cc98 = emotionGroups.find((group) => group.key === "cc98")!.emotions[0]; + expect(emotionToMarkdown(cc98)).toBe( + "![CC98 01](https://www.cc98.org/static/images/CC98/CC9801.gif)", + ); + + const ac = emotionGroups.find((group) => group.key === "ac")!.emotions[0]; + expect(emotionToMarkdown(ac)).toBe("![AC娘 01](https://www.cc98.org/static/images/ac/01.png)"); + }); + + test("AC 娘显示地址按主题切换,其余表情不受影响", () => { + const ac = emotionGroups.find((group) => group.key === "ac")!.emotions[0]; + const cc98 = emotionGroups.find((group) => group.key === "cc98")!.emotions[0]; + + 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 = "![AC娘 01](https://www.cc98.org/static/images/ac/01.png)"; + 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 = "![CC98](https://www.cc98.org/static/images/CC98/CC9801.gif)"; + 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 = "![AC娘 01](https://www.cc98.org/static/images/ac-dark/01.png)"; + 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..454fbd3 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -27,7 +27,9 @@ ## 进行中的计划 -当前没有进行中的计划。 +| 执行计划 | 说明 | +| -------------------------------------------- | ------------------------------------------- | +| `active/2026-08-04-markdown-editor-emoji.md` | Markdown 编辑器表情输入,计划已评审,待实施 | ## 已完成的计划 diff --git a/docs/exec-plans/active/2026-08-04-markdown-editor-emoji.md b/docs/exec-plans/active/2026-08-04-markdown-editor-emoji.md new file mode 100644 index 0000000..01401d4 --- /dev/null +++ b/docs/exec-plans/active/2026-08-04-markdown-editor-emoji.md @@ -0,0 +1,103 @@ +# 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 图片 `![AC娘 01](https://www.cc98.org/static/images/ac/01.png)`,渲染端 `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` 校验。 +- 默认不替换 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`:按 `emotion.ts` 的合法范围生成全部表情描述符 `{ family, code, src, alt }`,alt 中文命名对齐 `to-markdown.ts` 的 `emotionMarkdownAlt`(`CC98 01`、`AC娘 01`、`麻将脸 动物 001` 等)。每个生成项用 `resolveUbbEmotionTag` 校验,保证枚举与解析器一致。 + +### 插入 + +点击表情后执行 `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 即 `![alt](src)`。 + +`modelValue` 的同步不需要额外处理:Milkdown 的 `markdownUpdated` 监听已存在,插入会自然触发 `update:modelValue`。 + +### 表情枚举范围(与 `resolveUbbEmotionTag` 一致) + +- 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`、`vp run dev` 手动走查三处入口(发帖 / 回帖 / 编辑)。编译层验证完成,浏览器交互走查待用户执行。 + +## 验证 + +- `vp check`:format + lint + typecheck 通过(新增 4 个文件 + MarkdownEditor.vue)。 +- Vitest:新增 `tests/markdown-editor-emoji.test.ts` 4 个用例通过;全仓 295 个用例(website)+ 187(ubb)+ 21(api)全部通过。 +- `vp run -r build`、`knip --include files,exports,types`、`vp run website#check:colors` 均通过。 +- dev server 冒烟:首页与 MarkdownEditor.vue / EmojiPanel.vue / emoji-data.ts 模块编译加载正常(HTTP 200)。 +- 待用户本地走查:发帖页、回帖页、编辑页打开面板、切换分类、插入表情;预览与发布后渲染为图片;点击外部关闭面板;只读态无表情按钮;按钮图标与 topBar 其他按钮风格一致。 + +## 进展与调整 + +- 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 娘仍显示白天版,属渲染端一致性问题,待确认是否纳入二期。~~ 已随所见即所得方案一并处理(插入随主题 + 渲染端双向适配),不再遗留。 + +## 决策记录 + +- 图标用 `heroicons:face-smile-solid` 提取的 SVG 常量,默认不替换 Crepe 内置图标。若走查发现风格不协调,替换全部内置图标作为后续选项。 +- 表情数据放 website 侧而非扩展 `packages/ubb`:ubb 保持只读解析器定位,website 侧枚举用 `resolveUbbEmotionTag` 校验即可保证一致。 +- 用 image 节点插入而非字符串拼接:与 Crepe 现有图片插入语义一致,避免手写 Markdown 与节点解析的差异。 From d437cdcf8a0f8e63657042ba3d881478a993b5ef Mon Sep 17 00:00:00 2001 From: shellRaining Date: Thu, 6 Aug 2026 10:00:10 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(ubb):=20=E8=A1=A8=E6=83=85?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=94=B6=E5=BD=92=20ubb=EF=BC=8C=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E8=A1=A8=E6=83=85=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 表情的合法编号区间此前在 packages/ubb 和编辑器表情面板各存一份,面板还要 把拼出的 tag 反解一遍来校验。现在区间集中在 emotion.ts 的 FAMILY_SPECS, 对外暴露 listUbbEmotions、UBB_EMOTION_FAMILIES 和 ubbEmotionDisplayName, to-markdown 的 alt 文案也改用同一函数。 - emoji-data.ts 只保留 UI 配置:tab 顺序、文案、格子边长 - 麻将脸的三类直接作为三个 tab,去掉先合并再 filter 的派生结构 - 点击外部关闭改用 VueUse 的 onClickOutside,删掉手写 pointerdown 闩锁 - 测试改为守两个契约:面板分类与 ubb 权威列表一致、AC 娘明暗资源解析 --- .../website/src/components/MarkdownEditor.vue | 44 ++-- .../components/markdown-editor/EmojiPanel.vue | 142 +++--------- .../components/markdown-editor/emoji-data.ts | 124 ++--------- .../tests/markdown-editor-emoji.test.ts | 77 +------ packages/ubb/src/emotion.ts | 205 +++++++++++------- packages/ubb/src/index.ts | 9 +- packages/ubb/src/to-markdown.ts | 25 +-- packages/ubb/tests/emotion.test.ts | 47 ++++ 8 files changed, 253 insertions(+), 420 deletions(-) create mode 100644 packages/ubb/tests/emotion.test.ts diff --git a/apps/website/src/components/MarkdownEditor.vue b/apps/website/src/components/MarkdownEditor.vue index 95b7ea4..c5fd3e4 100644 --- a/apps/website/src/components/MarkdownEditor.vue +++ b/apps/website/src/components/MarkdownEditor.vue @@ -1,4 +1,5 @@ @@ -160,39 +116,19 @@ function select(emotion: EditorEmotion) { padding: 0.625rem; } -.emoji-panel__subgroup-title { - margin: 0.375rem 0 0.375rem; - color: var(--cc98-color-text-caption); - font-size: 0.75rem; - font-weight: 500; -} - -/* 网格尺寸沿用原 UBB 编辑器的习惯:AC 娘与 CC98/雀魂大图,麻将脸小图,其余适中。 */ +/* 格子尺寸沿用原 UBB 编辑器的习惯:AC 娘与 CC98/雀魂大图,麻将脸小图,其余适中。 */ .emoji-panel__grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(3rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(var(--emoji-cell), 1fr)); gap: 0.375rem; } -.emoji-panel__grid--ac { - grid-template-columns: repeat(auto-fill, minmax(4.75rem, 1fr)); -} - -.emoji-panel__grid--cc98, -.emoji-panel__grid--ms { - grid-template-columns: repeat(auto-fill, minmax(4rem, 1fr)); -} - -.emoji-panel__grid--mahjong { - grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr)); -} - .emoji-panel__item { display: flex; align-items: center; justify-content: center; - width: 3rem; - height: 3rem; + width: var(--emoji-cell); + height: var(--emoji-cell); padding: 0.25rem; border: 1px solid transparent; border-radius: var(--cc98-radius-sm); @@ -200,22 +136,6 @@ function select(emotion: EditorEmotion) { cursor: pointer; } -.emoji-panel__grid--ac .emoji-panel__item { - width: 4.75rem; - height: 4.125rem; -} - -.emoji-panel__grid--cc98 .emoji-panel__item, -.emoji-panel__grid--ms .emoji-panel__item { - width: 4rem; - height: 4rem; -} - -.emoji-panel__grid--mahjong .emoji-panel__item { - width: 2.5rem; - height: 2.5rem; -} - .emoji-panel__item:hover { background: var(--cc98-color-surface-subtle); border-color: var(--cc98-color-border); diff --git a/apps/website/src/components/markdown-editor/emoji-data.ts b/apps/website/src/components/markdown-editor/emoji-data.ts index 014bc88..6282890 100644 --- a/apps/website/src/components/markdown-editor/emoji-data.ts +++ b/apps/website/src/components/markdown-editor/emoji-data.ts @@ -1,126 +1,36 @@ -import { resolveUbbEmotionTag } from "@cc98/ubb"; -import type { UbbEmotionDescriptor } from "@cc98/ubb"; +import type { UbbEmotionDescriptor, UbbEmotionFamily } from "@cc98/ubb"; /** - * Markdown 编辑器表情面板使用的表情条目。 - * 数据与 packages/ubb 的 resolveUbbEmotionTag 保持一致,src 即官方表情资源地址。 + * 表情面板的分类配置。表情数据(编号、资源地址、展示名)来自 packages/ubb, + * 这里只描述 UI 呈现:tab 顺序、tab 文案、格子边长(按各分类图片实际尺寸取)。 */ -export interface EditorEmotion { - family: UbbEmotionDescriptor["family"]; - code: string; - src: string; - /** 展示名与图片 alt,命名对齐 packages/ubb/src/to-markdown.ts 的 emotionMarkdownAlt。 */ - alt: string; -} - -export interface EmotionGroup { - key: string; +interface EmotionGroup { + family: UbbEmotionFamily; label: string; - emotions: EditorEmotion[]; -} - -const FAMILY_LABELS: Record = { - em: "经典表情", - ac: "AC娘", - ms: "雀魂", - cc98: "CC98", - tb: "贴吧", - "mahjong-animal": "麻将脸 动物", - "mahjong-cartoon": "麻将脸 卡通", - "mahjong-face": "麻将脸", -}; - -function pad2(value: number): string { - return value < 10 ? `0${value}` : `${value}`; -} - -function pad3(value: number): string { - return value < 10 ? `00${value}` : value < 100 ? `0${value}` : `${value}`; -} - -/** 由 UBB 表情标签构建表情条目,解析失败返回 null。 */ -function emotionFromTag(tag: string): EditorEmotion | null { - const descriptor = resolveUbbEmotionTag(tag); - if (!descriptor) return null; - return { - family: descriptor.family, - code: descriptor.code, - src: descriptor.src, - alt: `${FAMILY_LABELS[descriptor.family]} ${descriptor.code}`, - }; + cell: string; } -function range(start: number, end: number): number[] { - return Array.from({ length: end - start + 1 }, (_, index) => start + index); -} - -function collect(tags: string[]): EditorEmotion[] { - return tags.flatMap((tag) => { - const emotion = emotionFromTag(tag); - return emotion ? [emotion] : []; - }); -} - -function buildGroup(key: string, label: string, tags: string[]): EmotionGroup { - return { key, label, emotions: collect(tags) }; -} +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 = ``; -/** 表情插入到正文后的 Markdown 形态,与 packages/ubb 的 UBB→Markdown 输出一致。 */ -export function emotionToMarkdown(emotion: EditorEmotion): string { - return `![${emotion.alt}](${emotion.src})`; -} - /** * 按当前主题解析 AC 娘资源的显示/插入地址:暗色模式使用 ac-dark 目录。 * 面板展示与编辑器插入共用,保证所见即所得;插入内容即当前主题版本。 */ -export function resolveEmotionDisplaySrc(emotion: EditorEmotion, isDark: boolean): string { +export function resolveEmotionDisplaySrc(emotion: UbbEmotionDescriptor, isDark: boolean): string { if (emotion.family === "ac" && isDark) { return emotion.src.replace("/ac/", "/ac-dark/"); } return emotion.src; } - -export const emotionGroups: EmotionGroup[] = [ - buildGroup( - "cc98", - "CC98", - range(1, 37).map((value) => `cc98${pad2(value)}`), - ), - buildGroup("ac", "AC娘", [ - ...range(1, 54).map((value) => `ac${pad2(value)}`), - ...range(1001, 1040).map((value) => `ac${value}`), - ...range(2001, 2055).map((value) => `ac${value}`), - ]), - buildGroup("mahjong", "麻将脸", [ - ...range(1, 16).map((value) => `a:${pad3(value)}`), - ...[3, 18, 19, 46, 49, 59, 96, 134, 189, 217].map((value) => `c:${pad3(value)}`), - ...range(1, 208).map((value) => `f:${pad3(value)}`), - ]), - buildGroup( - "tb", - "贴吧", - range(1, 33).map((value) => `tb${pad2(value)}`), - ), - buildGroup( - "ms", - "雀魂", - range(1, 54).map((value) => `ms${pad2(value)}`), - ), - buildGroup( - "em", - "经典", - range(0, 91).map((value) => `em${pad2(value)}`), - ), -]; - -/** 麻将脸在面板内按动物/卡通/脸子分区展示时使用。 */ -const mahjongEmotions = emotionGroups.find((group) => group.key === "mahjong")!.emotions; -export const mahjongSubgroups: { label: string; emotions: EditorEmotion[] }[] = [ - { label: "动物", emotions: mahjongEmotions.filter((e) => e.family === "mahjong-animal") }, - { label: "卡通", emotions: mahjongEmotions.filter((e) => e.family === "mahjong-cartoon") }, - { label: "脸", emotions: mahjongEmotions.filter((e) => e.family === "mahjong-face") }, -]; diff --git a/apps/website/tests/markdown-editor-emoji.test.ts b/apps/website/tests/markdown-editor-emoji.test.ts index 5e25f82..b68e097 100644 --- a/apps/website/tests/markdown-editor-emoji.test.ts +++ b/apps/website/tests/markdown-editor-emoji.test.ts @@ -1,82 +1,23 @@ -import { resolveUbbEmotionTag } from "@cc98/ubb"; +import { listUbbEmotions, resolveUbbEmotionTag, UBB_EMOTION_FAMILIES } from "@cc98/ubb"; import { describe, expect, test } from "vite-plus/test"; import { emotionGroups, - emotionToMarkdown, - mahjongSubgroups, resolveEmotionDisplaySrc, - type EditorEmotion, } from "../src/components/markdown-editor/emoji-data.ts"; -/** 把表情条目还原为对应的 UBB 标签,用于与 resolveUbbEmotionTag 对照。 */ -function emotionToTag(emotion: EditorEmotion): string { - switch (emotion.family) { - case "mahjong-animal": - return `a:${emotion.code}`; - case "mahjong-cartoon": - return `c:${emotion.code}`; - case "mahjong-face": - return `f:${emotion.code}`; - default: - return `${emotion.family}${emotion.code}`; - } -} - -describe("表情面板数据", () => { - test("分组与数量符合 resolveUbbEmotionTag 的合法范围", () => { - const counts = Object.fromEntries( - emotionGroups.map((group) => [group.key, group.emotions.length]), +describe("表情面板配置", () => { + test("tab 覆盖 ubb 的全部表情分类,且每个分类都有表情可选", () => { + expect(emotionGroups.map((group) => group.family).sort()).toEqual( + [...UBB_EMOTION_FAMILIES].sort(), ); - expect(counts).toEqual({ - cc98: 37, - ac: 149, - mahjong: 234, - tb: 33, - ms: 54, - em: 92, - }); - expect(mahjongSubgroups.map((group) => group.emotions.length)).toEqual([16, 10, 208]); - }); - - test("每个表情都能被 resolveUbbEmotionTag 解析且资源地址一致", () => { for (const group of emotionGroups) { - for (const emotion of group.emotions) { - const descriptor = resolveUbbEmotionTag(emotionToTag(emotion)); - expect(descriptor, `${emotionToTag(emotion)} 应能被解析`).not.toBeNull(); - expect(descriptor!.src).toBe(emotion.src); - } + expect(listUbbEmotions(group.family).length, `${group.label} 分类为空`).toBeGreaterThan(0); } }); - test("展示名与 UBB→Markdown 迁移输出一致", () => { - const byFamily = (family: EditorEmotion["family"]) => - emotionGroups - .flatMap((group) => group.emotions) - .find((emotion) => emotion.family === family)!; - - expect(byFamily("cc98").alt).toBe("CC98 01"); - expect(byFamily("ac").alt).toBe("AC娘 01"); - expect(byFamily("ms").alt).toBe("雀魂 01"); - expect(byFamily("tb").alt).toBe("贴吧 01"); - expect(byFamily("em").alt).toBe("经典表情 00"); - expect(byFamily("mahjong-animal").alt).toBe("麻将脸 动物 001"); - expect(byFamily("mahjong-cartoon").alt).toBe("麻将脸 卡通 003"); - expect(byFamily("mahjong-face").alt).toBe("麻将脸 001"); - }); - - test("插入后的 Markdown 为标准图片语法", () => { - const cc98 = emotionGroups.find((group) => group.key === "cc98")!.emotions[0]; - expect(emotionToMarkdown(cc98)).toBe( - "![CC98 01](https://www.cc98.org/static/images/CC98/CC9801.gif)", - ); - - const ac = emotionGroups.find((group) => group.key === "ac")!.emotions[0]; - expect(emotionToMarkdown(ac)).toBe("![AC娘 01](https://www.cc98.org/static/images/ac/01.png)"); - }); - - test("AC 娘显示地址按主题切换,其余表情不受影响", () => { - const ac = emotionGroups.find((group) => group.key === "ac")!.emotions[0]; - const cc98 = emotionGroups.find((group) => group.key === "cc98")!.emotions[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", 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 `![${alt}](${source})`; } -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(); + }); +}); From 486564524423c121078d472d3b3b192302f5fcd0 Mon Sep 17 00:00:00 2001 From: shellRaining Date: Thu, 6 Aug 2026 10:06:28 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E5=BD=92=E6=A1=A3=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E8=A1=A8=E6=83=85=E6=89=A7=E8=A1=8C=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐简化重构后的方案、验证与决策记录,撤销"表情数据放 website 侧"的 原非目标,计划移入 completed/ 并同步索引。 --- docs/exec-plans/README.md | 5 ++-- .../2026-08-04-markdown-editor-emoji.md | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) rename docs/exec-plans/{active => completed}/2026-08-04-markdown-editor-emoji.md (72%) diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md index 454fbd3..7a2830f 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -27,14 +27,13 @@ ## 进行中的计划 -| 执行计划 | 说明 | -| -------------------------------------------- | ------------------------------------------- | -| `active/2026-08-04-markdown-editor-emoji.md` | Markdown 编辑器表情输入,计划已评审,待实施 | +当前没有进行中的计划。 ## 已完成的计划 | 执行计划 | 说明 | | ------------------------------------------------------------------- | ------------------------------------------------------ | +| `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/active/2026-08-04-markdown-editor-emoji.md b/docs/exec-plans/completed/2026-08-04-markdown-editor-emoji.md similarity index 72% rename from docs/exec-plans/active/2026-08-04-markdown-editor-emoji.md rename to docs/exec-plans/completed/2026-08-04-markdown-editor-emoji.md index 01401d4..f9083ea 100644 --- a/docs/exec-plans/active/2026-08-04-markdown-editor-emoji.md +++ b/docs/exec-plans/completed/2026-08-04-markdown-editor-emoji.md @@ -20,7 +20,7 @@ Markdown 编辑器工具栏末尾增加"表情"按钮,点击弹出分类面板 ## 非目标 - 不加 Unicode emoji 面板(二期再议,另行计划)。 -- 不改 `packages/ubb` 的公共 API:表情枚举数据放 website 侧,编号规则与 `emotion.ts` 对齐并用 `resolveUbbEmotionTag` 校验。 +- ~~不改 `packages/ubb` 的公共 API:表情枚举数据放 website 侧,编号规则与 `emotion.ts` 对齐并用 `resolveUbbEmotionTag` 校验。~~ 2026-08-05 撤销,表情目录改由 ubb 导出,见决策记录。 - 默认不替换 Crepe 全部内置图标,除非视觉验收发现风格不协调(见方案,预计不需要)。 - 不涉及 UBB 内容编辑(本项目没有 UBB 编辑器)。 @@ -41,7 +41,7 @@ Crepe 的 `Icon` 组件通过 `innerHTML` 注入内联 SVG 字符串(DOMPurify 新增 `components/markdown-editor/EmojiPanel.vue`:分类 tab + 表情网格,浮层渲染在 `markdown-editor__shell` 内(topBar 下方),点击外部关闭;样式用 DESIGN.md 的 token(`--cc98-color-*`)+ UnoCSS;编辑器 `disabled` 时不渲染。 -新增 `components/markdown-editor/emoji-data.ts`:按 `emotion.ts` 的合法范围生成全部表情描述符 `{ family, code, src, alt }`,alt 中文命名对齐 `to-markdown.ts` 的 `emotionMarkdownAlt`(`CC98 01`、`AC娘 01`、`麻将脸 动物 001` 等)。每个生成项用 `resolveUbbEmotionTag` 校验,保证枚举与解析器一致。 +新增 `components/markdown-editor/emoji-data.ts`:只放面板的 UI 配置(分类 tab 顺序、tab 文案、格子边长)与笑脸图标 SVG 常量,表情条目由 `packages/ubb` 的 `listUbbEmotions(family)` 提供,展示名由 `ubbEmotionDisplayName` 提供(`CC98 01`、`AC娘 01`、`麻将脸 动物 001` 等),与 `to-markdown.ts` 输出同源。 ### 插入 @@ -52,7 +52,7 @@ Crepe 的 `Icon` 组件通过 `innerHTML` 注入内联 SVG 字符串(DOMPurify `modelValue` 的同步不需要额外处理:Milkdown 的 `markdownUpdated` 监听已存在,插入会自然触发 `update:modelValue`。 -### 表情枚举范围(与 `resolveUbbEmotionTag` 一致) +### 表情枚举范围(`packages/ubb/src/emotion.ts` 的 `FAMILY_SPECS`) - em:`em00`–`em91`(`em\d{2}`,数值 ≤ 91)。 - ac:`ac01`–`ac54`、`ac1001`–`ac1040`、`ac2001`–`ac2055`。 @@ -67,15 +67,14 @@ Crepe 的 `Icon` 组件通过 `innerHTML` 注入内联 SVG 字符串(DOMPurify 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`、`vp run dev` 手动走查三处入口(发帖 / 回帖 / 编辑)。编译层验证完成,浏览器交互走查待用户执行。 +5. 验证:`vp check`、`vp run -r test`、agent-browser 走查面板与插入。✅ +6. 2026-08-05 简化重构:表情目录收归 `packages/ubb`,面板与测试按单一事实源收敛。✅ ## 验证 -- `vp check`:format + lint + typecheck 通过(新增 4 个文件 + MarkdownEditor.vue)。 -- Vitest:新增 `tests/markdown-editor-emoji.test.ts` 4 个用例通过;全仓 295 个用例(website)+ 187(ubb)+ 21(api)全部通过。 -- `vp run -r build`、`knip --include files,exports,types`、`vp run website#check:colors` 均通过。 -- dev server 冒烟:首页与 MarkdownEditor.vue / EmojiPanel.vue / emoji-data.ts 模块编译加载正常(HTTP 200)。 -- 待用户本地走查:发帖页、回帖页、编辑页打开面板、切换分类、插入表情;预览与发布后渲染为图片;点击外部关闭面板;只读态无表情按钮;按钮图标与 topBar 其他按钮风格一致。 +- `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 插入 `![CC98 01](https://www.cc98.org/static/images/CC98/CC9801.gif)` 并自动关闭面板;按钮二次点击关闭、点击面板外关闭;暗色模式下 AC 娘格子与插入结果均为 `/ac-dark/01.png`。 +- 待用户在真实页面确认的剩余项:发帖页、回帖页、编辑页三处入口的实际观感,以及只读态无表情按钮。 ## 进展与调整 @@ -95,9 +94,15 @@ Crepe 的 `Icon` 组件通过 `innerHTML` 注入内联 SVG 字符串(DOMPurify 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 内置图标。若走查发现风格不协调,替换全部内置图标作为后续选项。 -- 表情数据放 website 侧而非扩展 `packages/ubb`:ubb 保持只读解析器定位,website 侧枚举用 `resolveUbbEmotionTag` 校验即可保证一致。 +- 2026-08-05 推翻"表情数据放 website 侧":表情编号区间是 ubb 解析器的固有知识,前端再抄一份就有两个事实源,且要靠反解自检来维持一致。改为 ubb 导出 `listUbbEmotions` / `ubbEmotionDisplayName`,website 只描述 UI 呈现。ubb 仍不含 UI 状态,只是从"只读解析"扩展为"表情目录 + 解析"。 - 用 image 节点插入而非字符串拼接:与 Crepe 现有图片插入语义一致,避免手写 Markdown 与节点解析的差异。