mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor(web): extract timeline selection logic into SelectableSegment and SelectableDay components
- Move asset selection, range selection, and keyboard interaction logic to SelectableSegment - Extract day group selection logic to SelectableDay component - Simplify Timeline component by removing selection-related state and handlers - Fix scroll compensation handling with dedicated while loop - Remove unused keyboard handlers from Scrubber component
This commit is contained in:
parent
0168353c2d
commit
ed63399181
8 changed files with 358 additions and 314 deletions
|
|
@ -121,6 +121,7 @@
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
mouseOver = false;
|
mouseOver = false;
|
||||||
|
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
|
||||||
62
web/src/lib/components/timeline/SelectableDay.svelte
Normal file
62
web/src/lib/components/timeline/SelectableDay.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
|
||||||
|
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||||
|
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: Snippet<
|
||||||
|
[
|
||||||
|
{
|
||||||
|
onDayGroupSelect: (dayGroup: DayGroup, asset: TimelineAsset[]) => void;
|
||||||
|
onDayGroupAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
onAssetSelect: (asset: TimelineAsset) => void;
|
||||||
|
assetInteraction: AssetInteraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { content, assetInteraction, onAssetSelect }: Props = $props();
|
||||||
|
|
||||||
|
// called when clicking asset with shift key pressed or with mouse
|
||||||
|
const onDayGroupAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
|
||||||
|
onAssetSelect(asset);
|
||||||
|
|
||||||
|
const assetsInDayGroup = dayGroup.getAssets();
|
||||||
|
const groupTitle = dayGroup.groupTitle;
|
||||||
|
|
||||||
|
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||||
|
const selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
||||||
|
assetInteraction.hasSelectedAsset(asset.id),
|
||||||
|
).length;
|
||||||
|
// if all assets are selected in a group, add the group to selected group
|
||||||
|
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
||||||
|
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
||||||
|
} else {
|
||||||
|
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDayGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
|
||||||
|
const group = dayGroup.groupTitle;
|
||||||
|
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) {
|
||||||
|
onAssetSelect(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render content({
|
||||||
|
onDayGroupSelect,
|
||||||
|
onDayGroupAssetSelect,
|
||||||
|
})}
|
||||||
208
web/src/lib/components/timeline/SelectableSegment.svelte
Normal file
208
web/src/lib/components/timeline/SelectableSegment.svelte
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
|
import { navigate } from '$lib/utils/navigation';
|
||||||
|
|
||||||
|
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||||
|
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
|
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: Snippet<
|
||||||
|
[
|
||||||
|
{
|
||||||
|
onAssetOpen: (asset: TimelineAsset) => void;
|
||||||
|
onAssetSelect: (asset: TimelineAsset) => void;
|
||||||
|
onAssetHover: (asset: TimelineAsset | null) => void;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
segment: PhotostreamSegment;
|
||||||
|
isSelectionMode: boolean;
|
||||||
|
singleSelect: boolean;
|
||||||
|
timelineManager: PhotostreamManager;
|
||||||
|
assetInteraction: AssetInteraction;
|
||||||
|
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
|
||||||
|
onAssetSelect?: (asset: TimelineAsset) => void;
|
||||||
|
|
||||||
|
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
segment,
|
||||||
|
content,
|
||||||
|
isSelectionMode,
|
||||||
|
singleSelect,
|
||||||
|
assetInteraction,
|
||||||
|
timelineManager,
|
||||||
|
onAssetOpen,
|
||||||
|
onAssetSelect,
|
||||||
|
onScrollCompensationMonthInDOM,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let shiftKeyIsDown = $state(false);
|
||||||
|
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||||
|
let lastMouseHoverAsset: TimelineAsset | null = $state(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (shiftKeyIsDown && lastMouseHoverAsset) {
|
||||||
|
void selectAssetCandidates(lastMouseHoverAsset);
|
||||||
|
}
|
||||||
|
if (isEmpty) {
|
||||||
|
assetInteraction.clearMultiselect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultAssetOpen = (asset: TimelineAsset) => {
|
||||||
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
|
handleAssetSelect(asset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnAssetOpen = (asset: TimelineAsset) => {
|
||||||
|
if (onAssetOpen) {
|
||||||
|
onAssetOpen(asset, () => defaultAssetOpen(asset));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
defaultAssetOpen(asset);
|
||||||
|
};
|
||||||
|
|
||||||
|
// called when clicking asset with shift key pressed or with mouse
|
||||||
|
const handleAssetSelect = (asset: TimelineAsset) => {
|
||||||
|
handleSelectAssets(asset);
|
||||||
|
|
||||||
|
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||||
|
isSelectingAllAssets.set(true);
|
||||||
|
} else {
|
||||||
|
isSelectingAllAssets.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (searchStore.isSearchEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
event.preventDefault();
|
||||||
|
shiftKeyIsDown = true;
|
||||||
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
if (lastMouseHoverAsset) {
|
||||||
|
void selectAssetCandidates(lastMouseHoverAsset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!assetInteraction.assetSelectionStart) {
|
||||||
|
assetInteraction.setAssetSelectionStart(assetInteraction.selectedAssets.at(-1) ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (searchStore.isSearchEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
event.preventDefault();
|
||||||
|
shiftKeyIsDown = false;
|
||||||
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnHover = (asset: TimelineAsset | null) => {
|
||||||
|
if (asset) {
|
||||||
|
if (assetInteraction.selectionActive) {
|
||||||
|
void selectAssetCandidates(asset);
|
||||||
|
}
|
||||||
|
lastMouseHoverAsset = asset;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAssets = (asset: TimelineAsset) => {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAssetSelect?.(asset);
|
||||||
|
|
||||||
|
if (singleSelect) {
|
||||||
|
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) {
|
||||||
|
const assets = timelineManager.retrieveLoadedRange(assetInteraction.assetSelectionStart, asset);
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (deselect) {
|
||||||
|
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||||
|
} else {
|
||||||
|
handleSelectAsset(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||||
|
if ('albumAssets' in timelineManager) {
|
||||||
|
const tm = timelineManager as TimelineManager;
|
||||||
|
if (tm.albumAssets.has(asset.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assetInteraction.selectAsset(asset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAssetCandidates = (endAsset: TimelineAsset) => {
|
||||||
|
if (!shiftKeyIsDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAsset = assetInteraction.assetSelectionStart;
|
||||||
|
if (!startAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(startAsset, endAsset));
|
||||||
|
assetInteraction.setAssetSelectionCandidates(assets);
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect.root(() => {
|
||||||
|
if (timelineManager.scrollCompensation.monthGroup === segment) {
|
||||||
|
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||||
|
|
||||||
|
{@render content({
|
||||||
|
onAssetOpen: handleOnAssetOpen,
|
||||||
|
onAssetSelect: (asset) => {
|
||||||
|
void handleSelectAssets(asset);
|
||||||
|
},
|
||||||
|
onAssetHover: handleOnHover,
|
||||||
|
})}
|
||||||
|
|
@ -5,20 +5,19 @@
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
|
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
|
||||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||||
|
import SelectableDay from '$lib/components/timeline/SelectableDay.svelte';
|
||||||
|
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
|
||||||
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
||||||
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
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 type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import {
|
import {
|
||||||
|
|
@ -54,22 +53,12 @@
|
||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto | null;
|
||||||
person?: PersonResponseDto | null;
|
person?: PersonResponseDto | null;
|
||||||
isShowDeleteConfirmation?: boolean;
|
isShowDeleteConfirmation?: boolean;
|
||||||
onSelect?: (asset: TimelineAsset) => void;
|
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
|
||||||
|
onAssetSelect?: (asset: TimelineAsset) => void;
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
empty?: Snippet;
|
empty?: Snippet;
|
||||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||||
onThumbnailClick?: (
|
|
||||||
asset: TimelineAsset,
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
dayGroup: DayGroup,
|
|
||||||
onClick: (
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
assets: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
) => void,
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -85,12 +74,13 @@
|
||||||
album = null,
|
album = null,
|
||||||
person = null,
|
person = null,
|
||||||
isShowDeleteConfirmation = $bindable(false),
|
isShowDeleteConfirmation = $bindable(false),
|
||||||
onSelect = () => {},
|
|
||||||
|
onAssetSelect,
|
||||||
|
onAssetOpen,
|
||||||
onEscape = () => {},
|
onEscape = () => {},
|
||||||
children,
|
children,
|
||||||
empty,
|
empty,
|
||||||
customThumbnailLayout,
|
customThumbnailLayout,
|
||||||
onThumbnailClick,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||||
|
|
@ -149,14 +139,26 @@
|
||||||
scrollTo(0);
|
scrollTo(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
|
||||||
|
const { heightDelta, scrollTop } = compensation;
|
||||||
|
if (heightDelta !== undefined) {
|
||||||
|
scrollBy(heightDelta);
|
||||||
|
} else if (scrollTop !== undefined) {
|
||||||
|
scrollTo(scrollTop);
|
||||||
|
}
|
||||||
|
timelineManager.clearScrollCompensation();
|
||||||
|
};
|
||||||
|
|
||||||
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
|
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
|
||||||
// the following method may trigger any layouts, so need to
|
// the following method may trigger any layouts, so need to
|
||||||
// handle any scroll compensation that may have been set
|
// handle any scroll compensation that may have been set
|
||||||
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
||||||
|
|
||||||
|
// this is in a while loop, since scrollCompensations invoke scrolls
|
||||||
|
// which may load months, triggering more scrollCompensations. Call
|
||||||
|
// this in a loop, until no more layouts occur.
|
||||||
while (timelineManager.scrollCompensation.monthGroup) {
|
while (timelineManager.scrollCompensation.monthGroup) {
|
||||||
handleScrollCompensation(timelineManager.scrollCompensation);
|
handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
|
||||||
timelineManager.clearScrollCompensation();
|
|
||||||
}
|
}
|
||||||
return height;
|
return height;
|
||||||
};
|
};
|
||||||
|
|
@ -252,19 +254,6 @@
|
||||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||||
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||||
|
|
||||||
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
|
|
||||||
if (heightDelta !== undefined) {
|
|
||||||
scrollBy(heightDelta);
|
|
||||||
} else if (scrollTop !== undefined) {
|
|
||||||
scrollTo(scrollTop);
|
|
||||||
}
|
|
||||||
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
|
|
||||||
// the above calls. However, this delay is enough time to set the intersecting property
|
|
||||||
// of the monthGroup to false, then true, which causes the DOM nodes to be recreated,
|
|
||||||
// causing bad perf, and also, disrupting focus of those elements.
|
|
||||||
updateSlidingWindow();
|
|
||||||
};
|
|
||||||
|
|
||||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -390,223 +379,14 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (event.key === 'Shift') {
|
|
||||||
event.preventDefault();
|
|
||||||
shiftKeyIsDown = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Shift') {
|
|
||||||
event.preventDefault();
|
|
||||||
shiftKeyIsDown = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
|
|
||||||
if (asset) {
|
|
||||||
void selectAssetCandidates(asset);
|
|
||||||
}
|
|
||||||
lastAssetMouseEvent = asset;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
|
|
||||||
const group = dayGroup.groupTitle;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSelectAssets = async (asset: TimelineAsset) => {
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelect(asset);
|
|
||||||
|
|
||||||
if (singleSelect) {
|
|
||||||
scrollTop(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.loadSegment(monthGroup.identifier);
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!lastAssetMouseEvent) {
|
|
||||||
assetInteraction.clearAssetSelectionCandidates();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!shiftKeyIsDown) {
|
|
||||||
assetInteraction.clearAssetSelectionCandidates();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (shiftKeyIsDown && lastAssetMouseEvent) {
|
|
||||||
void selectAssetCandidates(lastAssetMouseEvent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($showAssetViewer) {
|
if ($showAssetViewer) {
|
||||||
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
||||||
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const assetSelectHandler = (
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
assetsInDayGroup: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
) => {
|
|
||||||
void onSelectAssets(asset);
|
|
||||||
|
|
||||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
|
||||||
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
|
||||||
assetInteraction.hasSelectedAsset(asset.id),
|
|
||||||
).length;
|
|
||||||
|
|
||||||
// if all assets are selected in a group, add the group to selected group
|
|
||||||
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
|
||||||
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
|
||||||
} else {
|
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
|
||||||
isSelectingAllAssets.set(true);
|
|
||||||
} else {
|
|
||||||
isSelectingAllAssets.set(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const _onClick = (
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
assets: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
) => {
|
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
|
||||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
|
||||||
|
|
||||||
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
|
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
|
||||||
|
|
||||||
<TimelineKeyboardActions
|
<TimelineKeyboardActions
|
||||||
|
|
@ -629,21 +409,6 @@
|
||||||
{viewportTopMonth}
|
{viewportTopMonth}
|
||||||
{onScrub}
|
{onScrub}
|
||||||
bind:scrubberWidth
|
bind:scrubberWidth
|
||||||
onScrubKeyDown={(evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
let amount = 50;
|
|
||||||
if (shiftKeyIsDown) {
|
|
||||||
amount = 500;
|
|
||||||
}
|
|
||||||
if (evt.key === 'ArrowUp') {
|
|
||||||
amount = -amount;
|
|
||||||
if (shiftKeyIsDown) {
|
|
||||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
} else if (evt.key === 'ArrowDown') {
|
|
||||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -702,47 +467,58 @@
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<MonthSegment
|
<SelectableSegment
|
||||||
{assetInteraction}
|
segment={monthGroup}
|
||||||
{customThumbnailLayout}
|
onScrollCompensationMonthInDOM={handleTriggeredScrollCompensation}
|
||||||
{singleSelect}
|
|
||||||
{monthGroup}
|
|
||||||
{timelineManager}
|
{timelineManager}
|
||||||
onDayGroupSelect={handleGroupSelect}
|
{assetInteraction}
|
||||||
|
{isSelectionMode}
|
||||||
|
{singleSelect}
|
||||||
|
{onAssetOpen}
|
||||||
|
{onAssetSelect}
|
||||||
>
|
>
|
||||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
|
||||||
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
<SelectableDay {assetInteraction} {onAssetSelect}>
|
||||||
{@const isAssetSelected =
|
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
|
||||||
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
<MonthSegment
|
||||||
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
{assetInteraction}
|
||||||
<Thumbnail
|
{customThumbnailLayout}
|
||||||
showStackedIcon={withStacked}
|
{singleSelect}
|
||||||
{showArchiveIcon}
|
{monthGroup}
|
||||||
{asset}
|
{timelineManager}
|
||||||
{groupIndex}
|
{onDayGroupSelect}
|
||||||
onClick={(asset) => {
|
>
|
||||||
if (typeof onThumbnailClick === 'function') {
|
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
} else {
|
{@const isAssetSelected =
|
||||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
||||||
}
|
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
||||||
}}
|
<Thumbnail
|
||||||
onSelect={() => {
|
showStackedIcon={withStacked}
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
{showArchiveIcon}
|
||||||
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
|
{asset}
|
||||||
return;
|
{groupIndex}
|
||||||
}
|
onClick={() => onAssetOpen(asset)}
|
||||||
void onSelectAssets(asset);
|
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
|
||||||
}}
|
onMouseEvent={(isMouseOver) => {
|
||||||
onMouseEvent={() => handleSelectAssetCandidates(asset)}
|
if (isMouseOver) {
|
||||||
selected={isAssetSelected}
|
onAssetHover(asset);
|
||||||
selectionCandidate={isAssetSelectionCandidate}
|
} else {
|
||||||
disabled={isAssetDisabled}
|
onAssetHover(null);
|
||||||
thumbnailWidth={position.width}
|
}
|
||||||
thumbnailHeight={position.height}
|
}}
|
||||||
/>
|
selected={isAssetSelected}
|
||||||
|
selectionCandidate={isAssetSelectionCandidate}
|
||||||
|
disabled={isAssetDisabled}
|
||||||
|
thumbnailWidth={position.width}
|
||||||
|
thumbnailHeight={position.height}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</MonthSegment>
|
||||||
|
{/snippet}
|
||||||
|
</SelectableDay>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</MonthSegment>
|
</SelectableSegment>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -299,11 +299,15 @@ export abstract class PhotostreamManager {
|
||||||
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
|
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
|
retrieveLoadedRange(start: AssetDescriptor, end: AssetDescriptor): TimelineAsset[] {
|
||||||
const range: TimelineAsset[] = [];
|
const range: TimelineAsset[] = [];
|
||||||
let collecting = false;
|
let collecting = false;
|
||||||
|
|
||||||
for (const month of this.months) {
|
for (const month of this.months) {
|
||||||
|
if (collecting && !month.isLoaded) {
|
||||||
|
// if there are any unloaded months in the range, return empty []
|
||||||
|
return [];
|
||||||
|
}
|
||||||
for (const asset of month.assets) {
|
for (const asset of month.assets) {
|
||||||
if (asset.id === start.id) {
|
if (asset.id === start.id) {
|
||||||
collecting = true;
|
collecting = true;
|
||||||
|
|
@ -312,11 +316,15 @@ export abstract class PhotostreamManager {
|
||||||
range.push(asset);
|
range.push(asset);
|
||||||
}
|
}
|
||||||
if (asset.id === end.id) {
|
if (asset.id === end.id) {
|
||||||
return Promise.resolve(range);
|
return range;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve(range);
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
|
||||||
|
return Promise.resolve(this.retrieveLoadedRange(start, end));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,7 @@
|
||||||
{isSelectionMode}
|
{isSelectionMode}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{onSelect}
|
onAssetSelect={onSelect}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
>
|
>
|
||||||
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
|
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
|
||||||
|
|
|
||||||
|
|
@ -385,7 +385,7 @@
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||||
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||||
onSelect={handleSelectFeaturePhoto}
|
onAssetSelect={handleSelectFeaturePhoto}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
>
|
>
|
||||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
||||||
|
|
@ -110,17 +109,7 @@
|
||||||
return !!asset.latitude && !!asset.longitude;
|
return !!asset.latitude && !!asset.longitude;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (
|
const handleAssetOpen = (asset: TimelineAsset, defaultAssetOpen: () => void) => {
|
||||||
asset: TimelineAsset,
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
dayGroup: DayGroup,
|
|
||||||
onClick: (
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
assets: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
) => void,
|
|
||||||
) => {
|
|
||||||
if (hasGps(asset)) {
|
if (hasGps(asset)) {
|
||||||
locationUpdated = true;
|
locationUpdated = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -128,9 +117,9 @@
|
||||||
}, 1500);
|
}, 1500);
|
||||||
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
||||||
void setQueryValue('at', asset.id);
|
void setQueryValue('at', asset.id);
|
||||||
} else {
|
return;
|
||||||
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
|
||||||
}
|
}
|
||||||
|
defaultAssetOpen();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -193,7 +182,7 @@
|
||||||
removeAction={AssetAction.ARCHIVE}
|
removeAction={AssetAction.ARCHIVE}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
withStacked
|
withStacked
|
||||||
onThumbnailClick={handleThumbnailClick}
|
onAssetOpen={handleAssetOpen}
|
||||||
>
|
>
|
||||||
{#snippet customThumbnailLayout(asset: TimelineAsset)}
|
{#snippet customThumbnailLayout(asset: TimelineAsset)}
|
||||||
{#if hasGps(asset)}
|
{#if hasGps(asset)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue