diff --git a/web/package-lock.json b/web/package-lock.json index 4ddc6d9baa..7bcd5c2b01 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 1ba350022d..c84bbf0db4 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 0f75f9bb83..db216641d5 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -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} {/if} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 40a36fa0e0..4157c558d2 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -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')); } }; diff --git a/web/src/lib/utils/asset-utils.spec.ts b/web/src/lib/utils/asset-utils.spec.ts index 8970a6a652..b3a668192d 100644 --- a/web/src/lib/utils/asset-utils.spec.ts +++ b/web/src/lib/utils/asset-utils.spec.ts @@ -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); + }); +}); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index e309db5ff6..84a896452f 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -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((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 })]); +};