feat: 论坛 emoji 表情支持

- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件
- Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本
- 聊天室与首页适配
This commit is contained in:
2026-09-28 01:28:41 +08:00
parent 2f96a36275
commit 34aee91a13
3522 changed files with 1560 additions and 251 deletions

View File

@@ -17,6 +17,7 @@ import {
Reply,
Copy,
ChevronsDown,
Smile,
} from "lucide-react";
import {
apiListChatMessages,
@@ -56,6 +57,9 @@ import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
import EmojiPicker from "@/components/EmojiPicker";
import EmojiImg from "@/components/EmojiImg";
import { collectDyEmojiSpans, dyEmojiUrl } from "@/lib/dyEmoji";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
@@ -216,16 +220,36 @@ function highlightMentions(
users: Map<string, User>,
onOpenCard: (u: User) => void
) {
const parts: React.ReactNode[] = [];
if (!content) return null;
// 合并 @提及 与表情短代码 span(互不重叠:短代码字符集内无 @)
const emojiSpans = collectDyEmojiSpans(content);
const inEmoji = new Array<boolean>(content.length).fill(false);
for (const s of emojiSpans) {
for (let j = s.start; j < s.end; j++) inEmoji[j] = true;
}
// 纯表情消息(去掉短代码后只剩空白)按贴纸大图渲染
let rest = "";
let emojiPrev = 0;
for (const s of emojiSpans) {
rest += content.slice(emojiPrev, s.start);
emojiPrev = s.end;
}
rest += content.slice(emojiPrev);
const emojiOnly = emojiSpans.length > 0 && rest.trim() === "";
type Span = { start: number; end: number; node: React.ReactNode };
const spans: Span[] = [];
const re = /@([A-Za-z0-9_-]{3,32})/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = re.exec(content)) !== null) {
if (m.index > last) parts.push(content.slice(last, m.index));
if (inEmoji[m.index]) continue;
const user = users.get(m[1].toLowerCase());
parts.push(
user ? (
spans.push({
start: m.index,
end: m.index + m[0].length,
node: user ? (
<button
key={`m-${i}`}
type="button"
@@ -240,12 +264,33 @@ function highlightMentions(
</button>
) : (
<span key={`m-${i}`}>@{m[1]}</span>
)
);
last = m.index + m[0].length;
),
});
i++;
}
if (last < content.length) parts.push(content.slice(last));
for (const s of emojiSpans) {
spans.push({
start: s.start,
end: s.end,
node: (
<EmojiImg
key={`e-${s.start}`}
k={s.key}
className={`j13-chat-emoji${emojiOnly ? " j13-chat-emoji-lg" : ""}`}
/>
),
});
}
spans.sort((a, b) => a.start - b.start);
const parts: React.ReactNode[] = [];
let prev = 0;
spans.forEach((s) => {
if (s.start > prev) parts.push(content.slice(prev, s.start));
parts.push(s.node);
prev = s.end;
});
if (prev < content.length) parts.push(content.slice(prev));
return parts;
}
@@ -306,25 +351,163 @@ function collectMentionSpans(
return spans.sort((a, b) => a.start - b.start);
}
/** 输入区镜像层:已确认的 @ 以蓝色昵称呈现(胶囊样式在发出后的气泡里) */
function renderComposerMirror(content: string, tokens: Map<string, string>) {
if (!content) return null;
const spans = collectMentionSpans(content, tokens);
const parts: React.ReactNode[] = [];
let last = 0;
spans.forEach((s, i) => {
if (s.start > last) parts.push(content.slice(last, s.start));
parts.push(
<span key={`t-${i}`} className="j13-mention-token">
@{s.key}
</span>
);
last = s.end;
/**
* 富输入区(contentEditable):编辑框内直接渲染表情图片与 @ 昵称胶囊,
* content 状态始终保存纯文本(表情为 [:key:] 短代码、@ 为 @昵称),
* 发送/提醒/草稿等既有字符串逻辑不变。DOM 为临时视图,外部内容变更时整树重建。
*/
const ZWSP = "\u200B";
/** DOM 子树 → 纯文本长度(表情短代码按 data-sc 长度计,ZWSP 不计) */
function richNodeLen(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) {
return (node as Text).data.split(ZWSP).join("").length;
}
if (node.nodeType !== Node.ELEMENT_NODE) return 0;
const el = node as HTMLElement;
if (el.tagName === "IMG" && el.dataset.sc) return el.dataset.sc.length;
if (el.tagName === "BR") return 1;
let sum = 0;
el.childNodes.forEach((c) => {
sum += richNodeLen(c);
});
if (last < content.length) parts.push(content.slice(last));
// 末尾换行在 pre-wrap 镜像里需要占位,否则高度会少一行
if (content.endsWith("\n")) parts.push("\n");
return parts;
return sum;
}
/** contentEditable DOM → content 纯文本 */
function domToText(root: HTMLElement): string {
let out = "";
root.childNodes.forEach((node) => {
out += serializeRichNode(node);
});
return out;
}
function serializeRichNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return (node as Text).data.split(ZWSP).join("");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
if (el.tagName === "IMG" && el.dataset.sc) return el.dataset.sc;
if (el.tagName === "BR") return "\n";
let out = "";
el.childNodes.forEach((c) => {
out += serializeRichNode(c);
});
return out;
}
/** 光标在 content 纯文本中的偏移(表情按短代码长度、@ 胶囊按昵称长度计) */
function richCaretOffset(root: HTMLElement, container: Node, offsetIdx: number): number {
let count = 0;
let node: Node | null = container;
while (node && node !== root) {
const parent: HTMLElement | null = node.parentElement;
if (!parent) break;
let sib: Node | null = node.previousSibling;
while (sib) {
count += richNodeLen(sib);
sib = sib.previousSibling;
}
node = parent;
}
if (container.nodeType === Node.TEXT_NODE) {
count += (container as Text).data.slice(0, offsetIdx).split(ZWSP).join("").length;
} else if (container.nodeType === Node.ELEMENT_NODE) {
const kids = (container as Element).childNodes;
for (let i = 0; i < offsetIdx && i < kids.length; i++) {
count += richNodeLen(kids[i]);
}
}
return count;
}
/** content 纯文本偏移 → DOM 位置(仅用于定位纯文本区间,如 @ 触发串) */
function richResolveOffset(
root: HTMLElement,
target: number
): { node: Node; offset: number } {
let acc = 0;
const kids = root.childNodes;
for (let i = 0; i < kids.length; i++) {
const child = kids[i];
const len = richNodeLen(child);
if (target < acc + len) {
if (child.nodeType === Node.TEXT_NODE) {
// 文本节点可能以零宽字符开头(表情后输入的文本),偏移需按可见字符折算
const t = child as Text;
const data = t.data;
let j = 0;
while (j < data.length && data[j] === ZWSP) j++;
let need = target - acc;
while (j < data.length && need > 0) {
if (data[j] !== ZWSP) need--;
j++;
}
return { node: t, offset: j };
}
return { node: root, offset: i };
}
acc += len;
}
return { node: root, offset: kids.length };
}
/** content 纯文本 → 富 DOM(表情图 + @ 昵称胶囊 + 文本) */
function rebuildRichInput(
el: HTMLElement,
text: string,
tokens: Map<string, string>
) {
el.textContent = "";
const frag = document.createDocumentFragment();
const pieces = [
...collectMentionSpans(text, tokens).map((s) => ({ ...s, kind: "mention" as const })),
...collectDyEmojiSpans(text).map((s) => ({ ...s, kind: "emoji" as const })),
].sort((a, b) => a.start - b.start);
let last = 0;
for (const p of pieces) {
if (p.start < last) continue; // 与 @ 胶囊重叠的表情跳过
if (p.start > last) {
frag.appendChild(document.createTextNode(text.slice(last, p.start)));
}
if (p.kind === "mention") {
const span = document.createElement("span");
span.className = "j13-mention-token";
span.contentEditable = "false";
span.textContent = `@${p.key}`;
frag.appendChild(span);
} else {
frag.appendChild(makeEmojiNode(p.key));
}
last = p.end;
}
if (last < text.length) {
frag.appendChild(document.createTextNode(text.slice(last)));
}
el.appendChild(frag);
}
function makeEmojiNode(key: string): HTMLImageElement {
const img = document.createElement("img");
img.src = dyEmojiUrl(key);
img.dataset.sc = `[:${key}:]`;
img.alt = "表情";
img.title = `[:${key}:]`;
img.draggable = false;
img.className = "j13-input-emoji";
return img;
}
function placeCaretEnd(el: HTMLElement) {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
/** 发送前翻译:@昵称 → @用户名(后端按用户名解析提及) */
@@ -371,35 +554,6 @@ function hydrateMentionsIn(
return { text: out, tokens };
}
/** 光标前是否紧贴一个已确认的 @token(含可选尾随空格) */
function mentionTokenBefore(
text: string,
cursor: number,
tokens: Map<string, string>
): { start: number; end: number } | null {
for (const s of collectMentionSpans(text, tokens)) {
if (s.end === cursor) return { start: s.start, end: cursor };
if (s.end + 1 === cursor && text[s.end] === " ") {
return { start: s.start, end: cursor };
}
}
return null;
}
/** 光标后是否紧跟一个已确认的 @token(含可选尾随空格) */
function mentionTokenAfter(
text: string,
cursor: number,
tokens: Map<string, string>
): { start: number; end: number } | null {
for (const s of collectMentionSpans(text, tokens)) {
if (s.start !== cursor) continue;
const end = s.end < text.length && text[s.end] === " " ? s.end + 1 : s.end;
return { start: cursor, end };
}
return null;
}
export default function ChatRoomClient({
roomId,
roomName,
@@ -443,6 +597,7 @@ export default function ChatRoomClient({
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
const [mentionIndex, setMentionIndex] = useState(0);
const [emojiOpen, setEmojiOpen] = useState(false);
/** 从选择器/悬浮确认插入的 @:key=展示昵称,value=用户名(发送时翻译回) */
const [mentionTokens, setMentionTokens] = useState<Map<string, string>>(
() => new Map()
@@ -456,9 +611,52 @@ export default function ChatRoomClient({
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
/** 富输入区最近一次同步到 DOM 的 content 纯文本(区分内部输入与外部内容变更) */
const lastRichText = useRef<string>("");
const msgRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const composerBoxRef = useRef<HTMLDivElement>(null);
/** 表情按钮+面板容器:外点(含点击输入框)即关面板 */
const emojiPanelWrapRef = useRef<HTMLDivElement>(null);
// iOS/WebKit 不支持 interactive-widget=resizes-content(仅 Android Chrome 生效):
// 软键盘弹出时直接盖住锁视口布局的底部输入框,页面 overflow:hidden 又无处可滚。
// 监听 visualViewport,把 composer 整体上移键盘遮挡高度,并给消息列表加同等
// 底部内边距让最新消息保持在输入框上方。Android 下遮挡恒为 0,自动空转。
// 不按聚焦状态门控:键盘未收起期间保持位移,避免点发送按钮时按钮下移丢点击;
// 用 scale===1 排除双指缩放造成的 visualViewport 变化(键盘不改变 scale)。
useEffect(() => {
const vv = window.visualViewport;
const composerBox = composerBoxRef.current;
if (!vv || !composerBox) return;
let raf = 0;
const apply = () => {
raf = 0;
const overlap = Math.round(
window.innerHeight - vv.offsetTop - vv.height
);
const list = listRef.current;
if (overlap > 80 && vv.scale === 1) {
composerBox.style.transform = `translateY(-${overlap}px)`;
if (list) list.style.paddingBottom = `${overlap + 16}px`;
} else {
composerBox.style.transform = "";
if (list) list.style.paddingBottom = "";
}
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(apply);
};
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
return () => {
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
if (raf) cancelAnimationFrame(raf);
composerBox.style.transform = "";
if (listRef.current) listRef.current.style.paddingBottom = "";
};
}, []);
const isOwner = myRole === "owner";
const isRoomAdmin = myRole === "admin";
@@ -773,9 +971,10 @@ export default function ChatRoomClient({
})();
/**
* 插入 @提及:
* 插入 @提及(DOM 级操作):
* - replaceTrigger:替换光标前未完成的 "@xx" 触发串(选择器路径)
* - 否则在光标处追加(昵称悬浮 @ 路径),必要时补前导空格
* 插入后序列化 DOM 回 content 纯文本并记录 昵称→用户名 映射
*/
const insertMention = (
username: string,
@@ -783,29 +982,56 @@ export default function ChatRoomClient({
opts?: { replaceTrigger?: boolean }
) => {
const el = inputRef.current;
const val = content;
const cursor = el?.selectionStart ?? val.length;
let before = val.slice(0, cursor);
const after = val.slice(cursor);
if (opts?.replaceTrigger) {
const at = before.lastIndexOf("@");
if (at >= 0) before = before.slice(0, at);
} else if (before && !/\s$/.test(before)) {
before = `${before} `;
if (!el) return;
el.focus();
const sel = window.getSelection();
let range: Range;
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
range = sel.getRangeAt(0);
} else {
range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
}
// 编辑框直接写入昵称(宽度即昵称宽度),映射表记录昵称→用户名
const caret = richCaretOffset(el, range.startContainer, range.startOffset);
const text = domToText(el);
const display = nickname || username;
const next = `${before}@${display} ${after}`;
let removeStart = caret;
let leadSpace = false;
if (opts?.replaceTrigger) {
const at = text.slice(0, caret).lastIndexOf("@");
if (at >= 0) removeStart = at;
} else if (caret > 0 && text[caret - 1] && !/\s/.test(text[caret - 1])) {
leadSpace = true;
}
const span = document.createElement("span");
span.className = "j13-mention-token";
span.contentEditable = "false";
span.textContent = `@${display}`;
const space = document.createTextNode(" ");
const p1 = richResolveOffset(el, removeStart);
const r2 = document.createRange();
r2.setStart(p1.node, p1.offset);
const p2 = richResolveOffset(el, caret);
r2.setEnd(p2.node, p2.offset);
r2.deleteContents();
const frag = document.createDocumentFragment();
if (leadSpace) frag.appendChild(document.createTextNode(" "));
frag.appendChild(span);
frag.appendChild(space);
r2.insertNode(frag);
const r3 = document.createRange();
r3.setStartAfter(space);
r3.collapse(true);
sel?.removeAllRanges();
sel?.addRange(r3);
const next = domToText(el);
lastRichText.current = next;
setContent(next);
setMentionTokens((prev) => new Map(prev).set(display, username));
setMentionOpen(false);
setMentionFilter("");
requestAnimationFrame(() => {
const pos = before.length + display.length + 2;
el?.focus();
el?.setSelectionRange(pos, pos);
syncComposerHeight();
});
requestAnimationFrame(syncComposerHeight);
};
const pruneMentionTokens = (text: string, tokens: Map<string, string>) => {
@@ -821,12 +1047,98 @@ export default function ChatRoomClient({
return next;
};
const onComposerChange = (raw: string) => {
setContent(raw);
setMentionTokens((prev) => pruneMentionTokens(raw, prev));
/** 表情面板:外点(含输入框)/ Esc 关闭 */
useEffect(() => {
if (!emojiOpen) return;
const onDoc = (e: MouseEvent) => {
if (
emojiPanelWrapRef.current &&
!emojiPanelWrapRef.current.contains(e.target as Node)
) {
setEmojiOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
setEmojiOpen(false);
inputRef.current?.focus();
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey, true);
};
}, [emojiOpen]);
/** 表情面板选表情:光标处插入表情图节点,选完即关面板 */
const insertEmoji = (key: string) => {
const el = inputRef.current;
const cursor = el?.selectionStart ?? raw.length;
const before = raw.slice(0, cursor);
if (!el) return;
setEmojiOpen(false);
el.focus();
const sel = window.getSelection();
let range: Range;
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
range = sel.getRangeAt(0);
} else {
range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
}
range.deleteContents();
const img = makeEmojiNode(key);
range.insertNode(img);
// 表情后补零宽空格,给光标一个落点(序列化时剔除)
const zw = document.createTextNode(ZWSP);
range.setStartAfter(img);
range.collapse(true);
range.insertNode(zw);
range.setStartAfter(zw);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
const next = domToText(el);
lastRichText.current = next;
setContent(next);
setMentionTokens((prev) => pruneMentionTokens(next, prev));
requestAnimationFrame(syncComposerHeight);
};
/** 外部内容变更(清空/草稿/撤回后恢复等)→ 重建富 DOM;内部输入不触发(DOM 即视图) */
useEffect(() => {
const el = inputRef.current;
if (!el || lastRichText.current === content) return;
rebuildRichInput(el, content, mentionTokens);
lastRichText.current = content;
if (document.activeElement === el) placeCaretEnd(el);
requestAnimationFrame(syncComposerHeight);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content, mentionTokens]);
const handleRichInput = () => {
const el = inputRef.current;
if (!el) return;
const text = domToText(el);
if (text === lastRichText.current) return;
if (text.length > 2000) {
// 超 2000 字:回滚到上次合法内容
rebuildRichInput(el, lastRichText.current, mentionTokens);
placeCaretEnd(el);
return;
}
lastRichText.current = text;
setContent(text);
setMentionTokens((prev) => pruneMentionTokens(text, prev));
// @ 触发检测(与原 textarea 逻辑一致)
const sel = window.getSelection();
let caret = text.length;
if (sel && sel.rangeCount > 0) {
const r = sel.getRangeAt(0);
caret = richCaretOffset(el, r.startContainer, r.startOffset);
}
const before = text.slice(0, caret);
const m = before.match(/@([^\s@]{0,32})$/);
if (m && isRealMember && !isDirect) {
setMentionOpen(true);
@@ -838,7 +1150,9 @@ export default function ChatRoomClient({
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// IME 组合中不拦截按键
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (mentionOpen && mentionCandidates.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -870,76 +1184,44 @@ export default function ChatRoomClient({
return;
}
}
const el = e.currentTarget;
const start = el.selectionStart;
const end = el.selectionEnd;
// 已确认 @token 按整体跳过 / 删除(飞书体验)
if (start === end && mentionTokens.size > 0) {
if (e.key === "Backspace") {
const range = mentionTokenBefore(content, start, mentionTokens);
if (range) {
e.preventDefault();
const next = content.slice(0, range.start) + content.slice(range.end);
onComposerChange(next);
requestAnimationFrame(() => {
el.setSelectionRange(range.start, range.start);
syncComposerHeight();
});
return;
}
}
if (e.key === "Delete") {
const range = mentionTokenAfter(content, start, mentionTokens);
if (range) {
e.preventDefault();
const next = content.slice(0, range.start) + content.slice(range.end);
onComposerChange(next);
requestAnimationFrame(() => {
el.setSelectionRange(range.start, range.start);
syncComposerHeight();
});
return;
}
}
if (e.key === "ArrowLeft" && !e.shiftKey) {
const range = mentionTokenBefore(content, start, mentionTokens);
if (range) {
e.preventDefault();
el.setSelectionRange(range.start, range.start);
return;
}
}
if (e.key === "ArrowRight" && !e.shiftKey) {
const range = mentionTokenAfter(content, start, mentionTokens);
if (range) {
e.preventDefault();
el.setSelectionRange(range.end, range.end);
return;
}
}
}
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
document.execCommand("insertLineBreak");
} else {
send();
}
}
};
/** 光标落点在已确认 @token 内部时吸附到 token 末尾(token 原子化,飞书体验) */
const onComposerSelect = () => {
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
if (!text) return;
const el = inputRef.current;
if (!el || mentionTokens.size === 0) return;
const start = el.selectionStart;
const end = el.selectionEnd;
if (start !== end) return;
for (const s of collectMentionSpans(content, mentionTokens)) {
if (start > s.start && start < s.end) {
el.setSelectionRange(s.end, s.end);
return;
}
if (!el) return;
const sel = window.getSelection();
let range: Range;
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
range = sel.getRangeAt(0);
} else {
range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
}
range.deleteContents();
const frag = document.createDocumentFragment();
const lines = text.replace(/\r\n/g, "\n").split("\n");
lines.forEach((line, i) => {
if (i > 0) frag.appendChild(document.createElement("br"));
if (line) frag.appendChild(document.createTextNode(line));
});
range.insertNode(frag);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
handleRichInput();
};
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
@@ -1117,9 +1399,8 @@ export default function ChatRoomClient({
const el = inputRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(display.length, display.length);
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
placeCaretEnd(el);
syncComposerHeight();
});
};
@@ -1650,8 +1931,12 @@ export default function ChatRoomClient({
{isRealMember ? (
<div
ref={composerBoxRef}
className="shrink-0 px-3 sm:px-4 py-3"
style={{ borderTop: "1px solid var(--line)" }}
style={{
borderTop: "1px solid var(--line)",
background: "var(--panel)",
}}
>
{replyTo && (
<div
@@ -1735,57 +2020,50 @@ export default function ChatRoomClient({
// 点空白/内边距时聚焦输入,避免抢走发送按钮的点击
const t = e.target as HTMLElement;
if (t.closest("button, a, [role='button']")) return;
if (t.tagName === "TEXTAREA") return;
if (t.closest("[contenteditable='true']")) return;
e.preventDefault();
inputRef.current?.focus();
}}
>
<div className="relative flex-1 min-w-0 self-stretch flex items-center">
{content ? (
<div
ref={mirrorRef}
className="j13-chat-composer-mirror text-[14px] leading-5 py-2"
aria-hidden
>
{renderComposerMirror(content, mentionTokens)}
</div>
) : null}
<textarea
ref={inputRef}
value={content}
onChange={(e) => onComposerChange(e.target.value)}
onKeyDown={onKeyDown}
onSelect={onComposerSelect}
onScroll={(e) => {
if (mirrorRef.current) {
mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
mirrorRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
}}
rows={1}
placeholder={
isDirect
? "输入消息…"
: "输入消息,@ 可提醒成员"
}
maxLength={2000}
className={`j13-chat-scroll j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${
content ? " has-value" : ""
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
<button
type="button"
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
emojiOpen ? " bg-[var(--accent-soft)]" : " hover:bg-[var(--panel-2)]"
}`}
style={{ color: content ? undefined : "var(--ink)" }}
style={{ color: emojiOpen ? "var(--accent)" : "var(--ink-2)" }}
aria-label="表情"
aria-expanded={emojiOpen}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setEmojiOpen((v) => !v)}
>
<Smile size={19} />
</button>
{emojiOpen && (
<div className="j13-chat-emoji-panel">
<EmojiPicker onPick={insertEmoji} />
</div>
)}
</div>
<div
ref={inputRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label="输入消息"
data-placeholder={isDirect ? "输入消息…" : "输入消息,@ 可提醒成员"}
className="j13-chat-scroll j13-chat-composer-input j13-chat-rich-input relative flex-1 min-w-0 text-[14px] leading-5 py-2"
onInput={handleRichInput}
onKeyDown={handleRichKeyDown}
onPaste={handleRichPaste}
onFocus={(e) => {
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
const composer = e.currentTarget.closest(".j13-chat-composer");
composer?.scrollIntoView({ block: "end", behavior: "smooth" });
}}
onInput={(e) => {
const t = e.currentTarget;
t.style.height = "auto";
t.style.height = Math.min(t.scrollHeight, 128) + "px";
}}
/>
</div>
<button
type="button"
onClick={send}

View File

@@ -3662,20 +3662,50 @@ select:focus-visible {
.j13-chat-composer-input:focus-visible {
outline: none !important;
box-shadow: none !important;
resize: none !important;
}
.j13-chat-composer-input.has-value {
color: transparent;
/* 富输入区(contentEditable):表情图片与 @ 胶囊直接在输入框内渲染 */
.j13-chat-rich-input {
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
overflow-y: auto;
cursor: text;
min-height: 36px;
max-height: 128px;
color: var(--ink);
caret-color: var(--ink);
}
/* 框选时 ::selection 前景色会盖过透明字色,露出底层真实文本(@用户名);
这里保持字形透明 + 半透明高亮,让镜像层的 @昵称 透出(宽度已补偿对齐) */
.j13-chat-composer-input.has-value::selection {
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: transparent;
.j13-chat-rich-input:empty::before {
content: attr(data-placeholder);
color: var(--ink-3);
pointer-events: none;
}
.j13-chat-composer-input.has-value::placeholder {
color: transparent;
.j13-input-emoji {
display: inline-block;
width: 20px;
height: 20px;
object-fit: contain;
/* 与汉字视觉居中:汉字字面中心在基线上方 ≈0.385em,vertical-align = 0.385×字号 − 图高/2
20px 图 @14px 字号:5.4 − 10 = −4.6(负值=图片下沉;middle 会以 x-height 对齐导致偏下) */
vertical-align: -4.6px;
margin: 0 1px;
cursor: default;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* 输入框内的 @ 胶囊:与镜像期一致的蓝色文字风格,contenteditable=false 原子整体 */
.j13-chat-rich-input .j13-mention-token {
display: inline;
padding: 0;
margin: 0;
line-height: inherit;
font-weight: 500;
border-radius: 0;
box-shadow: none;
background: transparent !important;
color: var(--accent) !important;
cursor: text;
}
.admin-field-shell,
@@ -3732,16 +3762,6 @@ select:focus-visible {
width: 0;
height: 0;
}
.j13-chat-composer-mirror {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
color: var(--ink);
}
.j13-mention-token {
display: inline-block;
padding: 2px 8px;
@@ -3768,19 +3788,6 @@ a.j13-mention-token:hover {
background: var(--accent);
color: var(--accent-on) !important;
}
/* 输入镜像层:token 仅蓝色文字(胶囊样式只在发出后的气泡里显示);
基础胶囊色带 !important,此处需同权重覆盖 */
.j13-chat-composer-mirror .j13-mention-token {
display: inline;
padding: 0;
margin: 0;
line-height: inherit;
font-weight: 500;
border-radius: 0;
box-shadow: none;
background: transparent !important;
color: var(--accent) !important;
}
@layer components {
/* ----- 小标签(旧名 .kicker 保留兼容) ----- */
@@ -8586,6 +8593,8 @@ html.dark .prose-content pre[data-theme] span {
}
/* 首页桌面:顶部留白 20px → 8px,中栏信息流上移贴页头;
main 变 flex 列、三栏区 flex:1 撑满 main——分隔线不随帖子数量缩短,
从页头下沿贯通到页脚上沿;
sticky 侧栏的 --rail-top/--rail-max-h 在 #main 上重算(custom property
在声明处求值,:root 的 --rail-max-h 不会因子级重定义 --rail-top 而更新) */
@media (min-width: 1024px) {
@@ -8593,6 +8602,13 @@ html.dark .prose-content pre[data-theme] span {
padding-top: 0.5rem;
--rail-top: calc(var(--header-h) + 0.5rem);
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
display: flex;
flex-direction: column;
}
.j13-fluid-home {
display: flex;
flex-direction: column;
flex: 1;
}
}
@@ -8644,7 +8660,9 @@ html:not(.j13-has-site-bg) #main {
font-size: 13px;
}
/* 首页三栏竖向分隔线:与页头下边线同色同宽,居中于栏间距(gap-6 / xl:gap-7),
画在中栏伪元素上与左右栏共用一条;类名跟随左右栏显隐,避免悬空线 */
画在中栏伪元素上与左右栏共用一条;类名跟随左右栏显隐,避免悬空线。
上接页头下边线(抵消 #main 顶部 0.5rem)、下接页脚上沿(抵消 #main 底部
--main-pad-y):中栏 self-stretch 撑满行高(左右栏保持 items-start 不破坏 sticky) */
.j13-home-sep-l,
.j13-home-sep-r {
position: relative;
@@ -8654,8 +8672,8 @@ html:not(.j13-has-site-bg) #main {
.j13-home-sep-r::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
top: -0.5rem;
bottom: calc(-1 * var(--main-pad-y));
width: 1px;
background: var(--line);
}
@@ -9768,3 +9786,150 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
min-width: 0;
}
/* ── 抖音表情(douyin-emoji):编辑器弹层面板 / 聊天面板 / 行内图片 ── */
.j13-emoji-picker {
display: flex;
flex-direction: column;
min-width: 0;
}
.j13-emoji-tabs {
display: flex;
gap: 2px;
padding: 0 0 6px;
border-bottom: 1px solid var(--line);
margin-bottom: 6px;
}
.j13-emoji-tabs button {
padding: 4px 10px;
border-radius: 8px;
border: 0;
appearance: none;
-webkit-appearance: none;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
color: var(--ink-2);
background: transparent;
transition: background 0.12s ease, color 0.12s ease;
}
.j13-emoji-tabs button:hover {
background: var(--panel-2);
color: var(--ink);
}
.j13-emoji-tabs button.is-on {
background: var(--accent-soft);
color: var(--accent);
}
/* 分组滚动容器:整体滚动,分组小标题随内容排列 */
.j13-emoji-scroll {
max-height: 264px;
overflow-y: auto;
overscroll-behavior: contain;
}
.j13-emoji-group + .j13-emoji-group {
margin-top: 4px;
}
.j13-emoji-group-label {
padding: 6px 2px 4px;
font-size: 11.5px;
color: var(--ink-3);
}
.j13-emoji-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 36px);
justify-content: space-between;
gap: 2px;
}
.j13-emoji-cell {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
appearance: none;
-webkit-appearance: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
transition: background 0.12s ease;
}
.j13-emoji-cell:hover {
background: var(--panel-2);
}
.j13-emoji-cell:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 0;
}
.j13-emoji-cell img {
width: 28px;
height: 28px;
object-fit: contain;
pointer-events: none;
}
.j13-emoji-empty {
grid-column: 1 / -1;
text-align: center;
padding: 18px 0;
}
/* Markdown 正文中的表情:当作文字表情而非媒体图片——
显式关闭 .prose-content img 的边框/放大手势/hover 阴影;
垂直方向贴合文字视觉中线 */
.md-content-img.j13-md-emoji,
.prose-content img.j13-md-emoji {
display: inline-block;
width: 28px;
height: 28px;
object-fit: contain;
/* 与汉字视觉居中:28px 图 @16.5px 正文:6.35 − 14 = −7.7(推导同 .j13-input-emoji) */
vertical-align: -7.7px;
margin: 0 1px;
border: 0;
border-radius: 0;
cursor: default;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* 聊天消息气泡中的表情:视为文字表情而非图片——
不可选中、不可拖拽、长按/右键不出现图片操作(保存/拷贝图像等);
垂直方向贴合文字视觉中线 */
.j13-chat-emoji {
display: inline-block;
width: 28px;
height: 28px;
object-fit: contain;
/* 与汉字视觉居中:28px 图 @14px 字号:5.4 − 14 = −8.6(推导同 .j13-input-emoji) */
vertical-align: -8.6px;
margin: 0 1px;
cursor: default;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* 纯表情消息的贴纸大图 */
.j13-chat-emoji-lg {
width: 96px;
height: 96px;
vertical-align: middle;
}
/* 编辑器弹层里的表情面板容器 */
.j13-md-emoji-popover {
width: 320px;
max-width: calc(100vw - 32px);
}
/* 聊天输入区上方的表情面板 */
.j13-chat-emoji-panel {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 40;
width: 320px;
max-width: calc(100vw - 48px);
padding: 10px;
border-radius: 14px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow-lg);
}

View File

@@ -242,7 +242,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
<JsonLd data={listingLd} />
<WorkspaceNavMobile boards={boards} />
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "lg:mt-0"}`}>
<div className={`flex items-start gap-6 xl:gap-7 flex-1 ${activeBoard ? "" : "lg:mt-0"}`}>
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
{scopeLists.left.length > 0 && (
<aside className="j13-rail-left hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
@@ -250,9 +250,9 @@ export default async function HomePage({ searchParams }: HomePageProps) {
</aside>
)}
{/* 中栏:tab 栏与帖子流为同一个整体(左栏切板块只替换这一列) */}
{/* 中栏:tab 栏与帖子流为同一个整体(左栏切板块只替换这一列);self-stretch 撑满行高供分隔线贯通 */}
<div
className={`flex-1 min-w-0 ${hasLeftRail ? "j13-home-sep-l" : ""} ${
className={`flex-1 min-w-0 self-stretch ${hasLeftRail ? "j13-home-sep-l" : ""} ${
hasRightRail ? "j13-home-sep-r" : ""
}`}
>

View File

@@ -0,0 +1,30 @@
"use client";
import { useState } from "react";
import { dyEmojiUrl } from "@/lib/dyEmoji";
type EmojiImgProps = {
/** 表情 key(短代码 [:key:] 的 key) */
k: string;
className?: string;
};
/** 抖音表情小图:加载失败回退为 [表情] 文本,避免破图 */
export default function EmojiImg({ k, className }: EmojiImgProps) {
const [broken, setBroken] = useState(false);
if (broken) {
return <span className={className}>[表情]</span>;
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={dyEmojiUrl(k)}
alt={`[:${k}:]`}
title={`[:${k}:]`}
className={className}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
onError={() => setBroken(true)}
/>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { DY_EMOJI_BASE, DY_EMOJI_GROUPS, dyEmojiUrl, type DyEmojiManifest } from "@/lib/dyEmoji";
type EmojiPickerProps = {
onPick: (key: string) => void;
};
type Tab = "tos" | "fus";
const CHUNK = 120;
/** 最近使用一排的容量(与面板 8 列对齐,多了挤掉最旧的) */
const RECENT_MAX = 8;
const RECENT_STORAGE_KEY = "j13_dy_emoji_recent";
let manifestPromise: Promise<DyEmojiManifest | null> | null = null;
async function fetchManifest(): Promise<DyEmojiManifest | null> {
try {
const res = await fetch(`${DY_EMOJI_BASE}/manifest.json`);
if (!res.ok) return null;
const data = (await res.json()) as DyEmojiManifest;
if (!Array.isArray(data.tos) || !Array.isArray(data.fus)) return null;
return data;
} catch {
return null;
}
}
/** 拉取表情清单(模块级缓存,全站只请求一次;仅「合成」tab 使用) */
function useDyEmojiManifest(): { manifest: DyEmojiManifest | null; failed: boolean } {
const [state, setState] = useState<{ manifest: DyEmojiManifest | null; failed: boolean }>({
manifest: null,
failed: false,
});
useEffect(() => {
if (!manifestPromise) manifestPromise = fetchManifest();
let cancelled = false;
manifestPromise.then((m) => {
if (!cancelled) setState({ manifest: m, failed: m === null });
});
return () => {
cancelled = true;
};
}, []);
return state;
}
function loadRecentKeys(): string[] {
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter((k) => typeof k === "string").slice(0, RECENT_MAX) : [];
} catch {
return [];
}
}
function saveRecentKeys(keys: string[]) {
try {
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(keys));
} catch {
// 隐私模式等存储不可用时静默降级为会话内记忆
}
}
/** 记录最近使用:最新插前、去重、超出一排丢弃最旧(LRU),返回新列表 */
function pushRecentKey(k: string): string[] {
const next = [k, ...loadRecentKeys().filter((x) => x !== k)].slice(0, RECENT_MAX);
saveRecentKeys(next);
return next;
}
function EmojiCell({ k, onPick }: { k: string; onPick: (k: string) => void }) {
return (
<button
type="button"
className="j13-emoji-cell"
aria-label={`表情 ${k}`}
title={`[:${k}:]`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(k)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={dyEmojiUrl(k)} alt="" loading="lazy" draggable={false} />
</button>
);
}
/**
* 抖音表情面板:默认 tab 按分组排列(同类型相邻)+ 顶部最近使用一排;
* 合成 tab 分块渲染避免一次挂载数千图片。
*/
export default function EmojiPicker({ onPick }: EmojiPickerProps) {
const { manifest, failed } = useDyEmojiManifest();
const [tab, setTab] = useState<Tab>("tos");
const [visible, setVisible] = useState(CHUNK);
const [recent, setRecent] = useState<string[]>(() => loadRecentKeys());
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const fusKeys = useMemo(() => (manifest ? manifest.fus : []), [manifest]);
// 切 tab / 清单变化时重置分块
useEffect(() => {
setVisible(CHUNK);
}, [tab, manifest]);
// 合成 tab 滚动到底部分块加载(哨兵离开/进入视口反复触发)
useEffect(() => {
const el = sentinelRef.current;
if (tab !== "fus" || !el || visible >= fusKeys.length) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setVisible((v) => Math.min(v + CHUNK, fusKeys.length));
}
},
{ root: scrollRef.current, rootMargin: "240px" }
);
io.observe(el);
return () => io.disconnect();
}, [tab, visible, fusKeys.length]);
const pick = (k: string) => {
setRecent(pushRecentKey(k));
onPick(k);
};
return (
<div className="j13-emoji-picker">
<div className="j13-emoji-tabs" role="tablist" aria-label="表情类型">
<button
type="button"
role="tab"
aria-selected={tab === "tos"}
className={tab === "tos" ? "is-on" : ""}
onClick={() => setTab("tos")}
>
默认
</button>
<button
type="button"
role="tab"
aria-selected={tab === "fus"}
className={tab === "fus" ? "is-on" : ""}
onClick={() => setTab("fus")}
>
合成
</button>
</div>
<div className="j13-emoji-scroll" ref={scrollRef}>
{recent.length > 0 && (
<div className="j13-emoji-group" aria-label="最近使用">
<div className="j13-emoji-group-label">最近</div>
<div className="j13-emoji-grid">
{recent.map((k) => (
<EmojiCell key={k} k={k} onPick={pick} />
))}
</div>
</div>
)}
{tab === "tos"
? DY_EMOJI_GROUPS.map((g) => (
<div className="j13-emoji-group" key={g.label}>
<div className="j13-emoji-group-label">{g.label}</div>
<div className="j13-emoji-grid">
{g.keys.map((k) => (
<EmojiCell key={k} k={k} onPick={pick} />
))}
</div>
</div>
))
: (
<div className="j13-emoji-group">
<div className="j13-emoji-group-label">合成</div>
<div className="j13-emoji-grid">
{fusKeys.slice(0, visible).map((k) => (
<EmojiCell key={k} k={k} onPick={pick} />
))}
{fusKeys.length > visible && <div ref={sentinelRef} aria-hidden />}
</div>
</div>
)}
{tab === "fus" && !manifest && !failed && <p className="j13-emoji-empty meta">表情加载中…</p>}
{tab === "fus" && !manifest && failed && <p className="j13-emoji-empty meta">表情加载失败</p>}
{manifest && tab === "fus" && fusKeys.length === 0 && (
<p className="j13-emoji-empty meta">暂无表情</p>
)}
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import type { Components } from "react-markdown";
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
import { splitContentSegments } from "@/lib/directiveBlocks";
import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
@@ -28,7 +29,7 @@ export default async function MarkdownBody({
loginHref,
loggedIn = false,
}: MarkdownBodyProps) {
const segs = splitContentSegments(content || "");
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""));
const nodes = [];
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
@@ -48,7 +49,7 @@ export default async function MarkdownBody({
continue;
}
// 条目正文不挂目录 id,避免与块外 headingRenderer 错位
bodies.push(await renderMarkdownToReact(it.body));
bodies.push(await renderMarkdownToReact(expandDyEmojiShortcodes(it.body)));
}
nodes.push(
<MdTimeline
@@ -75,7 +76,7 @@ export default async function MarkdownBody({
continue;
}
const inner = seg.body.trim()
? await renderMarkdownToReact(seg.body)
? await renderMarkdownToReact(expandDyEmojiShortcodes(seg.body))
: null;
nodes.push(
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>

View File

@@ -4,6 +4,7 @@ import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
import { splitContentSegments } from "@/lib/directiveBlocks";
import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
@@ -28,7 +29,7 @@ export default function MarkdownBodyClient({
loginHref,
loggedIn = false,
}: MarkdownBodyClientProps) {
const segs = splitContentSegments(content || "");
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""));
const mdComponents = mergeMarkdownComponents(components);
// 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位
const nestedMd = mergeMarkdownComponents();
@@ -52,7 +53,7 @@ export default function MarkdownBodyClient({
renderBody={(it) =>
it.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
{it.body}
{expandDyEmojiShortcodes(it.body)}
</ReactMarkdown>
) : null
}
@@ -77,7 +78,7 @@ export default function MarkdownBodyClient({
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
{seg.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
{seg.body}
{expandDyEmojiShortcodes(seg.body)}
</ReactMarkdown>
) : null}
</MdHideBlock>

View File

@@ -32,12 +32,14 @@ import {
ChevronDown,
History,
Ellipsis,
Smile,
} from "lucide-react";
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
import { toast } from "@/lib/toast";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import ImageInsertModal from "@/components/ImageInsertModal";
import TimelineEditor from "@/components/TimelineEditor";
import EmojiPicker from "@/components/EmojiPicker";
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
import { postPath } from "@/lib/urlStyle";
@@ -88,7 +90,7 @@ type WrapOpts = {
linePrefix?: string;
};
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | null;
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | null;
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
@@ -412,6 +414,36 @@ export default function MarkdownEditor({
[value, onChange]
);
/** 行内插入(无换行 padding):表情等短代码用 */
const insertInline = useCallback(
(text: string) => {
const el = taRef.current;
if (!el) {
onChange(value + text);
return;
}
const start = savedSel.current?.start ?? el.selectionStart;
const end = savedSel.current?.end ?? el.selectionEnd;
const next = value.slice(0, start) + text + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
const caret = start + text.length;
el.setSelectionRange(caret, caret);
});
savedSel.current = null;
},
[value, onChange]
);
const pickEmoji = useCallback(
(key: string) => {
insertInline(`[:${key}:]`);
setPopover(null);
},
[insertInline]
);
const uploadImageFile = async (file: File | null) => {
if (!file || disabled) return;
retryFile.current = file;
@@ -976,6 +1008,19 @@ export default function MarkdownEditor({
</div>
)}
</div>
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="表情"
icon={<Smile size={15} />}
active={popover === "emoji"}
onClick={() => openPopover("emoji")}
/>
{popover === "emoji" && (
<div className="j13-md-popover j13-md-emoji-popover" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
</div>
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}

View File

@@ -42,6 +42,29 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
const provided = (typeof alt === "string" ? alt.trim() : "") || (typeof title === "string" ? title.trim() : "");
const imgAlt = provided || "图片";
// 抖音表情(/emoji/ 路径):行内小图,不进图箱、不带 caption;
// alt 由 expandDyEmojiShortcodes 产出("dy:<key>"),悬停 title 显示其短代码
const isEmoji = typeof src === "string" && src.includes("/emoji/");
const emojiSc =
typeof alt === "string" && alt.startsWith("dy:") ? `[:${alt.slice(3)}:]` : undefined;
if (isEmoji && !broken) {
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img
{...rest}
src={src}
alt={imgAlt}
title={emojiSc}
className={`j13-md-emoji${className ? ` ${className}` : ""}`}
loading="lazy"
decoding="async"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
onError={() => setBroken(true)}
/>
);
}
if (!src || broken) {
return (
<span className="md-img-fallback" role="img" aria-label={provided || "图片加载失败"}>

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
DY_EMOJI_ALIASES,
DY_EMOJI_GROUPS,
DY_EMOJI_RE,
dyEmojiFileKey,
dyEmojiUrl,
expandDyEmojiShortcodes,
} from "./dyEmoji.ts";
const CAT_HASH = DY_EMOJI_ALIASES["猫猫"];
describe("DY_EMOJI_ALIASES", () => {
it("中文语义名 → hash 文件名,文件名全局唯一", () => {
assert.match(CAT_HASH, /^[A-Za-z0-9_-]+$/);
const files = Object.values(DY_EMOJI_ALIASES);
assert.equal(new Set(files).size, files.length);
});
it("完整覆盖 manifest.tos(集合一致;面板分组顺序独立于 manifest)", () => {
const manifest = JSON.parse(
readFileSync(fileURLToPath(new URL("../public/emoji/dy/manifest.json", import.meta.url)), "utf8")
);
assert.deepEqual(Object.values(DY_EMOJI_ALIASES).sort(), [...manifest.tos].sort());
});
});
describe("DY_EMOJI_GROUPS", () => {
it("分组 keys 无重复", () => {
const all = DY_EMOJI_GROUPS.flatMap((g) => g.keys);
assert.equal(new Set(all).size, all.length);
});
it("分组 keys 恰好覆盖全部中文语义名(不多不少)", () => {
const grouped = DY_EMOJI_GROUPS.flatMap((g) => g.keys).sort();
const allKeys = Object.keys(DY_EMOJI_ALIASES).sort();
assert.deepEqual(grouped, allKeys);
});
});
describe("dyEmojiFileKey / dyEmojiUrl", () => {
it("中文 key 解析为实际文件名,合成表情组合名透传", () => {
assert.equal(dyEmojiFileKey("猫猫"), CAT_HASH);
assert.equal(dyEmojiFileKey("001--002--heart"), "001--002--heart");
});
it("拼接扁平目录 URL", () => {
assert.equal(dyEmojiUrl("猫猫"), `/emoji/dy/${CAT_HASH}.png`);
assert.equal(dyEmojiUrl("001--002--heart"), "/emoji/dy/001--002--heart.png");
});
it("未知 key 原样 URL 编码透传", () => {
assert.equal(dyEmojiUrl("不存在"), `/emoji/dy/${encodeURIComponent("不存在")}.png`);
});
});
describe("expandDyEmojiShortcodes", () => {
it("中文短代码替换为 markdown 图片", () => {
assert.equal(
expandDyEmojiShortcodes("你好[:猫猫:]!"),
`你好![dy:猫猫](/emoji/dy/${CAT_HASH}.png)!`
);
});
it("合成表情组合名短代码", () => {
assert.equal(
expandDyEmojiShortcodes("[:001--002--heart:]"),
"![dy:001--002--heart](/emoji/dy/001--002--heart.png)"
);
});
it("多次替换且共享前缀", () => {
assert.equal(
expandDyEmojiShortcodes("[:猫猫:][:aa:][:猫猫:]"),
`![dy:猫猫](/emoji/dy/${CAT_HASH}.png)![dy:aa](/emoji/dy/aa.png)![dy:猫猫](/emoji/dy/${CAT_HASH}.png)`
);
});
it("无短代码时原样返回", () => {
const s = "普通文本 [a:] :x: [[y]]";
assert.equal(expandDyEmojiShortcodes(s), s);
assert.equal(expandDyEmojiShortcodes(""), "");
});
it("非法 key 不匹配(含 . / 空 / @ / 空白)", () => {
assert.equal(expandDyEmojiShortcodes("[:a.b:][:]"), "[:a.b:][:]");
assert.equal(expandDyEmojiShortcodes("[:空 格:]"), "[:空 格:]");
assert.equal(expandDyEmojiShortcodes("[:@x:]"), "[:@x:]");
});
it("跨行文本", () => {
assert.equal(
expandDyEmojiShortcodes("第一行[:猫猫:]\n第二行"),
`第一行![dy:猫猫](/emoji/dy/${CAT_HASH}.png)\n第二行`
);
});
it("连续使用共享同一正则(lastIndex 复用安全)", () => {
assert.equal(expandDyEmojiShortcodes("[:猫猫:]"), `![dy:猫猫](/emoji/dy/${CAT_HASH}.png)`);
assert.equal(expandDyEmojiShortcodes("[:b:]"), "![dy:b](/emoji/dy/b.png)");
assert.equal(expandDyEmojiShortcodes("纯文本"), "纯文本");
assert.equal(expandDyEmojiShortcodes("[:c:]"), "![dy:c](/emoji/dy/c.png)");
});
it("正则含 g 标志(供分段渲染使用)", () => {
assert.ok(DY_EMOJI_RE.global);
});
});

341
frontend/lib/dyEmoji.ts Normal file
View File

@@ -0,0 +1,341 @@
/**
* 抖音表情共享逻辑:短代码正则 / URL / 展开为 markdown 图片 / span 收集。
* 纯逻辑模块(无 React 依赖),服务端与客户端组件均可安全引入。
*/
/**
* 抖音表情短代码:[:key:]。key 有两种形态:
* - 默认表情:中文语义名(如 猫猫),经 DY_EMOJI_ALIASES 映射到实际文件
* - 合成表情:组合文件名(fus),原样透传
* 字符集排除 [ ] : 空白 . @(. 防止误吞文件扩展名写法;@ 保证与 @提及 span 不重叠)
*/
export const DY_EMOJI_RE = /\[:([^\[\]:\n\s.@]+):\]/g;
export const DY_EMOJI_BASE = "/emoji/dy";
/**
* 默认表情语义别名(人工看图命名),顺序与 manifest.json 的 tos 一致。
* 面板「默认」tab 与消息内容均以中文短代码为准;更新表情资产时需同步维护本表。
* 用有序数组做单一数据源:对象键会把整数型 key(如 "666")重排到最前,不能依赖键顺序。
*/
const DY_EMOJI_ALIAS_PAIRS: readonly (readonly [string, string])[] = [
["猫猫", "003d8daaa58e4a2fb324bcd3baf4c785"],
["大哭", "0043de2fff65408ab7522538f7b3292a"],
["晕", "01ccc692f9094a0f9839706665c7e8bb"],
["炸弹", "02a91594d67e462faf7c77e827a16eec"],
["禁止", "02fcc587c8b4499080e0c03994967511"],
["5V5", "032007fd6c254a72a162d8c5bf78d512"],
["坏笑", "0431a35382c641679fe74f690241b533"],
["纸飞机", "0636e634f58f4410a8b22675d36f52a9"],
["玫瑰", "06abb0913446422cbac6ab18ea8fa44a"],
["翻白眼", "06df57cc81954ef8bf65dad06a01a69a"],
["无语", "072caed2f2bd4ed5ba35142965845d85"],
["摸头", "0795718a5fe247b19ff80b064d02c66d"],
["礼物", "08d888562e1f4ea086fc4f3eff2ef517"],
["火苗", "094c1f852c2343658b2677023755249b"],
["嘟嘴", "0e13c8681abe4239b3d468635b723d40"],
["心碎", "0eb6e5cde36346289150a83a118bfa62"],
["精选", "1482ac09926b45a1bab8f1dc98a68d22"],
["笑哭", "167a2c32c7874da889fc902f8cf4500a"],
["不屑", "1691cd385ecd4fb6b888d000900d74bc"],
["鞭炮", "16c850aade5b475fa83b67e0266f0222"],
["庆祝", "18d02bf442484371bb918e1f07c43a05"],
["粽子", "1e8aef1795db468682f8b63521834f39"],
["酷哭", "1eabd1db85dd4bddbbd842abe9cdce49"],
["大喊", "1ec163608a2a49ffbbc766e505885bb1"],
["怒", "1edc3de7713647c2b269f7f48b494b2d"],
["发奋", "1f71ffdc4df041ac836cdc81940f68ff"],
["OK", "21b1e1cdd15742e9aacc6cfd764260cc"],
["气球", "24a048472a4c429c8d01d0401e846285"],
["抱抱", "24b237f43dee47c5a068bf4457b97145"],
["生气", "2609e5a35a3f436084bd618ef819227f"],
["卖萌", "2618a9c1f9424c78aaf7e6174f0242b4"],
["尴尬", "28d74529207c43178cc72cba847daf12"],
["得意", "29288834d417481f97eb63fee5c30b53"],
["凋谢", "2b7cfe4c3c524b709bd3f15537c06911"],
["大笑", "2d1fca45aceb46b8b3a8d55084977851"],
["太阳", "2fb7d94d6e7642349793def00e5b3e0c"],
["彩带", "30d222de7c4a42cdb6d7a9ea7bea162b"],
["睡觉", "30d9ccb7e4ae4a43997e76fbabd6b5cd"],
["奸笑", "3139a081129f4832a70da8d7808114fa"],
["OK手", "317a9b7fe5f94023a8b2987173708362"],
["露齿笑", "33745171e4a544ad913b52ebb6b46b60"],
["捂脸笑", "3545637c30284f6bb732a444f91196ab"],
["吐舌", "365405db173b45978ea6a45bd47e9d87"],
["手枪", "3b366738018343ae9831a79fa17b3dae"],
["惊恐", "3c8520db7cbd418f95aea788ee6a7cf0"],
["舔屏", "3d13332de5da45498cd1ef193cc4afff"],
["捂脸", "3df5f3ca07e546b697b301ab9e2e3c23"],
["难受", "3e8a89ca5dc74cb88b4000551a1312a7"],
["眯眼", "4201288b0e1b47b59f9f031a53c3d687"],
["嘿嘿", "42fd189ab7ad4f478f220d34046916fb"],
["亲亲", "434e5830fe874fdca427b0d8b23a751d"],
["花痴", "4364693f63a34e2999c54cbbc75df4e9"],
["眨眼", "44ddc9a6bcd9471a858a33c382f6fafa"],
["鸡腿", "45a1632fd059454b91c71d8d54e46a1b"],
["小蓝", "45f8ce9a22b0466fbcd1bb2a841d2225"],
["愤怒", "476e92daceb14bee99e36b8f50a40e81"],
["祈祷", "478afb0911604f5289d9cbfd4183b404"],
["贱笑", "47eb8964fef7479c80a1088f405beb43"],
["汗", "47fe86652a1b4cf288de5f929cba5d80"],
["擦汗", "49b5a54103ae43b0b02a564a3feec5a8"],
["思考", "49bb5e7832d644dd99f50d3b6733a4c6"],
["咖啡", "4b5f538005d148248338d97c47e20f9e"],
["晕圈", "4c0f3ba463734e50981103650f440be0"],
["点赞", "4c4a175aaf2b4e0c98b8d46684d64564"],
["可怜", "4cd68b81179d421ebaf2c4e36b3630c6"],
["开心", "4ec5e9c2001249b79bf4ff0c6a431cae"],
["圣诞帽", "4f1aeeedf61a4051aaf0bc96a6ead49f"],
["爱心眼", "4f6a86e4bfd14f8c8434f6d643782a41"],
["碰拳", "4f815c8c1795412cbb67233685d785a1"],
["冒烟", "517f7385630e4641af73c3ea93e44c7d"],
["委屈", "52aeca47432745729c08609d46211ed1"],
["指左", "54994d1e88d94722a71960508a8e9ce6"],
["二哈", "54afb78ebf6a45a09c6dc7d77fc3d3fb"],
["加油", "54e9a10981a242dba426a4f63d4103ab"],
["疑问", "55656c6385754a35acce9f807c4afa32"],
["惊呆", "55c305ac8ebe4f2a8e5fed639f188614"],
["笑泪", "590d12b8677d4738ad9e989ea0280f81"],
["绝", "5aaaff01fd1f493a82821b1a3943bf68"],
["香蕉", "5b0b93a490394665bed2a52ae67a2975"],
["福", "5cdc7b1819c3484db61fa60eeb989e2e"],
["捧腹", "5d7d5f3c3f9548679fb73090a69f538a"],
["红包", "5defde4959044aaeae628e060e82043f"],
["猫震惊", "5faa8e1516204480b74b6f6a3122d578"],
["呆", "600e9cfaf3a14174b451e2206baf1e0f"],
["柴犬", "603e30989b05474788e3709d7eebd4ef"],
["666", "61267a18cbf847b3af73190bc52ae36a"],
["握手", "613459694fa24ae689aa306fd07944f8"],
["吐", "616147770ba044a3abed4224c46fe605"],
["惊讶", "622ddf449a5d4ac6a0e15445356cfb94"],
["套头", "626ec790acdf4c10b5309a99ae75ecb1"],
["嫌弃", "62d34256ab7845709616c5a7bf42aee1"],
["给力", "63b22c1e04f34e8a8816ba2b8ac46ae3"],
["斜眼", "6572851608ba4ab3acc0f0c879594d1b"],
["星星脸", "66fb1287c3f04b4bbb427a1368d12a22"],
["蓝怪", "6b280673ae0740e78d82178ee018a811"],
["猪猪", "6c3589f8daec4143bd9b9d9b41e802ec"],
["点踩", "6c80753d816442718d2f5ca44e1eb13a"],
["关注我", "6ed70f4a306a48bcbc12a138165fb09d"],
["巧克力", "71e20043b5f34b03b7dd85afb69f0503"],
["调皮", "72045b3ac8654458a94767f463804c82"],
["红心", "740e29f4ca6547ccb4f7cf3fe0463629"],
["泪喷", "770948b40fe14c51a73e18dff5a8d29c"],
["红唇", "77a76f873a1f4e6ba5eeff343391b213"],
["怪笑", "77e480a1a9234d94bfcab3429f5f96bb"],
["钻戒", "7d2da9bea4f0477aa530340355fda832"],
["崩溃", "7e606e0d73d843a2bc1c15219a1318cd"],
["绿帽", "7e79587db89a4404a0ebcf4aabba3d2d"],
["圣诞树", "8260b9fef99e430aa08ff9259355e907"],
["敲打", "831ef27451104b0e9eb89660c3d7b716"],
["龇牙", "86d772fc25a0404090613bad4a123947"],
["扎心", "8a054c74604844498fa9c4ae809f1c79"],
["爱心枪", "8a7a9fb41d444aa68dd5568ba72040aa"],
["心动", "8bb95fd220bf4efeb44c9b3e6c63afdf"],
["墨镜笑", "8cd39208016c428ca80d3468e24edb0a"],
["贴贴", "8dcb4606e9f14613a3bbf98a1646e56f"],
["微笑", "907e61eb04e54074a101b43035d50145"],
["挨揍", "91656d99d6d645a5995187454e3293f4"],
["耳机", "91aa9af22f664ba08ca3c66184f2a838"],
["打响指", "94266b5d083d45afb4309ed6ed905dd6"],
["转眼", "94c011b83a3e40cbbcfeb09c6adb2b43"],
["狂笑", "9501220f825547d699c8409184846166"],
["麻将", "956e469de272416ebeb0aafd780bd78e"],
["雪花", "98c4f361b8cc49e2aeff0b546664af42"],
["飞吻", "9a0887a55ff94cbbbadbbbdb14c7396a"],
["指右", "9a35f66a62804e90b31455ab6f0d99ee"],
["沉思", "9e2b0289cd994a91a77676265d5ab7a7"],
["裂开", "9eb467c1c4d849f39b45ad4e7beff0ce"],
["烟花", "9ebf943dbb974a5e8dd13e5a339cff18"],
["仓鼠", "9ff343dc38e54a9f98e7fa896dd95715"],
["揉脸", "a3d1ce9301c5410d88cc2470a3e11c30"],
["鼓掌", "a4d28f23c10e4efaa979b4d119b7ade4"],
["黑脸", "a4d9f336b64f4554a0790eed0f0e1a71"],
["数钱", "a7a5a4db025642a392ee10d5c74e65c9"],
["哇", "a90f7139a14140dbba575425193ca6c5"],
["尬笑", "a993e64d05cd428e876600a75129332f"],
["舒心", "ab54a06f377346f88f094fe6281103ff"],
["作揖", "abf7cc107d4740e4bc1e238b770ac889"],
["窃喜", "aca47d7bc40a442b933812e3f482549d"],
["跌倒", "aceb3d3827574b68a40f7c7ebf1e3049"],
["楚楚", "ad1d6ca9bc694b78b8b3ad816aaccf0d"],
["拍脸", "ada62e0815f141cdbdc5a76e6781786d"],
["击掌", "ae9cd379b8c448d5a3c3aa107c3272d8"],
["香吻", "aec920e3a21444c79a79defd76a45a75"],
["抓狂", "afa666d51d474fc6b2f0a10aca57c372"],
["摇扇", "b1c3d09acfe84b9280a28c4190fa0056"],
["doge", "b217d170f0c349fc8ea86615738a5c81"],
["咋舌", "b21a518ebd8b48caab9c814e40b63115"],
["咧嘴", "b341f7cdcaea452a83a78eb81b5a30b4"],
["掩面", "b379ea709e484581830373adf1bce572"],
["狗拳", "b485611a693d4eadadf29f5f055511f6"],
["糖葫芦", "b67a198b447a45d0a36041196e26e447"],
["黄瓜", "ba6c382121e94f52bb44f41b9156f447"],
["脸红", "bbcb745bbb1c463089a0d40177623c9e"],
["叫好", "bd1068daba564862be71631451611ba7"],
["白菜狗", "bd5541fc02514e7e8aefde9b0aabc652"],
["捂嘴", "bf6b3de54ab34732af82809635c704f8"],
["互粉", "bfa5963893bd41519ff31c7415f6956f"],
["蛋糕", "c08640142c304d76959ee198cc8a2efd"],
["撇嘴", "c10ec8abb6da4adbb3388d435a980568"],
["求机位", "c5c18a6bfb3d43f9a96f833f9a758d71"],
["心心眼", "c6605942715f4717805101c311b41f24"],
["蓝毛", "c6646c7cd6fc4f8d89f332c2d46e4597"],
["元宝", "c67bf9ed7bb34e00af6ef863d6ecfdad"],
["吐血", "c98569a7f04943d7a8de0c15cef36151"],
["便便", "cabe6bcd9889449daa87c2df765f379e"],
["鞠躬", "cb9f16ad45d84f7ca4256f20048e422a"],
["苹果", "cc5884dd4b814d81a9982bbcee136e45"],
["痴笑", "cca02095fe5f448ea2289ece0e2ac49f"],
["哈士奇", "cce25443e61846699d1f08782fb365bb"],
["猜拳", "cd619c9151a14094ab32f44f7487b980"],
["棒棒糖", "cd8647d2106b48fd9142c9c41562c8f7"],
["掩嘴", "cf1a3c5b317f4c0399ed39888d73abe7"],
["彩虹", "cfeddc926d3846eabda23e750364e82d"],
["酷脸", "d15c89c9f3364485b514d51b43b16771"],
["吃橘", "d2a361001193458ab3250ab557c7ec4b"],
["西瓜", "d2c0b2c0484b4bd59d6b266c5a80c199"],
["躺平", "d3084c0ac6e74eae84dd39cbc26d990d"],
["掩脸", "d34c2cb72f9f45a1bd21669f832b9a94"],
["鬼脸", "d3cbf267d0d8426e90edfb9c75059314"],
["加油牌", "d525a68955854abda1c8bdf7f62ebadb"],
["出拳", "d8a316cb0427498b87e396350b83b504"],
["加一", "d9840b29c0d84082b758580ff6da0281"],
["吃瓜", "db6bb519e6bc4b9b819e04b05e194586"],
["瞪眼", "dcaa1bb24fed447b9ef155462db2a890"],
["黑球", "ddd30bbe0a194430af6bcd6fef98570f"],
["狼狗", "de102f5602824afb88009091971a862d"],
["猫哭", "df7b9c648eef4720962f604f1c81a77c"],
["捂眼", "dfaa3f289fd54142ad6c8088ee516852"],
["蹲下", "e44b432ef18e4d84844e9eb1e7d135ac"],
["干笑", "e4b71ab984154f0ab1709bd88e9defda"],
["不悦", "e5275fd92a234e34984044e3b2213ebc"],
["捧脸", "e58ab050664b44eaa7a7c8b8d6fe9cf7"],
["羞涩", "e7f9e93ae5da435884e17c57442729f1"],
["盯", "e80c05fa6c4943759dba8486512901e0"],
["开怀", "e9078540aac945b4992ae19dbf4d0783"],
["红酒", "e959a423dd0a43fd8fcd5dd0499928cf"],
["灯笼", "ea0d63f135594b82ac3e386cf684046a"],
["干杯", "ea97458fd890435c9fa6df3520f66b1f"],
["军帽", "eb8d8e8a04e34db082437c083539dd2f"],
["禁烟花", "ebff2644a4ab4149a41f1bc7282879b1"],
["减一", "efa80a6e10f04dcc8de89092744b2bf9"],
["叼玫瑰", "f1d000c0d62a4d77b14641076a05f5b9"],
["嚣张", "f266b481b9e14e588228016138913c75"],
["拍我", "f2efb004f2f842b1bc20284bd2e010e3"],
["干饭", "f68c5e1160c449508d55cbe2acc0bd47"],
["德牧", "f730fe9f0bd44b59b8f0ed5069a747dc"],
["糖果", "f75de5be772c458e9a5469b32c75413e"],
["甜点", "f840c0bdd606425a8dd1190b80126674"],
["比心", "f87a6fb3ac774f2e8f9f1760e3d2de45"],
["迷恋", "fba0f99d4b8049cd905bfd076d4efbf2"],
["惊呼", "fba1c2ac23394a31bc1c3405148bd363"],
["郁闷", "fbbbf98a169642d386020f79d06af11c"],
["含泪", "fbd4350861fe49cd991e76e82e382715"],
["三文鱼", "fd24c9f2258f4640af610472b442f9ec"],
];
/**
* 默认表情分组:同类型相邻排列(参考抖音/飞书面板)。
* keys 必须恰好覆盖 DY_EMOJI_ALIAS_PAIRS 的全部中文 key 且不重复(有单测锁定)。
*/
export const DY_EMOJI_GROUPS: readonly { label: string; keys: string[] }[] = [
{
label: "笑脸",
keys: [
"微笑", "露齿笑", "笑哭", "笑泪", "捂脸笑", "嘿嘿", "贱笑", "奸笑", "怪笑", "痴笑",
"狂笑", "大笑", "捧腹", "开怀", "窃喜", "尬笑", "干笑", "咧嘴", "龇牙", "吐舌",
"舔屏", "星星脸", "花痴", "得意", "坏笑", "墨镜笑", "嚣张", "酷哭", "酷脸", "调皮",
"卖萌", "呆", "鬼脸", "脸红", "羞涩", "楚楚", "捧脸", "舒心", "开心", "精选",
],
},
{
label: "情绪",
keys: [
"大哭", "含泪", "泪喷", "委屈", "难受", "郁闷", "不悦", "无语", "汗", "擦汗",
"晕", "晕圈", "崩溃", "抓狂", "怒", "生气", "愤怒", "黑脸", "尴尬", "可怜",
"裂开", "吐", "吐血", "嘟嘴", "撇嘴", "嫌弃", "不屑", "翻白眼", "斜眼", "哇",
"大喊", "惊呆", "惊讶", "惊呼", "惊恐", "瞪眼", "盯", "眨眼", "眯眼", "转眼",
"疑问", "思考", "沉思", "咋舌", "绝", "睡觉", "躺平",
],
},
{
label: "手势",
keys: [
"OK", "OK手", "点赞", "点踩", "碰拳", "击掌", "出拳", "猜拳", "狗拳", "握手",
"作揖", "鞠躬", "祈祷", "打响指", "鼓掌", "加油", "加油牌", "发奋", "指左", "指右",
"摸头", "揉脸", "捂脸", "掩面", "掩脸", "掩嘴", "捂嘴", "捂眼", "拍脸", "拍我",
"套头", "挨揍", "跌倒", "蹲下", "抱抱",
],
},
{
label: "爱心",
keys: [
"红心", "心碎", "爱心眼", "心心眼", "爱心枪", "心动", "比心", "迷恋", "贴贴", "亲亲",
"香吻", "飞吻", "红唇", "绿帽",
],
},
{
label: "动物",
keys: [
"猫猫", "猫震惊", "猫哭", "二哈", "哈士奇", "柴犬", "白菜狗", "德牧", "狼狗", "猪猪",
"仓鼠", "小蓝", "蓝怪", "蓝毛", "三文鱼", "doge",
],
},
{
label: "食物",
keys: [
"鸡腿", "咖啡", "香蕉", "苹果", "西瓜", "黄瓜", "吃瓜", "吃橘", "糖葫芦", "棒棒糖",
"巧克力", "蛋糕", "糖果", "甜点", "粽子", "红酒", "干饭", "干杯",
],
},
{
label: "物品",
keys: [
"炸弹", "禁止", "手枪", "火苗", "冒烟", "太阳", "雪花", "彩虹", "气球", "纸飞机",
"玫瑰", "凋谢", "叼玫瑰", "礼物", "钻戒", "元宝", "红包", "数钱", "便便", "黑球",
"耳机", "摇扇", "麻将", "军帽", "圣诞帽", "圣诞树", "灯笼", "福", "鞭炮", "烟花",
"禁烟花", "彩带", "庆祝", "叫好", "求机位", "关注我", "互粉", "5V5", "666", "加一",
"减一", "给力", "敲打", "扎心",
],
},
];
/** 中文语义名 → 实际文件名查找表 */
export const DY_EMOJI_ALIASES: Record<string, string> =
Object.fromEntries(DY_EMOJI_ALIAS_PAIRS);
/** key → 实际文件名:中文语义名查别名表,合成表情组合名原样透传 */
export function dyEmojiFileKey(key: string): string {
return DY_EMOJI_ALIASES[key] ?? key;
}
export function dyEmojiUrl(key: string): string {
return `${DY_EMOJI_BASE}/${encodeURIComponent(dyEmojiFileKey(key))}.png`;
}
/**
* 把 [:key:] 短代码展开为 markdown 行内图片(供 Markdown 渲染管线)。
* 注意:不感知代码围栏,围栏内的短代码也会被展开(v1 已知限制)。
*/
export function expandDyEmojiShortcodes(text: string): string {
if (!text || !text.includes("[:")) return text;
return text.replace(DY_EMOJI_RE, (_m, key: string) => {
return `![dy:${key}](${dyEmojiUrl(key)})`;
});
}
export type DyEmojiManifest = { tos: string[]; fus: string[] };
/** 收集文本中的表情短代码位置(聊天等纯文本渲染用;每次用独立正则避免 g 的 lastIndex 状态) */
export function collectDyEmojiSpans(
text: string
): { start: number; end: number; key: string }[] {
if (!text || !text.includes("[:")) return [];
const spans: { start: number; end: number; key: string }[] = [];
const re = new RegExp(DY_EMOJI_RE.source, "g");
for (let m = re.exec(text); m; m = re.exec(text)) {
spans.push({ start: m.index, end: m.index + m[0].length, key: m[1] });
}
return spans;
}

View File

@@ -7,7 +7,7 @@
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts"
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts"
},
"dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Some files were not shown because too many files have changed in this diff Show More