Files
BlackVortexAI-bv_nodepack/ui/src/components/SmartPipeEditorWindow.tsx
T

66 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, BvFooterActions, BvManagedWindow, BvMinimizedWindow, BvWindowNavigator, Callout, CheckboxField, DraftJsonActions, EmptyState, getWindowSwitchMode, mergeBvDraft, rememberBvWindowInstance, SelectField, SortableItem, SortableList, TextField, UnsavedChangesDialog, useBvHistory, useBvHistoryShortcuts, type BvWindowGeometry, type BvWindowMode } from "../ui";
import { getApi, getApp } from "../appHelper";
import { collectScopedNodes, scopedNodeKey, setWindowMenuVisible, useWindowMenuVisibility, windowMenuVisible } from "../ui/windowRegistry";
type Kind="pipe"|"merge";
type Target={node:any;kind:Kind};
type Presentation={mode:BvWindowMode;geometry:BvWindowGeometry};
type SessionActions={dirty:boolean;save:()=>void;discard:()=>void};
const bridge=()=>((globalThis as any).__bvSmartPipeEditorBridge??{});
const nodeType=(node:any)=>String(node?.type??node?.comfyClass??"");
const kindOf=(node:any):Kind=>nodeType(node)==="BV Smart Pipe Merge"?"merge":"pipe";
const editorRoot=()=>((getApp() as any).rootGraph??(getApp() as any).graph);
const editorNodes=()=>collectScopedNodes(editorRoot(),node=>["BV Smart Pipe","BV Smart Pipe Merge"].includes(nodeType(node)));
export default function SmartPipeEditorWindow(){
const [sessions,setSessions]=useState<Array<{key:string;target:Target}>>([]),[activeKey,setActiveKey]=useState<string|null>(null),[activation,setActivation]=useState(0),[presentations,setPresentations]=useState<Record<string,Presentation>>({});
const sessionActions=useRef(new Map<string,SessionActions>()),[pending,setPending]=useState<null|{key:string;action:()=>void}>(null);
const defaultPresentation=():Presentation=>({mode:"floating",geometry:{x:Math.max(16,(window.innerWidth-860)/2),y:Math.max(16,(window.innerHeight-650)/2),width:860,height:650}});
const keyFor=(target:Target)=>`${target.kind}:${scopedNodeKey(editorRoot(),target.node)}`;
const requestExit=(key:string,action:()=>void)=>sessionActions.current.get(key)?.dirty?setPending({key,action}):action();
const activate=(target:Target,replaceCurrent:boolean)=>{const nextKey=keyFor(target),currentKey=activeKey;const apply=()=>{rememberBvWindowInstance(target.kind,scopedNodeKey(editorRoot(),target.node));setSessions(current=>{const retained=replaceCurrent&&currentKey&&currentKey!==nextKey?current.filter(item=>item.key!==currentKey):current;return retained.some(item=>item.key===nextKey)?retained:[...retained,{key:nextKey,target}]});setPresentations(current=>({...current,[nextKey]:currentKey&&current[currentKey]?structuredClone(current[currentKey]):current[nextKey]??defaultPresentation()}));setActiveKey(nextKey);setActivation(value=>value+1)};if(replaceCurrent&&currentKey&&currentKey!==nextKey)requestExit(currentKey,apply);else apply()};
const closeSession=(key:string)=>requestExit(key,()=>{sessionActions.current.delete(key);setSessions(current=>current.filter(item=>item.key!==key));setPresentations(current=>{const next={...current};delete next[key];return next});setActiveKey(current=>current===key?null:current)});
useEffect(()=>{const open=(event:Event)=>{const detail=(event as CustomEvent<Target>).detail;if(!detail?.node)return;activate({node:detail.node,kind:detail.kind??kindOf(detail.node)},getWindowSwitchMode()==="replace")};window.addEventListener("bv-open-smart-pipe-editor",open);return()=>window.removeEventListener("bv-open-smart-pipe-editor",open)});
return <>{sessions.map(session=>{const presentation=presentations[session.key]??defaultPresentation();return <SmartPipeEditorSession key={session.key} sessionKey={session.key} target={session.target} open={session.key===activeKey} activation={activation} presentation={presentation} onPresentation={next=>setPresentations(current=>({...current,[session.key]:next}))} onActions={actions=>sessionActions.current.set(session.key,actions)} onClose={()=>closeSession(session.key)} onRemap={node=>setSessions(current=>current.map(item=>item.key===session.key?{...item,target:{node,kind:kindOf(node)}}:item))} onNavigate={(node,replace)=>activate({node,kind:kindOf(node)},replace)}/>})}{sessions.filter(session=>session.key!==activeKey).map(session=><BvMinimizedWindow key={`shelf:${session.key}`} title={`${session.target.kind==="merge"?"Smart Pipe Merge":"Smart Pipe Editor"} · ${session.target.node.title||`#${session.target.node.id}`}`} onRestore={()=>activate(session.target,false)} onClose={()=>closeSession(session.key)}/>) }<UnsavedChangesDialog open={Boolean(pending)} editorName="Smart Pipe editor" onKeep={()=>setPending(null)} onDiscard={()=>{const request=pending;request&&sessionActions.current.get(request.key)?.discard();setPending(null);request?.action()}} onSave={()=>{const request=pending;request&&sessionActions.current.get(request.key)?.save();setPending(null);request?.action()}}/></>;
}
function SmartPipeEditorSession({sessionKey,target,open,activation,presentation,onPresentation,onActions,onClose,onRemap,onNavigate}:{sessionKey:string;target:Target;open:boolean;activation:number;presentation:Presentation;onPresentation:(next:Presentation)=>void;onActions:(actions:SessionActions)=>void;onClose:()=>void;onRemap:(node:any)=>void;onNavigate:(node:any,replace:boolean)=>void}){
const adapter=bridge()[target.kind],initial=useMemo(()=>adapter?.read(target.node)??{},[adapter,target.node]),history=useBvHistory<any>(initial),draft=history.value;
useBvHistoryShortcuts(history);
const baseline=useRef(structuredClone(initial)),applyingSave=useRef(false),[conflict,setConflict]=useState<"missing"|string|null>(null),targetKey=useMemo(()=>scopedNodeKey(editorRoot(),target.node),[target.node]);
useEffect(()=>{const changed=()=>{const currentTarget=editorNodes().find(item=>item.key===targetKey);if(!currentTarget){setConflict("missing");return}if(currentTarget.node!==target.node){onRemap(currentTarget.node);return}if(applyingSave.current)return;const current=adapter?.read(target.node)??{};if(JSON.stringify(current)===JSON.stringify(baseline.current))return;if(!history.canUndo){baseline.current=structuredClone(current);history.replace(current,true);setConflict(null);return}const merged=mergeBvDraft(baseline.current,draft,current);if(merged.ok){baseline.current=structuredClone(current);history.replace(merged.value)}else setConflict(merged.reason)};getApi().addEventListener("graphChanged",changed);return()=>getApi().removeEventListener("graphChanged",changed)},[adapter,draft,history.canUndo,history.replace,onRemap,target.node,targetKey]);
const visible=useWindowMenuVisibility(target.node),nodes=editorNodes(),options=nodes.filter(item=>windowMenuVisible(item.node)).map(item=>({value:item.key,label:`${item.breadcrumb?`${item.breadcrumb} › `:""}${item.node.title||nodeType(item.node)} · #${item.node.id}`}));
const update=(mutate:(value:any)=>void)=>history.commit((current:any)=>{mutate(current);return current});
const validationIssues=target.kind==="pipe"?(adapter?.validation?.(target.node,draft)??[]):[];
const save=()=>{applyingSave.current=true;try{adapter?.save(target.node,draft);const saved=adapter?.read(target.node)??draft;baseline.current=structuredClone(saved);history.replace(saved,true);setConflict(null)}finally{applyingSave.current=false}};
const discard=()=>{const current=adapter?.read(target.node)??{};baseline.current=structuredClone(current);history.replace(current,true);setConflict(null)};
useEffect(()=>{onActions({dirty:history.canUndo,save,discard})},[adapter,draft,history.canUndo,history.replace,onActions,sessionKey,target.node]);
const navigator=<BvWindowNavigator label="Smart Pipe node" value={nodes.find(item=>item.node===target.node)?.key??""} options={options} onNavigate={(key,replace)=>{const next=nodes.find(item=>item.key===key);if(next)onNavigate(next.node,replace)}}/>;
return <BvManagedWindow open={open} activationToken={activation} title={target.kind==="merge"?"Smart Pipe Merge":"Smart Pipe Editor"} shortTitle="Smart Pipe" menuVisible={visible} onMenuVisible={value=>setWindowMenuVisible(target.node,value)} context={navigator} mode={presentation.mode} initialGeometry={presentation.geometry} onModeChange={mode=>onPresentation({...presentation,mode})} onGeometry={geometry=>onPresentation({...presentation,geometry})} minSize={{width:420,height:360}} onClose={onClose} status={validationIssues.length?"Required inputs missing":history.canUndo?"Unsaved changes":"Saved"} actions={<BvFooterActions primary={<Button intent="primary" onClick={save}>Save</Button>} secondary={<><Button disabled={!history.canUndo} onClick={discard}>Discard</Button><DraftJsonActions label={target.kind==="merge"?"Smart Pipe Merge":"Smart Pipe"} value={{schema:"bv.editor-draft",version:1,type:target.kind,data:draft}} onImport={(value:any)=>{if(value?.schema!=="bv.editor-draft"||value?.version!==1||value?.type!==target.kind||!value?.data)throw new Error("Draft type or version does not match this editor.");history.replace(value.data)}}/></>} overflow={[{id:"undo",label:"Undo",disabled:!history.canUndo,onSelect:history.undo},{id:"redo",label:"Redo",disabled:!history.canRedo,onSelect:history.redo}]}/> }>
{conflict
? <Callout tone="danger" title={conflict==="missing"?"Node no longer exists":"Smart Pipe changed externally"}>{conflict==="missing"?"This draft cannot be saved because its workflow node was removed.":"An external change could not be migrated safely. Reload the editor to discard the incompatible draft."}<div className="bv-callout-actions"><DraftJsonActions label={target.kind==="merge"?"Smart Pipe Merge":"Smart Pipe"} value={{schema:"bv.editor-draft",version:1,type:target.kind,data:draft}} onImport={()=>{}} allowImport={false}/><Button onClick={()=>{if(conflict==="missing")onClose();else{const current=adapter?.read(target.node)??{};baseline.current=structuredClone(current);history.replace(current,true);setConflict(null)}}}>{conflict==="missing"?"Close window":"Reload editor"}</Button></div></Callout>
: target.kind==="pipe"
? <PipeDraft node={target.node} draft={draft} update={update}/>
: <MergeDraft draft={draft} update={update}/>}
</BvManagedWindow>;
}
function PipeDraft({node,draft,update}:{node:any;draft:any;update:(mutate:(value:any)=>void)=>void}){
const adapter=bridge().pipe,[predecessor,setPredecessor]=useState(()=>adapter?.predecessor(node)??{value:"Start new pipe",options:[]});
const issues=adapter?.validation?.(node,draft)??[];
useEffect(()=>{const sync=()=>setPredecessor(adapter?.predecessor(node)??{value:"Start new pipe",options:[]});getApi().addEventListener("graphChanged",sync);const timer=window.setInterval(sync,750);return()=>{getApi().removeEventListener("graphChanged",sync);window.clearInterval(timer)}},[adapter,node]);
const local=draft.definition?.localSlots??[],inherited=draft.projection?.inheritedSlots??[];
const items:SortableItem[]=[...local.map((slot:any,index:number)=>({id:slot.id,title:slot.name||"New slot",description:"Local · input required",summary:slot.type||"*",content:<div className="bv-ui-stack"><TextField label="Name" value={slot.name??""} onValue={value=>update(valueDraft=>{valueDraft.definition.localSlots[index].name=value})}/><TextField label="Type" value={slot.type??"*"} onValue={value=>update(valueDraft=>{valueDraft.definition.localSlots[index].type=value||"*"})}/><CheckboxField label="Expose output" checked={!!slot.showOutput} onValue={value=>update(valueDraft=>{valueDraft.definition.localSlots[index].showOutput=value})}/></div>})),...inherited.map((slot:any,index:number)=>({id:slot.id,title:slot.name||slot.id,description:"Inherited override",content:<div className="bv-ui-stack"><CheckboxField label="Expose input" checked={!!slot.showInput} onValue={value=>update(valueDraft=>{valueDraft.projection.inheritedSlots[index].showInput=value})}/><CheckboxField label="Expose output" checked={!!slot.showOutput} onValue={value=>update(valueDraft=>{valueDraft.projection.inheritedSlots[index].showOutput=value})}/></div>}))];
const add=()=>update(value=>{value.definition.localSlots.push({id:crypto.randomUUID(),name:"new_slot",ordinal:value.definition.nextOrdinal++,type:"*",showInput:true,showOutput:false})});
return <div className="bv-ui-stack"><SelectField label="Pipe predecessor" value={predecessor.value} options={predecessor.options.length?predecessor.options:[{value:predecessor.value,label:predecessor.value}]} onValue={value=>{adapter?.setPredecessor(node,value);setPredecessor(adapter?.predecessor(node)??{value,options:predecessor.options})}}/>{issues.length>0&&<Callout tone="danger" title="Required Smart Pipe inputs are missing">{issues.map((issue:any)=><div key={issue.id}>{issue.message}</div>)}</Callout>}<Button onClick={add}>Add slot</Button>{items.length?<SortableList items={items} onReorder={ordered=>update(value=>{const byId=new Map(value.definition.localSlots.map((slot:any)=>[slot.id,slot]));value.definition.localSlots=ordered.map(item=>byId.get(item.id)).filter(Boolean)})} onRemove={id=>update(value=>{value.definition.localSlots=value.definition.localSlots.filter((slot:any)=>slot.id!==id);value.projection.inheritedSlots=value.projection.inheritedSlots.filter((slot:any)=>slot.id!==id)})}/>:<EmptyState title="No slots" description="Add a local slot or connect a predecessor."/>}</div>;
}
function MergeDraft({draft,update}:{draft:any;update:(mutate:(value:any)=>void)=>void}){
const sources=draft.sources??[],[selected,setSelected]=useState("");
const items:SortableItem[]=sources.map((source:any)=>({id:source.key,title:source.label||source.key,description:source.mode==="wired"?"Wired":"Wireless"}));
const candidate=draft.candidates?.find((item:any)=>item.address===selected);
const add=()=>{if(!candidate)return;update(value=>value.sources.push({key:`pipe_${String((value.sources.length+1)).padStart(3,"0")}`,mode:"wireless",address:candidate.address,label:candidate.label}));setSelected("")};
return <div className="bv-ui-stack"><div className="bv-ui-inline-footer bv-smart-pipe-merge-source"><SelectField label="Wireless source" value={selected} options={(draft.candidates??[]).map((item:any)=>({value:item.address,label:item.label}))} onValue={setSelected}/><Button disabled={!candidate} onClick={add}>Add</Button></div>{items.length?<SortableList items={items} onReorder={ordered=>update(value=>{const byKey=new Map(value.sources.map((source:any)=>[source.key,source]));value.sources=ordered.map(item=>byKey.get(item.id)).filter(Boolean)})} onRemove={id=>update(value=>{value.sources=value.sources.filter((source:any)=>source.key!==id)})}/>:<EmptyState title="No merge sources" description="Connect a pipe or add a wireless source."/>}</div>;
}