From f68b200e85c1b7a68e39f7c6bc13b98648ad3f30 Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:18:03 +0000 Subject: [PATCH 1/2] Add a context menu for copying and downloading images --- .changeset/feat_add_image_menu.md | 5 + .../components/image-viewer/ImageViewer.tsx | 440 +++++++++++------- src/app/utils/dom.ts | 12 + 3 files changed, 280 insertions(+), 177 deletions(-) create mode 100644 .changeset/feat_add_image_menu.md diff --git a/.changeset/feat_add_image_menu.md b/.changeset/feat_add_image_menu.md new file mode 100644 index 000000000..11aabf0ba --- /dev/null +++ b/.changeset/feat_add_image_menu.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add a context menu for copying and downloading images. diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index f4e2455e5..9e969f98b 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,7 +1,21 @@ +import type { MouseEventHandler } from 'react'; import { useEffect, useRef, useState } from 'react'; import FileSaver from 'file-saver'; import classNames from 'classnames'; -import { Box, Chip, Header, IconButton, Text, as } from 'folds'; +import { + Box, + Chip, + Header, + IconButton, + Menu, + MenuItem, + PopOut, + type RectCords, + Text, + as, + config, + toRem, +} from 'folds'; import { ArrowLeft, ArrowsClockwise, @@ -9,6 +23,7 @@ import { Image, Minus, Plus, + menuIcon, phosphorSizeRem, sizedIcon, } from '$components/icons/phosphor'; @@ -18,7 +33,10 @@ import { isPixelatedRendering, settingsAtom } from '$state/settings'; import { downloadMedia } from '$utils/matrix'; import * as css from './ImageViewer.css'; import type { IImageInfo } from '$types/matrix/common'; -import { CheckerboardIcon } from '@phosphor-icons/react'; +import { CheckerboardIcon, CopyIcon, DownloadIcon } from '@phosphor-icons/react'; +import FocusTrap from 'focus-trap-react'; +import { stopPropagation } from '$utils/keyboard'; +import { copyImageToClipboard } from '$utils/dom'; export type ImageViewerProps = { alt: string; @@ -83,195 +101,263 @@ export const ImageViewer = as<'div', ImageViewerProps>( FileSaver.saveAs(fileContent, alt); }; + const [menuAnchor, setMenuAnchor] = useState(); + + const handleContextMenu: MouseEventHandler = (evt) => { + if (evt.altKey || !window.getSelection()?.isCollapsed) return; + const tag = (evt.target as HTMLElement).tagName; + if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; + + evt.preventDefault(); + setMenuAnchor({ + x: evt.clientX, + y: evt.clientY, + width: 0, + height: 0, + }); + }; + return ( - -
- - - {sizedIcon(ArrowLeft, '200')} - - - {alt} - - - - setIsPixelated(!isPixelated)} - aria-label="Toggle Pixelation" - title={`Turn ${isPixelated ? 'Anti-aliasing' : 'Pixelation'} on`} - > - - - { - setZoom(1); + <> + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, }} - aria-label="View Original Size" - title="View Original Size" > - {sizedIcon(Image, '200')} - - { - resetTransforms(); - enableResizeWithWindow(); - setZoom(fitRatio); - }} - aria-label="Reset Zoom" - title="Zoom to Fill Container" - > - {sizedIcon(ArrowsClockwise, '200')} - - - {sizedIcon(Minus, '50')} - - { - setZoomInput(Math.round(transforms.zoom * 100).toString()); - setIsEditingZoom(true); - }} - title="Update Zoom" - > - + + { + setMenuAnchor(undefined); + const fileContent = await downloadMedia(src); + await copyImageToClipboard(fileContent); + }} + > + + Copy image + + + { + setMenuAnchor(undefined); + handleDownload(); + }} + > + + Save image + + + + + + } + /> + +
+ + + {sizedIcon(ArrowLeft, '200')} + + + {alt} + + + + setIsPixelated(!isPixelated)} + aria-label="Toggle Pixelation" + title={`Turn ${isPixelated ? 'Anti-aliasing' : 'Pixelation'} on`} + > + + + { + setZoom(1); }} + aria-label="View Original Size" + title="View Original Size" > - {isEditingZoom ? ( - - { - setZoomInput(e.target.value); - }} - onBlur={() => { - const next = parseInt(zoomInput, 10); - if (!Number.isNaN(next)) { - setZoom(next / 100); - } - setIsEditingZoom(false); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { + {sizedIcon(Image, '200')} + + { + resetTransforms(); + enableResizeWithWindow(); + setZoom(fitRatio); + }} + aria-label="Reset Zoom" + title="Zoom to Fill Container" + > + {sizedIcon(ArrowsClockwise, '200')} + + + {sizedIcon(Minus, '50')} + + { + setZoomInput(Math.round(transforms.zoom * 100).toString()); + setIsEditingZoom(true); + }} + title="Update Zoom" + > + + {isEditingZoom ? ( + + { + setZoomInput(e.target.value); + }} + onBlur={() => { const next = parseInt(zoomInput, 10); if (!Number.isNaN(next)) { setZoom(next / 100); } setIsEditingZoom(false); - } - }} - /> - % - - ) : ( - `${Math.round(transforms.zoom * 100)}%` - )} - - - 1 ? 'Success' : 'SurfaceVariant'} - outlined={transforms.zoom > 1} - size="300" - radii="Pill" - onClick={zoomIn} - aria-label="Zoom In" - title="Zoom In" - > - {sizedIcon(Plus, '50')} - - - Download - - -
- - { + if (e.key === 'Enter') { + const next = parseInt(zoomInput, 10); + if (!Number.isNaN(next)) { + setZoom(next / 100); + } + setIsEditingZoom(false); + } + }} + /> + % + + ) : ( + `${Math.round(transforms.zoom * 100)}%` + )} +
+
+ 1 ? 'Success' : 'SurfaceVariant'} + outlined={transforms.zoom > 1} + size="300" + radii="Pill" + onClick={zoomIn} + aria-label="Zoom In" + title="Zoom In" + > + {sizedIcon(Plus, '50')} + + + Download + +
+
+ ) => { - handleImageLoad(event); - setIsImageReady(true); - }} - /> + onContextMenu={handleContextMenu} + > + {alt}) => { + handleImageLoad(event); + setIsImageReady(true); + }} + /> +
- + ); } ); diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index e947d9746..223b044c9 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -187,6 +187,18 @@ export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'insta }); }; +export const copyImageToClipboard = async (blob: Blob): Promise => { + if (navigator.clipboard) { + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + return true; + } catch { + return false; + } + } + return false; +}; + export const copyToClipboard = async (text: string): Promise => { if (navigator.clipboard) { try { From c5e59346f411609ac50fe4dacd4e2f02369f3068 Mon Sep 17 00:00:00 2001 From: Shea Date: Thu, 25 Jun 2026 22:49:36 +0300 Subject: [PATCH 2/2] add wider support Signed-off-by: Shea --- src/app/utils/dom.ts | 51 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 223b044c9..45099c9ad 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -187,16 +187,51 @@ export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'insta }); }; +async function getBitmap(blob: Blob): Promise { + if (!blob.type.startsWith('image/svg+xml')) return createImageBitmap(blob); + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + + await new Promise((resolve, reject) => { + img.addEventListener('load', () => resolve(), { once: true }); + img.addEventListener('error', reject, { once: true }); + img.src = url; + }); + + return await createImageBitmap(img); + } finally { + URL.revokeObjectURL(url); + } +} + export const copyImageToClipboard = async (blob: Blob): Promise => { - if (navigator.clipboard) { - try { - await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); - return true; - } catch { - return false; - } + const bitmap = await getBitmap(blob); + + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + + const ctx = canvas.getContext('2d'); + ctx?.drawImage(bitmap, 0, 0); + + try { + const finalBlob = await new Promise((resolve) => { + canvas.toBlob((result) => { + if (result) resolve(result); + }, 'image/png'); + }); + + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': finalBlob, + }), + ]); + + return true; + } catch { + return false; } - return false; }; export const copyToClipboard = async (text: string): Promise => {