66 lines
14 KiB
TypeScript
66 lines
14 KiB
TypeScript
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&¤tKey&¤tKey!==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&¤t[currentKey]?structuredClone(current[currentKey]):current[nextKey]??defaultPresentation()}));setActiveKey(nextKey);setActivation(value=>value+1)};if(replaceCurrent&¤tKey&¤tKey!==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>;
|
||
}
|