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