1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

feat(web): keyboard accessible context menus (#10017)

* feat(web,a11y): context menu keyboard navigation

* wip: all context menus visible

* wip: more migrations to the ButtonContextMenu, usability improvements

* wip: migrate Administration, PeopleCard

* wip: refocus the button on click, docs

* fix: more intuitive RightClickContextMenu

- configurable title
- focus management: tab keys, clicks, closing the menu
- automatically closing when an option is selected

* fix: refining the little details

- adjust the aria attributes
- intuitive escape key propagation
- extract context into its own file

* fix: dropdown options not clickable in a <Portal>

* wip: small fixes

- export selectedColor to prevent unexpected styling
- better context function naming

* chore: revert changes to list navigation, to reduce scope of the PR

* fix: remove topBorder prop

* feat: automatically select the first option on enter or space keypress

* fix: use Svelte store instead to handle selecting menu options

- better prop naming for ButtonContextMenu

* feat: hovering the mouse can change the active element

* fix: remove Portal, more predictable open/close behavior

* feat: make selected item visible using a scroll

- also: minor cleanup of the context-menu-navigation Svelte action

* feat: maintain context menu position on resize

* fix: use the whole padding class as better tailwind convention

* fix: options not announcing with screen reader for ButtonContextMenu

* fix: screen reader announcing right click context menu options

* fix: handle focus out scenario

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Ben 2024-06-18 03:52:38 +00:00 committed by GitHub
parent 99c6fdbc1c
commit b71aa4473b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 639 additions and 441 deletions

View file

@ -0,0 +1,108 @@
import { shortcuts } from '$lib/actions/shortcut';
import { tick } from 'svelte';
import type { Action } from 'svelte/action';
interface Options {
/**
* A function that is called when the dropdown should be closed.
*/
closeDropdown: () => void;
/**
* The container element that with direct children that should be navigated.
*/
container: HTMLElement;
/**
* Indicates if the dropdown is open.
*/
isOpen: boolean;
/**
* Override the default behavior for the escape key.
*/
onEscape?: (event: KeyboardEvent) => void;
/**
* A function that is called when the dropdown should be opened.
*/
openDropdown?: (event: KeyboardEvent) => void;
/**
* The id of the currently selected element.
*/
selectedId: string | undefined;
/**
* A function that is called when the selection changes, to notify consumers of the new selected id.
*/
selectionChanged: (id: string | undefined) => void;
}
export const contextMenuNavigation: Action<HTMLElement, Options> = (node, options: Options) => {
const getCurrentElement = () => {
const { container, selectedId: activeId } = options;
return container?.querySelector(`#${activeId}`) as HTMLElement | null;
};
const close = () => {
const { closeDropdown, selectionChanged } = options;
selectionChanged(undefined);
closeDropdown();
};
const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
const { selectionChanged, container, openDropdown } = options;
if (openDropdown) {
openDropdown(event);
await tick();
}
const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
if (children.length === 0) {
return;
}
const currentEl = getCurrentElement();
const currentIndex = currentEl ? children.indexOf(currentEl) : -1;
const directionFactor = (direction === 'up' ? -1 : 1) + (direction === 'up' && currentIndex === -1 ? 1 : 0);
const newIndex = (currentIndex + directionFactor + children.length) % children.length;
const selectedNode = children[newIndex];
selectedNode?.scrollIntoView({ block: 'nearest' });
selectionChanged(selectedNode?.id);
};
const onEscape = (event: KeyboardEvent) => {
const { onEscape } = options;
if (onEscape) {
onEscape(event);
return;
}
event.stopPropagation();
close();
};
const handleClick = (event: KeyboardEvent) => {
const { selectedId, isOpen, closeDropdown } = options;
if (isOpen && !selectedId) {
closeDropdown();
return;
}
if (!selectedId) {
void moveSelection('down', event);
return;
}
const currentEl = getCurrentElement();
currentEl?.click();
};
const { destroy } = shortcuts(node, [
{ shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) },
{ shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) },
{ shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) },
{ shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) },
{ shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) },
]);
return {
update(newOptions) {
options = newOptions;
},
destroy,
};
};

View file

@ -1,5 +1,5 @@
interface Options { interface Options {
onFocusOut?: () => void; onFocusOut?: (event: FocusEvent) => void;
} }
export function focusOutside(node: HTMLElement, options: Options = {}) { export function focusOutside(node: HTMLElement, options: Options = {}) {
@ -7,7 +7,7 @@ export function focusOutside(node: HTMLElement, options: Options = {}) {
const handleFocusOut = (event: FocusEvent) => { const handleFocusOut = (event: FocusEvent) => {
if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) { if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) {
onFocusOut(); onFocusOut(event);
} }
}; };

View file

@ -3,7 +3,7 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import type { AlbumResponseDto } from '@immich/sdk'; import type { AlbumResponseDto } from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js'; import { mdiDotsVertical } from '@mdi/js';
import { getContextMenuPosition, type ContextMenuPosition } from '$lib/utils/context-menu'; import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time'; import { getShortDateRange } from '$lib/utils/date-time';
import AlbumCover from '$lib/components/album-page/album-cover.svelte'; import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
@ -20,7 +20,7 @@
const showAlbumContextMenu = (e: MouseEvent) => { const showAlbumContextMenu = (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
onShowContextMenu?.(getContextMenuPosition(e)); onShowContextMenu?.(getContextMenuPositionFromEvent(e));
}; };
</script> </script>

View file

@ -3,7 +3,6 @@
import { groupBy, orderBy } from 'lodash-es'; import { groupBy, orderBy } from 'lodash-es';
import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk'; import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js'; import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte'; import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import { import {
@ -167,6 +166,7 @@
let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 }; let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
let contextMenuTargetAlbum: AlbumResponseDto | null = null; let contextMenuTargetAlbum: AlbumResponseDto | null = null;
let isOpen = false;
// Step 1: Filter between Owned and Shared albums, or both. // Step 1: Filter between Owned and Shared albums, or both.
$: { $: {
@ -224,7 +224,6 @@
albumGroupIds = groupedAlbums.map(({ id }) => id); albumGroupIds = groupedAlbums.map(({ id }) => id);
} }
$: showContextMenu = !!contextMenuTargetAlbum;
$: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id; $: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;
onMount(async () => { onMount(async () => {
@ -253,10 +252,11 @@
x: contextMenuDetail.x, x: contextMenuDetail.x,
y: contextMenuDetail.y, y: contextMenuDetail.y,
}; };
isOpen = true;
}; };
const closeAlbumContextMenu = () => { const closeAlbumContextMenu = () => {
contextMenuTargetAlbum = null; isOpen = false;
}; };
const handleDownloadAlbum = async () => { const handleDownloadAlbum = async () => {
@ -419,34 +419,18 @@
{/if} {/if}
<!-- Context Menu --> <!-- Context Menu -->
<RightClickContextMenu {...contextMenuPosition} isOpen={showContextMenu} onClose={closeAlbumContextMenu}> <RightClickContextMenu title={$t('album_options')} {...contextMenuPosition} {isOpen} onClose={closeAlbumContextMenu}>
{#if showFullContextMenu} {#if showFullContextMenu}
<MenuOption on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}> <MenuOption
<p class="flex gap-2"> icon={mdiRenameOutline}
<Icon path={mdiRenameOutline} size="18" /> text={$t('edit_album')}
Edit on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}
</p> />
</MenuOption> <MenuOption icon={mdiShareVariantOutline} text={$t('share')} on:click={() => openShareModal()} />
<MenuOption on:click={() => openShareModal()}>
<p class="flex gap-2">
<Icon path={mdiShareVariantOutline} size="18" />
Share
</p>
</MenuOption>
{/if} {/if}
<MenuOption on:click={() => handleDownloadAlbum()}> <MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} on:click={() => handleDownloadAlbum()} />
<p class="flex gap-2">
<Icon path={mdiFolderDownloadOutline} size="18" />
Download
</p>
</MenuOption>
{#if showFullContextMenu} {#if showFullContextMenu}
<MenuOption on:click={() => setAlbumToDelete()}> <MenuOption icon={mdiDeleteOutline} text={$t('delete')} on:click={() => setAlbumToDelete()} />
<p class="flex gap-2">
<Icon path={mdiDeleteOutline} size="18" />
Delete
</p>
</MenuOption>
{/if} {/if}
</RightClickContextMenu> </RightClickContextMenu>

View file

@ -9,16 +9,14 @@
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js'; import { mdiDotsVertical } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { getContextMenuPosition } from '../../utils/context-menu';
import { handleError } from '../../utils/handle-error'; import { handleError } from '../../utils/handle-error';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte'; import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
export let onClose: () => void; export let onClose: () => void;
@ -29,8 +27,6 @@
}>(); }>();
let currentUser: UserResponseDto; let currentUser: UserResponseDto;
let position = { x: 0, y: 0 };
let selectedMenuUser: UserResponseDto | null = null;
let selectedRemoveUser: UserResponseDto | null = null; let selectedRemoveUser: UserResponseDto | null = null;
$: isOwned = currentUser?.id == album.ownerId; $: isOwned = currentUser?.id == album.ownerId;
@ -43,15 +39,8 @@
} }
}); });
const showContextMenu = (event: MouseEvent, user: UserResponseDto) => { const handleMenuRemove = (user: UserResponseDto) => {
position = getContextMenuPosition(event); selectedRemoveUser = user;
selectedMenuUser = user;
selectedRemoveUser = null;
};
const handleMenuRemove = () => {
selectedRemoveUser = selectedMenuUser;
selectedMenuUser = null;
}; };
const handleRemoveUser = async () => { const handleRemoveUser = async () => {
@ -118,31 +107,17 @@
{/if} {/if}
</div> </div>
{#if isOwned} {#if isOwned}
<div> <ButtonContextMenu icon={mdiDotsVertical} size="20" title={$t('options')}>
<CircleIconButton {#if role === AlbumUserRole.Viewer}
title={$t('options')} <MenuOption on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
on:click={(event) => showContextMenu(event, user)} {:else}
icon={mdiDotsVertical} <MenuOption
size="20" on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
/> text={$t('disallow_edits')}
/>
{#if selectedMenuUser === user}
<ContextMenu {...position} onClose={() => (selectedMenuUser = null)}>
{#if role === AlbumUserRole.Viewer}
<MenuOption
on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)}
text={$t('allow_edits')}
/>
{:else}
<MenuOption
on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
text={$t('disallow_edits')}
/>
{/if}
<MenuOption on:click={handleMenuRemove} text={$t('remove')} />
</ContextMenu>
{/if} {/if}
</div> <MenuOption on:click={() => handleMenuRemove(user)} text={$t('remove')} />
</ButtonContextMenu>
{:else if user.id == currentUser?.id} {:else if user.id == currentUser?.id}
<button <button
type="button" type="button"

View file

@ -4,8 +4,6 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName } from '$lib/utils'; import { getAssetJobName } from '$lib/utils';
import { clickOutside } from '$lib/actions/click-outside';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { import {
@ -36,9 +34,9 @@
mdiUpload, mdiUpload,
} from '@mdi/js'; } from '@mdi/js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
export let album: AlbumResponseDto | null = null; export let album: AlbumResponseDto | null = null;
@ -79,21 +77,11 @@
const dispatch = createEventDispatcher<EventTypes>(); const dispatch = createEventDispatcher<EventTypes>();
let contextMenuPosition = { x: 0, y: 0 };
let isShowAssetOptions = false;
const showOptionsMenu = (event: MouseEvent) => {
contextMenuPosition = getContextMenuPosition(event, 'top-right');
isShowAssetOptions = !isShowAssetOptions;
};
const onJobClick = (name: AssetJobName) => { const onJobClick = (name: AssetJobName) => {
isShowAssetOptions = false;
dispatch('runJob', name); dispatch('runJob', name);
}; };
const onMenuClick = (eventName: keyof EventTypes) => { const onMenuClick = (eventName: keyof EventTypes) => {
isShowAssetOptions = false;
dispatch(eventName); dispatch(eventName);
}; };
</script> </script>
@ -187,90 +175,72 @@
on:delete={() => dispatch('delete')} on:delete={() => dispatch('delete')}
on:permanentlyDelete={() => dispatch('permanentlyDelete')} on:permanentlyDelete={() => dispatch('permanentlyDelete')}
/> />
<div <ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
use:clickOutside={{ {#if showSlideshow}
onOutclick: () => (isShowAssetOptions = false), <MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text={$t('slideshow')} />
onEscape: () => (isShowAssetOptions = false),
}}
>
<CircleIconButton color="opaque" icon={mdiDotsVertical} on:click={showOptionsMenu} title={$t('more')} />
{#if isShowAssetOptions}
<ContextMenu {...contextMenuPosition} direction="left">
{#if showSlideshow}
<MenuOption
icon={mdiPresentationPlay}
on:click={() => onMenuClick('playSlideShow')}
text={$t('slideshow')}
/>
{/if}
{#if showDownloadButton}
<MenuOption
icon={mdiFolderDownloadOutline}
on:click={() => onMenuClick('download')}
text={$t('download')}
/>
{/if}
{#if asset.isTrashed}
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
{:else}
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
<MenuOption
icon={mdiShareVariantOutline}
on:click={() => onMenuClick('addToSharedAlbum')}
text={$t('add_to_shared_album')}
/>
{/if}
{#if isOwner}
{#if hasStackChildren}
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
{/if}
{#if album}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
on:click={() => onMenuClick('setAsAlbumCover')}
/>
{/if}
{#if asset.type === AssetTypeEnum.Image}
<MenuOption
icon={mdiAccountCircleOutline}
on:click={() => onMenuClick('asProfileImage')}
text={$t('set_as_profile_picture')}
/>
{/if}
<MenuOption
on:click={() => onMenuClick('toggleArchive')}
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
/>
<MenuOption
icon={mdiUpload}
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text={$t('replace_with_upload')}
/>
<hr />
<MenuOption
icon={mdiDatabaseRefreshOutline}
on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
text={getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
text={getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiCogRefreshOutline}
on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
text={getAssetJobName(AssetJobName.TranscodeVideo)}
/>
{/if}
{/if}
</ContextMenu>
{/if} {/if}
</div> {#if showDownloadButton}
<MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text={$t('download')} />
{/if}
{#if asset.isTrashed}
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
{:else}
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
<MenuOption
icon={mdiShareVariantOutline}
on:click={() => onMenuClick('addToSharedAlbum')}
text={$t('add_to_shared_album')}
/>
{/if}
{#if isOwner}
{#if hasStackChildren}
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
{/if}
{#if album}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
on:click={() => onMenuClick('setAsAlbumCover')}
/>
{/if}
{#if asset.type === AssetTypeEnum.Image}
<MenuOption
icon={mdiAccountCircleOutline}
on:click={() => onMenuClick('asProfileImage')}
text={$t('set_as_profile_picture')}
/>
{/if}
<MenuOption
on:click={() => onMenuClick('toggleArchive')}
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
/>
<MenuOption
icon={mdiUpload}
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text={$t('replace_with_upload')}
/>
<hr />
<MenuOption
icon={mdiDatabaseRefreshOutline}
on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
text={getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
text={getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiCogRefreshOutline}
on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
text={getAssetJobName(AssetJobName.TranscodeVideo)}
/>
{/if}
{/if}
</ButtonContextMenu>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -1,12 +1,21 @@
<script lang="ts" context="module">
export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
</script>
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
export let type: 'button' | 'submit' | 'reset' = 'button'; export let type: 'button' | 'submit' | 'reset' = 'button';
export let icon: string; export let icon: string;
export let color: Color = 'transparent'; export let color: Color = 'transparent';
export let title: string; export let title: string;
/**
* The padding of the button, used by the `p-{padding}` Tailwind CSS class.
*/
export let padding = '3'; export let padding = '3';
/**
* Size of the button, used for a CSS value.
*/
export let size = '24'; export let size = '24';
export let hideMobile = false; export let hideMobile = false;
export let buttonSize: string | undefined = undefined; export let buttonSize: string | undefined = undefined;
@ -14,6 +23,10 @@
* viewBox attribute for the SVG icon. * viewBox attribute for the SVG icon.
*/ */
export let viewBox: string | undefined = undefined; export let viewBox: string | undefined = undefined;
export let id: string | undefined = undefined;
export let ariaHasPopup: boolean | undefined = undefined;
export let ariaExpanded: boolean | undefined = undefined;
export let ariaControls: string | undefined = undefined;
/** /**
* Override the default styling of the button for specific use cases, such as the icon color. * Override the default styling of the button for specific use cases, such as the icon color.
@ -33,14 +46,19 @@
$: colorClass = colorClasses[color]; $: colorClass = colorClasses[color];
$: mobileClass = hideMobile ? 'hidden sm:flex' : ''; $: mobileClass = hideMobile ? 'hidden sm:flex' : '';
$: paddingClass = `p-${padding}`;
</script> </script>
<button <button
{id}
{title} {title}
{type} {type}
style:width={buttonSize ? buttonSize + 'px' : ''} style:width={buttonSize ? buttonSize + 'px' : ''}
style:height={buttonSize ? buttonSize + 'px' : ''} style:height={buttonSize ? buttonSize + 'px' : ''}
class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}" class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}"
aria-haspopup={ariaHasPopup}
aria-expanded={ariaExpanded}
aria-controls={ariaControls}
on:click on:click
> >
<Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" /> <Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { type PersonResponseDto } from '@immich/sdk'; import { type PersonResponseDto } from '@immich/sdk';
import { import {
mdiAccountEditOutline, mdiAccountEditOutline,
@ -12,11 +11,10 @@
} from '@mdi/js'; } from '@mdi/js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { focusOutside } from '$lib/actions/focus-outside';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let person: PersonResponseDto; export let person: PersonResponseDto;
export let preload = false; export let preload = false;
@ -30,17 +28,7 @@
}>(); }>();
let showVerticalDots = false; let showVerticalDots = false;
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
const showMenu = (event: MouseEvent) => {
contextMenuPosition = getContextMenuPosition(event);
showContextMenu = !showContextMenu;
};
const onMenuExit = () => {
showContextMenu = false;
};
const onMenuClick = (event: MenuItemEvent) => { const onMenuClick = (event: MenuItemEvent) => {
onMenuExit();
dispatch(event); dispatch(event);
}; };
</script> </script>
@ -51,8 +39,13 @@
on:mouseenter={() => (showVerticalDots = true)} on:mouseenter={() => (showVerticalDots = true)}
on:mouseleave={() => (showVerticalDots = false)} on:mouseleave={() => (showVerticalDots = false)}
role="group" role="group"
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
> >
<a href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}" draggable="false"> <a
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
draggable="false"
on:focus={() => (showVerticalDots = true)}
>
<div class="w-full h-full rounded-xl brightness-95 filter"> <div class="w-full h-full rounded-xl brightness-95 filter">
<ImageThumbnail <ImageThumbnail
shadow shadow
@ -73,22 +66,15 @@
{/if} {/if}
</a> </a>
<div class="absolute right-2 top-2" class:hidden={!showVerticalDots}> <div class="absolute top-2 right-2">
<CircleIconButton <ButtonContextMenu
buttonClass="icon-white-drop-shadow focus:opacity-100 {showVerticalDots ? 'opacity-100' : 'opacity-0'}"
color="opaque" color="opaque"
padding="2"
size="20"
icon={mdiDotsVertical} icon={mdiDotsVertical}
title={$t('show_person_options')} title={$t('show_person_options')}
size="20" >
padding="2"
class="icon-white-drop-shadow"
on:click={showMenu}
/>
</div>
</div>
{#if showContextMenu}
<Portal target="body">
<ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}>
<MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} /> <MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} />
<MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} /> <MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} />
<MenuOption <MenuOption
@ -101,6 +87,6 @@
icon={mdiAccountMultipleCheckOutline} icon={mdiAccountMultipleCheckOutline}
text={$t('merge_people')} text={$t('merge_people')}
/> />
</ContextMenu> </ButtonContextMenu>
</Portal> </div>
{/if} </div>

View file

@ -12,7 +12,7 @@
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
@ -146,20 +146,20 @@
<CreateSharedLink /> <CreateSharedLink />
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} /> <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</AssetSelectContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} /> <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} /> <ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
<DeleteAssets menuItem {onAssetDelete} /> <DeleteAssets menuItem {onAssetDelete} />
</AssetSelectContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
</div> </div>
{/if} {/if}

View file

@ -3,7 +3,6 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils'; import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
import type { AlbumResponseDto } from '@immich/sdk'; import type { AlbumResponseDto } from '@immich/sdk';
import { getMenuContext } from '../asset-select-context-menu.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -13,16 +12,13 @@
let showAlbumPicker = false; let showAlbumPicker = false;
const { getAssets, clearSelect } = getAssetControlContext(); const { getAssets, clearSelect } = getAssetControlContext();
const closeMenu = getMenuContext();
const handleHideAlbumPicker = () => { const handleHideAlbumPicker = () => {
showAlbumPicker = false; showAlbumPicker = false;
closeMenu();
}; };
const handleAddToNewAlbum = async (albumName: string) => { const handleAddToNewAlbum = async (albumName: string) => {
showAlbumPicker = false; showAlbumPicker = false;
closeMenu();
const assetIds = [...getAssets()].map((asset) => asset.id); const assetIds = [...getAssets()].map((asset) => asset.id);
await addAssetsToNewAlbum(albumName, assetIds); await addAssetsToNewAlbum(albumName, assetIds);

View file

@ -1,35 +0,0 @@
<script lang="ts" context="module">
import { clickOutside } from '$lib/actions/click-outside';
import { createContext } from '$lib/utils/context';
const { get: getMenuContext, set: setContext } = createContext<() => void>();
export { getMenuContext };
</script>
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { getContextMenuPosition } from '$lib/utils/context-menu';
export let icon: string;
export let title: string;
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
const handleShowMenu = (event: MouseEvent) => {
contextMenuPosition = getContextMenuPosition(event, 'top-left');
showContextMenu = !showContextMenu;
};
setContext(() => (showContextMenu = false));
</script>
<div use:clickOutside={{ onOutclick: () => (showContextMenu = false) }}>
<CircleIconButton {title} {icon} on:click={handleShowMenu} />
{#if showContextMenu}
<ContextMenu {...contextMenuPosition}>
<slot />
</ContextMenu>
{/if}
</div>

View file

@ -0,0 +1,151 @@
<script lang="ts">
import CircleIconButton, { type Color } from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import {
getContextMenuPositionFromBoundingRect,
getContextMenuPositionFromEvent,
type Align,
} from '$lib/utils/context-menu';
import { generateId } from '$lib/utils/generate-id';
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { clickOutside } from '$lib/actions/click-outside';
import { shortcuts } from '$lib/actions/shortcut';
export let icon: string;
export let title: string;
/**
* The alignment of the context menu relative to the button.
*/
export let align: Align = 'top-left';
/**
* The direction in which the context menu should open.
*/
export let direction: 'left' | 'right' = 'right';
export let color: Color = 'transparent';
export let size: string | undefined = undefined;
export let padding: string | undefined = undefined;
/**
* Additional classes to apply to the button.
*/
export let buttonClass: string | undefined = undefined;
let isOpen = false;
let contextMenuPosition = { x: 0, y: 0 };
let menuContainer: HTMLUListElement;
let buttonContainer: HTMLDivElement;
const id = generateId();
const buttonId = `context-menu-button-${id}`;
const menuId = `context-menu-${id}`;
$: {
if (isOpen) {
$optionClickCallbackStore = handleOptionClick;
}
}
const openDropdown = (event: KeyboardEvent | MouseEvent) => {
contextMenuPosition = getContextMenuPositionFromEvent(event, align);
isOpen = true;
menuContainer?.focus();
};
const handleClick = (event: MouseEvent) => {
if (isOpen) {
closeDropdown();
return;
}
openDropdown(event);
};
const onEscape = (event: KeyboardEvent) => {
if (isOpen) {
// if the dropdown is open, stop the event from propagating
event.stopPropagation();
}
closeDropdown();
};
const onResize = () => {
if (!isOpen) {
return;
}
contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align);
};
const closeDropdown = () => {
if (!isOpen) {
return;
}
focusButton();
isOpen = false;
$selectedIdStore = undefined;
};
const handleOptionClick = () => {
closeDropdown();
};
const focusButton = () => {
const button: HTMLButtonElement | null = buttonContainer.querySelector(`#${buttonId}`);
button?.focus();
};
</script>
<svelte:window on:resize={onResize} />
<div
use:contextMenuNavigation={{
closeDropdown,
container: menuContainer,
isOpen,
onEscape,
openDropdown,
selectedId: $selectedIdStore,
selectionChanged: (id) => ($selectedIdStore = id),
}}
use:clickOutside={{ onOutclick: closeDropdown }}
on:resize={onResize}
>
<div bind:this={buttonContainer}>
<CircleIconButton
{color}
{icon}
{padding}
{size}
{title}
ariaControls={menuId}
ariaExpanded={isOpen}
ariaHasPopup={true}
class={buttonClass}
id={buttonId}
on:click={handleClick}
/>
</div>
<div
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeDropdown,
preventDefault: false,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeDropdown,
preventDefault: false,
},
]}
>
<ContextMenu
{...contextMenuPosition}
{direction}
ariaActiveDescendant={$selectedIdStore}
ariaLabelledBy={buttonId}
bind:menuElement={menuContainer}
id={menuId}
isVisible={isOpen}
>
<slot />
</ContextMenu>
</div>
</div>

View file

@ -3,11 +3,16 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { clickOutside } from '$lib/actions/click-outside'; import { clickOutside } from '$lib/actions/click-outside';
export let isVisible: boolean = false;
export let direction: 'left' | 'right' = 'right'; export let direction: 'left' | 'right' = 'right';
export let x = 0; export let x = 0;
export let y = 0; export let y = 0;
export let id: string | undefined = undefined;
export let ariaLabel: string | undefined = undefined;
export let ariaLabelledBy: string | undefined = undefined;
export let ariaActiveDescendant: string | undefined = undefined;
export let menuElement: HTMLDivElement | undefined = undefined; export let menuElement: HTMLUListElement | undefined = undefined;
export let onClose: (() => void) | undefined = undefined; export let onClose: (() => void) | undefined = undefined;
let left: number; let left: number;
@ -30,16 +35,25 @@
</script> </script>
<div <div
bind:this={menuElement}
bind:clientHeight={height} bind:clientHeight={height}
transition:slide={{ duration: 250, easing: quintOut }} class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
class="absolute z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
style:top="{top}px"
style:left="{left}px" style:left="{left}px"
role="menu" style:top="{top}px"
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }} transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }}
> >
<div class="flex flex-col rounded-lg"> <ul
{id}
aria-activedescendant={ariaActiveDescendant ?? ''}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
bind:this={menuElement}
class:max-h-[100vh]={isVisible}
class:max-h-0={!isVisible}
class="flex flex-col transition-all duration-[250ms] ease-in-out"
role="menu"
tabindex="-1"
>
<slot /> <slot />
</div> </ul>
</div> </div>

View file

@ -1,15 +1,37 @@
<script> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { generateId } from '$lib/utils/generate-id';
import { createEventDispatcher } from 'svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
export let text = ''; export let text = '';
export let subtitle = ''; export let subtitle = '';
export let icon = ''; export let icon = '';
let id: string = generateId();
$: isActive = $selectedIdStore === id;
const dispatch = createEventDispatcher<{
click: void;
}>();
const handleClick = () => {
$optionClickCallbackStore?.();
dispatch('click');
};
</script> </script>
<button <!-- svelte-ignore a11y-click-events-have-key-events -->
type="button" <!-- svelte-ignore a11y-mouse-events-have-key-events -->
on:click <li
class="w-full bg-slate-100 p-4 text-left text-sm font-medium text-immich-fg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg" {id}
on:click={handleClick}
on:mouseover={() => ($selectedIdStore = id)}
on:mouseleave={() => ($selectedIdStore = undefined)}
class="w-full p-4 text-left text-sm font-medium text-immich-fg focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg cursor-pointer border-gray-200"
class:bg-slate-300={isActive}
class:bg-slate-100={!isActive}
role="menuitem" role="menuitem"
> >
{#if text} {#if text}
@ -30,4 +52,4 @@
{subtitle} {subtitle}
</p> </p>
</slot> </slot>
</button> </li>

View file

@ -1,3 +0,0 @@
const key = {};
export { key };

View file

@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { shortcuts } from '$lib/actions/shortcut';
import { generateId } from '$lib/utils/generate-id';
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
export let title: string;
export let direction: 'left' | 'right' = 'right'; export let direction: 'left' | 'right' = 'right';
export let x = 0; export let x = 0;
export let y = 0; export let y = 0;
@ -9,7 +14,19 @@
export let onClose: (() => unknown) | undefined; export let onClose: (() => unknown) | undefined;
let uniqueKey = {}; let uniqueKey = {};
let contextMenuElement: HTMLDivElement; let menuContainer: HTMLUListElement;
let triggerElement: HTMLElement | undefined = undefined;
const id = generateId();
const menuId = `context-menu-${id}`;
$: {
if (isOpen && menuContainer) {
triggerElement = document.activeElement as HTMLElement;
menuContainer.focus();
$optionClickCallbackStore = closeContextMenu;
}
}
const reopenContextMenu = async (event: MouseEvent) => { const reopenContextMenu = async (event: MouseEvent) => {
const contextMenuEvent = new MouseEvent('contextmenu', { const contextMenuEvent = new MouseEvent('contextmenu', {
@ -22,7 +39,7 @@
const elements = document.elementsFromPoint(event.x, event.y); const elements = document.elementsFromPoint(event.x, event.y);
if (elements.includes(contextMenuElement)) { if (elements.includes(menuContainer)) {
// User right-clicked on the context menu itself, we keep the context // User right-clicked on the context menu itself, we keep the context
// menu as is // menu as is
return; return;
@ -38,20 +55,51 @@
}; };
const closeContextMenu = () => { const closeContextMenu = () => {
triggerElement?.focus();
onClose?.(); onClose?.();
}; };
</script> </script>
{#key uniqueKey} {#key uniqueKey}
{#if isOpen} {#if isOpen}
<section <div
class="fixed left-0 top-0 z-10 flex h-screen w-screen" use:contextMenuNavigation={{
on:contextmenu|preventDefault={reopenContextMenu} closeDropdown: closeContextMenu,
role="presentation" container: menuContainer,
isOpen,
selectedId: $selectedIdStore,
selectionChanged: (id) => ($selectedIdStore = id),
}}
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeContextMenu,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeContextMenu,
},
]}
> >
<ContextMenu {x} {y} {direction} onClose={closeContextMenu} bind:menuElement={contextMenuElement}> <section
<slot /> class="fixed left-0 top-0 z-10 flex h-screen w-screen"
</ContextMenu> on:contextmenu|preventDefault={reopenContextMenu}
</section> role="presentation"
>
<ContextMenu
{direction}
{x}
{y}
ariaActiveDescendant={$selectedIdStore}
ariaLabel={title}
bind:menuElement={menuContainer}
id={menuId}
isVisible
onClose={closeContextMenu}
>
<slot />
</ContextMenu>
</section>
</div>
{/if} {/if}
{/key} {/key}

View file

@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
const selectedIdStore = writable<string | undefined>(undefined);
const optionClickCallbackStore = writable<(() => void) | undefined>(undefined);
export { optionClickCallbackStore, selectedIdStore };

View file

@ -2,22 +2,31 @@ export type Align = 'middle' | 'top-left' | 'top-right';
export type ContextMenuPosition = { x: number; y: number }; export type ContextMenuPosition = { x: number; y: number };
export const getContextMenuPosition = (event: MouseEvent, align: Align = 'middle'): ContextMenuPosition => { export const getContextMenuPositionFromEvent = (
const { x, y, currentTarget, target } = event; event: MouseEvent | KeyboardEvent,
align: Align = 'middle',
): ContextMenuPosition => {
const { currentTarget, target } = event;
const x = 'x' in event ? event.x : 0;
const y = 'y' in event ? event.y : 0;
const box = ((currentTarget || target) as HTMLElement)?.getBoundingClientRect(); const box = ((currentTarget || target) as HTMLElement)?.getBoundingClientRect();
if (box) { if (box) {
switch (align) { return getContextMenuPositionFromBoundingRect(box, align);
case 'middle': {
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
case 'top-left': {
return { x: box.x, y: box.y };
}
case 'top-right': {
return { x: box.x + box.width, y: box.y };
}
}
} }
return { x, y }; return { x, y };
}; };
export const getContextMenuPositionFromBoundingRect = (rect: DOMRect, align: Align = 'middle'): ContextMenuPosition => {
switch (align) {
case 'middle': {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}
case 'top-left': {
return { x: rect.x, y: rect.y };
}
case 'top-right': {
return { x: rect.x + rect.width, y: rect.y };
}
}
};

View file

@ -22,9 +22,8 @@
import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte'; import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
@ -43,8 +42,6 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { handlePromiseError, s } from '$lib/utils'; import { handlePromiseError, s } from '$lib/utils';
import { downloadAlbum } from '$lib/utils/asset-utils'; import { downloadAlbum } from '$lib/utils/asset-utils';
import { clickOutside } from '$lib/actions/click-outside';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation'; import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
@ -103,7 +100,6 @@
SELECT_USERS = 'select-users', SELECT_USERS = 'select-users',
SELECT_THUMBNAIL = 'select-thumbnail', SELECT_THUMBNAIL = 'select-thumbnail',
SELECT_ASSETS = 'select-assets', SELECT_ASSETS = 'select-assets',
ALBUM_OPTIONS = 'album-options',
VIEW_USERS = 'view-users', VIEW_USERS = 'view-users',
VIEW = 'view', VIEW = 'view',
OPTIONS = 'options', OPTIONS = 'options',
@ -112,7 +108,6 @@
let backUrl: string = AppRoute.ALBUMS; let backUrl: string = AppRoute.ALBUMS;
let viewMode = ViewMode.VIEW; let viewMode = ViewMode.VIEW;
let isCreatingSharedAlbum = false; let isCreatingSharedAlbum = false;
let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
let isShowActivity = false; let isShowActivity = false;
let isLiked: ActivityResponseDto | null = null; let isLiked: ActivityResponseDto | null = null;
let reactions: ActivityResponseDto[] = []; let reactions: ActivityResponseDto[] = [];
@ -305,11 +300,6 @@
timelineInteractionStore.clearMultiselect(); timelineInteractionStore.clearMultiselect();
}; };
const handleOpenAlbumOptions = (event: MouseEvent) => {
contextMenuPosition = getContextMenuPosition(event, 'top-left');
viewMode = viewMode === ViewMode.VIEW ? ViewMode.ALBUM_OPTIONS : ViewMode.VIEW;
};
const handleSelectFromComputer = async () => { const handleSelectFromComputer = async () => {
await openFileUploadDialog({ albumId: album.id }); await openFileUploadDialog({ albumId: album.id });
timelineInteractionStore.clearMultiselect(); timelineInteractionStore.clearMultiselect();
@ -420,14 +410,14 @@
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} /> <SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</AssetSelectContextMenu> </ButtonContextMenu>
{#if isAllUserOwned} {#if isAllUserOwned}
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
{/if} {/if}
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{album.albumName}.zip" /> <DownloadAction menuItem filename="{album.albumName}.zip" />
{#if isAllUserOwned} {#if isAllUserOwned}
<ChangeDate menuItem /> <ChangeDate menuItem />
@ -447,10 +437,10 @@
{#if isAllUserOwned} {#if isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} /> <DeleteAssets menuItem onAssetDelete={handleRemoveAssets} />
{/if} {/if}
</AssetSelectContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
{#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS} {#if viewMode === ViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}> <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}>
<svelte:fragment slot="trailing"> <svelte:fragment slot="trailing">
{#if isEditor} {#if isEditor}
@ -474,32 +464,19 @@
<CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} /> <CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
{#if isOwned} {#if isOwned}
<div use:clickOutside={{ onOutclick: () => (viewMode = ViewMode.VIEW) }}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('album_options')}>
<CircleIconButton <MenuOption
title={$t('album_options')} icon={mdiImageOutline}
on:click={handleOpenAlbumOptions} text={$t('select_album_cover')}
icon={mdiDotsVertical} on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
/> />
{#if viewMode === ViewMode.ALBUM_OPTIONS} <MenuOption
<ContextMenu {...contextMenuPosition}> icon={mdiCogOutline}
<MenuOption text={$t('options')}
icon={mdiImageOutline} on:click={() => (viewMode = ViewMode.OPTIONS)}
text={$t('select_album_cover')} />
on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} <MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} on:click={() => handleRemoveAlbum()} />
/> </ButtonContextMenu>
<MenuOption
icon={mdiCogOutline}
text={$t('options')}
on:click={() => (viewMode = ViewMode.OPTIONS)}
/>
<MenuOption
icon={mdiDeleteOutline}
text={$t('delete_album')}
on:click={() => handleRemoveAlbum()}
/>
</ContextMenu>
{/if}
</div>
{/if} {/if}
{/if} {/if}

View file

@ -8,7 +8,7 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
@ -32,15 +32,15 @@
<ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> <ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} /> <SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</AssetSelectContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
</AssetSelectContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{/if} {/if}

View file

@ -10,7 +10,7 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
@ -35,17 +35,17 @@
<FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} /> <FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} />
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} /> <SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</AssetSelectContextMenu> </ButtonContextMenu>
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
</AssetSelectContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{/if} {/if}

View file

@ -4,7 +4,7 @@
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
@ -30,10 +30,10 @@
{#if $isMultiSelectState} {#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}> <AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
<CreateSharedLink /> <CreateSharedLink />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add')}> <ButtonContextMenu icon={mdiPlus} title={$t('add')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</AssetSelectContextMenu> </ButtonContextMenu>
<DownloadAction /> <DownloadAction />
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}

View file

@ -17,7 +17,6 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
@ -57,6 +56,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { listNavigation } from '$lib/actions/list-navigation'; import { listNavigation } from '$lib/actions/list-navigation';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let data: PageData; export let data: PageData;
@ -380,12 +380,12 @@
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} /> <SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</AssetSelectContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" /> <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
<MenuOption <MenuOption
icon={mdiAccountMultipleCheckOutline} icon={mdiAccountMultipleCheckOutline}
@ -396,13 +396,13 @@
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} /> <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} /> <DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
</AssetSelectContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(previousRoute)}> <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(previousRoute)}>
<svelte:fragment slot="trailing"> <svelte:fragment slot="trailing">
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<MenuOption <MenuOption
text={$t('select_featured_photo')} text={$t('select_featured_photo')}
icon={mdiAccountBoxOutline} icon={mdiAccountBoxOutline}
@ -423,7 +423,7 @@
icon={mdiAccountMultipleCheckOutline} icon={mdiAccountMultipleCheckOutline}
on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)} on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)}
/> />
</AssetSelectContextMenu> </ButtonContextMenu>
</svelte:fragment> </svelte:fragment>
</ControlAppBar> </ControlAppBar>
{/if} {/if}

View file

@ -12,7 +12,7 @@
import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte'; import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
@ -58,12 +58,12 @@
> >
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} /> <SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</AssetSelectContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
{#if $selectedAssets.size > 1 || isAssetStackSelected} {#if $selectedAssets.size > 1 || isAssetStackSelected}
<StackAction <StackAction
@ -78,7 +78,7 @@
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
<hr /> <hr />
<AssetJobActions /> <AssetJobActions />
</AssetSelectContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{/if} {/if}

View file

@ -11,7 +11,7 @@
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
@ -211,19 +211,19 @@
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}> <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<CreateSharedLink /> <CreateSharedLink />
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} /> <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</AssetSelectContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} /> <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} /> <ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
<DeleteAssets menuItem {onAssetDelete} /> <DeleteAssets menuItem {onAssetDelete} />
</AssetSelectContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
</div> </div>
{:else} {:else}

View file

@ -6,16 +6,13 @@
import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte'; import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
import LibraryUserPickerForm from '$lib/components/forms/library-user-picker-form.svelte'; import LibraryUserPickerForm from '$lib/components/forms/library-user-picker-form.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { import {
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units'; import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { import {
createLibrary, createLibrary,
@ -35,9 +32,9 @@
import { fade, slide } from 'svelte/transition'; import { fade, slide } from 'svelte/transition';
import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte'; import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let data: PageData; export let data: PageData;
@ -63,10 +60,6 @@
let deleteAssetCount = 0; let deleteAssetCount = 0;
let dropdownOpen: boolean[] = []; let dropdownOpen: boolean[] = [];
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
let selectedLibraryIndex = 0;
let selectedLibrary: LibraryResponseDto | null = null;
let toCreateLibrary = false; let toCreateLibrary = false;
@ -79,25 +72,12 @@
editScanSettings = null; editScanSettings = null;
renameLibrary = null; renameLibrary = null;
updateLibraryIndex = null; updateLibraryIndex = null;
showContextMenu = false;
for (let index = 0; index < dropdownOpen.length; index++) { for (let index = 0; index < dropdownOpen.length; index++) {
dropdownOpen[index] = false; dropdownOpen[index] = false;
} }
}; };
const showMenu = (event: MouseEvent, library: LibraryResponseDto, index: number) => {
contextMenuPosition = getContextMenuPosition(event);
showContextMenu = !showContextMenu;
selectedLibraryIndex = index;
selectedLibrary = library;
};
const onMenuExit = () => {
showContextMenu = false;
};
const refreshStats = async (listIndex: number) => { const refreshStats = async (listIndex: number) => {
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id }); stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId }); owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
@ -233,72 +213,72 @@
} }
}; };
const onRenameClicked = () => { const onRenameClicked = (index: number) => {
closeAll(); closeAll();
renameLibrary = selectedLibraryIndex; renameLibrary = index;
updateLibraryIndex = selectedLibraryIndex; updateLibraryIndex = index;
}; };
const onEditImportPathClicked = () => { const onEditImportPathClicked = (index: number) => {
closeAll(); closeAll();
editImportPaths = selectedLibraryIndex; editImportPaths = index;
updateLibraryIndex = selectedLibraryIndex; updateLibraryIndex = index;
}; };
const onScanNewLibraryClicked = async () => { const onScanNewLibraryClicked = async (library: LibraryResponseDto) => {
closeAll(); closeAll();
if (selectedLibrary) { if (library) {
await handleScan(selectedLibrary.id); await handleScan(library.id);
} }
}; };
const onScanSettingClicked = () => { const onScanSettingClicked = (index: number) => {
closeAll(); closeAll();
editScanSettings = selectedLibraryIndex; editScanSettings = index;
updateLibraryIndex = selectedLibraryIndex; updateLibraryIndex = index;
}; };
const onScanAllLibraryFilesClicked = async () => { const onScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
closeAll(); closeAll();
if (selectedLibrary) { if (library) {
await handleScanChanges(selectedLibrary.id); await handleScanChanges(library.id);
} }
}; };
const onForceScanAllLibraryFilesClicked = async () => { const onForceScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
closeAll(); closeAll();
if (selectedLibrary) { if (library) {
await handleForceScan(selectedLibrary.id); await handleForceScan(library.id);
} }
}; };
const onRemoveOfflineFilesClicked = async () => { const onRemoveOfflineFilesClicked = async (library: LibraryResponseDto) => {
closeAll(); closeAll();
if (selectedLibrary) { if (library) {
await handleRemoveOffline(selectedLibrary.id); await handleRemoveOffline(library.id);
} }
}; };
const onDeleteLibraryClicked = async () => { const onDeleteLibraryClicked = async (library: LibraryResponseDto, index: number) => {
closeAll(); closeAll();
if (!selectedLibrary) { if (!library) {
return; return;
} }
const isConfirmedLibrary = await dialogController.show({ const isConfirmedLibrary = await dialogController.show({
id: 'delete-library', id: 'delete-library',
prompt: $t('admin.confirm_delete_library', { values: { library: selectedLibrary.name } }), prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
}); });
if (!isConfirmedLibrary) { if (!isConfirmedLibrary) {
return; return;
} }
await refreshStats(selectedLibraryIndex); await refreshStats(index);
if (totalCount[selectedLibraryIndex] > 0) { if (totalCount[index] > 0) {
deleteAssetCount = totalCount[selectedLibraryIndex]; deleteAssetCount = totalCount[index];
const isConfirmedLibraryAssetCount = await dialogController.show({ const isConfirmedLibraryAssetCount = await dialogController.show({
id: 'delete-library-assets', id: 'delete-library-assets',
@ -310,7 +290,7 @@
} }
await handleDelete(); await handleDelete();
} else { } else {
deletedLibrary = selectedLibrary; deletedLibrary = library;
await handleDelete(); await handleDelete();
} }
}; };
@ -392,46 +372,38 @@
{/if} {/if}
<td class=" text-ellipsis px-4 text-sm"> <td class=" text-ellipsis px-4 text-sm">
<CircleIconButton <ButtonContextMenu
align="top-right"
direction="left"
color="primary" color="primary"
size="16"
icon={mdiDotsVertical} icon={mdiDotsVertical}
title={$t('library_options')} title={$t('library_options')}
size="16" >
on:click={(e) => showMenu(e, library, index)} <MenuOption on:click={() => onRenameClicked(index)} text={$t('rename')} />
/> <MenuOption on:click={() => onEditImportPathClicked(index)} text={$t('edit_import_paths')} />
<MenuOption on:click={() => onScanSettingClicked(index)} text={$t('scan_settings')} />
{#if showContextMenu} <hr />
<Portal target="body"> <MenuOption on:click={() => onScanNewLibraryClicked(library)} text={$t('scan_new_library_files')} />
<ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}> <MenuOption
<MenuOption on:click={() => onRenameClicked()} text={$t('rename')} /> on:click={() => onScanAllLibraryFilesClicked(library)}
text={$t('scan_all_library_files')}
{#if selectedLibrary} subtitle={$t('only_refreshes_modified_files')}
<MenuOption on:click={() => onEditImportPathClicked()} text={$t('edit_import_paths')} /> />
<MenuOption on:click={() => onScanSettingClicked()} text={$t('scan_settings')} /> <MenuOption
<hr /> on:click={() => onForceScanAllLibraryFilesClicked(library)}
<MenuOption on:click={() => onScanNewLibraryClicked()} text={$t('scan_new_library_files')} /> text={$t('force_re-scan_library_files')}
<MenuOption subtitle={$t('refreshes_every_file')}
on:click={() => onScanAllLibraryFilesClicked()} />
text={$t('scan_all_library_files')} <hr />
subtitle={$t('only_refreshes_modified_files')} <MenuOption
/> on:click={() => onRemoveOfflineFilesClicked(library)}
<MenuOption text={$t('remove_offline_files')}
on:click={() => onForceScanAllLibraryFilesClicked()} />
text={$t('force_re-scan_library_files')} <MenuOption on:click={() => onDeleteLibraryClicked(library, index)}>
subtitle={$t('refreshes_every_file')} <p class="text-red-600">{$t('delete_library')}</p>
/> </MenuOption>
<hr /> </ButtonContextMenu>
<MenuOption
on:click={() => onRemoveOfflineFilesClicked()}
text={$t('remove_offline_files')}
/>
<MenuOption on:click={() => onDeleteLibraryClicked()}>
<p class="text-red-600">{$t('delete_library')}</p>
</MenuOption>
{/if}
</ContextMenu>
</Portal>
{/if}
</td> </td>
</tr> </tr>
{#if renameLibrary === index} {#if renameLibrary === index}