feat: optimize playground media references

This commit is contained in:
2026-05-13 22:06:13 +08:00
parent 5868dd7e68
commit 0cd4e6fed1
6 changed files with 1522 additions and 39 deletions
@@ -0,0 +1,554 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { FileText, Music2, Video } from 'lucide-react';
export type PlaygroundMentionUploadKind = 'audio' | 'file' | 'image' | 'video';
export interface PlaygroundMentionUpload {
id: string;
kind: PlaygroundMentionUploadKind;
name: string;
url: string;
}
const resourceTokenPrefix = '<<<playground-resource:';
const resourceTokenSuffix = '>>>';
const resourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
const resourceTokenWithSpacePattern = /<<<playground-resource:([^>]+)>>>\s?/g;
export function buildPlaygroundResourceToken(id: string) {
return `${resourceTokenPrefix}${id}${resourceTokenSuffix}`;
}
export function removeInvalidPlaygroundResourceTokens(raw: string, uploads: PlaygroundMentionUpload[]) {
if (!raw.includes(resourceTokenPrefix)) return raw;
const validIds = new Set(uploads.map((item) => item.id));
return raw.replace(resourceTokenWithSpacePattern, (full, id: string) => validIds.has(id) ? full : '');
}
export function replacePlaygroundResourceTokens(
raw: string,
uploads: PlaygroundMentionUpload[],
mode: 'image' | 'video',
) {
if (!raw.includes(resourceTokenPrefix)) return raw;
const byId = new Map(uploads.map((item) => [item.id, item]));
return raw.replace(resourceTokenPattern, (full, id: string) => {
const item = byId.get(id);
if (!item) return '';
return resourcePromptLabel(item, uploads, mode);
});
}
export function PlaygroundPromptMentionInput(props: {
disabled?: boolean;
placeholder: string;
uploads: PlaygroundMentionUpload[];
value: string;
onChange: (value: string) => void;
}) {
const editableRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const blurTimerRef = useRef<number | undefined>(undefined);
const [text, setText] = useState(props.value);
const [focused, setFocused] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionAtIndex, setMentionAtIndex] = useState(-1);
const [mentionSearch, setMentionSearch] = useState('');
const [highlightIndex, setHighlightIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, placement: 'bottom' as 'bottom' | 'top' });
const showPlaceholder = text.trim().length === 0;
const uploadSignature = useMemo(
() => props.uploads.map((item) => `${item.id}:${item.kind}:${item.name}:${item.url}`).join('|'),
[props.uploads],
);
const mentionItems = useMemo(() => props.uploads.map((item, index) => ({
item,
label: mentionDisplayLabel(item, props.uploads),
searchText: `${item.name} ${mentionDisplayLabel(item, props.uploads)} ${uploadKindChinese(item.kind)} ${index + 1}`.toLowerCase(),
token: buildPlaygroundResourceToken(item.id),
})), [props.uploads]);
const filteredMentionItems = useMemo(() => {
const keyword = mentionSearch.trim().toLowerCase();
if (!keyword) return mentionItems;
return mentionItems.filter((item) => item.searchText.includes(keyword));
}, [mentionItems, mentionSearch]);
useEffect(() => {
if (props.value === text) return;
setText(props.value);
if (!focused) {
requestAnimationFrame(() => renderToEditable(props.value));
}
}, [focused, props.value, text]);
useEffect(() => {
const cleaned = removeInvalidPlaygroundResourceTokens(text, props.uploads);
if (cleaned !== text) {
setText(cleaned);
props.onChange(cleaned);
requestAnimationFrame(() => renderToEditable(cleaned));
return;
}
if (!focused) {
requestAnimationFrame(() => renderToEditable(text));
}
}, [uploadSignature]);
useEffect(() => {
requestAnimationFrame(() => renderToEditable(text));
return () => {
if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);
};
}, []);
const updateMentionDropdownPosition = useCallback(() => {
const editable = editableRef.current;
const root = editable?.parentElement;
if (!editable || !root) return;
const rootRect = root.getBoundingClientRect();
const caretRect = getCaretClientRect(editable) ?? editable.getBoundingClientRect();
const dropdown = dropdownRef.current;
const viewportPadding = 8;
const gap = 6;
const dropdownWidth = Math.min(dropdown?.offsetWidth ?? 320, Math.max(180, window.innerWidth - viewportPadding * 2));
const dropdownHeight = Math.min(dropdown?.offsetHeight ?? 220, Math.max(120, window.innerHeight - viewportPadding * 2));
const minLeft = viewportPadding - rootRect.left;
const maxLeft = window.innerWidth - viewportPadding - dropdownWidth - rootRect.left;
const left = clamp(caretRect.left - rootRect.left, minLeft, Math.max(minLeft, maxLeft));
const belowTop = caretRect.bottom + gap;
const aboveTop = caretRect.top - dropdownHeight - gap;
const shouldOpenAbove = belowTop + dropdownHeight > window.innerHeight - viewportPadding && aboveTop >= viewportPadding;
const viewportTop = shouldOpenAbove
? Math.max(viewportPadding, aboveTop)
: Math.min(belowTop, window.innerHeight - viewportPadding - dropdownHeight);
setDropdownPosition({
top: viewportTop - rootRect.top,
left,
placement: shouldOpenAbove ? 'top' : 'bottom',
});
}, []);
useEffect(() => {
if (!mentionOpen) return;
const frame = window.requestAnimationFrame(updateMentionDropdownPosition);
const handleViewportChange = () => updateMentionDropdownPosition();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [filteredMentionItems.length, mentionOpen, mentionSearch, updateMentionDropdownPosition]);
function renderToEditable(nextText = text, caret?: number) {
const editable = editableRef.current;
if (!editable) return;
editable.innerHTML = textToHtml(nextText, props.uploads);
if (typeof caret === 'number') {
editable.focus();
setCaretOffset(editable, caret);
}
}
function commitFromEditable(shouldInspectMention: boolean, event?: InputEvent) {
const editable = editableRef.current;
if (!editable || props.disabled || isComposing) return;
const nextText = serializeEditableToPlainText(editable);
setText(nextText);
props.onChange(nextText);
if (!shouldInspectMention) return;
inspectMentionTrigger(nextText, getCaretOffset(editable), event);
}
function inspectMentionTrigger(nextText: string, caret: number, event?: InputEvent) {
const beforeCaret = nextText.slice(0, caret);
const atIndex = Math.max(beforeCaret.lastIndexOf('@'), beforeCaret.lastIndexOf(''));
if (atIndex < 0) {
closeMention();
return;
}
const search = beforeCaret.slice(atIndex + 1);
if (/\s/.test(search)) {
closeMention();
return;
}
const inputData = event?.data ?? '';
if (!mentionOpen && search.length > 0 && inputData !== '@' && inputData !== '') {
closeMention();
return;
}
setMentionAtIndex(atIndex);
setMentionSearch(search);
setMentionOpen(true);
setHighlightIndex(0);
requestAnimationFrame(updateMentionDropdownPosition);
}
function closeMention() {
setMentionOpen(false);
setMentionSearch('');
setMentionAtIndex(-1);
setHighlightIndex(0);
}
function applyMention(token: string) {
const editable = editableRef.current;
if (!editable || mentionAtIndex < 0) return;
const currentText = serializeEditableToPlainText(editable);
const replaceEnd = Math.max(mentionAtIndex + 1, mentionAtIndex + 1 + mentionSearch.length);
const before = currentText.slice(0, mentionAtIndex);
const after = currentText.slice(replaceEnd);
const nextText = `${before}${token} ${after}`;
const nextCaret = before.length + token.length + 1;
setText(nextText);
props.onChange(nextText);
closeMention();
requestAnimationFrame(() => renderToEditable(nextText, nextCaret));
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (props.disabled) {
event.preventDefault();
return;
}
if (!mentionOpen || !filteredMentionItems.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightIndex((current) => (current + 1) % filteredMentionItems.length);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightIndex((current) => (current - 1 + filteredMentionItems.length) % filteredMentionItems.length);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const item = filteredMentionItems[Math.min(highlightIndex, filteredMentionItems.length - 1)];
if (item) applyMention(item.token);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeMention();
}
}
return (
<div className="promptMentionInput">
<div
ref={editableRef}
className="promptMentionEditable"
contentEditable={!props.disabled}
role="textbox"
aria-label={props.placeholder}
aria-multiline="true"
suppressContentEditableWarning
onBlur={() => {
blurTimerRef.current = window.setTimeout(() => {
setFocused(false);
closeMention();
commitFromEditable(false);
}, 120);
}}
onCompositionEnd={() => {
setIsComposing(false);
requestAnimationFrame(() => commitFromEditable(true));
}}
onCompositionStart={() => setIsComposing(true)}
onFocus={() => {
setFocused(true);
if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);
}}
onInput={(event) => commitFromEditable(true, event.nativeEvent instanceof InputEvent ? event.nativeEvent : undefined)}
onKeyDown={handleKeyDown}
onPaste={(event) => {
if (props.disabled) return;
event.preventDefault();
const plainText = event.clipboardData.getData('text/plain');
document.execCommand('insertText', false, plainText);
}}
/>
{showPlaceholder && <div className="promptMentionPlaceholder" aria-hidden="true">{props.placeholder}</div>}
{mentionOpen && (
<div
ref={dropdownRef}
className="promptMentionDropdown"
data-placement={dropdownPosition.placement}
style={{ left: dropdownPosition.left, top: dropdownPosition.top }}
onMouseDown={(event) => event.preventDefault()}
>
{filteredMentionItems.length ? (
filteredMentionItems.map((candidate, index) => (
<button
type="button"
className="promptMentionItem"
data-active={index === highlightIndex}
key={candidate.item.id}
onMouseEnter={() => setHighlightIndex(index)}
onMouseDown={(event) => {
event.preventDefault();
applyMention(candidate.token);
}}
>
<MentionThumb item={candidate.item} />
<span>
<strong>{candidate.label}</strong>
<small>{candidate.item.name}</small>
</span>
<em>{uploadKindChinese(candidate.item.kind)}</em>
</button>
))
) : (
<div className="promptMentionEmpty"></div>
)}
</div>
)}
</div>
);
}
function MentionThumb(props: { item: PlaygroundMentionUpload }) {
if (props.item.kind === 'image') {
return <img className="promptMentionThumb" src={props.item.url} alt="" draggable={false} />;
}
if (props.item.kind === 'video') {
return <video className="promptMentionThumb" src={props.item.url} muted playsInline preload="metadata" />;
}
if (props.item.kind === 'audio') {
return <Music2 className="promptMentionThumbIcon" size={16} />;
}
return <FileText className="promptMentionThumbIcon" size={16} />;
}
function textToHtml(raw: string, uploads: PlaygroundMentionUpload[]) {
const uploadById = new Map(uploads.map((item) => [item.id, item]));
const parts: string[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
const re = new RegExp(resourceTokenPattern);
while ((match = re.exec(raw)) !== null) {
if (match.index > lastIndex) {
parts.push(escapeHtml(raw.slice(lastIndex, match.index)));
}
const token = match[0] ?? '';
const id = match[1] ?? '';
const item = uploadById.get(id);
parts.push(item ? mentionChipHtml(token, item, uploads) : '');
lastIndex = match.index + token.length;
}
if (lastIndex < raw.length) {
parts.push(escapeHtml(raw.slice(lastIndex)));
}
return parts.join('').replace(/\n/g, '<br>');
}
function mentionChipHtml(token: string, item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
const label = mentionDisplayLabel(item, uploads);
const thumb = item.kind === 'image'
? `<img class="promptMentionChipThumb" src="${escapeAttr(item.url)}" alt="" draggable="false">`
: item.kind === 'video'
? `<video class="promptMentionChipThumb" src="${escapeAttr(item.url)}" muted preload="metadata" playsinline></video>`
: `<span class="promptMentionChipThumb promptMentionChipThumbPlaceholder">${escapeHtml(uploadKindShort(item.kind))}</span>`;
return `<span contenteditable="false" class="promptMentionChip" data-token="${escapeAttr(token)}">${thumb}<span>${escapeHtml(label)}</span></span>`;
}
function getCaretClientRect(editable: HTMLElement) {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const originalRange = selection.getRangeAt(0);
if (!editable.contains(originalRange.startContainer)) return null;
const collapsedRange = originalRange.cloneRange();
collapsedRange.collapse(true);
let rect = collapsedRange.getBoundingClientRect();
if (rect.width || rect.height) return rect;
const marker = document.createElement('span');
marker.textContent = '\u200b';
const restoreRange = originalRange.cloneRange();
collapsedRange.insertNode(marker);
rect = marker.getBoundingClientRect();
marker.remove();
selection.removeAllRanges();
selection.addRange(restoreRange);
return rect.width || rect.height ? rect : null;
}
function serializeEditableToPlainText(editable: HTMLElement) {
const parts: string[] = [];
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
parts.push(node.textContent ?? '');
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as HTMLElement;
const token = element.getAttribute('data-token');
if (token) {
parts.push(token);
return;
}
if (element.tagName === 'BR') {
parts.push('\n');
return;
}
if (element.tagName === 'DIV' && parts.length && !parts[parts.length - 1]?.endsWith('\n')) {
parts.push('\n');
}
element.childNodes.forEach(walk);
};
editable.childNodes.forEach(walk);
return parts.join('');
}
function getCaretOffset(editable: HTMLElement) {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
let offset = 0;
const walk = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
const textLength = (node.textContent ?? '').length;
if (node === range.startContainer) {
offset += range.startOffset;
return true;
}
offset += textLength;
return false;
}
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const element = node as HTMLElement;
const token = element.getAttribute('data-token');
if (token) {
if (node === range.startContainer || element.contains(range.startContainer)) {
offset += range.startOffset > 0 ? token.length : 0;
return true;
}
offset += token.length;
return false;
}
if (element.tagName === 'BR') {
offset += 1;
return false;
}
for (const child of Array.from(element.childNodes)) {
if (walk(child)) return true;
}
return false;
};
for (const child of Array.from(editable.childNodes)) {
if (walk(child)) break;
}
return offset;
}
function setCaretOffset(editable: HTMLElement, targetOffset: number) {
const selection = document.getSelection();
if (!selection) return;
let offset = 0;
let targetNode: Node | null = null;
let targetNodeOffset = 0;
const walk = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
const textLength = (node.textContent ?? '').length;
if (offset + textLength >= targetOffset) {
targetNode = node;
targetNodeOffset = Math.max(0, targetOffset - offset);
return true;
}
offset += textLength;
return false;
}
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const element = node as HTMLElement;
const token = element.getAttribute('data-token');
if (token) {
if (offset + token.length >= targetOffset) {
targetNode = node;
targetNodeOffset = targetOffset <= offset ? 0 : 1;
return true;
}
offset += token.length;
return false;
}
if (element.tagName === 'BR') {
if (offset + 1 >= targetOffset) {
targetNode = node;
targetNodeOffset = 0;
return true;
}
offset += 1;
return false;
}
for (const child of Array.from(element.childNodes)) {
if (walk(child)) return true;
}
return false;
};
for (const child of Array.from(editable.childNodes)) {
if (walk(child)) break;
}
if (!targetNode) {
targetNode = editable;
targetNodeOffset = editable.childNodes.length;
}
const range = document.createRange();
range.setStart(targetNode, targetNode.nodeType === Node.TEXT_NODE ? targetNodeOffset : Math.min(targetNodeOffset, targetNode.childNodes.length));
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
function mentionDisplayLabel(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
return `@${uploadKindChinese(item.kind)} ${uploadKindIndex(item, uploads)}`;
}
function resourcePromptLabel(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[], mode: 'image' | 'video') {
const kind = mode === 'image' ? 'image' : uploadKindEnglish(item.kind);
return `${kind} ${uploadKindIndex(item, uploads)}`;
}
function uploadKindIndex(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
const sameKind = uploads.filter((upload) => upload.kind === item.kind);
return Math.max(1, sameKind.findIndex((upload) => upload.id === item.id) + 1);
}
function uploadKindChinese(kind: PlaygroundMentionUploadKind) {
if (kind === 'image') return '图像';
if (kind === 'video') return '视频';
if (kind === 'audio') return '音频';
return '文件';
}
function uploadKindEnglish(kind: PlaygroundMentionUploadKind) {
if (kind === 'image') return 'image';
if (kind === 'video') return 'video';
if (kind === 'audio') return 'audio';
return 'file';
}
function uploadKindShort(kind: PlaygroundMentionUploadKind) {
if (kind === 'image') return '图';
if (kind === 'video') return '视';
if (kind === 'audio') return '音';
return '文';
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeAttr(value: string) {
return escapeHtml(value).replace(/"/g, '&quot;');
}