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
+41 -14
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).
+14 -1
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,
+2
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,
}); });
} }
} }
+63 -54
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