fix(bulk): restore card selection when virtual scroller recreates cards

This commit is contained in:
Will Miao
2026-08-26 09:28:17 +08:00
parent 08895f77ff
commit 0b08ad283a
4 changed files with 172 additions and 3 deletions
+3 -2
View File
@@ -543,8 +543,9 @@ export function createModelCard(model, modelType) {
card.classList.add('excluded-model');
}
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
// state.selectedModels resolves to the active page's set (selectedLoras
// included) - do not narrow this back to selectedLoras/LORA-only.
if (state.bulkMode && state.selectedModels.has(model.file_path)) {
card.classList.add('selected');
}
+6
View File
@@ -544,6 +544,12 @@ export class MasonryScroller {
modelCard.style.minWidth = '0';
}
// Scroll recycling rebuilds cards from scratch, dropping `.selected`
// even though the filepath is still selected in state.
if (state.bulkMode && modelCard && state.selectedModels.has(modelCard.dataset.filepath)) {
modelCard.classList.add('selected');
}
return element;
}
+6
View File
@@ -497,6 +497,12 @@ export class VirtualScroller {
modelCard.style.minWidth = '0';
}
// Scroll recycling rebuilds cards from scratch, dropping `.selected`
// even though the filepath is still selected in state.
if (state.bulkMode && modelCard && state.selectedModels.has(modelCard.dataset.filepath)) {
modelCard.classList.add('selected');
}
return element;
}
+157 -1
View File
@@ -75,7 +75,10 @@ describe('MasonryScroller', () => {
while (liveScrollers.length > 0) {
liveScrollers.pop().dispose();
}
getCurrentPageState().duplicatesMode = false;
const pageState = getCurrentPageState();
pageState.duplicatesMode = false;
pageState.bulkMode = false;
pageState.selectedModels.clear();
});
function track(setup) {
@@ -235,6 +238,94 @@ describe('MasonryScroller', () => {
expect(rendered[0].style.minWidth).toBe('0');
});
// Root-is-.model-card stub with data-filepath; the .card-preview child is
// required by updateSingleItem's update indicator.
function createRecipeStyleCreateItemFn() {
return (item) => {
const el = document.createElement('div');
el.className = 'model-card';
el.dataset.filepath = item.file_path;
const preview = document.createElement('div');
preview.className = 'card-preview';
el.appendChild(preview);
return el;
};
}
it('restores .selected on recreated cards after scroll recycling in bulk mode', () => {
const items = makeItems(Array.from({ length: 6 }, () => ({ width: 100, height: 100 })));
const pageState = getCurrentPageState();
const { scroller, grid, wrapper } = track(createScroller({
items,
viewportHeight: 300,
createItemFn: createRecipeStyleCreateItemFn(),
}));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.renderItems();
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBeGreaterThan(0);
pageState.bulkMode = true;
pageState.selectedModels.add('/recipes/item-0.png');
pageState.selectedModels.add('/recipes/item-1.png');
wrapper.scrollTop = 100000;
scroller.renderItems();
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(0);
wrapper.scrollTop = 0;
scroller.renderItems();
const selectedPaths = [...grid.querySelectorAll('.model-card.selected')]
.map((card) => card.dataset.filepath)
.sort();
expect(selectedPaths).toEqual(['/recipes/item-0.png', '/recipes/item-1.png']);
});
it('does not mark recreated cards as selected when bulk mode is off', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const pageState = getCurrentPageState();
const { scroller, grid, wrapper } = track(createScroller({
items,
viewportHeight: 3000,
createItemFn: createRecipeStyleCreateItemFn(),
}));
pageState.selectedModels.add('/recipes/item-0.png');
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.renderItems();
expect(grid.querySelectorAll('.model-card.selected').length).toBe(0);
});
it('updateSingleItem keeps the selected class on the rebuilt card', () => {
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
const pageState = getCurrentPageState();
const { scroller, grid, wrapper } = track(createScroller({
items,
viewportHeight: 3000,
createItemFn: createRecipeStyleCreateItemFn(),
}));
pageState.bulkMode = true;
pageState.selectedModels.add('/recipes/item-1.png');
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.renderItems();
const result = scroller.updateSingleItem('/recipes/item-1.png', { title: 'Updated title' });
expect(result).toBe(true);
const updatedCard = grid.querySelector('.virtual-scroll-item.updated');
expect(updatedCard).not.toBeNull();
expect(updatedCard.classList.contains('selected')).toBe(true);
expect(updatedCard.dataset.filepath).toBe('/recipes/item-1.png');
});
it('triggers loadMoreItems when scrolled to the bottom', async () => {
const firstPage = makeItems([
{ width: 100, height: 200 },
@@ -637,3 +728,68 @@ describe('MasonryScroller', () => {
expect(scroller.findIndexByFilePath('')).toBe(-1);
});
});
describe('VirtualScroller selection restore', () => {
beforeEach(() => {
setCurrentPageType('recipes');
getCurrentPageState().duplicatesMode = false;
});
afterEach(() => {
const pageState = getCurrentPageState();
pageState.duplicatesMode = false;
pageState.bulkMode = false;
pageState.selectedModels.clear();
});
function createVirtualScroller({ items, viewportHeight = 600 }) {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'clientWidth', { value: CONTAINER_WIDTH, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: viewportHeight, configurable: true });
const grid = document.createElement('div');
wrapper.appendChild(grid);
document.body.appendChild(wrapper);
const scroller = new VirtualScroller({
gridElement: grid,
containerElement: wrapper,
scrollContainer: wrapper,
createItemFn: (item) => {
const el = document.createElement('div');
el.className = 'model-card';
el.dataset.filepath = item.file_path;
return el;
},
fetchItemsFn: vi.fn(async () => ({ items, totalItems: items.length, hasMore: false })),
});
return { scroller, grid, wrapper };
}
it('restores .selected on cards recreated after scrolling away and back', () => {
const items = makeItems(Array.from({ length: 6 }, () => ({ width: 100, height: 100 })));
const pageState = getCurrentPageState();
const { scroller, grid, wrapper } = createVirtualScroller({ items, viewportHeight: 600 });
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.renderItems();
pageState.bulkMode = true;
pageState.selectedModels.add('/recipes/item-0.png');
pageState.selectedModels.add('/recipes/item-2.png');
wrapper.scrollTop = 1000000;
scroller.renderItems();
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(0);
wrapper.scrollTop = 0;
scroller.renderItems();
const selectedPaths = [...grid.querySelectorAll('.model-card.selected')]
.map((card) => card.dataset.filepath)
.sort();
expect(selectedPaths).toEqual(['/recipes/item-0.png', '/recipes/item-2.png']);
});
});