576 lines
21 KiB
TypeScript
576 lines
21 KiB
TypeScript
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 isComposingRef = useRef(false);
|
||
const [text, setText] = useState(props.value);
|
||
const [focused, setFocused] = useState(false);
|
||
const [hasEditableContent, setHasEditableContent] = useState(() => promptTextHasContent(props.value));
|
||
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 = !hasEditableContent;
|
||
|
||
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) {
|
||
setHasEditableContent(promptTextHasContent(props.value));
|
||
return;
|
||
}
|
||
setText(props.value);
|
||
setHasEditableContent(promptTextHasContent(props.value));
|
||
if (!focused) {
|
||
requestAnimationFrame(() => renderToEditable(props.value));
|
||
}
|
||
}, [focused, props.value, text]);
|
||
|
||
useEffect(() => {
|
||
const cleaned = removeInvalidPlaygroundResourceTokens(text, props.uploads);
|
||
if (cleaned !== text) {
|
||
setText(cleaned);
|
||
setHasEditableContent(promptTextHasContent(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);
|
||
setHasEditableContent(promptTextHasContent(nextText));
|
||
if (typeof caret === 'number') {
|
||
editable.focus();
|
||
setCaretOffset(editable, caret);
|
||
}
|
||
}
|
||
|
||
function commitFromEditable(shouldInspectMention: boolean, event?: InputEvent) {
|
||
const editable = editableRef.current;
|
||
if (!editable || props.disabled) return;
|
||
const nextText = serializeEditableToPlainText(editable);
|
||
setHasEditableContent(promptTextHasContent(nextText));
|
||
if (isComposingRef.current || event?.isComposing) return;
|
||
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);
|
||
setHasEditableContent(promptTextHasContent(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={() => {
|
||
isComposingRef.current = false;
|
||
setHasEditableContent(editablePromptTextHasContent(editableRef.current));
|
||
requestAnimationFrame(() => commitFromEditable(true));
|
||
}}
|
||
onCompositionStart={() => {
|
||
isComposingRef.current = 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 promptTextHasContent(raw: string) {
|
||
return raw.trim().length > 0;
|
||
}
|
||
|
||
function editablePromptTextHasContent(editable: HTMLElement | null) {
|
||
return editable ? promptTextHasContent(serializeEditableToPlainText(editable)) : false;
|
||
}
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
function escapeAttr(value: string) {
|
||
return escapeHtml(value).replace(/"/g, '"');
|
||
}
|