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,36 +220,77 @@ 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"
className="j13-mention-token"
onClick={(e) => {
e.stopPropagation();
onOpenCard(user);
}}
onContextMenu={(e) => e.stopPropagation()}
>
key={`m-${i}`}
type="button"
className="j13-mention-token"
onClick={(e) => {
e.stopPropagation();
onOpenCard(user);
}}
onContextMenu={(e) => e.stopPropagation()}
>
@{user.nickname || user.username}
</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();
send();
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)" }}
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";
}}
/>
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" });
}}
/>
<button
type="button"
onClick={send}