Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9051fa6949 |
@@ -1037,7 +1037,6 @@
|
|||||||
"exif_bottom_sheet_description_error": "Error updating description",
|
"exif_bottom_sheet_description_error": "Error updating description",
|
||||||
"exif_bottom_sheet_details": "DETAILS",
|
"exif_bottom_sheet_details": "DETAILS",
|
||||||
"exif_bottom_sheet_location": "LOCATION",
|
"exif_bottom_sheet_location": "LOCATION",
|
||||||
"exif_bottom_sheet_no_description": "No description",
|
|
||||||
"exif_bottom_sheet_people": "PEOPLE",
|
"exif_bottom_sheet_people": "PEOPLE",
|
||||||
"exif_bottom_sheet_person_add_person": "Add name",
|
"exif_bottom_sheet_person_add_person": "Add name",
|
||||||
"exit_slideshow": "Exit Slideshow",
|
"exit_slideshow": "Exit Slideshow",
|
||||||
|
|||||||
@@ -120,10 +120,6 @@ class RemoteAlbumService {
|
|||||||
return _repository.getSharedUsers(albumId);
|
return _repository.getSharedUsers(albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AlbumUserRole?> getUserRole(String albumId, String userId) {
|
|
||||||
return _repository.getUserRole(albumId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getAssets(String albumId) {
|
Future<List<RemoteAsset>> getAssets(String albumId) {
|
||||||
return _repository.getAssets(albumId);
|
return _repository.getAssets(albumId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,15 +221,6 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AlbumUserRole?> getUserRole(String albumId, String userId) async {
|
|
||||||
final query = _db.remoteAlbumUserEntity.select()
|
|
||||||
..where((row) => row.albumId.equals(albumId) & row.userId.equals(userId))
|
|
||||||
..limit(1);
|
|
||||||
|
|
||||||
final result = await query.getSingleOrNull();
|
|
||||||
return result?.role;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getAssets(String albumId) {
|
Future<List<RemoteAsset>> getAssets(String albumId) {
|
||||||
final query = _db.remoteAlbumAssetEntity.select().join([
|
final query = _db.remoteAlbumAssetEntity.select().join([
|
||||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||||
|
|||||||
@@ -169,11 +169,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
context.pushRoute(const DriftActivitiesRoute());
|
context.pushRoute(const DriftActivitiesRoute());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> showOptionSheet(BuildContext context) async {
|
void showOptionSheet(BuildContext context) {
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
final isOwner = user != null ? user.id == _album.ownerId : false;
|
final isOwner = user != null ? user.id == _album.ownerId : false;
|
||||||
final canAddPhotos =
|
|
||||||
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -195,30 +193,22 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
context.pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onAddPhotos: isOwner || canAddPhotos
|
onAddPhotos: () async {
|
||||||
? () async {
|
await addAssets(context);
|
||||||
await addAssets(context);
|
context.pop();
|
||||||
context.pop();
|
},
|
||||||
}
|
onToggleAlbumOrder: () async {
|
||||||
: null,
|
await toggleAlbumOrder();
|
||||||
onToggleAlbumOrder: isOwner
|
context.pop();
|
||||||
? () async {
|
},
|
||||||
await toggleAlbumOrder();
|
onEditAlbum: () async {
|
||||||
context.pop();
|
context.pop();
|
||||||
}
|
await showEditTitleAndDescription(context);
|
||||||
: null,
|
},
|
||||||
onEditAlbum: isOwner
|
onCreateSharedLink: () async {
|
||||||
? () async {
|
context.pop();
|
||||||
context.pop();
|
context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
|
||||||
await showEditTitleAndDescription(context);
|
},
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onCreateSharedLink: isOwner
|
|
||||||
? () async {
|
|
||||||
context.pop();
|
|
||||||
context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onShowOptions: () {
|
onShowOptions: () {
|
||||||
context.pop();
|
context.pop();
|
||||||
context.pushRoute(const DriftAlbumOptionsRoute());
|
context.pushRoute(const DriftAlbumOptionsRoute());
|
||||||
@@ -230,9 +220,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final user = ref.watch(currentUserProvider);
|
|
||||||
final isOwner = user != null ? user.id == _album.ownerId : false;
|
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (didPop) {
|
if (didPop) {
|
||||||
@@ -256,8 +243,8 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
appBar: RemoteAlbumSliverAppBar(
|
appBar: RemoteAlbumSliverAppBar(
|
||||||
icon: Icons.photo_album_outlined,
|
icon: Icons.photo_album_outlined,
|
||||||
onShowOptions: () => showOptionSheet(context),
|
onShowOptions: () => showOptionSheet(context),
|
||||||
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
|
onToggleAlbumOrder: () => toggleAlbumOrder(),
|
||||||
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
|
onEditTitle: () => showEditTitleAndDescription(context),
|
||||||
onActivity: () => showActivity(context),
|
onActivity: () => showActivity(context),
|
||||||
),
|
),
|
||||||
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
const ShareActionButton(source: ActionSource.viewer),
|
||||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||||
if (asset.type == AssetType.image && isOwner) const EditImageActionButton(),
|
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||||
if (isOwner) ...[
|
if (isOwner) ...[
|
||||||
if (asset.hasRemote && isOwner && isArchived)
|
if (asset.hasRemote && isOwner && isArchived)
|
||||||
const UnArchiveActionButton(source: ActionSource.viewer)
|
const UnArchiveActionButton(source: ActionSource.viewer)
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
|
||||||
|
|
||||||
return SliverList.list(
|
return SliverList.list(
|
||||||
children: [
|
children: [
|
||||||
@@ -148,10 +147,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
_SheetTile(
|
_SheetTile(
|
||||||
title: _getDateTime(context, asset),
|
title: _getDateTime(context, asset),
|
||||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
|
||||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
onTap: asset.hasRemote ? () async => await _editDateTime(context, ref) : null,
|
||||||
),
|
),
|
||||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
|
||||||
const SheetPeopleDetails(),
|
const SheetPeopleDetails(),
|
||||||
const SheetLocationDetails(),
|
const SheetLocationDetails(),
|
||||||
// Details header
|
// Details header
|
||||||
@@ -265,9 +264,8 @@ class _SheetTile extends ConsumerWidget {
|
|||||||
|
|
||||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||||
final ExifInfo exif;
|
final ExifInfo exif;
|
||||||
final bool isEditable;
|
|
||||||
|
|
||||||
const _SheetAssetDescription({required this.exif, this.isEditable = true, super.key});
|
const _SheetAssetDescription({required this.exif});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
||||||
@@ -313,33 +311,27 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||||||
|
|
||||||
// Update controller text when EXIF data changes
|
// Update controller text when EXIF data changes
|
||||||
final currentDescription = currentExifInfo?.description ?? '';
|
final currentDescription = currentExifInfo?.description ?? '';
|
||||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
||||||
_controller.text = currentDescription;
|
_controller.text = currentDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||||
child: IgnorePointer(
|
child: TextField(
|
||||||
ignoring: !widget.isEditable,
|
controller: _controller,
|
||||||
child: TextField(
|
keyboardType: TextInputType.multiline,
|
||||||
controller: _controller,
|
focusNode: _descriptionFocus,
|
||||||
keyboardType: TextInputType.multiline,
|
maxLines: null, // makes it grow as text is added
|
||||||
focusNode: _descriptionFocus,
|
decoration: InputDecoration(
|
||||||
maxLines: null, // makes it grow as text is added
|
hintText: 'exif_bottom_sheet_description'.t(context: context),
|
||||||
decoration: InputDecoration(
|
border: InputBorder.none,
|
||||||
hintText: hintText,
|
enabledBorder: InputBorder.none,
|
||||||
border: InputBorder.none,
|
focusedBorder: InputBorder.none,
|
||||||
enabledBorder: InputBorder.none,
|
disabledBorder: InputBorder.none,
|
||||||
focusedBorder: InputBorder.none,
|
errorBorder: InputBorder.none,
|
||||||
disabledBorder: InputBorder.none,
|
focusedErrorBorder: InputBorder.none,
|
||||||
errorBorder: InputBorder.none,
|
|
||||||
focusedErrorBorder: InputBorder.none,
|
|
||||||
),
|
|
||||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
|
||||||
),
|
),
|
||||||
|
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final showViewInTimelineButton =
|
final showViewInTimelineButton =
|
||||||
(previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
|
(previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
|
||||||
previousRouteName != AssetViewerRoute.name &&
|
previousRouteName != AssetViewerRoute.name &&
|
||||||
previousRouteName != null &&
|
previousRouteName != null;
|
||||||
isOwner;
|
|
||||||
|
|
||||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||||
|
|||||||
+15
-24
@@ -24,7 +24,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
|||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
|
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
|
||||||
@@ -54,7 +53,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final multiselect = ref.watch(multiSelectProvider);
|
final multiselect = ref.watch(multiSelectProvider);
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||||
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
|
|
||||||
|
|
||||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||||
final selectedAssets = multiselect.selectedAssets;
|
final selectedAssets = multiselect.selectedAssets;
|
||||||
@@ -95,35 +93,28 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
const ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
|
const ArchiveActionButton(source: ActionSource.timeline),
|
||||||
if (ownsAlbum) ...[
|
const FavoriteActionButton(source: ActionSource.timeline),
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
|
||||||
const FavoriteActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
|
||||||
const DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
if (ownsAlbum) ...[
|
isTrashEnable
|
||||||
isTrashEnable
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
if (multiselect.hasLocal) ...[
|
if (multiselect.hasLocal) ...[
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
const UploadActionButton(source: ActionSource.timeline),
|
const UploadActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||||
|
],
|
||||||
|
slivers: [
|
||||||
|
const AddToAlbumHeader(),
|
||||||
|
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||||
],
|
],
|
||||||
slivers: ownsAlbum
|
|
||||||
? [
|
|
||||||
const AddToAlbumHeader(),
|
|
||||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
|
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { getTabbable } from '$lib/utils/focus-util';
|
import { getTabbable } from '$lib/utils/focus-util';
|
||||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
import { type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { mdiPlay } from '@mdi/js';
|
import { mdiPlay } from '@mdi/js';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
@@ -11,18 +11,31 @@
|
|||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** Offset from the top of the timeline (e.g., for headers) */
|
||||||
timelineTopOffset?: number;
|
timelineTopOffset?: number;
|
||||||
|
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||||
timelineBottomOffset?: number;
|
timelineBottomOffset?: number;
|
||||||
|
/** Total height of the scrubber component */
|
||||||
height?: number;
|
height?: number;
|
||||||
|
/** Timeline manager instance that controls the timeline state */
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
scrubOverallPercent?: number;
|
/** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */
|
||||||
scrubberMonthPercent?: number;
|
timelineScrollPercent?: number;
|
||||||
scrubberMonth?: { year: number; month: number };
|
/** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
|
||||||
leadout?: boolean;
|
viewportTopMonthScrollPercent?: number;
|
||||||
|
/** The year/month of the timeline month at the top of the viewport */
|
||||||
|
viewportTopMonth?: TimelineYearMonth;
|
||||||
|
/** Indicates whether the viewport is currently in the lead-out section (after all months) */
|
||||||
|
isInLeadOutSection?: boolean;
|
||||||
|
/** Width of the scrubber component in pixels (bindable for parent component margin adjustments) */
|
||||||
scrubberWidth?: number;
|
scrubberWidth?: number;
|
||||||
|
/** Callback fired when user interacts with the scrubber to navigate */
|
||||||
onScrub?: ScrubberListener;
|
onScrub?: ScrubberListener;
|
||||||
|
/** Callback fired when keyboard events occur on the scrubber */
|
||||||
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
||||||
|
/** Callback fired when scrubbing starts */
|
||||||
startScrub?: ScrubberListener;
|
startScrub?: ScrubberListener;
|
||||||
|
/** Callback fired when scrubbing stops */
|
||||||
stopScrub?: ScrubberListener;
|
stopScrub?: ScrubberListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,10 +44,10 @@
|
|||||||
timelineBottomOffset = 0,
|
timelineBottomOffset = 0,
|
||||||
height = 0,
|
height = 0,
|
||||||
timelineManager,
|
timelineManager,
|
||||||
scrubOverallPercent = 0,
|
timelineScrollPercent = 0,
|
||||||
scrubberMonthPercent = 0,
|
viewportTopMonthScrollPercent = 0,
|
||||||
scrubberMonth = undefined,
|
viewportTopMonth = undefined,
|
||||||
leadout = false,
|
isInLeadOutSection = false,
|
||||||
onScrub = undefined,
|
onScrub = undefined,
|
||||||
onScrubKeyDown = undefined,
|
onScrubKeyDown = undefined,
|
||||||
startScrub = undefined,
|
startScrub = undefined,
|
||||||
@@ -100,7 +113,7 @@
|
|||||||
offset += scrubberMonthPercent * relativeBottomOffset;
|
offset += scrubberMonthPercent * relativeBottomOffset;
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
} else if (leadout) {
|
} else if (isInLeadOutSection) {
|
||||||
let offset = relativeTopOffset;
|
let offset = relativeTopOffset;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
offset += segment.height;
|
offset += segment.height;
|
||||||
@@ -111,7 +124,9 @@
|
|||||||
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent));
|
let scrollY = $derived(
|
||||||
|
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
||||||
|
);
|
||||||
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||||
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||||
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||||
@@ -295,12 +310,24 @@
|
|||||||
|
|
||||||
const scrollPercent = toTimelineY(hoverY);
|
const scrollPercent = toTimelineY(hoverY);
|
||||||
if (wasDragging === false && isDragging) {
|
if (wasDragging === false && isDragging) {
|
||||||
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
void startScrub?.({
|
||||||
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
scrubberMonth: segmentDate!,
|
||||||
|
overallScrollPercent: scrollPercent,
|
||||||
|
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||||
|
});
|
||||||
|
void onScrub?.({
|
||||||
|
scrubberMonth: segmentDate!,
|
||||||
|
overallScrollPercent: scrollPercent,
|
||||||
|
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasDragging && !isDragging) {
|
if (wasDragging && !isDragging) {
|
||||||
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
void stopScrub?.({
|
||||||
|
scrubberMonth: segmentDate!,
|
||||||
|
overallScrollPercent: scrollPercent,
|
||||||
|
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +335,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
void onScrub?.({
|
||||||
|
scrubberMonth: segmentDate!,
|
||||||
|
overallScrollPercent: scrollPercent,
|
||||||
|
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
/* eslint-disable tscompat/tscompat */
|
/* eslint-disable tscompat/tscompat */
|
||||||
const getTouch = (event: TouchEvent) => {
|
const getTouch = (event: TouchEvent) => {
|
||||||
@@ -412,7 +443,11 @@
|
|||||||
}
|
}
|
||||||
if (next) {
|
if (next) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
|
void onScrub?.({
|
||||||
|
scrubberMonth: { year: next.year, month: next.month },
|
||||||
|
overallScrollPercent: -1,
|
||||||
|
scrubberMonthScrollPercent: 0,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,7 +457,11 @@
|
|||||||
const next = segments[idx + 1];
|
const next = segments[idx + 1];
|
||||||
if (next) {
|
if (next) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
|
void onScrub?.({
|
||||||
|
scrubberMonth: { year: next.year, month: next.month },
|
||||||
|
overallScrollPercent: -1,
|
||||||
|
scrubberMonthScrollPercent: 0,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,14 +110,19 @@
|
|||||||
let timelineElement: HTMLElement | undefined = $state();
|
let timelineElement: HTMLElement | undefined = $state();
|
||||||
let showSkeleton = $state(true);
|
let showSkeleton = $state(true);
|
||||||
let isShowSelectDate = $state(false);
|
let isShowSelectDate = $state(false);
|
||||||
let scrubberMonthPercent = $state(0);
|
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
||||||
let scrubberMonth: { year: number; month: number } | undefined = $state(undefined);
|
// Note: There may be multiple months visible within the viewport at any given time.
|
||||||
let scrubOverallPercent: number = $state(0);
|
let viewportTopMonthScrollPercent = $state(0);
|
||||||
|
// The timeline month intersecting the top position of the viewport
|
||||||
|
let viewportTopMonth: { year: number; month: number } | undefined = $state(undefined);
|
||||||
|
// Overall scroll percentage through the entire timeline (0-1)
|
||||||
|
let timelineScrollPercent: number = $state(0);
|
||||||
let scrubberWidth = $state(0);
|
let scrubberWidth = $state(0);
|
||||||
|
|
||||||
// 60 is the bottom spacer element at 60px
|
// 60 is the bottom spacer element at 60px
|
||||||
let bottomSectionHeight = 60;
|
let bottomSectionHeight = 60;
|
||||||
let leadout = $state(false);
|
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
||||||
|
let isInLeadOutSection = $state(false);
|
||||||
|
|
||||||
const maxMd = $derived(mobileDevice.maxMd);
|
const maxMd = $derived(mobileDevice.maxMd);
|
||||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
@@ -301,20 +306,19 @@
|
|||||||
scrollTop(scrollToTop);
|
scrollTop(scrollToTop);
|
||||||
};
|
};
|
||||||
|
|
||||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||||
const onScrub: ScrubberListener = (
|
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
|
||||||
scrubMonth: { year: number; month: number },
|
const onScrub: ScrubberListener = (scrubberData) => {
|
||||||
overallScrollPercent: number,
|
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
||||||
scrubberMonthScrollPercent: number,
|
|
||||||
) => {
|
if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||||
if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
|
||||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
const offset = maxScroll * overallScrollPercent;
|
const offset = maxScroll * overallScrollPercent;
|
||||||
scrollTop(offset);
|
scrollTop(offset);
|
||||||
} else {
|
} else {
|
||||||
const monthGroup = timelineManager.months.find(
|
const monthGroup = timelineManager.months.find(
|
||||||
({ yearMonth: { year, month } }) => year === scrubMonth.year && month === scrubMonth.month,
|
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||||
);
|
);
|
||||||
if (!monthGroup) {
|
if (!monthGroup) {
|
||||||
return;
|
return;
|
||||||
@@ -325,7 +329,7 @@
|
|||||||
|
|
||||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||||
const handleTimelineScroll = () => {
|
const handleTimelineScroll = () => {
|
||||||
leadout = false;
|
isInLeadOutSection = false;
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
@@ -334,19 +338,19 @@
|
|||||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||||
|
|
||||||
scrubberMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
scrubberMonthPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
} else {
|
} else {
|
||||||
let top = element.scrollTop;
|
let top = element.scrollTop;
|
||||||
if (top < timelineManager.topSectionHeight) {
|
if (top < timelineManager.topSectionHeight) {
|
||||||
// in the lead-in area
|
// in the lead-in area
|
||||||
scrubberMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
scrubberMonthPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
|
|
||||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,15 +375,15 @@
|
|||||||
let next = top - monthGroupHeight * maxScrollPercent;
|
let next = top - monthGroupHeight * maxScrollPercent;
|
||||||
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||||
if (next < -1 && monthGroup) {
|
if (next < -1 && monthGroup) {
|
||||||
scrubberMonth = monthGroup;
|
viewportTopMonth = monthGroup;
|
||||||
|
|
||||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||||
scrubberMonthPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
||||||
|
|
||||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||||
if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||||
scrubberMonth = timelineManager.months[i + 1].yearMonth;
|
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
||||||
scrubberMonthPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
found = true;
|
found = true;
|
||||||
@@ -388,10 +392,10 @@
|
|||||||
top = next;
|
top = next;
|
||||||
}
|
}
|
||||||
if (!found) {
|
if (!found) {
|
||||||
leadout = true;
|
isInLeadOutSection = true;
|
||||||
scrubberMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
scrubberMonthPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
scrubOverallPercent = 1;
|
timelineScrollPercent = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -854,10 +858,10 @@
|
|||||||
height={timelineManager.viewportHeight}
|
height={timelineManager.viewportHeight}
|
||||||
timelineTopOffset={timelineManager.topSectionHeight}
|
timelineTopOffset={timelineManager.topSectionHeight}
|
||||||
timelineBottomOffset={bottomSectionHeight}
|
timelineBottomOffset={bottomSectionHeight}
|
||||||
{leadout}
|
{isInLeadOutSection}
|
||||||
{scrubOverallPercent}
|
{timelineScrollPercent}
|
||||||
{scrubberMonthPercent}
|
{viewportTopMonthScrollPercent}
|
||||||
{scrubberMonth}
|
{viewportTopMonth}
|
||||||
{onScrub}
|
{onScrub}
|
||||||
bind:scrubberWidth
|
bind:scrubberWidth
|
||||||
onScrubKeyDown={(evt) => {
|
onScrubKeyDown={(evt) => {
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ export type TimelineDateTime = TimelineDate & {
|
|||||||
millisecond: number;
|
millisecond: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScrubberListener = (
|
export type ScrubberListener = (scrubberData: {
|
||||||
scrubberMonth: { year: number; month: number },
|
scrubberMonth: { year: number; month: number };
|
||||||
overallScrollPercent: number,
|
overallScrollPercent: number;
|
||||||
scrubberMonthScrollPercent: number,
|
scrubberMonthScrollPercent: number;
|
||||||
) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
|
|
||||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||||
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||||
|
|||||||
Reference in New Issue
Block a user