feat: 论坛 emoji 表情支持
- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件 - Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本 - 聊天室与首页适配
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user