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}

View File

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

View File

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