fix: 私信表情插入编辑框,不再作为图片附件发送

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 03:12:33 +08:00
parent c958dcfd80
commit 4d93e455f9
3 changed files with 275 additions and 40 deletions

View File

@@ -0,0 +1,227 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import type { Sticker } from '../data/stickers';
export type PmComposerInputHandle = {
insertSticker: (sticker: Sticker) => void;
insertText: (text: string) => void;
focus: () => void;
};
type Props = {
value: string;
onChange: (next: string) => void;
placeholder?: string;
maxLength?: number;
disabled?: boolean;
onSubmit?: () => void;
};
/** 站点内置贴纸路径(评论/私信共用) */
export function isPmStickerSrc(src: string) {
return src.includes('/stickers/');
}
function escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function draftToHtml(text: string): string {
if (!text) return '';
const parts = text.split(/(!\[[^\]]*]\([^)]+\))/g);
return parts.map((part) => {
const m = part.match(/^!\[([^\]]*)]\(([^)]+)\)$/);
if (m) {
const alt = escapeHtml(m[1] || '表情');
const src = escapeHtml(m[2]);
const cls = isPmStickerSrc(m[2]) ? 'pm-composer__sticker' : 'pm-composer__inline-img';
return `<img class="${cls}" src="${src}" alt="${alt}" draggable="false">`;
}
return escapeHtml(part).replace(/\n/g, '<br>');
}).join('');
}
function serializeComposer(root: HTMLElement): string {
let out = '';
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
out += node.textContent || '';
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as HTMLElement;
const tag = el.tagName;
if (tag === 'IMG') {
const src = el.getAttribute('src') || '';
const alt = el.getAttribute('alt') || '表情';
out += `![${alt}](${src})`;
return;
}
if (tag === 'BR') {
out += '\n';
return;
}
if (tag === 'DIV' || tag === 'P') {
if (out.length > 0 && !out.endsWith('\n')) out += '\n';
el.childNodes.forEach(walk);
return;
}
el.childNodes.forEach(walk);
};
root.childNodes.forEach(walk);
return out.replace(/\n$/, '');
}
function placeCaretAfter(node: Node) {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
/** 私信输入框:可内联插入贴纸(与评论编辑器一致),序列化为 markdown 图片语法 */
const PmComposerInput = forwardRef<PmComposerInputHandle, Props>(function PmComposerInput(
{ value, onChange, placeholder, maxLength = 4000, disabled, onSubmit },
ref,
) {
const elRef = useRef<HTMLDivElement>(null);
const savedRangeRef = useRef<Range | null>(null);
const valueRef = useRef(value);
valueRef.current = value;
const emit = useCallback(() => {
const el = elRef.current;
if (!el) return;
const next = serializeComposer(el);
if (maxLength && next.length > maxLength) {
el.innerHTML = draftToHtml(valueRef.current);
return;
}
el.dataset.empty = next ? 'false' : 'true';
if (next !== valueRef.current) onChange(next);
}, [maxLength, onChange]);
const restoreRange = useCallback(() => {
const el = elRef.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel) return;
const saved = savedRangeRef.current;
if (saved && el.contains(saved.startContainer)) {
sel.removeAllRanges();
sel.addRange(saved);
return;
}
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}, []);
const saveRange = useCallback(() => {
const el = elRef.current;
const sel = window.getSelection();
if (!el || !sel || sel.rangeCount === 0) return;
if (!el.contains(sel.anchorNode)) return;
savedRangeRef.current = sel.getRangeAt(0).cloneRange();
}, []);
const insertNodes = useCallback((nodes: Node[]) => {
const el = elRef.current;
if (!el || !nodes.length) return;
restoreRange();
const sel = window.getSelection();
if (!sel) return;
const range = sel.rangeCount > 0 ? sel.getRangeAt(0) : document.createRange();
range.deleteContents();
const frag = document.createDocumentFragment();
nodes.forEach((n) => frag.appendChild(n));
const last = nodes[nodes.length - 1];
range.insertNode(frag);
placeCaretAfter(last);
saveRange();
emit();
}, [emit, restoreRange, saveRange]);
useImperativeHandle(ref, () => ({
insertSticker(sticker) {
if (!sticker.url) return;
const img = document.createElement('img');
img.className = 'pm-composer__sticker';
img.src = sticker.url;
img.alt = sticker.name || '表情';
img.draggable = false;
// 尾随空格,光标可停在贴纸右侧(对齐评论编辑器)
insertNodes([img, document.createTextNode(' ')]);
},
insertText(text) {
if (!text) return;
insertNodes([document.createTextNode(text)]);
},
focus() {
restoreRange();
},
}), [insertNodes, restoreRange]);
// 外部改 value切会话 / 发送清空)时回填;输入过程中 serialize 与 value 一致则不重绘
useEffect(() => {
const el = elRef.current;
if (!el) return;
if (serializeComposer(el) === value) {
el.dataset.empty = value ? 'false' : 'true';
return;
}
el.innerHTML = draftToHtml(value);
el.dataset.empty = value ? 'false' : 'true';
}, [value]);
useEffect(() => {
const onSel = () => saveRange();
document.addEventListener('selectionchange', onSel);
return () => document.removeEventListener('selectionchange', onSel);
}, [saveRange]);
return (
<div
ref={elRef}
className="pm-composer__input"
role="textbox"
aria-multiline="true"
aria-label="私信内容"
contentEditable={!disabled}
data-placeholder={placeholder || ''}
data-empty={value ? 'false' : 'true'}
suppressContentEditableWarning
onInput={emit}
onBlur={saveRange}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
if (text) insertNodes([document.createTextNode(text)]);
}}
onDrop={(e) => {
// 禁止把文件拖成「大图附件」;贴纸只走选择器
if (e.dataTransfer?.files?.length) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit?.();
}
}}
/>
);
});
export default PmComposerInput;

View File

@@ -31,6 +31,7 @@ import { userPath } from '../utils/userPath';
import { InFlowSiteFooter } from '../components/SiteFooter';
import StickerPicker from '../components/emoji/StickerPicker';
import type { Sticker } from '../data/stickers';
import PmComposerInput, { isPmStickerSrc, type PmComposerInputHandle } from '../components/PmComposerInput';
import { ArticleImagePickerDialog } from '../components/editor/ArticleImagePickerDialog';
import { Tooltip } from '../components/ui/Tooltip';
import { cn } from '@/lib/utils';
@@ -130,12 +131,13 @@ function PmBubbleContent({ content }: { content: string }) {
{parts.map((part, i) => {
const m = part.match(/^!\[([^\]]*)]\(([^)]+)\)$/);
if (m) {
const sticker = isPmStickerSrc(m[2]);
return (
<img
key={i}
className="pm-bubble__img"
className={sticker ? 'pm-bubble__sticker' : 'pm-bubble__img'}
src={m[2]}
alt={m[1] || '图片'}
alt={m[1] || (sticker ? '表情' : '图片')}
loading="lazy"
/>
);
@@ -273,7 +275,7 @@ export default function MessagesPage() {
const [draftEmbeds, setDraftEmbeds] = useState<PmDraftEmbed[]>([]);
const [showSticker, setShowSticker] = useState(false);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const draftRef = useRef<HTMLTextAreaElement>(null);
const composerRef = useRef<PmComposerInputHandle>(null);
const draftsByPeerRef = useRef<Map<number, PmDraft>>(new Map());
const draftPeerRef = useRef<number | null>(null);
const draftTextRef = useRef(draftText);
@@ -714,7 +716,7 @@ export default function MessagesPage() {
const send = async () => {
if (!peerSelected || selectedPeer === null || selectedPeer === 0) return;
const content = serializePmDraft(draftText, draftEmbeds);
const content = serializePmDraft(draftTextRef.current, draftEmbedsRef.current);
if (!content) {
notify.warning('请填写内容或添加图片');
return;
@@ -764,34 +766,15 @@ export default function MessagesPage() {
}
};
const insertAtCursor = useCallback((text: string) => {
const el = draftRef.current;
if (!el) {
updateDraftText((d) => d + text);
return;
}
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? el.value.length;
const next = el.value.slice(0, start) + text + el.value.slice(end);
updateDraftText(next);
requestAnimationFrame(() => {
el.focus();
const pos = start + text.length;
el.setSelectionRange(pos, pos);
});
}, [updateDraftText]);
const onPickSticker = useCallback((sticker: Sticker) => {
if (sticker.type === 'text' && sticker.text) {
insertAtCursor(sticker.text);
composerRef.current?.insertText(sticker.text);
} else if (sticker.url) {
updateDraftEmbeds((prev) => [
...prev,
{ id: newEmbedId(), url: sticker.url!, name: sticker.name || '表情' },
]);
composerRef.current?.insertSticker(sticker);
}
setShowSticker(false);
}, [insertAtCursor, updateDraftEmbeds]);
composerRef.current?.focus();
}, []);
const onInsertImages = useCallback((urls: string[]) => {
if (!urls.length) return;
@@ -1173,20 +1156,13 @@ export default function MessagesPage() {
{canCompose && (
<footer className="pm-composer">
<textarea
ref={draftRef}
className="pm-composer__input"
<PmComposerInput
ref={composerRef}
value={draftText}
onChange={(e) => updateDraftText(e.target.value)}
rows={3}
maxLength={4000}
onChange={updateDraftText}
placeholder={`发送给 ${title}`}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
onSubmit={() => { void send(); }}
disabled={sending}
/>
{draftEmbeds.length > 0 && (
<ul className="pm-composer__embeds" aria-label="待发送图片">

View File

@@ -13547,7 +13547,8 @@ a.pm-thread-head__name:hover {
box-sizing: border-box;
min-height: 72px;
max-height: 160px;
resize: vertical;
overflow-y: auto;
cursor: text;
border: 1px solid var(--j13-border-light);
border-radius: 10px;
padding: 10px 14px;
@@ -13557,6 +13558,8 @@ a.pm-thread-head__name:hover {
color: var(--color-text-1);
background: var(--j13-bg-workspace);
outline: none;
white-space: pre-wrap;
word-break: break-word;
transition: border-color 0.12s, box-shadow 0.12s;
}
@@ -13565,6 +13568,35 @@ a.pm-thread-head__name:hover {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--j13-green) 8%, transparent);
}
.pm-composer__input[data-empty="true"]::before {
content: attr(data-placeholder);
color: var(--color-text-4);
pointer-events: none;
}
.pm-composer__sticker,
.pm-bubble__sticker {
display: inline-block;
vertical-align: middle;
width: 28px;
height: 28px;
max-width: 28px;
margin: 0 1px;
border-radius: 4px;
object-fit: contain;
background: transparent;
}
.pm-composer__inline-img {
display: inline-block;
vertical-align: middle;
max-width: 96px;
max-height: 64px;
margin: 0 2px;
border-radius: 6px;
object-fit: contain;
}
.pm-composer__embeds {
display: flex;
flex-wrap: wrap;