perf(web): optimize shift+click range selection

- Add O(1) hasSelectionCandidate via #candidateSet (was O(n) .some() scan per thumbnail)
- Add assetId index lookup map in GalleryViewer for O(1) range slicing (was O(n) findIndex x2)
- Add same-month fast path in retrieveRange() to skip async iteration
pull/28636/head
Maarten Coppens 2026-05-25 11:04:36 +02:00
parent 8682be4774
commit d1dd7dcfb2
3 changed files with 42 additions and 4 deletions

View File

@ -85,6 +85,15 @@
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0);
// Asset ID index lookup map for O(1) range computation
let assetIndexMap = $derived.by(() => {
const map = new Map<string, number>();
for (let i = 0; i < assets.length; i++) {
map.set(assets[i].id, i);
}
return map;
});
let slidingWindow = $derived.by(() => {
const top = (scrollTop || 0) - slidingWindowOffset;
const bottom = top + viewport.height + slidingWindowOffset;
@ -175,8 +184,12 @@
return;
}
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
let start = assetIndexMap.get(startAsset.id);
let end = assetIndexMap.get(endAsset.id);
if (start === undefined || end === undefined) {
return;
}
if (start > end) {
[start, end] = [end, start];

View File

@ -9,6 +9,7 @@ export type AssetMultiSelectOptions = {
};
export class AssetMultiSelectManager {
#selectedMap = new SvelteMap<string, TimelineAsset>();
#candidateSet = new SvelteSet<string>();
selectAll = $state(false);
startAsset = $state<TimelineAsset | null>(null);
@ -55,7 +56,7 @@ export class AssetMultiSelectManager {
}
hasSelectionCandidate(assetId: string) {
return this.candidates.some((asset) => asset.id === assetId);
return this.#candidateSet.has(assetId);
}
selectAsset(asset: TimelineAsset) {
@ -64,7 +65,7 @@ export class AssetMultiSelectManager {
selectAssets(assets: TimelineAsset[]) {
for (const asset of assets) {
this.selectAsset(asset);
this.#selectedMap.set(asset.id, asset);
}
}
@ -86,10 +87,15 @@ export class AssetMultiSelectManager {
setAssetSelectionCandidates(assets: TimelineAsset[]) {
this.candidates = assets;
this.#candidateSet.clear();
for (const asset of assets) {
this.#candidateSet.add(asset.id);
}
}
clearCandidates() {
this.candidates = [];
this.#candidateSet.clear();
}
clear() {
@ -101,6 +107,7 @@ export class AssetMultiSelectManager {
// Range selection
this.candidates = [];
this.#candidateSet.clear();
this.startAsset = null;
}
}

View File

@ -131,6 +131,24 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
[startTimelineMonth, endTimelineMonth] = [endTimelineMonth, startTimelineMonth];
}
// Fast path: both assets in the same loaded month — collect synchronously
if (startTimelineMonth === endTimelineMonth && startTimelineMonth.isLoaded) {
const range: TimelineAsset[] = [];
let collecting = false;
for (const asset of startTimelineMonth.assetsIterator()) {
if (!collecting && asset.id === startAsset.id) {
collecting = true;
}
if (collecting) {
range.push(asset);
}
if (asset.id === endAsset.id) {
break;
}
}
return range;
}
const range: TimelineAsset[] = [];
const startTimelineDay = startTimelineMonth.findTimelineDayForAsset(startAsset);
for await (const targetAsset of timelineManager.assetsIterator({