Fix Regional LoRA scope switching

This commit is contained in:
BlackVortexAI
2026-08-23 18:12:23 +02:00
parent dab986ef4b
commit 068beffbbe
4 changed files with 29 additions and 21 deletions
+16 -16
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -8,6 +8,8 @@ import {emptyLoraV3Config,parseLoraV3Config,serializeLoraV3Config} from "../ui/s
const collectors=[{id:"collector",label:"Styles",resources:[{id:"skin",label:"Skin"}]}];
const editorSource=readFileSync(new URL("../ui/src/regional/LoraV3EditorWindow.tsx",import.meta.url),"utf8");
const pickerSource=readFileSync(new URL("../ui/src/regional/LoraV3ResourcePickerPanel.tsx",import.meta.url),"utf8");
const regionalEditorSource=readFileSync(new URL("../ui/src/regional/RegionalEditor.tsx",import.meta.url),"utf8");
const styles=readFileSync(new URL("../ui/src/index.css",import.meta.url),"utf8");
test("the production picker renders Collector / Resource from persisted ids",()=>{
const config={version:1,collector_id:"collector",entries:[{id:"entry",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"}]}]};
@@ -41,6 +43,13 @@ test("Regional Prompt easy mode renders every stack assigned to its scope",()=>{
assert.match(html,/LoRA enabled/);assert.match(html,/LoRA Stack 1/);assert.match(html,/LoRA Stack 2/);assert.match(html,/Add LoRA stack/);
assert.match(html,/aria-label="Remove LoRA Stack 1"/);assert.doesNotMatch(html,/>Remove</);
});
test("Regional Prompt easy mode changes scope immediately when the selected region changes",()=>{
assert.doesNotMatch(pickerSource,/\[enabled,setEnabled\]=useState/);
assert.match(regionalEditorSource,/OptionalLoraV3ScopePicker key=\{selectedRegion\.id\}/);
const config={version:1,collector_id:"collector",entries:[{id:"left-entry",source:{kind:"external",resource_id:"skin"},targets:[{scope:"region",document_id:"doc",region_id:"left"}]}]};
const right=renderToStaticMarkup(React.createElement(OptionalLoraV3ScopePicker,{collectors,config,target:{scope:"region",document_id:"doc",region_id:"right"},resolved:true,onCollector(){},onResource(){},onAdd(){},onRemove(){},onClear(){}}));
assert.match(right,/LoRA disabled/);assert.doesNotMatch(right,/LoRA Stack 1/);
});
test("Regional Prompt keeps the LoRA toggle above the stack card without overlap",()=>{
assert.match(styles,/\.bv-lora-v3-enable-row\{[^}]*grid-template-columns:minmax\(0,1fr\)/);
assert.doesNotMatch(styles,/\.bv-lora-v3-enable-row>\.bv-control-field\{[^}]*padding-top/);
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { Button, ResourcePicker, ResourcePickerCollector, ToggleField, type SelectOption } from "../ui/components";
export type LoraV3Entry={id:string;source:{kind:"external";resource_id:string}|{kind:"native";lora_name:string;model_strength:number;clip_strength:number};targets:Record<string,unknown>[]};
@@ -23,9 +23,8 @@ export function LoraV3ScopePicker({collectors,config,target,resolved,onCollector
}
export function OptionalLoraV3ScopePicker(props:ScopeProps&{onClear:(target:LoraV3Target)=>void}){
const targetKey=loraV3TargetValue(props.target),assigned=externalFor(props.config,props.target).length>0,[enabled,setEnabled]=useState(assigned);
useEffect(()=>setEnabled(assigned),[assigned,targetKey]);
return <div className="bv-optional-lora-scope"><div className="bv-lora-v3-enable-row"><ToggleField label="" value={enabled} trueLabel="LoRA enabled" falseLabel="LoRA disabled" onValue={next=>{setEnabled(next);if(next&&!assigned)props.onAdd(props.target);if(!next)props.onClear(props.target);}}/>{enabled&&<LoraV3ScopePicker {...props}/>}</div></div>;
const enabled=externalFor(props.config,props.target).length>0;
return <div className="bv-optional-lora-scope"><div className="bv-lora-v3-enable-row"><ToggleField label="" value={enabled} trueLabel="LoRA enabled" falseLabel="LoRA disabled" onValue={next=>{if(next)props.onAdd(props.target);else props.onClear(props.target);}}/>{enabled&&<LoraV3ScopePicker {...props}/>}</div></div>;
}
export function LoraV3ResourcePickerPanel({collectors,config,resolved,targetOptions=[],onCollector,onResource,onAddExternal,onRemove}:{collectors:ResourcePickerCollector[];config:LoraV3Config;resolved:boolean;targetOptions?:LoraV3TargetOption[];onCollector:(id:string)=>void;onResource:(entryId:string,resourceId:string)=>void;onAddExternal:(target:LoraV3Target)=>void;onRemove:(entryId:string,target:LoraV3Target)=>void}){
+1 -1
View File
@@ -331,7 +331,7 @@ export default function RegionalEditor({ open, activationToken=0, nodes, initial
const loraSummary = loraV3Config.entries.length?`${loraV3Config.entries.length} V3 LoRA assignment${loraV3Config.entries.length===1?"":"s"}`:documentValue ? bindingSummary(loraBindings, loraStacks, Object.fromEntries(documentValue.regions.map(region => [region.id, region.name]))) : "";
const loraV3Collectors=node?loraV3Catalog(node).filter(item=>item.resources.length>0):[],loraV3IsResolved=node?loraV3Resolved(node,loraV3Config):false;
const v3ScopeProps={collectors:loraV3Collectors,config:loraV3Config,resolved:loraV3IsResolved,onCollector:(id:string)=>node&&setLoraV3Config(setLoraV3Collector(node,loraV3Config,id)),onResource:(entryId:string,id:string)=>node&&setLoraV3Config(setLoraV3EntryResource(node,loraV3Config,entryId,id)),onAdd:(target:LoraV3Target)=>node&&setLoraV3Config(addLoraV3TargetEntry(node,loraV3Config,target)),onRemove:(entryId:string,target:LoraV3Target)=>node&&setLoraV3Config(removeLoraV3TargetEntry(node,loraV3Config,entryId,target)),onClear:(target:LoraV3Target)=>node&&setLoraV3Config(clearLoraV3Target(node,loraV3Config,target))};
const optionsPanel = (mode: "selection" | "region" | "document") => documentValue ? <OptionsPanel mode={mode} region={selectedRegion} layer={selectedLayer} bounds={selectionBounds} canvas={documentValue.canvas} automaticRegionColor={selectedRegion ? automaticRegionColor(documentValue.regions.indexOf(selectedRegion)) : null} globalPrompts={documentValue.prompts.global} backgroundPrompts={documentValue.prompts.background} negativeMode={documentValue.negative_mode} promptSections={viewState.promptSections} loraBindings={loraBindings} loraStacks={loraStacks} loraV3GlobalEditor={<OptionalLoraV3ScopePicker {...v3ScopeProps} target={{scope:"global"}}/>} loraV3RegionEditor={selectedRegion?<OptionalLoraV3ScopePicker {...v3ScopeProps} target={{scope:"region",document_id:documentValue.document_id,region_id:selectedRegion.id}}/>:null} onGlobalLoraStack={setGlobalLoraStack} onRegionLoraStack={setRegionLoraStack} onPromptSection={(section, open) => setViewState(current => ({ ...current, promptSections: { ...current.promptSections, [section]: open } }))} onNegativeMode={negativeMode => mutate(value => { value.negative_mode = negativeMode; })} onGlobalPrompts={prompts => mutate(value => { value.prompts.global = prompts; })} onBackgroundPrompts={prompts => mutate(value => { value.prompts.background = prompts; })} onRegion={updateRegion} onLayerBounds={bounds => selectedLayerId && updateLayer(selectedLayerId, (operations, region) => { const replacements = new Map(setLayerBounds(operations, bounds, documentValue.canvas).map(item => [item.id, item])); region.geometry = region.geometry.map(item => replacements.get(item.id) ?? item); })} onBrushSetting={(field, setting) => selectedLayerId && updateLayer(selectedLayerId, operations => operations.forEach(item => { if (item.type === "brush_stroke") Object.assign(item, { [field]: setting }); }))}/> : null;
const optionsPanel = (mode: "selection" | "region" | "document") => documentValue ? <OptionsPanel mode={mode} region={selectedRegion} layer={selectedLayer} bounds={selectionBounds} canvas={documentValue.canvas} automaticRegionColor={selectedRegion ? automaticRegionColor(documentValue.regions.indexOf(selectedRegion)) : null} globalPrompts={documentValue.prompts.global} backgroundPrompts={documentValue.prompts.background} negativeMode={documentValue.negative_mode} promptSections={viewState.promptSections} loraBindings={loraBindings} loraStacks={loraStacks} loraV3GlobalEditor={<OptionalLoraV3ScopePicker {...v3ScopeProps} target={{scope:"global"}}/>} loraV3RegionEditor={selectedRegion?<OptionalLoraV3ScopePicker key={selectedRegion.id} {...v3ScopeProps} target={{scope:"region",document_id:documentValue.document_id,region_id:selectedRegion.id}}/>:null} onGlobalLoraStack={setGlobalLoraStack} onRegionLoraStack={setRegionLoraStack} onPromptSection={(section, open) => setViewState(current => ({ ...current, promptSections: { ...current.promptSections, [section]: open } }))} onNegativeMode={negativeMode => mutate(value => { value.negative_mode = negativeMode; })} onGlobalPrompts={prompts => mutate(value => { value.prompts.global = prompts; })} onBackgroundPrompts={prompts => mutate(value => { value.prompts.background = prompts; })} onRegion={updateRegion} onLayerBounds={bounds => selectedLayerId && updateLayer(selectedLayerId, (operations, region) => { const replacements = new Map(setLayerBounds(operations, bounds, documentValue.canvas).map(item => [item.id, item])); region.geometry = region.geometry.map(item => replacements.get(item.id) ?? item); })} onBrushSetting={(field, setting) => selectedLayerId && updateLayer(selectedLayerId, operations => operations.forEach(item => { if (item.type === "brush_stroke") Object.assign(item, { [field]: setting }); }))}/> : null;
return <>{keptNodeIds.map(id=>{const kept=nodes.find(item=>keyFor(item)===id);return kept?<BvMinimizedWindow key={id} title={`Regional Editor · ${kept.title||"BV Regional Prompt"} · #${kept.id}`} onRestore={()=>navigateNode(id,false,false)} onClose={()=>setKeptNodeIds(ids=>ids.filter(value=>value!==id))}/>:null})}<BvManagedWindow open={open} activationToken={activationToken} title="BV Regional Editor" menuVisible={menuVisible} onMenuVisible={visible=>setWindowMenuVisible(node,visible)} context={<BvWindowNavigator label="Regional prompt document" value={keyFor(node)} onNavigate={navigateNode} options={nodes.filter(item=>windowMenuVisible(item)).map(item => ({ value:keyFor(item), label: `${item.title || "BV Regional Prompt"} · #${item.id}` }))}/>} center={<EditorMenus openMenu={viewState.openMenu} onOpenMenu={updateOpenMenu} displayOpacity={displayOpacity} backgroundOpacity={backgroundOpacity} isolate={isolate} binaryMaskPreview={viewState.binaryMaskPreview} hasSelection={!!selectedLayerId} onDisplayOpacity={setDisplayOpacity} onBackgroundOpacity={setBackgroundOpacity} onToggleIsolate={() => setIsolate(value => !value)} onToggleBinaryMaskPreview={() => { cancelGesture(); setViewState(current => ({ ...current, binaryMaskPreview: !current.binaryMaskPreview })); }} onExportDocument={() => documentValue && downloadJson(`${documentValue.title || "bv-regional"}.json`, documentValue)} onExportRegions={() => documentValue && downloadJson(`${documentValue.title || "bv-regions"}.regions.json`, { schema: "bv.regions", version: 2, canvas: documentValue.canvas, regions: documentValue.regions })} onImportDocument={importDocument} onImportRegions={importRegions} onUndo={undo} onRedo={redo} canUndo={!!history.length} canRedo={!!future.length} canMergeLayers={canMergeLayers} onMergeLayers={mergeLayers} canvas={documentValue?.canvas ?? { width: 1024, height: 1024 }} onCanvas={canvas => mutate(value => { value.canvas = canvas; })}/>} mode={viewState.mode} initialGeometry={windowGeometry} minSize={{width:780,height:520}} className="bv-regional-shell" bodyClassName="bv-regional-window-body" onModeChange={mode=>setViewState(current=>({...current,mode}))} onGeometry={geometry=>setViewState(current=>({...current,windows:{...current.windows,floating:geometry}}))} onClose={onClose} status={documentValue?<><span>{loraWarnings[0] ? `⚠ ${loraWarnings[0]}` : `✓ ${loraSummary}`}</span><span>{documentValue.regions.length} regions · layout adjusted for this session</span></>:null} actions={documentValue?<ResetLayoutButton storageId={`regional-editor:${documentValue.document_id}`} editorType="regional" onClick={() => setDockResetSignal(value => value + 1)}/>:null}>
{error ? <div className="bv-regional-error">{error}</div> : documentValue && <>
<main className="bv-regional-dock-host">