fix(widgets): persist LoRA entry selection and active tab across save/load

This commit is contained in:
Will Miao
2026-07-17 18:26:30 +08:00
parent 090e0297d4
commit 681cc13e90
5 changed files with 121 additions and 70 deletions

View File

@@ -97,6 +97,12 @@ interface LoraInfoWidget {
value?: unknown value?: unknown
onSetValue?: (v: unknown) => void onSetValue?: (v: unknown) => void
callback?: unknown callback?: unknown
options?: {
getValue?: () => LoraInfoWidgetValue
setValue?: (v: unknown) => void
}
node?: { widgets?: Array<{ id?: string }>; widgets_values?: Array<unknown> }
id?: string
_setLoraInfo?: (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => void _setLoraInfo?: (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => void
__pendingLoraInfo?: { name: string; notes: string; filePath: string; activeTab?: string } | null __pendingLoraInfo?: { name: string; notes: string; filePath: string; activeTab?: string } | null
} }
@@ -267,38 +273,59 @@ function onWheel(event: WheelEvent) {
} }
onMounted(() => { onMounted(() => {
// Persist tab state along with other widget data // Build current state snapshot for serialization
props.widget.serializeValue = async (): Promise<LoraInfoWidgetValue> => ({ const buildValue = (): LoraInfoWidgetValue => ({
name: loraName.value, name: loraName.value,
notes: notes.value, notes: notes.value,
filePath: filePath.value, filePath: filePath.value,
activeTab: activeTab.value, activeTab: activeTab.value,
}) })
// Handle external value updates (e.g., loading workflow, paste) // Set value from external source (workflow load, paste, etc.)
props.widget.onSetValue = (v: unknown) => { const applyValue = (v: unknown) => {
if (v && typeof v === 'object') { if (v && typeof v === 'object') {
const data = v as LoraInfoWidgetValue const data = v as LoraInfoWidgetValue
// Set activeTab before filePath so the filePath watcher sees the correct tab
// and triggers fetchDescription() when restoring description tab
if (data.activeTab !== undefined) activeTab.value = data.activeTab
if (data.name !== undefined) loraName.value = data.name if (data.name !== undefined) loraName.value = data.name
if (data.notes !== undefined) { if (data.notes !== undefined) {
notes.value = data.notes notes.value = data.notes
originalNotes.value = data.notes originalNotes.value = data.notes
} }
if (data.filePath !== undefined) filePath.value = data.filePath if (data.filePath !== undefined) filePath.value = data.filePath
if (data.activeTab !== undefined) activeTab.value = data.activeTab
} }
} }
// Restore from saved value if exists (for workflow loading) // ComponentWidgetImpl.value getter/setter delegates to options.getValue/options.setValue.
if (props.widget.value && typeof props.widget.value === 'object') { // These must be set for workflow JSON persistence (LGraphNode.serialize/configure) to work.
const data = props.widget.value as LoraInfoWidgetValue props.widget.options.getValue = buildValue
if (data.name !== undefined) loraName.value = data.name props.widget.options.setValue = applyValue
if (data.notes !== undefined) {
notes.value = data.notes // Also set serializeValue for prompt/API serialization path (executionUtil.ts)
originalNotes.value = data.notes props.widget.serializeValue = async () => buildValue()
// Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = applyValue
// Restore from saved value. Because configure() may call widget.value = data
// before onMounted fires (and before options.setValue is assigned), we check
// widgets_values directly in case the value was already pushed.
const widgetIndex = props.widget.node?.widgets?.findIndex(
(w: { id?: string }) => w.id === props.widget.id
)
let restored = false
if (widgetIndex !== undefined && widgetIndex >= 0) {
const savedValue = props.widget.node?.widgets_values?.[widgetIndex]
if (savedValue && typeof savedValue === 'object') {
applyValue(savedValue)
restored = true
} }
if (data.filePath !== undefined) filePath.value = data.filePath }
if (data.activeTab !== undefined) activeTab.value = data.activeTab // Fallback: if configure() ran after onMounted, widget.value (via options.getValue)
// already has the saved data. Only use this path if the widgets_values lookup didn't restore.
if (!restored && props.widget.value && typeof props.widget.value === 'object') {
applyValue(props.widget.value)
} }
// Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js). // Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js).

View File

@@ -711,7 +711,11 @@ export function addLorasWidget(node, name, opts, callback) {
// Create widget with new DOM Widget API // Create widget with new DOM Widget API
const widget = node.addDOMWidget(name, "custom", container, { const widget = node.addDOMWidget(name, "custom", container, {
getValue: function() { getValue: function() {
return widgetValue; return widgetValue.map(lora => {
const entry = { ...lora };
entry.selected = lora.name === selectedLora;
return entry;
});
}, },
setValue: function(v) { setValue: function(v) {
// Remove duplicates by keeping the last occurrence of each lora name // Remove duplicates by keeping the last occurrence of each lora name
@@ -738,6 +742,15 @@ export function addLorasWidget(node, name, opts, callback) {
}); });
widgetValue = updatedValue; widgetValue = updatedValue;
// Restore selection state when loading a saved workflow
if (!selectedLora) {
const selectedEntry = updatedValue.find(lora => lora.selected);
if (selectedEntry) {
selectedLora = selectedEntry.name;
}
}
renderLoras(widgetValue, widget); renderLoras(widgetValue, widget);
}, },
hideOnZoom: true, hideOnZoom: true,

View File

@@ -438,6 +438,7 @@ export function mergeLoras(lorasText, lorasArr) {
active: lora.active !== undefined ? lora.active : true, active: lora.active !== undefined ? lora.active : true,
expanded: lora.expanded !== undefined ? lora.expanded : false, expanded: lora.expanded !== undefined ? lora.expanded : false,
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength, clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
selected: !!lora.selected,
}); });
usedNames.add(lora.name); usedNames.add(lora.name);
} }
@@ -451,6 +452,7 @@ export function mergeLoras(lorasText, lorasArr) {
strength: parsedLoras[name].strength, strength: parsedLoras[name].strength,
active: true, active: true,
clipStrength: parsedLoras[name].clipStrength, clipStrength: parsedLoras[name].clipStrength,
selected: false,
}); });
} }
} }

View File

@@ -2224,7 +2224,7 @@ to { transform: rotate(360deg);
resize: vertical !important; resize: vertical !important;
} }
.lora-info-widget[data-v-a65e9027] { .lora-info-widget[data-v-41af3987] {
padding: 12px; padding: 12px;
background: rgba(40, 44, 52, 0.6); background: rgba(40, 44, 52, 0.6);
border-radius: 4px; border-radius: 4px;
@@ -2240,45 +2240,45 @@ to { transform: rotate(360deg);
determined solely by CSS — not by descendant content. This breaks the determined solely by CSS — not by descendant content. This breaks the
feedback loop where content grows → ResizeObserver resizes → content feedback loop where content grows → ResizeObserver resizes → content
reflows → repeat. Same technique used by tags_widget.js + lm_styles.css. */ reflows → repeat. Same technique used by tags_widget.js + lm_styles.css. */
.lora-info-widget.lm-vue-node[data-v-a65e9027] { .lora-info-widget.lm-vue-node[data-v-41af3987] {
contain: layout size; contain: layout size;
} }
/* ── Tab bar ── */ /* ── Tab bar ── */
.lora-info-tabs[data-v-a65e9027] { .lora-info-tabs[data-v-41af3987] {
display: flex; display: flex;
gap: 0; gap: 0;
margin-bottom: 10px; margin-bottom: 10px;
border-bottom: 1px solid var(--border-color, #444); border-bottom: 1px solid var(--border-color, #444);
flex-shrink: 0; flex-shrink: 0;
} }
.lora-info-tab[data-v-a65e9027] { .lora-info-tab[data-v-41af3987] {
flex: 1; flex: 1;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
padding: 6px 0; padding: 6px 0;
position: relative; position: relative;
} }
.lora-info-tab-input[data-v-a65e9027] { .lora-info-tab-input[data-v-41af3987] {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
width: 0; width: 0;
height: 0; height: 0;
} }
.lora-info-tab-label[data-v-a65e9027] { .lora-info-tab-label[data-v-41af3987] {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
opacity: 0.5; opacity: 0.5;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.lora-info-tab:hover .lora-info-tab-label[data-v-a65e9027] { .lora-info-tab:hover .lora-info-tab-label[data-v-41af3987] {
opacity: 0.75; opacity: 0.75;
} }
.lora-info-tab.active .lora-info-tab-label[data-v-a65e9027] { .lora-info-tab.active .lora-info-tab-label[data-v-41af3987] {
opacity: 1; opacity: 1;
} }
.lora-info-tab.active[data-v-a65e9027]::after { .lora-info-tab.active[data-v-41af3987]::after {
content: ''; content: '';
position: absolute; position: absolute;
bottom: -1px; bottom: -1px;
@@ -2290,16 +2290,16 @@ to { transform: rotate(360deg);
} }
/* ── Tab content ── */ /* ── Tab content ── */
.tab-content[data-v-a65e9027] { .tab-content[data-v-41af3987] {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.notes-tab[data-v-a65e9027] { .notes-tab[data-v-41af3987] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.description-tab[data-v-a65e9027] { .description-tab[data-v-41af3987] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
@@ -2307,12 +2307,12 @@ to { transform: rotate(360deg);
} }
/* ── Info fields (shared) ── */ /* ── Info fields (shared) ── */
.info-field[data-v-a65e9027] { .info-field[data-v-41af3987] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.info-label[data-v-a65e9027] { .info-label[data-v-41af3987] {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -2320,18 +2320,18 @@ to { transform: rotate(360deg);
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
opacity: 0.6; opacity: 0.6;
} }
.lora-filename[data-v-a65e9027] { .lora-filename[data-v-41af3987] {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
word-break: break-all; word-break: break-all;
margin-bottom: 8px; margin-bottom: 8px;
} }
.notes-field[data-v-a65e9027] { .notes-field[data-v-41af3987] {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.lora-notes[data-v-a65e9027] { .lora-notes[data-v-41af3987] {
width: 100%; width: 100%;
flex: 1; flex: 1;
min-height: 60px; min-height: 60px;
@@ -2346,14 +2346,14 @@ to { transform: rotate(360deg);
font-family: inherit; font-family: inherit;
outline: none; outline: none;
} }
.lora-notes[data-v-a65e9027]:focus { .lora-notes[data-v-41af3987]:focus {
border-color: var(--comfy-input-border, #444); border-color: var(--comfy-input-border, #444);
} }
.lora-notes[data-v-a65e9027]:disabled { .lora-notes[data-v-41af3987]:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.save-btn[data-v-a65e9027] { .save-btn[data-v-41af3987] {
width: 100%; width: 100%;
margin-top: 8px; margin-top: 8px;
padding: 6px 12px; padding: 6px 12px;
@@ -2367,11 +2367,11 @@ to { transform: rotate(360deg);
box-sizing: border-box; box-sizing: border-box;
flex-shrink: 0; flex-shrink: 0;
} }
.save-btn[data-v-a65e9027]:hover:not(:disabled) { .save-btn[data-v-41af3987]:hover:not(:disabled) {
background: rgba(66, 153, 225, 0.25); background: rgba(66, 153, 225, 0.25);
border-color: rgba(66, 153, 225, 0.6); border-color: rgba(66, 153, 225, 0.6);
} }
.save-btn[data-v-a65e9027]:disabled { .save-btn[data-v-41af3987]:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
background: rgba(66, 153, 225, 0.05); background: rgba(66, 153, 225, 0.05);
@@ -2379,7 +2379,7 @@ to { transform: rotate(360deg);
} }
/* ── Description states ── */ /* ── Description states ── */
.description-state[data-v-a65e9027] { .description-state[data-v-41af3987] {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -2391,22 +2391,22 @@ to { transform: rotate(360deg);
min-height: 0; min-height: 0;
flex-shrink: 0; flex-shrink: 0;
} }
.description-state.error[data-v-a65e9027] { .description-state.error[data-v-41af3987] {
opacity: 0.7; opacity: 0.7;
color: #f87171; color: #f87171;
} }
/* ── Description content ── */ /* ── Description content ── */
.description-content[data-v-a65e9027] { .description-content[data-v-41af3987] {
min-height: 0; min-height: 0;
} }
.description-section[data-v-a65e9027] { .description-section[data-v-41af3987] {
margin-bottom: 14px; margin-bottom: 14px;
} }
.description-section[data-v-a65e9027]:last-child { .description-section[data-v-41af3987]:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.description-text[data-v-a65e9027] { .description-text[data-v-41af3987] {
padding: 8px 0; padding: 8px 0;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
@@ -2414,41 +2414,41 @@ to { transform: rotate(360deg);
opacity: 0.85; opacity: 0.85;
word-break: break-word; word-break: break-word;
} }
.description-text[data-v-a65e9027] p { .description-text[data-v-41af3987] p {
margin: 0 0 8px 0; margin: 0 0 8px 0;
} }
.description-text[data-v-a65e9027] p:last-child { .description-text[data-v-41af3987] p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.description-text[data-v-a65e9027] a { .description-text[data-v-41af3987] a {
color: rgba(66, 153, 225, 0.9); color: rgba(66, 153, 225, 0.9);
} }
.description-text[data-v-a65e9027] ul, .description-text[data-v-41af3987] ul,
.description-text[data-v-a65e9027] ol { .description-text[data-v-41af3987] ol {
padding-left: 20px; padding-left: 20px;
margin: 4px 0; margin: 4px 0;
} }
.description-text[data-v-a65e9027] h1, .description-text[data-v-41af3987] h1,
.description-text[data-v-a65e9027] h2, .description-text[data-v-41af3987] h2,
.description-text[data-v-a65e9027] h3 { .description-text[data-v-41af3987] h3 {
font-size: 13px; font-size: 13px;
margin: 10px 0 4px 0; margin: 10px 0 4px 0;
font-weight: 600; font-weight: 600;
opacity: 0.95; opacity: 0.95;
} }
.description-text[data-v-a65e9027] code { .description-text[data-v-41af3987] code {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
padding: 1px 4px; padding: 1px 4px;
border-radius: 3px; border-radius: 3px;
font-size: 11px; font-size: 11px;
} }
.description-text[data-v-a65e9027] img { .description-text[data-v-41af3987] img {
max-width: 100%; max-width: 100%;
border-radius: 4px; border-radius: 4px;
} }
/* ── Placeholder (shared) ── */ /* ── Placeholder (shared) ── */
.placeholder[data-v-a65e9027] { .placeholder[data-v-41af3987] {
font-style: italic; font-style: italic;
color: rgba(226, 232, 240, 0.5); color: rgba(226, 232, 240, 0.5);
text-align: center; text-align: center;
@@ -2457,10 +2457,10 @@ to { transform: rotate(360deg);
} }
/* ── Spinner (Font Awesome) ── */ /* ── Spinner (Font Awesome) ── */
.fa-spinner[data-v-a65e9027] { .fa-spinner[data-v-41af3987] {
animation: fa-spin-a65e9027 1s linear infinite; animation: fa-spin-41af3987 1s linear infinite;
} }
@keyframes fa-spin-a65e9027 { @keyframes fa-spin-41af3987 {
0% { transform: rotate(0deg); 0% { transform: rotate(0deg);
} }
100% { transform: rotate(360deg); 100% { transform: rotate(360deg);
@@ -15484,33 +15484,42 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
comfyApp.canvas.processMouseWheel(event); comfyApp.canvas.processMouseWheel(event);
} }
onMounted(() => { onMounted(() => {
props.widget.serializeValue = async () => ({ var _a2, _b, _c, _d;
const buildValue = () => ({
name: loraName.value, name: loraName.value,
notes: notes.value, notes: notes.value,
filePath: filePath.value, filePath: filePath.value,
activeTab: activeTab.value activeTab: activeTab.value
}); });
props.widget.onSetValue = (v2) => { const applyValue = (v2) => {
if (v2 && typeof v2 === "object") { if (v2 && typeof v2 === "object") {
const data = v2; const data = v2;
if (data.activeTab !== void 0) activeTab.value = data.activeTab;
if (data.name !== void 0) loraName.value = data.name; if (data.name !== void 0) loraName.value = data.name;
if (data.notes !== void 0) { if (data.notes !== void 0) {
notes.value = data.notes; notes.value = data.notes;
originalNotes.value = data.notes; originalNotes.value = data.notes;
} }
if (data.filePath !== void 0) filePath.value = data.filePath; if (data.filePath !== void 0) filePath.value = data.filePath;
if (data.activeTab !== void 0) activeTab.value = data.activeTab;
} }
}; };
if (props.widget.value && typeof props.widget.value === "object") { props.widget.options.getValue = buildValue;
const data = props.widget.value; props.widget.options.setValue = applyValue;
if (data.name !== void 0) loraName.value = data.name; props.widget.serializeValue = async () => buildValue();
if (data.notes !== void 0) { props.widget.onSetValue = applyValue;
notes.value = data.notes; const widgetIndex = (_b = (_a2 = props.widget.node) == null ? void 0 : _a2.widgets) == null ? void 0 : _b.findIndex(
originalNotes.value = data.notes; (w2) => w2.id === props.widget.id
);
let restored = false;
if (widgetIndex !== void 0 && widgetIndex >= 0) {
const savedValue = (_d = (_c = props.widget.node) == null ? void 0 : _c.widgets_values) == null ? void 0 : _d[widgetIndex];
if (savedValue && typeof savedValue === "object") {
applyValue(savedValue);
restored = true;
} }
if (data.filePath !== void 0) filePath.value = data.filePath; }
if (data.activeTab !== void 0) activeTab.value = data.activeTab; if (!restored && props.widget.value && typeof props.widget.value === "object") {
applyValue(props.widget.value);
} }
props.widget._setLoraInfo = (data) => { props.widget._setLoraInfo = (data) => {
if (data) { if (data) {
@@ -15624,7 +15633,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
}; };
} }
}); });
const LoraInfoWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a65e9027"]]); const LoraInfoWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-41af3987"]]);
function createVueWidgetCleanup(vueApp, onCleanup) { function createVueWidgetCleanup(vueApp, onCleanup) {
let didUnmount = false; let didUnmount = false;
return () => { return () => {

File diff suppressed because one or more lines are too long