Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c3b3a710c | |||
| 34aee91a13 | |||
| 2f96a36275 | |||
| 7d8c79f61f |
42
backend/model/board_seed_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 默认板块只在空库播种:站长改名/删除板块后重启不得复活默认板块(0.2.1 事故回归)
|
||||
func TestSeedDefaultBoardsOnlyWhenEmpty(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Board{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
// 空库首启:播种 4 个默认板块
|
||||
seedDefaultBoards(db)
|
||||
var n int64
|
||||
db.Model(&Board{}).Count(&n)
|
||||
if n != 4 {
|
||||
t.Fatalf("空库应播种 4 个默认板块,实际 %d", n)
|
||||
}
|
||||
|
||||
// 站长改名后重启:不再复活默认板块
|
||||
if err := db.Model(&Board{}).Where("name = ?", "综合讨论").Update("name", "新综合").Error; err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
seedDefaultBoards(db)
|
||||
db.Model(&Board{}).Count(&n)
|
||||
if n != 4 {
|
||||
t.Fatalf("改名后重启不应重复播种,实际 %d 个板块", n)
|
||||
}
|
||||
var revived int64
|
||||
db.Model(&Board{}).Where("name = ?", "综合讨论").Count(&revived)
|
||||
if revived != 0 {
|
||||
t.Fatal("默认板块「综合讨论」被复活")
|
||||
}
|
||||
}
|
||||
@@ -382,20 +382,23 @@ func backfillBoardDefaults(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultBoards 写入默认板块
|
||||
// seedDefaultBoards 仅在板块表为空(全新站点首启)时写入默认板块。
|
||||
// 曾按「名字是否存在」补种:站长改名板块后每次重启都会复活一套默认板块
|
||||
// (真实事故:0.2.1 重启后默认 4 板块重现)。表里已有任何未删除板块即视为已初始化。
|
||||
func seedDefaultBoards(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&Board{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
defaults := []Board{
|
||||
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
}
|
||||
for _, b := range defaults {
|
||||
var count int64
|
||||
db.Model(&Board{}).Where("name = ?", b.Name).Count(&count)
|
||||
if count == 0 {
|
||||
_ = db.Create(&b).Error
|
||||
}
|
||||
for i := range defaults {
|
||||
_ = db.Create(&defaults[i]).Error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
30
frontend/components/EmojiImg.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
195
frontend/components/EmojiPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? "上传中…" : "图片"}
|
||||
|
||||
@@ -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 || "图片加载失败"}>
|
||||
|
||||
100
frontend/lib/dyEmoji.test.ts
Normal 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("你好[:猫猫:]!"),
|
||||
`你好!`
|
||||
);
|
||||
});
|
||||
it("合成表情组合名短代码", () => {
|
||||
assert.equal(
|
||||
expandDyEmojiShortcodes("[:001--002--heart:]"),
|
||||
""
|
||||
);
|
||||
});
|
||||
it("多次替换且共享前缀", () => {
|
||||
assert.equal(
|
||||
expandDyEmojiShortcodes("[:猫猫:][:aa:][:猫猫:]"),
|
||||
``
|
||||
);
|
||||
});
|
||||
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第二行"),
|
||||
`第一行\n第二行`
|
||||
);
|
||||
});
|
||||
it("连续使用共享同一正则(lastIndex 复用安全)", () => {
|
||||
assert.equal(expandDyEmojiShortcodes("[:猫猫:]"), ``);
|
||||
assert.equal(expandDyEmojiShortcodes("[:b:]"), "");
|
||||
assert.equal(expandDyEmojiShortcodes("纯文本"), "纯文本");
|
||||
assert.equal(expandDyEmojiShortcodes("[:c:]"), "");
|
||||
});
|
||||
it("正则含 g 标志(供分段渲染使用)", () => {
|
||||
assert.ok(DY_EMOJI_RE.global);
|
||||
});
|
||||
});
|
||||
341
frontend/lib/dyEmoji.ts
Normal 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 `})`;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
BIN
frontend/public/emoji/dy/002--001--eye.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
BIN
frontend/public/emoji/dy/002--003--eye.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/emoji/dy/002--004--eye.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
BIN
frontend/public/emoji/dy/002--008--eye.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
BIN
frontend/public/emoji/dy/002--010--eye.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/emoji/dy/002--011--eye.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
BIN
frontend/public/emoji/dy/002--012--eye.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |