Refactor date group actions from Svelte component to TypeScript class

• Convert asset-date-group-actions.svelte to date-group-actions-lib.svelte.ts class
• Remove complex prop binding between asset-grid-without-scrubber and asset-date-group
• Use class instance in asset-date-group with direct method access
This commit is contained in:
midzelis 2025-08-14 20:16:22 +00:00
parent 0beeea6985
commit 9a30198238
4 changed files with 220 additions and 245 deletions

View file

@ -1,218 +0,0 @@
<script lang="ts">
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { searchStore } from '$lib/stores/search.svelte';
interface Props {
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
singleSelect?: boolean;
handleScrollTop: (scrollTop: number) => void;
onSelect?: (asset: TimelineAsset) => void;
onDateGroupSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => Promise<void>;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
}
let {
singleSelect = false,
timelineManager,
assetInteraction,
handleScrollTop = () => {},
onSelect = () => {},
onDateGroupSelect = $bindable(),
onSelectAssets = $bindable(),
onSelectAssetCandidates = $bindable(),
}: Props = $props();
const handleSelectAsset = (asset: TimelineAsset) => {
if (!timelineManager.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
};
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false);
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
onSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
void selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
onDateGroupSelect = ({ title: group, assets }: { title: string; assets: TimelineAsset[] }) => {
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
handleSelectAsset(asset);
}
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
onSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
onSelect(asset);
if (singleSelect) {
handleScrollTop(0);
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
return;
}
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
}
if (monthGroup === endBucket) {
break;
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
$effect(() => {
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
$effect(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (!shiftKeyIsDown) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
void selectAssetCandidates(lastAssetMouseEvent);
}
});
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />

View file

@ -6,7 +6,7 @@
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation';
@ -18,6 +18,9 @@
import { flip } from 'svelte/animate';
import { fly, scale } from 'svelte/transition';
import { onMount } from 'svelte';
import { DateGroupActionLib } from './date-group-actions-lib.svelte';
let { isUploading } = uploadAssetsStore;
interface Props {
@ -29,11 +32,13 @@
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
customLayout?: Snippet<[TimelineAsset]>;
onSelect: (asset: TimelineAsset) => void;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
// onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
// onSelectAssets: (asset: TimelineAsset) => void;
// onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
scrollTop: (top: number) => void;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
@ -57,12 +62,21 @@
timelineManager,
customLayout,
onSelect,
onSelectAssets,
onSelectAssetCandidates,
onScrollCompensation,
scrollTop,
onThumbnailClick,
}: Props = $props();
const actionLib = new DateGroupActionLib();
onMount(() => {
actionLib.assetInteraction = assetInteraction;
actionLib.timelineManager = timelineManager;
actionLib.singleSelect = singleSelect;
actionLib.onSelect = onSelect;
actionLib.scrollTop = scrollTop;
});
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
@ -83,15 +97,14 @@
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
// called when clicking asset with shift key pressed or with mouse
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
void actionLib.onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
@ -117,7 +130,7 @@
hoveredDayGroup = groupTitle;
if (assetInteraction.selectionActive) {
onSelectAssetCandidates(asset);
actionLib.onSelectAssetCandidates(asset);
}
};
@ -143,6 +156,8 @@
});
</script>
<svelte:document onkeydown={actionLib.onKeyDown} onkeyup={actionLib.onKeyUp} />
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
@ -173,8 +188,10 @@
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block pe-2 hover:cursor-pointer"
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onclick={() =>
actionLib.onDateGroupSelect({ title: dayGroup.groupTitle, assets: assetsSnapshot(dayGroup.getAssets()) })}
onkeydown={() =>
actionLib.onDateGroupSelect({ title: dayGroup.groupTitle, assets: assetsSnapshot(dayGroup.getAssets()) })}
>
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<Icon path={mdiCheckCircle} size="24" class="text-primary" />

View file

@ -2,7 +2,6 @@
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import AssetDateGroupActions from '$lib/components/photos-page/asset-date-group-actions.svelte';
import AssetGridActions from '$lib/components/photos-page/asset-grid-actions.svelte';
import AssetViewerAndActions from '$lib/components/photos-page/asset-viewer-and-actions.svelte';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
@ -75,7 +74,7 @@
album = null,
person = null,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
onSelect = (asset: TimelineAsset) => void 0,
onEscape = () => {},
children,
empty,
@ -278,16 +277,6 @@
let onSelectAssetCandidates = <(asset: TimelineAsset | null) => void>$state();
</script>
<AssetDateGroupActions
{timelineManager}
{assetInteraction}
handleScrollTop={scrollTop}
{onSelect}
bind:onDateGroupSelect
bind:onSelectAssets
bind:onSelectAssetCandidates
></AssetDateGroupActions>
<AssetGridActions {scrollToAsset} {timelineManager} {assetInteraction} bind:isShowDeleteConfirmation {onEscape}
></AssetGridActions>
@ -356,9 +345,8 @@
{isSelectionMode}
{singleSelect}
{monthGroup}
onSelect={onDateGroupSelect}
{onSelectAssetCandidates}
{onSelectAssets}
{onSelect}
{scrollTop}
onScrollCompensation={handleScrollCompensation}
{customLayout}
{onThumbnailClick}

View file

@ -0,0 +1,188 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { searchStore } from '$lib/stores/search.svelte';
/** Glue functions that update the assetInteraction (asset selection) in response to AssetDateGroup UI
*
*/
export class DateGroupActionLib {
onSelect: (asset: TimelineAsset) => void = () => void 0;
scrollTop: (top: number) => void = () => void 0;
assetInteraction: AssetInteraction = $state(new AssetInteraction());
timelineManager: TimelineManager = $state(new TimelineManager());
singleSelect: boolean = $state(false);
lastAssetMouseEvent: TimelineAsset | null = $state(null);
shiftKeyIsDown = $state(false);
isEmpty = $derived(this.timelineManager.isInitialized && this.timelineManager.months.length === 0);
constructor() {
$effect(() => {
if (!this.lastAssetMouseEvent || !this.lastAssetMouseEvent) {
this.assetInteraction.clearAssetSelectionCandidates();
}
if (this.shiftKeyIsDown && this.lastAssetMouseEvent) {
void this.selectAssetCandidates(this.lastAssetMouseEvent);
}
if (this.isEmpty) {
this.assetInteraction.clearMultiselect();
}
});
}
handleSelectAsset(asset: TimelineAsset) {
if (!this.timelineManager.albumAssets.has(asset.id)) {
this.assetInteraction.selectAsset(asset);
}
}
onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
this.shiftKeyIsDown = true;
}
};
onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
this.shiftKeyIsDown = false;
}
};
onSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
void this.selectAssetCandidates(asset);
}
this.lastAssetMouseEvent = asset;
};
onDateGroupSelect = ({ title: group, assets }: { title: string; assets: TimelineAsset[] }) => {
if (this.assetInteraction.selectedGroup.has(group)) {
this.assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
this.assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
this.assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
this.handleSelectAsset(asset);
}
}
if (this.timelineManager.assetCount == this.assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
onSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
this.onSelect(asset);
if (this.singleSelect) {
this.scrollTop(0);
return;
}
const rangeSelection = this.assetInteraction.assetSelectionCandidates.length > 0;
const deselect = this.assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of this.assetInteraction.assetSelectionCandidates) {
this.assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
this.assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of this.assetInteraction.assetSelectionCandidates) {
this.handleSelectAsset(candidate);
}
this.handleSelectAsset(asset);
}
this.assetInteraction.clearAssetSelectionCandidates();
if (this.assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = this.timelineManager.getMonthGroupByAssetId(this.assetInteraction.assetSelectionStart.id);
let endBucket = this.timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
return;
}
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of this.timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await this.timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
this.assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
this.handleSelectAsset(asset);
}
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of this.timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => this.assetInteraction.hasSelectedAsset(a.id))) {
this.assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
this.assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
}
if (monthGroup === endBucket) {
break;
}
}
}
this.assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!this.shiftKeyIsDown) {
return;
}
const startAsset = this.assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(await this.timelineManager.retrieveRange(startAsset, endAsset));
this.assetInteraction.setAssetSelectionCandidates(assets);
};
}