feat: optimize copy image to clipboard (#12366)

* feat: optimize copy image to clipboard

* pr feedback

* fix: urlToBlob

Co-authored-by: Jason Rasmussen <jason@rasm.me>

* fix: imgToBlob

Co-authored-by: Jason Rasmussen <jason@rasm.me>

* chore: finish rename

* fix: dimensions

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
martin 2024-09-06 15:16:59 +02:00 committed by GitHub
parent ecc85ff6c6
commit a653d9d29f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 52 additions and 19 deletions

6
web/package-lock.json generated
View File

@ -17,7 +17,6 @@
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
"@photo-sphere-viewer/video-plugin": "^5.7.2",
"@zoom-image/svelte": "^0.2.6",
"copy-image-clipboard": "^2.1.2",
"dom-to-image": "^2.6.0",
"handlebars": "^4.7.8",
"intl-messageformat": "^10.5.14",
@ -3284,11 +3283,6 @@
"node": ">= 0.6"
}
},
"node_modules/copy-image-clipboard": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/copy-image-clipboard/-/copy-image-clipboard-2.1.2.tgz",
"integrity": "sha512-3VCXVl2IpFfOyD8drv9DozcNlwmqBqxOlsgkEGyVAzadjlPk1go8YNZyy8QmTnwHPxSFpeCR9OdsStEdVK7qDA=="
},
"node_modules/core-js-compat": {
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz",

View File

@ -73,7 +73,6 @@
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
"@photo-sphere-viewer/video-plugin": "^5.7.2",
"@zoom-image/svelte": "^0.2.6",
"copy-image-clipboard": "^2.1.2",
"dom-to-image": "^2.6.0",
"handlebars": "^4.7.8",
"intl-messageformat": "^10.5.14",

View File

@ -41,7 +41,7 @@
mdiPresentationPlay,
mdiUpload,
} from '@mdi/js';
import { canCopyImagesToClipboard } from 'copy-image-clipboard';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
@ -101,7 +101,7 @@
on:click={onZoomImage}
/>
{/if}
{#if canCopyImagesToClipboard() && asset.type === AssetTypeEnum.Image}
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image}
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
{/if}

View File

@ -8,17 +8,17 @@
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { isWebCompatibleImage, canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { type SwipeCustomEvent, swipe } from 'svelte-gestures';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
export let asset: AssetResponseDto;
export let preloadAssets: AssetResponseDto[] | undefined = undefined;
@ -81,23 +81,19 @@
};
copyImage = async () => {
if (!canCopyImagesToClipboard()) {
if (!canCopyImageToClipboard()) {
return;
}
try {
await copyImageToClipboard(assetFileUrl);
await copyImageToClipboard($photoViewer ?? assetFileUrl);
notificationController.show({
type: NotificationType.Info,
message: $t('copied_image_to_clipboard'),
timeout: 3000,
});
} catch (error) {
console.error('Error [photo-viewer]:', error);
notificationController.show({
type: NotificationType.Error,
message: 'Copying image to clipboard failed.',
});
handleError(error, $t('copy_error'));
}
};

View File

@ -1,5 +1,5 @@
import type { AssetResponseDto } from '@immich/sdk';
import { getAssetFilename, getFilenameExtension } from './asset-utils';
import { canCopyImageToClipboard, getAssetFilename, getFilenameExtension } from './asset-utils';
describe('get file extension from filename', () => {
it('returns the extension without including the dot', () => {
@ -56,3 +56,9 @@ describe('get asset filename', () => {
}
});
});
describe('copy image to clipboard', () => {
it('should not allow copy image to clipboard', () => {
expect(canCopyImageToClipboard()).toEqual(false);
});
});

View File

@ -527,3 +527,41 @@ export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean
export const delay = async (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const canCopyImageToClipboard = (): boolean => {
return !!(navigator.clipboard && window.ClipboardItem);
};
const imgToBlob = async (imageElement: HTMLImageElement) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = imageElement.naturalWidth;
canvas.height = imageElement.naturalHeight;
if (context) {
context.drawImage(imageElement, 0, 0);
return await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
throw new Error('Canvas conversion to Blob failed');
}
});
});
}
throw new Error('Canvas context is null');
};
const urlToBlob = async (imageSource: string) => {
const response = await fetch(imageSource);
return await response.blob();
};
export const copyImageToClipboard = async (source: HTMLImageElement | string) => {
const blob = source instanceof HTMLImageElement ? await imgToBlob(source) : await urlToBlob(source);
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
};