Download frontend/src/components/widgets/multitrack/PromptContentEditor.tsx from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 35.1 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/frontend/src/components/widgets/multitrack/PromptContentEditor.tsx
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/frontend/src/components/widgets/multitrack/PromptContentEditor.tsx
-
curl -L -o PromptContentEditor.tsx https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/frontend/src/components/widgets/multitrack/PromptContentEditor.tsx
35.1 kB
| import { useEffect, useMemo, useRef, useState } from 'react' | |
| import { createPortal } from 'react-dom' | |
| import { AudioLines, Clapperboard, ImageIcon } from 'lucide-react' | |
| import { Button } from '@/components/ui/button' | |
| import { CUSTOM_NODE_CLASS } from '@/lib/constants' | |
| import { useT } from '@/lib/i18n' | |
| import { cn } from '@/lib/utils' | |
| export type PromptReferenceType = 'image' | 'audio' | 'video' | |
| export interface PromptReferenceResource { | |
| id: string | |
| type: PromptReferenceType | |
| index: number | |
| label: string | |
| detail: string | |
| token: string | |
| color: string | |
| thumbnailUrl?: string | |
| } | |
| interface PromptContentEditorProps { | |
| value: string | |
| onChange: (value: string) => void | |
| ariaLabel: string | |
| placeholder: string | |
| resources?: PromptReferenceResource[] | |
| mentionsEnabled?: boolean | |
| interactivePlaceholder?: boolean | |
| highlightSystemVariables?: boolean | |
| highlightPromptSemantics?: boolean | |
| highlightPipes?: boolean | |
| className?: string | |
| testId?: string | |
| } | |
| interface MentionState { | |
| start: number | |
| end: number | |
| query: string | |
| left: number | |
| top: number | |
| activeIndex: number | |
| } | |
| const H3_LANGUAGE_LABELS = new Set([ | |
| 'arabic', | |
| 'chinese', | |
| 'english', | |
| 'french', | |
| 'german', | |
| 'hindi', | |
| 'indonesian', | |
| 'italian', | |
| 'japanese', | |
| 'korean', | |
| 'malay', | |
| 'portuguese', | |
| 'russian', | |
| 'spanish', | |
| 'thai', | |
| 'turkish', | |
| 'vietnamese', | |
| ]) | |
| const TOKEN_PATTERN = /<[^<>\n]*>|@(?:图片|音频|视频|Picture|Image|Audio|Video)\s*\d+|\{[^{}\n]*\}|\[[^\[\]\n]*\]|\(S\d+(?:\s*,\s*S?\d+)*\)|(?:subject_definitions|summary|retention_analysis|detailed_description|overall_soundscape|non_diegetic_music)\s*:\s*|(?:主体定义|摘要|留存分析|详细描述|整体声景|非叙事性音乐)\s*[::]\s*|[||]/gi | |
| const PROMPT_CHIP_CLASS = 'prompt-reference-chip inline-flex h-[1.6em] max-w-full items-center gap-1 rounded-md border px-1.5 py-0.5 mx-1 align-middle font-semibold leading-none shadow-sm' | |
| const EMPHASIS_CLASS = 'font-semibold text-muted-foreground' | |
| function isH3PromptSemantic(token: string): boolean { | |
| if (/^<Subject\s+\d+>$/i.test(token)) return true | |
| if (/^<\/?d>$/i.test(token) || /^<(?:scenetrans|cutoff)>$/i.test(token)) return true | |
| if (/^\[Shot\s+\d+\]$/i.test(token) || /^\(S\d+(?:\s*,\s*S?\d+)*\)$/i.test(token)) return true | |
| return isH3LanguageSemantic(token) | |
| } | |
| function isH3LanguageSemantic(token: string): boolean { | |
| const language = token.match(/^\[([A-Za-z]+)\]$/)?.[1].toLowerCase() | |
| return language !== undefined && H3_LANGUAGE_LABELS.has(language) | |
| } | |
| function isH3ThemeSemantic(token: string): boolean { | |
| return /^\[Shot\s+\d+\]$/i.test(token) | |
| } | |
| function isH3SectionHeading(token: string): boolean { | |
| return /^(?:(?:subject_definitions|summary|retention_analysis|detailed_description|overall_soundscape|non_diegetic_music)\s*:|(?:主体定义|摘要|留存分析|详细描述|整体声景|非叙事性音乐)\s*[::])\s*$/i.test(token) | |
| } | |
| function referenceColor(reference: { type: PromptReferenceType }, resource?: PromptReferenceResource): string { | |
| if (resource?.color) return resource.color | |
| if (reference.type === 'audio') return 'var(--multitrack-audio-waveform)' | |
| if (reference.type === 'video') return 'var(--multitrack-video-waveform)' | |
| return 'var(--multitrack-task-bg)' | |
| } | |
| function applyChipColor(chip: HTMLElement, color: string) { | |
| chip.style.color = color | |
| chip.style.borderColor = color | |
| chip.style.backgroundColor = `color-mix(in srgb, ${color} 10%, transparent)` | |
| } | |
| function replacementReferenceToken(currentToken: string, resource: PromptReferenceResource): string { | |
| const type = resource.type === 'image' ? 'Picture' : resource.type === 'audio' ? 'Audio' : 'Video' | |
| if (/^<(?:Picture|Image|Audio|Video)\s+\d+>$/i.test(currentToken)) { | |
| return `<${type} ${resource.index}>` | |
| } | |
| if (/^@(?:Picture|Image|Audio|Video)\s*\d+$/i.test(currentToken)) { | |
| return `@${type} ${resource.index}` | |
| } | |
| return resource.token | |
| } | |
| function normalizedReference(token: string): { type: PromptReferenceType; index: number } | null { | |
| const match = token.match(/^@?(图片|音频|视频|Picture|Image|Audio|Video)\s*(\d+)>?$/i) | |
| ?? token.match(/^<(Picture|Audio|Video)\s+(\d+)>$/i) | |
| if (!match) return null | |
| const rawType = match[1].toLowerCase() | |
| const type: PromptReferenceType = rawType === '图片' || rawType === 'picture' || rawType === 'image' | |
| ? 'image' | |
| : rawType === '音频' || rawType === 'audio' | |
| ? 'audio' | |
| : 'video' | |
| return { type, index: Number(match[2]) } | |
| } | |
| function serializePromptContent(container: ParentNode): string { | |
| let value = '' | |
| const append = (text: string) => { | |
| value += text.replaceAll('\u200B', '') | |
| } | |
| const visit = (node: Node) => { | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| append(node.textContent ?? '') | |
| return | |
| } | |
| if (!(node instanceof HTMLElement)) return | |
| const atomicToken = node.dataset.promptReferenceToken | |
| ?? node.dataset.promptSemanticToken | |
| if (atomicToken !== undefined) { | |
| append(atomicToken) | |
| return | |
| } | |
| if (node.dataset.promptEmphasisText !== undefined) { | |
| append(node.textContent ?? '') | |
| return | |
| } | |
| if (node.tagName === 'BR') { | |
| append('\n') | |
| return | |
| } | |
| const block = node.tagName === 'DIV' || node.tagName === 'P' | |
| if (block && value && !value.endsWith('\n')) append('\n') | |
| node.childNodes.forEach(visit) | |
| } | |
| container.childNodes.forEach(visit) | |
| return value | |
| } | |
| function serializeEditor(editor: HTMLElement): string { | |
| return serializePromptContent(editor) | |
| } | |
| function normalizeSerializedEditorValue(value: string): string { | |
| return /^\n*$/.test(value) ? '' : value | |
| } | |
| function caretOffset(editor: HTMLElement): number { | |
| const selection = window.getSelection() | |
| if (!selection?.rangeCount) return serializeEditor(editor).length | |
| const range = selection.getRangeAt(0) | |
| if (!editor.contains(range.endContainer)) return serializeEditor(editor).length | |
| const before = range.cloneRange() | |
| before.selectNodeContents(editor) | |
| before.setEnd(range.endContainer, range.endOffset) | |
| return serializePromptContent(before.cloneContents()).length | |
| } | |
| function valueDecorationKey( | |
| value: string, | |
| highlightSystemVariables: boolean, | |
| highlightPromptSemantics: boolean, | |
| highlightPipes: boolean, | |
| ): string { | |
| const decorations: string[] = [] | |
| TOKEN_PATTERN.lastIndex = 0 | |
| for (const match of value.matchAll(TOKEN_PATTERN)) { | |
| const token = match[0] | |
| const reference = normalizedReference(token) | |
| if (reference && !highlightSystemVariables) { | |
| decorations.push(`reference:${reference.type}:${reference.index}`) | |
| } else if (highlightPromptSemantics && isH3PromptSemantic(token)) { | |
| decorations.push(`semantic:${token.toLowerCase()}`) | |
| } else if (highlightPromptSemantics && isH3SectionHeading(token)) { | |
| decorations.push(`emphasis:${token.toLowerCase()}`) | |
| } else if (highlightSystemVariables && (token.startsWith('{') || token.startsWith('<'))) { | |
| decorations.push('system') | |
| } else if (highlightPipes && (token === '|' || token === '|')) { | |
| decorations.push('pipe') | |
| } | |
| } | |
| return decorations.join('|') | |
| } | |
| function editorDecorationKey(editor: HTMLElement): string { | |
| return Array.from(editor.querySelectorAll<HTMLElement>( | |
| '[data-prompt-reference-token], [data-prompt-semantic-token], [data-prompt-emphasis-text], [data-system-prompt-variable], [data-pipe]', | |
| )).map((element) => { | |
| const token = element.dataset.promptReferenceToken | |
| if (token !== undefined) { | |
| const reference = normalizedReference(token) | |
| return reference ? `reference:${reference.type}:${reference.index}` : 'reference:unknown' | |
| } | |
| if (element.dataset.systemPromptVariable !== undefined) return 'system' | |
| if (element.dataset.promptSemanticToken !== undefined) { | |
| return `semantic:${element.dataset.promptSemanticToken.toLowerCase()}` | |
| } | |
| if (element.dataset.promptEmphasisText !== undefined) { | |
| return `emphasis:${element.dataset.promptEmphasisText.toLowerCase()}` | |
| } | |
| return 'pipe' | |
| }).join('|') | |
| } | |
| function setCaretOffset(editor: HTMLElement, targetOffset: number) { | |
| const selection = window.getSelection() | |
| if (!selection) return | |
| let offset = Math.max(0, targetOffset) | |
| for (const node of editor.childNodes) { | |
| const element = node instanceof HTMLElement ? node : null | |
| const decoratedToken = element?.dataset.promptReferenceToken | |
| ?? element?.dataset.promptSemanticToken | |
| ?? element?.dataset.promptEmphasisText | |
| const length = decoratedToken !== undefined | |
| ? decoratedToken.length | |
| : element?.tagName === 'BR' | |
| ? 1 | |
| : node.textContent?.replaceAll('\u200B', '').length ?? 0 | |
| if (offset <= length) { | |
| const range = document.createRange() | |
| if (decoratedToken !== undefined) { | |
| offset === 0 ? range.setStartBefore(node) : range.setStartAfter(node) | |
| } else if (element?.tagName === 'BR') { | |
| offset === 0 ? range.setStartBefore(node) : range.setStartAfter(node) | |
| } else if (node.nodeType === Node.TEXT_NODE) { | |
| range.setStart(node, Math.min(offset, node.textContent?.length ?? 0)) | |
| } else { | |
| const textNode = document.createTreeWalker(node, NodeFilter.SHOW_TEXT).nextNode() | |
| if (textNode) range.setStart(textNode, Math.min(offset, textNode.textContent?.length ?? 0)) | |
| else range.setStartAfter(node) | |
| } | |
| range.collapse(true) | |
| selection.removeAllRanges() | |
| selection.addRange(range) | |
| return | |
| } | |
| offset -= length | |
| } | |
| const range = document.createRange() | |
| range.selectNodeContents(editor) | |
| range.collapse(false) | |
| selection.removeAllRanges() | |
| selection.addRange(range) | |
| } | |
| function referenceDeletionRange( | |
| value: string, | |
| offset: number, | |
| key: 'Backspace' | 'Delete', | |
| ): { start: number; end: number } | null { | |
| TOKEN_PATTERN.lastIndex = 0 | |
| for (const match of value.matchAll(TOKEN_PATTERN)) { | |
| if (!normalizedReference(match[0])) continue | |
| const start = match.index ?? 0 | |
| const end = start + match[0].length | |
| if (key === 'Backspace' && offset > start && offset <= end) return { start, end } | |
| if (key === 'Delete' && offset >= start && offset < end) return { start, end } | |
| } | |
| return null | |
| } | |
| function appendText(container: HTMLElement | DocumentFragment, text: string) { | |
| text.split('\n').forEach((part, index) => { | |
| if (index > 0) container.append(document.createElement('br')) | |
| if (part) container.append(document.createTextNode(part)) | |
| }) | |
| } | |
| function appendDialogueText(container: HTMLElement, text: string) { | |
| text.split('\n').forEach((part, index) => { | |
| if (index > 0) container.append(document.createElement('br')) | |
| if (!part) return | |
| const dialogue = document.createElement('span') | |
| dialogue.dataset.promptDialogueContent = 'true' | |
| dialogue.className = EMPHASIS_CLASS | |
| dialogue.textContent = part | |
| container.append(dialogue) | |
| }) | |
| } | |
| function insertPlainTextAtSelection(editor: HTMLElement, text: string): boolean { | |
| const selection = window.getSelection() | |
| if (!selection?.rangeCount) return false | |
| const range = selection.getRangeAt(0) | |
| if (!editor.contains(range.commonAncestorContainer)) return false | |
| range.deleteContents() | |
| const fragment = document.createDocumentFragment() | |
| appendText(fragment, text) | |
| const caretMarker = document.createTextNode('\u200B') | |
| fragment.append(caretMarker) | |
| range.insertNode(fragment) | |
| const caret = document.createRange() | |
| caret.setStart(caretMarker, caretMarker.textContent?.length ?? 0) | |
| caret.collapse(true) | |
| selection.removeAllRanges() | |
| selection.addRange(caret) | |
| return true | |
| } | |
| function selectedPromptText(editor: HTMLElement): string | null { | |
| const selection = window.getSelection() | |
| if (!selection?.rangeCount || selection.isCollapsed) return null | |
| const range = selection.getRangeAt(0) | |
| if (!editor.contains(range.commonAncestorContainer)) return null | |
| return serializePromptContent(range.cloneContents()) | |
| } | |
| function appendReferenceChip( | |
| container: HTMLElement, | |
| token: string, | |
| resource: PromptReferenceResource | undefined, | |
| ) { | |
| const parsed = normalizedReference(token) | |
| const chip = document.createElement('span') | |
| chip.contentEditable = 'false' | |
| chip.dataset.promptReferenceToken = token | |
| chip.dataset.promptReferenceType = parsed?.type ?? '' | |
| chip.className = `${PROMPT_CHIP_CLASS} cursor-pointer` | |
| applyChipColor(chip, parsed ? referenceColor(parsed, resource) : 'var(--muted-foreground)') | |
| if (resource?.type === 'image' && resource.thumbnailUrl) { | |
| const image = document.createElement('img') | |
| image.src = resource.thumbnailUrl | |
| image.alt = '' | |
| image.draggable = false | |
| image.className = 'h-4 w-4 shrink-0 rounded-sm object-cover align-middle' | |
| chip.append(image) | |
| } else { | |
| const icon = document.createElement('span') | |
| icon.className = 'prompt-reference-icon inline-flex h-4 w-4 shrink-0 items-center justify-center' | |
| icon.dataset.referenceIcon = resource?.type ?? parsed?.type ?? 'image' | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') | |
| svg.setAttribute('viewBox', '0 0 24 24') | |
| svg.setAttribute('fill', 'none') | |
| svg.setAttribute('stroke', 'currentColor') | |
| svg.setAttribute('stroke-width', '2') | |
| svg.setAttribute('stroke-linecap', 'round') | |
| svg.setAttribute('stroke-linejoin', 'round') | |
| svg.setAttribute('aria-hidden', 'true') | |
| svg.classList.add('lucide', 'h-4', 'w-4') | |
| const type = resource?.type ?? parsed?.type ?? 'image' | |
| const paths = type === 'audio' | |
| ? ['M2 10v3', 'M6 6v11', 'M10 3v18', 'M14 8v7', 'M18 5v13', 'M22 10v3'] | |
| : type === 'video' | |
| ? ['m16 13 5.223-3.482a.5.5 0 0 1 .777.416v8.132a.5.5 0 0 1-.777.416L16 15', 'M2 6h14v12H2z', 'm2 6 3-4', 'm7 6 3-4', 'm12 6 3-4'] | |
| : ['M14.5 4h-9A2.5 2.5 0 0 0 3 6.5v11A2.5 2.5 0 0 0 5.5 20h13a2.5 2.5 0 0 0 2.5-2.5v-8', 'm3 16 5-5c.928-.893 2.072-.893 3 0l4 4', 'm14 14 1-1c.928-.893 2.072-.893 3 0l3 3', 'M14 7h.01'] | |
| paths.forEach((pathData) => { | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') | |
| path.setAttribute('d', pathData) | |
| svg.append(path) | |
| }) | |
| icon.append(svg) | |
| chip.append(icon) | |
| } | |
| const label = document.createElement('span') | |
| label.className = 'inline-flex h-full items-center leading-none' | |
| label.textContent = token | |
| chip.append(label) | |
| chip.title = resource?.detail ?? token | |
| container.append(chip) | |
| } | |
| function appendSemanticIcon(chip: HTMLElement, type: 'subject' | 'dialogue') { | |
| const icon = document.createElement('span') | |
| icon.className = 'inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center' | |
| icon.dataset.semanticIcon = type | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') | |
| svg.setAttribute('viewBox', '0 0 24 24') | |
| svg.setAttribute('fill', 'none') | |
| svg.setAttribute('stroke', 'currentColor') | |
| svg.setAttribute('stroke-width', '2') | |
| svg.setAttribute('stroke-linecap', 'round') | |
| svg.setAttribute('stroke-linejoin', 'round') | |
| svg.setAttribute('aria-hidden', 'true') | |
| svg.classList.add('lucide', 'h-3.5', 'w-3.5') | |
| const paths = type === 'subject' | |
| ? ['M18 21a8 8 0 0 0-16 0', 'M10 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8', 'M19 8v6', 'M22 11h-6'] | |
| : ['M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z'] | |
| paths.forEach((pathData) => { | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') | |
| path.setAttribute('d', pathData) | |
| svg.append(path) | |
| }) | |
| icon.append(svg) | |
| chip.append(icon) | |
| } | |
| function appendSemanticChip(container: HTMLElement, token: string) { | |
| const chip = document.createElement('span') | |
| chip.contentEditable = 'false' | |
| chip.dataset.promptSemanticToken = token | |
| chip.className = PROMPT_CHIP_CLASS | |
| const subject = /^<Subject\s+\d+>$/i.test(token) | |
| const dialogue = /^<\/?d>$/i.test(token) | |
| const usesDefaultChipSurface = isH3ThemeSemantic(token) || isH3LanguageSemantic(token) | |
| const color = subject || isH3ThemeSemantic(token) | |
| ? 'var(--highlight)' | |
| : dialogue | |
| ? 'var(--prompt-dialogue)' | |
| : 'var(--prompt-semantic)' | |
| if (usesDefaultChipSurface) { | |
| chip.classList.add('border-border', 'bg-background', 'text-foreground') | |
| } else { | |
| applyChipColor(chip, color) | |
| } | |
| if (subject) appendSemanticIcon(chip, 'subject') | |
| if (dialogue) appendSemanticIcon(chip, 'dialogue') | |
| const label = document.createElement('span') | |
| label.className = 'inline-flex h-full items-center leading-none' | |
| label.textContent = token | |
| chip.append(label) | |
| container.append(chip) | |
| } | |
| function appendEmphasisText(container: HTMLElement, token: string) { | |
| const emphasis = document.createElement('span') | |
| emphasis.dataset.promptEmphasisText = token | |
| emphasis.className = EMPHASIS_CLASS | |
| emphasis.textContent = token | |
| container.append(emphasis) | |
| } | |
| function renderValue( | |
| editor: HTMLElement, | |
| value: string, | |
| resources: PromptReferenceResource[], | |
| highlightSystemVariables: boolean, | |
| highlightPromptSemantics: boolean, | |
| highlightPipes: boolean, | |
| ) { | |
| editor.textContent = '' | |
| let cursor = 0 | |
| let inDialogue = false | |
| TOKEN_PATTERN.lastIndex = 0 | |
| for (const match of value.matchAll(TOKEN_PATTERN)) { | |
| const index = match.index ?? 0 | |
| const precedingText = value.slice(cursor, index) | |
| if (inDialogue) appendDialogueText(editor, precedingText) | |
| else appendText(editor, precedingText) | |
| const token = match[0] | |
| const opensDialogue = /^<d>$/i.test(token) | |
| const closesDialogue = /^<\/d>$/i.test(token) | |
| if (closesDialogue) inDialogue = false | |
| const reference = normalizedReference(token) | |
| if (reference && !highlightSystemVariables) { | |
| const resource = resources.find((item) => item.type === reference.type && item.index === reference.index) | |
| appendReferenceChip(editor, token, resource) | |
| } else if (highlightPromptSemantics && isH3PromptSemantic(token)) { | |
| appendSemanticChip(editor, token) | |
| } else if (highlightPromptSemantics && isH3SectionHeading(token)) { | |
| appendEmphasisText(editor, token) | |
| } else if (highlightSystemVariables && (token.startsWith('{') || token.startsWith('<'))) { | |
| const variable = document.createElement('span') | |
| variable.dataset.systemPromptVariable = 'true' | |
| variable.className = 'text-highlight' | |
| variable.textContent = token | |
| editor.append(variable) | |
| } else if (highlightPipes && (token === '|' || token === '|')) { | |
| const pipe = document.createElement('span') | |
| pipe.dataset.pipe = 'true' | |
| pipe.className = 'text-highlight' | |
| pipe.textContent = '|' | |
| editor.append(pipe) | |
| } else if (inDialogue) { | |
| appendDialogueText(editor, token) | |
| } else { | |
| appendText(editor, token) | |
| } | |
| if (highlightPromptSemantics && opensDialogue) inDialogue = true | |
| cursor = index + token.length | |
| } | |
| const trailingText = value.slice(cursor) | |
| if (inDialogue) appendDialogueText(editor, trailingText) | |
| else appendText(editor, trailingText) | |
| } | |
| function ReferenceIcon({ type }: Readonly<{ type: PromptReferenceType }>) { | |
| if (type === 'audio') return <AudioLines className="h-4 w-4" /> | |
| if (type === 'video') return <Clapperboard className="h-4 w-4" /> | |
| return <ImageIcon className="h-4 w-4" /> | |
| } | |
| export function PromptContentEditor({ | |
| value, | |
| onChange, | |
| ariaLabel, | |
| placeholder, | |
| resources = [], | |
| mentionsEnabled = false, | |
| interactivePlaceholder = false, | |
| highlightSystemVariables = false, | |
| highlightPromptSemantics = false, | |
| highlightPipes = false, | |
| className, | |
| testId, | |
| }: Readonly<PromptContentEditorProps>) { | |
| const t = useT() | |
| const wrapperRef = useRef<HTMLDivElement>(null) | |
| const editorRef = useRef<HTMLDivElement>(null) | |
| const composingRef = useRef(false) | |
| const lastRenderKeyRef = useRef('') | |
| const [mention, setMention] = useState<MentionState | null>(null) | |
| const [editorEmpty, setEditorEmpty] = useState(value.length === 0) | |
| const filteredResources = useMemo(() => { | |
| if (!mention) return resources | |
| const query = mention.query.toLocaleLowerCase() | |
| return resources.filter((resource) => ( | |
| !query || `${resource.label} ${resource.detail}`.toLocaleLowerCase().includes(query) | |
| )) | |
| }, [mention, resources]) | |
| const renderKey = useMemo(() => JSON.stringify({ | |
| value, | |
| highlightSystemVariables, | |
| highlightPromptSemantics, | |
| highlightPipes, | |
| resources: resources.map((resource) => [ | |
| resource.id, | |
| resource.index, | |
| resource.label, | |
| resource.detail, | |
| resource.token, | |
| resource.color, | |
| resource.thumbnailUrl, | |
| ]), | |
| }), [highlightPipes, highlightPromptSemantics, highlightSystemVariables, resources, value]) | |
| useEffect(() => { | |
| setEditorEmpty(value.length === 0) | |
| const editor = editorRef.current | |
| if (!editor || lastRenderKeyRef.current === renderKey) return | |
| const focused = document.activeElement === editor | |
| const offset = focused ? caretOffset(editor) : 0 | |
| renderValue(editor, value, resources, highlightSystemVariables, highlightPromptSemantics, highlightPipes) | |
| if (focused) setCaretOffset(editor, Math.min(offset, value.length)) | |
| lastRenderKeyRef.current = renderKey | |
| }, [highlightPipes, highlightPromptSemantics, highlightSystemVariables, renderKey, resources, value]) | |
| function syncMention(valueText: string, offset: number) { | |
| if (!mentionsEnabled) { | |
| setMention(null) | |
| return | |
| } | |
| const beforeCaret = valueText.slice(0, offset) | |
| const match = beforeCaret.match(/@([^\s@|<>]*)$/) | |
| if (!match) { | |
| setMention(null) | |
| return | |
| } | |
| if (normalizedReference(match[0])) { | |
| setMention(null) | |
| return | |
| } | |
| const wrapper = wrapperRef.current | |
| const selection = window.getSelection() | |
| const range = selection?.rangeCount ? selection.getRangeAt(0) : null | |
| const caretRect = range?.getBoundingClientRect?.() | |
| const wrapperRect = wrapper?.getBoundingClientRect() | |
| const caretLeft = caretRect?.left ?? wrapperRect?.left ?? 0 | |
| const caretTop = caretRect?.top ?? wrapperRect?.top ?? 0 | |
| const caretBottom = caretRect?.bottom ?? wrapperRect?.bottom ?? 0 | |
| const left = Math.min(Math.max(8, caretLeft), Math.max(8, window.innerWidth - 264)) | |
| const top = caretBottom + 214 <= window.innerHeight | |
| ? caretBottom + 6 | |
| : Math.max(8, caretTop - 214) | |
| const start = offset - match[0].length | |
| setMention((current) => ({ | |
| start, | |
| end: offset, | |
| query: match[1], | |
| left, | |
| top, | |
| activeIndex: current?.start === start ? current.activeIndex : 0, | |
| })) | |
| } | |
| function openPlaceholderMention(anchor: HTMLElement) { | |
| if (!mentionsEnabled || !editorEmpty) return | |
| const rect = anchor.getBoundingClientRect() | |
| const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 264)) | |
| const top = rect.bottom + 214 <= window.innerHeight | |
| ? rect.bottom + 6 | |
| : Math.max(8, rect.top - 214) | |
| setMention({ start: 0, end: 0, query: '', left, top, activeIndex: 0 }) | |
| } | |
| function openReferenceMention(chip: HTMLElement) { | |
| const editor = editorRef.current | |
| const token = chip.dataset.promptReferenceToken | |
| if (!mentionsEnabled || !editor || token === undefined) return | |
| const beforeChip = document.createRange() | |
| beforeChip.selectNodeContents(editor) | |
| beforeChip.setEndBefore(chip) | |
| const start = serializePromptContent(beforeChip.cloneContents()).length | |
| const rect = chip.getBoundingClientRect() | |
| const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 264)) | |
| const top = rect.bottom + 214 <= window.innerHeight | |
| ? rect.bottom + 6 | |
| : Math.max(8, rect.top - 214) | |
| setMention({ start, end: start + token.length, query: '', left, top, activeIndex: 0 }) | |
| } | |
| function commitEditorChange() { | |
| const editor = editorRef.current | |
| if (!editor) return | |
| const nextValue = normalizeSerializedEditorValue(serializeEditor(editor)) | |
| const offset = nextValue.length === 0 ? 0 : caretOffset(editor) | |
| const needsDecorationRender = nextValue.length === 0 || editorDecorationKey(editor) !== valueDecorationKey( | |
| nextValue, | |
| highlightSystemVariables, | |
| highlightPromptSemantics, | |
| highlightPipes, | |
| ) | |
| if (needsDecorationRender) { | |
| const scrollTop = editor.scrollTop | |
| const scrollLeft = editor.scrollLeft | |
| renderValue(editor, nextValue, resources, highlightSystemVariables, highlightPromptSemantics, highlightPipes) | |
| setCaretOffset(editor, offset) | |
| editor.scrollTop = scrollTop | |
| editor.scrollLeft = scrollLeft | |
| } | |
| setEditorEmpty(nextValue.length === 0) | |
| lastRenderKeyRef.current = JSON.stringify({ | |
| value: nextValue, | |
| highlightSystemVariables, | |
| highlightPromptSemantics, | |
| highlightPipes, | |
| resources: resources.map((resource) => [ | |
| resource.id, | |
| resource.index, | |
| resource.label, | |
| resource.detail, | |
| resource.token, | |
| resource.color, | |
| resource.thumbnailUrl, | |
| ]), | |
| }) | |
| onChange(nextValue) | |
| syncMention(nextValue, offset) | |
| } | |
| function chooseResource(resource: PromptReferenceResource) { | |
| const editor = editorRef.current | |
| if (!editor || !mention) return | |
| const currentValue = serializeEditor(editor) | |
| const currentToken = currentValue.slice(mention.start, mention.end) | |
| const replacementToken = replacementReferenceToken(currentToken, resource) | |
| const nextValue = `${currentValue.slice(0, mention.start)}${replacementToken}${currentValue.slice(mention.end)}` | |
| renderValue(editor, nextValue, resources, highlightSystemVariables, highlightPromptSemantics, highlightPipes) | |
| setEditorEmpty(false) | |
| setCaretOffset(editor, mention.start + replacementToken.length) | |
| lastRenderKeyRef.current = '' | |
| onChange(nextValue) | |
| setMention(null) | |
| editor.focus() | |
| } | |
| function deleteAdjacentReference(key: 'Backspace' | 'Delete'): boolean { | |
| const editor = editorRef.current | |
| if (!editor) return false | |
| const currentValue = serializeEditor(editor) | |
| const deletion = referenceDeletionRange(currentValue, caretOffset(editor), key) | |
| if (!deletion) return false | |
| const nextValue = `${currentValue.slice(0, deletion.start)}${currentValue.slice(deletion.end)}` | |
| renderValue(editor, nextValue, resources, highlightSystemVariables, highlightPromptSemantics, highlightPipes) | |
| setEditorEmpty(nextValue.length === 0) | |
| setCaretOffset(editor, deletion.start) | |
| lastRenderKeyRef.current = '' | |
| setMention(null) | |
| onChange(nextValue) | |
| return true | |
| } | |
| return ( | |
| <div | |
| ref={wrapperRef} | |
| className="relative h-full min-h-0 max-h-full overflow-hidden [contain:size_layout_paint]" | |
| > | |
| <div | |
| ref={editorRef} | |
| role="textbox" | |
| aria-label={ariaLabel} | |
| aria-multiline="true" | |
| data-testid={testId} | |
| data-placeholder={placeholder} | |
| contentEditable | |
| suppressContentEditableWarning | |
| className={cn( | |
| 'prompt-content-editor h-full min-h-0 max-h-full overflow-auto whitespace-pre-wrap wrap-break-word rounded-md px-3 py-2 text-[10px] leading-normal text-foreground caret-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring', | |
| !interactivePlaceholder && 'empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]', | |
| className, | |
| )} | |
| onInput={() => { | |
| if (!composingRef.current) commitEditorChange() | |
| }} | |
| onCompositionStart={() => { | |
| composingRef.current = true | |
| }} | |
| onCompositionEnd={() => { | |
| composingRef.current = false | |
| commitEditorChange() | |
| }} | |
| onPaste={(event) => { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| event.nativeEvent.stopImmediatePropagation?.() | |
| const editor = editorRef.current | |
| const text = event.clipboardData.getData('text/plain') | |
| if (editor && insertPlainTextAtSelection(editor, text)) commitEditorChange() | |
| }} | |
| onCopy={(event) => { | |
| event.stopPropagation() | |
| const editor = editorRef.current | |
| const text = editor ? selectedPromptText(editor) : null | |
| if (text === null) return | |
| event.preventDefault() | |
| event.clipboardData.setData('text/plain', text) | |
| }} | |
| onKeyDown={(event) => { | |
| if ((event.key === 'Backspace' || event.key === 'Delete') && deleteAdjacentReference(event.key)) { | |
| event.preventDefault() | |
| return | |
| } | |
| if (mention) { | |
| if (event.key === 'Escape') { | |
| event.preventDefault() | |
| setMention(null) | |
| } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { | |
| event.preventDefault() | |
| setMention((current) => current && filteredResources.length > 0 ? { | |
| ...current, | |
| activeIndex: ( | |
| current.activeIndex + (event.key === 'ArrowDown' ? 1 : -1) + filteredResources.length | |
| ) % filteredResources.length, | |
| } : current) | |
| } else if (event.key === 'Enter' && filteredResources.length > 0) { | |
| event.preventDefault() | |
| chooseResource(filteredResources[Math.min(mention.activeIndex, filteredResources.length - 1)]) | |
| return | |
| } | |
| } | |
| if ( | |
| event.key === 'Enter' | |
| && !composingRef.current | |
| && !event.nativeEvent.isComposing | |
| ) { | |
| event.preventDefault() | |
| const editor = editorRef.current | |
| if (editor && insertPlainTextAtSelection(editor, '\n')) commitEditorChange() | |
| return | |
| } | |
| if ((event.ctrlKey || event.metaKey) && ['a', 'c', 'v', 'x'].includes(event.key.toLowerCase())) { | |
| event.stopPropagation() | |
| } | |
| }} | |
| onClick={(event) => { | |
| const target = event.target instanceof Element ? event.target : null | |
| const referenceChip = target?.closest<HTMLElement>('[data-prompt-reference-token]') | |
| if (referenceChip && editorRef.current?.contains(referenceChip)) { | |
| openReferenceMention(referenceChip) | |
| } else { | |
| setMention(null) | |
| } | |
| }} | |
| onBlur={(event) => { | |
| if (wrapperRef.current?.contains(event.relatedTarget as Node | null)) return | |
| setMention(null) | |
| }} | |
| /> | |
| {interactivePlaceholder && editorEmpty && (() => { | |
| const triggerIndex = placeholder.indexOf('@') | |
| const beforeTrigger = triggerIndex >= 0 ? placeholder.slice(0, triggerIndex) : placeholder | |
| const afterTrigger = triggerIndex >= 0 ? placeholder.slice(triggerIndex + 1) : '' | |
| return ( | |
| <div className="pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap wrap-break-word px-3 py-2 text-[10px] leading-normal text-muted-foreground"> | |
| {beforeTrigger} | |
| {triggerIndex >= 0 && ( | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| aria-label={t('multitrack.openReferenceResources')} | |
| className={cn(PROMPT_CHIP_CLASS, 'pointer-events-auto min-w-0 border-border bg-background text-highlight text-[10px]')} | |
| onPointerDown={(event) => event.preventDefault()} | |
| onClick={(event) => openPlaceholderMention(event.currentTarget)} | |
| > | |
| @ | |
| </Button> | |
| )} | |
| {afterTrigger} | |
| </div> | |
| ) | |
| })()} | |
| {mention && createPortal(( | |
| <div className={CUSTOM_NODE_CLASS}> | |
| <div | |
| role="listbox" | |
| aria-label={t('multitrack.referenceResources')} | |
| className={cn( | |
| 'fixed z-50 flex w-64 flex-col overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md', | |
| filteredResources.length === 0 ? 'h-64' : 'max-h-52', | |
| )} | |
| style={{ left: mention.left, top: mention.top }} | |
| > | |
| <div className="shrink-0 px-2 py-1.5 text-[10px] font-semibold text-muted-foreground"> | |
| {t('multitrack.referenceResources')} | |
| </div> | |
| {filteredResources.length === 0 ? ( | |
| <div | |
| data-testid="reference-resources-empty" | |
| className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 px-5 pb-7 text-center text-muted-foreground" | |
| > | |
| <div aria-hidden="true" className="relative h-14 w-20 opacity-60"> | |
| <span className="absolute left-8 -top-2 flex h-10 w-10 -rotate-12 items-center justify-center rounded-md border border-border bg-muted shadow-sm"> | |
| <ImageIcon className="h-5 w-5" /> | |
| </span> | |
| <span className="absolute -left-2 top-1 flex h-10 w-10 rotate-3 items-center justify-center rounded-md border border-border bg-muted shadow-sm"> | |
| <AudioLines className="h-5 w-5" /> | |
| </span> | |
| <span className="absolute left-6 top-4 flex h-10 w-10 rotate-6 items-center justify-center rounded-md border border-border bg-muted shadow-sm"> | |
| <Clapperboard className="h-5 w-5" /> | |
| </span> | |
| </div> | |
| <span className=" text-[10px] leading-relaxed"> | |
| {t('multitrack.referenceResourcesEmpty')} | |
| </span> | |
| </div> | |
| ) : filteredResources.map((resource, index) => ( | |
| <Button | |
| key={resource.id} | |
| type="button" | |
| variant="ghost" | |
| role="option" | |
| aria-selected={index === mention.activeIndex} | |
| className={cn( | |
| 'h-auto w-full justify-start gap-2 px-2 py-1.5 text-left', | |
| index === mention.activeIndex && 'bg-accent text-accent-foreground', | |
| )} | |
| onPointerDown={(event) => event.preventDefault()} | |
| onPointerMove={() => setMention((current) => current ? { ...current, activeIndex: index } : current)} | |
| onClick={() => chooseResource(resource)} | |
| > | |
| {resource.type === 'image' && resource.thumbnailUrl ? ( | |
| <img | |
| src={resource.thumbnailUrl} | |
| alt="" | |
| className="h-8 w-8 shrink-0 rounded-sm object-cover" | |
| /> | |
| ) : ( | |
| <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-muted"> | |
| <ReferenceIcon type={resource.type} /> | |
| </span> | |
| )} | |
| <span className="min-w-0"> | |
| <span className="block truncate text-[11px] font-semibold" style={{ color: resource.color }}> | |
| {resource.label} | |
| </span> | |
| <span className="block truncate text-[9px] text-muted-foreground">{resource.detail}</span> | |
| </span> | |
| </Button> | |
| ))} | |
| </div> | |
| </div> | |
| ), document.body)} | |
| </div> | |
| ) | |
| } | |