From 4d93e455f9ccf0796cd2c9547af8ced622ba37f7 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 2 Sep 2026 03:12:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A7=81=E4=BF=A1=E8=A1=A8=E6=83=85?= =?UTF-8?q?=E6=8F=92=E5=85=A5=E7=BC=96=E8=BE=91=E6=A1=86=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=BD=9C=E4=B8=BA=E5=9B=BE=E7=89=87=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/src/components/PmComposerInput.tsx | 227 ++++++++++++++++++++ frontend/src/pages/MessagesPage.tsx | 54 ++--- frontend/src/styles/global.css | 34 ++- 3 files changed, 275 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/PmComposerInput.tsx diff --git a/frontend/src/components/PmComposerInput.tsx b/frontend/src/components/PmComposerInput.tsx new file mode 100644 index 0000000..6dda478 --- /dev/null +++ b/frontend/src/components/PmComposerInput.tsx @@ -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, '"'); +} + +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 `${alt}`; + } + return escapeHtml(part).replace(/\n/g, '
'); + }).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(function PmComposerInput( + { value, onChange, placeholder, maxLength = 4000, disabled, onSubmit }, + ref, +) { + const elRef = useRef(null); + const savedRangeRef = useRef(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 ( +
{ + 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; diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx index b4d914f..dcac5a3 100644 --- a/frontend/src/pages/MessagesPage.tsx +++ b/frontend/src/pages/MessagesPage.tsx @@ -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 ( {m[1] ); @@ -273,7 +275,7 @@ export default function MessagesPage() { const [draftEmbeds, setDraftEmbeds] = useState([]); const [showSticker, setShowSticker] = useState(false); const [imagePickerOpen, setImagePickerOpen] = useState(false); - const draftRef = useRef(null); + const composerRef = useRef(null); const draftsByPeerRef = useRef>(new Map()); const draftPeerRef = useRef(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 && (