diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx index cbccd94..51a0cc3 100644 --- a/frontend/app/chat/[id]/ChatRoomClient.tsx +++ b/frontend/app/chat/[id]/ChatRoomClient.tsx @@ -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, onOpenCard: (u: User) => void ) { - const parts: React.ReactNode[] = []; + if (!content) return null; + // 合并 @提及 与表情短代码 span(互不重叠:短代码字符集内无 @) + const emojiSpans = collectDyEmojiSpans(content); + const inEmoji = new Array(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 ? ( ) : ( @{m[1]} - ) - ); - 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: ( + + ), + }); + } + 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) { - 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( - - @{s.key} - - ); - 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 +) { + 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 -): { 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 -): { 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>( () => new Map() @@ -456,9 +611,52 @@ export default function ChatRoomClient({ const longPressTimer = useRef | null>(null); const listRef = useRef(null); - const inputRef = useRef(null); - const mirrorRef = useRef(null); + const inputRef = useRef(null); + /** 富输入区最近一次同步到 DOM 的 content 纯文本(区分内部输入与外部内容变更) */ + const lastRichText = useRef(""); const msgRefs = useRef>(new Map()); + const composerBoxRef = useRef(null); + /** 表情按钮+面板容器:外点(含点击输入框)即关面板 */ + const emojiPanelWrapRef = useRef(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) => { @@ -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) => { + const handleRichKeyDown = (e: React.KeyboardEvent) => { + // 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 = () => { + /** 粘贴一律按纯文本插入(换行转
),避免富文本结构污染序列化 */ + const handleRichPaste = (e: React.ClipboardEvent) => { + 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 ? (
{replyTo && (
-
- {content ? ( -
- {renderComposerMirror(content, mentionTokens)} -
- ) : null} -