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 { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import StickerPicker from '../components/emoji/StickerPicker';
|
import StickerPicker from '../components/emoji/StickerPicker';
|
||||||
import type { Sticker } from '../data/stickers';
|
import type { Sticker } from '../data/stickers';
|
||||||
|
import PmComposerInput, { isPmStickerSrc, type PmComposerInputHandle } from '../components/PmComposerInput';
|
||||||
import { ArticleImagePickerDialog } from '../components/editor/ArticleImagePickerDialog';
|
import { ArticleImagePickerDialog } from '../components/editor/ArticleImagePickerDialog';
|
||||||
import { Tooltip } from '../components/ui/Tooltip';
|
import { Tooltip } from '../components/ui/Tooltip';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -130,12 +131,13 @@ function PmBubbleContent({ content }: { content: string }) {
|
|||||||
{parts.map((part, i) => {
|
{parts.map((part, i) => {
|
||||||
const m = part.match(/^!\[([^\]]*)]\(([^)]+)\)$/);
|
const m = part.match(/^!\[([^\]]*)]\(([^)]+)\)$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
|
const sticker = isPmStickerSrc(m[2]);
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
key={i}
|
key={i}
|
||||||
className="pm-bubble__img"
|
className={sticker ? 'pm-bubble__sticker' : 'pm-bubble__img'}
|
||||||
src={m[2]}
|
src={m[2]}
|
||||||
alt={m[1] || '图片'}
|
alt={m[1] || (sticker ? '表情' : '图片')}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -273,7 +275,7 @@ export default function MessagesPage() {
|
|||||||
const [draftEmbeds, setDraftEmbeds] = useState<PmDraftEmbed[]>([]);
|
const [draftEmbeds, setDraftEmbeds] = useState<PmDraftEmbed[]>([]);
|
||||||
const [showSticker, setShowSticker] = useState(false);
|
const [showSticker, setShowSticker] = useState(false);
|
||||||
const [imagePickerOpen, setImagePickerOpen] = 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 draftsByPeerRef = useRef<Map<number, PmDraft>>(new Map());
|
||||||
const draftPeerRef = useRef<number | null>(null);
|
const draftPeerRef = useRef<number | null>(null);
|
||||||
const draftTextRef = useRef(draftText);
|
const draftTextRef = useRef(draftText);
|
||||||
@@ -714,7 +716,7 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!peerSelected || selectedPeer === null || selectedPeer === 0) return;
|
if (!peerSelected || selectedPeer === null || selectedPeer === 0) return;
|
||||||
const content = serializePmDraft(draftText, draftEmbeds);
|
const content = serializePmDraft(draftTextRef.current, draftEmbedsRef.current);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
notify.warning('请填写内容或添加图片');
|
notify.warning('请填写内容或添加图片');
|
||||||
return;
|
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) => {
|
const onPickSticker = useCallback((sticker: Sticker) => {
|
||||||
if (sticker.type === 'text' && sticker.text) {
|
if (sticker.type === 'text' && sticker.text) {
|
||||||
insertAtCursor(sticker.text);
|
composerRef.current?.insertText(sticker.text);
|
||||||
} else if (sticker.url) {
|
} else if (sticker.url) {
|
||||||
updateDraftEmbeds((prev) => [
|
composerRef.current?.insertSticker(sticker);
|
||||||
...prev,
|
|
||||||
{ id: newEmbedId(), url: sticker.url!, name: sticker.name || '表情' },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
setShowSticker(false);
|
setShowSticker(false);
|
||||||
}, [insertAtCursor, updateDraftEmbeds]);
|
composerRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onInsertImages = useCallback((urls: string[]) => {
|
const onInsertImages = useCallback((urls: string[]) => {
|
||||||
if (!urls.length) return;
|
if (!urls.length) return;
|
||||||
@@ -1173,20 +1156,13 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
{canCompose && (
|
{canCompose && (
|
||||||
<footer className="pm-composer">
|
<footer className="pm-composer">
|
||||||
<textarea
|
<PmComposerInput
|
||||||
ref={draftRef}
|
ref={composerRef}
|
||||||
className="pm-composer__input"
|
|
||||||
value={draftText}
|
value={draftText}
|
||||||
onChange={(e) => updateDraftText(e.target.value)}
|
onChange={updateDraftText}
|
||||||
rows={3}
|
|
||||||
maxLength={4000}
|
|
||||||
placeholder={`发送给 ${title}…`}
|
placeholder={`发送给 ${title}…`}
|
||||||
onKeyDown={(e) => {
|
onSubmit={() => { void send(); }}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
disabled={sending}
|
||||||
e.preventDefault();
|
|
||||||
void send();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{draftEmbeds.length > 0 && (
|
{draftEmbeds.length > 0 && (
|
||||||
<ul className="pm-composer__embeds" aria-label="待发送图片">
|
<ul className="pm-composer__embeds" aria-label="待发送图片">
|
||||||
|
|||||||
@@ -13547,7 +13547,8 @@ a.pm-thread-head__name:hover {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 72px;
|
min-height: 72px;
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
resize: vertical;
|
overflow-y: auto;
|
||||||
|
cursor: text;
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border-light);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -13557,6 +13558,8 @@ a.pm-thread-head__name:hover {
|
|||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
background: var(--j13-bg-workspace);
|
background: var(--j13-bg-workspace);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
transition: border-color 0.12s, box-shadow 0.12s;
|
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);
|
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 {
|
.pm-composer__embeds {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user