fix: 私信表情插入编辑框,不再作为图片附件发送
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
227
frontend/src/components/PmComposerInput.tsx
Normal file
227
frontend/src/components/PmComposerInput.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 += ``;
|
||||
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;
|
||||
@@ -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="待发送图片">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user