"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject, } from "react"; import { CloseOutlined, FileTextOutlined, RightOutlined } from "@ant-design/icons"; import { Dropdown, Popover, Tooltip } from "antd"; import { useI18n } from "@/i18n"; import { getReferenceMaterialKey, type ComposerReferenceMaterial, } from "@/utils/composerReferenceSubjects"; import { useWorkflowStore } from "@/store/workflowStore"; import type { GenerationNodeConfig } from "@/utils/generationConfig"; import { ComposerMentionInput, type ComposerMentionReferenceItem } from "@/components/composer/ComposerMentionInput"; import { ComposerReferenceMaterialCard } from "@/components/composer/ComposerReferenceMaterialCard"; import type { ComposerCapability } from "@/utils/composerNodeDescriptor"; import type { SelectedModel } from "@/types"; import { POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME, POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, POPI_VIDEO_SUBTYPE_OMNI_REFERENCE, } from "@/constants/generationTask"; export interface ComposerConnectedTextCard { text: string; edgeId?: string; } interface ComposerVideoSubTypeOption { value: number; label: string; disabledReason?: string; } const EMPTY_PARAMETERS: Record = {}; function selectedModelSubTypes(model: SelectedModel): number[] { return Array.isArray(model.subTypes) ? model.subTypes.filter((subType) => Number.isFinite(subType)) : []; } function isVideoSubTypeAllowedForMaterials( subType: number, referenceImageCount: number, hasVideoOrAudio: boolean ): boolean { if (subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO) return referenceImageCount === 1 && !hasVideoOrAudio; if (subType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME) return referenceImageCount === 2 && !hasVideoOrAudio; return true; } type AssistMenuType = "command"; interface AssistMenuItem { key: string; label: string; value: string; image?: string; sourceNodeId?: string; referenceType?: ComposerReferenceMaterial["type"]; } interface ComposerPromptMaterialsProps { hasMediaHeader: boolean; headerStatusContent?: ReactNode; activeCapability: ComposerCapability; nodeId?: string; selectedModel: SelectedModel; connectedTextCards: ComposerConnectedTextCard[]; referenceMaterials: ComposerReferenceMaterial[]; playingReferenceAudioKey: string | null; onRemoveConnectedEdge: (edgeId: string) => void; onReferenceMaterialDragStart: ( event: ReactDragEvent, material: ComposerReferenceMaterial ) => void; onReferenceMaterialDrop: ( event: ReactDragEvent, material: ComposerReferenceMaterial ) => void; onRemoveReferenceMaterial: (material: ComposerReferenceMaterial) => void; onToggleReferenceAudio: (material: ComposerReferenceMaterial) => void; onStopReferenceAudio: () => void; onVideoSubTypeChange?: (subType: number) => void; bodyContent?: ReactNode; promptInputRef: RefObject; promptValue: string; promptReadOnly: boolean; promptMinHeightPx: number; promptMaxHeightPx: number; referenceItems: ComposerMentionReferenceItem[]; onDraftPatch: (patch: Partial, fields: Array) => void; onPromptFocus?: () => void; onSubmitShortcut: () => void; } function getContentEditableCaretOffset(element: HTMLDivElement): number { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return element.textContent?.length ?? 0; const range = selection.getRangeAt(0); if (!element.contains(range.endContainer)) return element.textContent?.length ?? 0; const beforeRange = range.cloneRange(); beforeRange.selectNodeContents(element); beforeRange.setEnd(range.endContainer, range.endOffset); return beforeRange.toString().length; } function getTextNodeAtOffset(root: Node, offset: number): { node: Text; offset: number } | null { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let remaining = offset; let current = walker.nextNode(); while (current) { const text = current.textContent ?? ""; if (remaining <= text.length) { return { node: current as Text, offset: remaining }; } remaining -= text.length; current = walker.nextNode(); } return null; } function setContentEditableCaretOffset(element: HTMLDivElement, offset: number): void { const selection = window.getSelection(); if (!selection) return; const textLength = element.textContent?.length ?? 0; const safeOffset = Math.max(0, Math.min(offset, textLength)); const textPosition = getTextNodeAtOffset(element, safeOffset); const range = document.createRange(); if (textPosition) { range.setStart(textPosition.node, textPosition.offset); } else { range.setStart(element, element.childNodes.length); } range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } export function ComposerPromptMaterials({ hasMediaHeader, headerStatusContent, activeCapability, nodeId, selectedModel, connectedTextCards, referenceMaterials, playingReferenceAudioKey, onRemoveConnectedEdge, onReferenceMaterialDragStart, onReferenceMaterialDrop, onRemoveReferenceMaterial, onToggleReferenceAudio, onStopReferenceAudio, onVideoSubTypeChange, bodyContent, promptInputRef, promptValue, promptReadOnly, promptMinHeightPx, promptMaxHeightPx, referenceItems, onDraftPatch, onPromptFocus, onSubmitShortcut, }: ComposerPromptMaterialsProps) { const { t } = useI18n(); const storeSubType = useWorkflowStore((state) => { if (!nodeId) return undefined; const node = state.nodes.find((candidate) => candidate.id === nodeId); const subType = (node?.data as { subType?: unknown } | undefined)?.subType; return typeof subType === "number" && Number.isFinite(subType) ? subType : undefined; }); const assistTriggerIndexRef = useRef(null); const [assistMenu, setAssistMenu] = useState(null); const [assistMenuActiveIndex, setAssistMenuActiveIndex] = useState(0); const [assistMenuPosition, setAssistMenuPosition] = useState<{ left: number; top: number } | null>(null); const videoSubTypeOptions = useMemo(() => { if (activeCapability !== "video") return []; const subTypes = selectedModelSubTypes(selectedModel); const shouldDisableInvalidOptions = subTypes.length > 1; const referenceImageCount = referenceMaterials.filter((material) => material.type === "image").length; const hasVideoOrAudio = referenceMaterials.some((material) => material.type === "video" || material.type === "audio"); return subTypes.flatMap((subType): ComposerVideoSubTypeOption[] => { const disabledReason = shouldDisableInvalidOptions && !isVideoSubTypeAllowedForMaterials(subType, referenceImageCount, hasVideoOrAudio) ? subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO ? t("composer.videoSubTypeImageToVideoImageCountRequired") : t("composer.videoSubTypeFirstLastFrameImageCountRequired") : undefined; let label: string | null = null; if (subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO) { label = t("composer.videoSubTypeMultiReference"); } if (subType === POPI_VIDEO_SUBTYPE_OMNI_REFERENCE) { label = t("composer.videoSubTypeTextToVideo"); } if (subType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME) { label = t("composer.videoSubTypeFirstLastFrame"); } return label ? [{ value: subType, label, disabledReason }] : []; }); }, [activeCapability, referenceMaterials, selectedModel, t]); const selectedVideoSubType = typeof storeSubType === "number" && Number.isFinite(storeSubType) ? storeSubType : typeof selectedModel.subType === "number" && Number.isFinite(selectedModel.subType) ? selectedModel.subType : videoSubTypeOptions[0]?.value; const hasVideoSubTypeHeader = videoSubTypeOptions.length > 0; const hasReferenceHeader = connectedTextCards.length > 0 || referenceMaterials.length > 0; const commandAssistItems = useMemo(() => [ { key: "character", label: t("composer.commandCharacter"), value: t("composer.commandCharacterValue") }, { key: "turnaround", label: t("composer.commandTurnaround"), value: t("composer.commandTurnaroundValue") }, { key: "video-shot", label: t("composer.commandVideoShot"), value: t("composer.commandVideoShotValue") }, ], [t]); const activeAssistItems = assistMenu === "command" ? commandAssistItems : []; const closeAssistMenu = useCallback(() => { assistTriggerIndexRef.current = null; setAssistMenu(null); setAssistMenuPosition(null); }, []); const syncAssistMenuFromPromptCursor = useCallback((input: HTMLDivElement | null) => { if (promptReadOnly || !input) { closeAssistMenu(); return; } const cursor = getContentEditableCaretOffset(input); const inputValue = input.textContent ?? ""; const previousChar = cursor > 0 ? inputValue[cursor - 1] : ""; const selection = window.getSelection(); const selectionRect = selection?.rangeCount ? selection.getRangeAt(0).getBoundingClientRect() : null; const inputRect = input.getBoundingClientRect(); const nextMenuPosition = { left: Math.max(8, Math.min((selectionRect?.left || inputRect.left) + 16, window.innerWidth - 296)), top: Math.max(8, (selectionRect?.top || inputRect.top) - 8), }; if (previousChar === "/") { assistTriggerIndexRef.current = cursor - 1; setAssistMenuPosition(nextMenuPosition); setAssistMenu("command"); setAssistMenuActiveIndex(0); return; } closeAssistMenu(); }, [closeAssistMenu, promptReadOnly]); useEffect(() => { if (promptReadOnly) closeAssistMenu(); }, [closeAssistMenu, promptReadOnly]); useEffect(() => { if (!assistMenu) return; setAssistMenuActiveIndex((current) => activeAssistItems.length === 0 ? 0 : Math.min(current, activeAssistItems.length - 1) ); }, [activeAssistItems.length, assistMenu]); const insertAssistValue = useCallback((value: string, trigger: "/" | "@") => { const input = promptInputRef.current; const fallbackIndex = promptValue.endsWith(trigger) ? promptValue.length - 1 : promptValue.length; const triggerIndex = assistTriggerIndexRef.current ?? fallbackIndex; const shouldReplaceTrigger = promptValue[triggerIndex] === trigger; const before = shouldReplaceTrigger ? promptValue.slice(0, triggerIndex) : promptValue.slice(0, triggerIndex); const after = shouldReplaceTrigger ? promptValue.slice(triggerIndex + 1) : promptValue.slice(triggerIndex); const nextPrompt = `${before}${value}${after}`; onDraftPatch({ prompt: nextPrompt }, ["prompt"]); closeAssistMenu(); window.setTimeout(() => { input?.focus(); const nextCursor = before.length + value.length; if (input) setContentEditableCaretOffset(input, nextCursor); }, 0); }, [closeAssistMenu, onDraftPatch, promptInputRef, promptValue]); const insertCommand = useCallback((value: string) => { insertAssistValue(value, "/"); }, [insertAssistValue]); const selectAssistMenuItem = useCallback((item: AssistMenuItem) => { if (assistMenu === "command") { insertCommand(item.value); } }, [assistMenu, insertCommand]); const handlePromptKeyDown = useCallback((event: ReactKeyboardEvent) => { if (assistMenu && activeAssistItems.length > 0) { if (event.key === "ArrowDown") { event.preventDefault(); setAssistMenuActiveIndex((current) => (current + 1) % activeAssistItems.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setAssistMenuActiveIndex((current) => (current - 1 + activeAssistItems.length) % activeAssistItems.length); return; } if (event.key === "Enter") { event.preventDefault(); selectAssistMenuItem(activeAssistItems[assistMenuActiveIndex] ?? activeAssistItems[0]); return; } } if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); onSubmitShortcut(); } }, [activeAssistItems, assistMenu, assistMenuActiveIndex, onSubmitShortcut, selectAssistMenuItem]); const assistMenuOverlay = assistMenu && assistMenuPosition ? ( { if (nextOpen) return; closeAssistMenu(); }} trigger={["click"]} popupRender={() => (
{t("composer.commands")}
{activeAssistItems.map((item, itemIndex) => { const isActive = itemIndex === assistMenuActiveIndex; return ( ); })}
)} > ) : null)} )}
{bodyContent ?? ( <> { onDraftPatch({ prompt: nextValue }, ["prompt"]); syncAssistMenuFromPromptCursor(promptInputRef.current); }} onFocus={onPromptFocus} onClick={(element) => syncAssistMenuFromPromptCursor(element)} onKeyUp={(event) => { if (assistMenu && ["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return; syncAssistMenuFromPromptCursor(event.currentTarget); }} onKeyDown={handlePromptKeyDown} referenceItems={referenceItems} referenceMenuLabel={t("composer.referenceMaterials")} className={`nodrag nopan nowheel w-full overflow-y-auto rounded-lg bg-transparent pr-12 text-sm leading-6 outline-none ${promptReadOnly ? "text-[var(--text-secondary)]" : "text-[var(--text-primary)]"}`} /> {promptReadOnly && (
{t("composer.promptProvidedByUpstream")}
)} )}
); }