From baa7331c877f77598b2017cd1722a62e5cd5e3fb Mon Sep 17 00:00:00 2001 From: Ben Hammond Date: Fri, 4 Oct 2024 15:59:07 -0600 Subject: [PATCH] Frontend: Adds "Save Image To Clipboard" option (#3705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description and Motivation ## Img to Clipboard Feature - closes #3703 - repurposed "save image" function to allow saving to clipboard or download - makes new card option button to save img to clipboard - adds snackbar notification that clipboard save was successful - adds image thumbnail into snackbar notice ## Refactors - refactors `` to accept a child element to make it more reusable, rather than sending in specific pieces of the message as props - new reusable component for Card Export Menu Items - new hook to co-locate all card image/share stuff - settings tweak to auto-organize imports and remove unused imports automatically ## Has this been tested? How? - passing ## Screenshots (if appropriate) Screenshot 2024-10-04 at 2 15 13 PM Screenshot 2024-10-04 at 2 15 26 PM Screenshot 2024-10-04 at 2 15 41 PM ## Types of changes (leave all that apply) - New content or feature - Refactor / chore ## New frontend preview link is below in the Netlify comment 😎 --- .vscode/settings.json | 1 + frontend/src/cards/CardWrapper.tsx | 6 +- frontend/src/cards/ui/CardOptionsMenu.tsx | 18 +-- .../src/cards/ui/CardShareIconButtons.tsx | 104 ++++++++++++++++ frontend/src/cards/ui/CardShareIcons.tsx | 91 -------------- .../ui/CopyCardImageToClipboardButton.tsx | 51 ++++++++ frontend/src/cards/ui/CopyLinkButton.tsx | 44 ++----- .../src/cards/ui/DownloadCardImageButton.tsx | 33 ++---- frontend/src/cards/ui/MultiMapDialog.tsx | 46 ++++--- .../HetComponents/HetCardExportMenuItem.tsx | 30 +++++ .../src/styles/HetComponents/HetDialog.tsx | 6 +- .../hooks/useCardImage.tsx} | 112 ++++++++++++++++-- 12 files changed, 348 insertions(+), 194 deletions(-) create mode 100644 frontend/src/cards/ui/CardShareIconButtons.tsx delete mode 100644 frontend/src/cards/ui/CardShareIcons.tsx create mode 100644 frontend/src/cards/ui/CopyCardImageToClipboardButton.tsx create mode 100644 frontend/src/styles/HetComponents/HetCardExportMenuItem.tsx rename frontend/src/{cards/ui/DownloadCardImageHelpers.ts => utils/hooks/useCardImage.tsx} (53%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 223a00dc60..a5a7a2ad24 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,7 @@ ], "editor.codeActionsOnSave": { "quickfix.biome": "explicit", + "source.organizeImports": "always", "source.addMissingImports": "always" }, "[javascript]": { diff --git a/frontend/src/cards/CardWrapper.tsx b/frontend/src/cards/CardWrapper.tsx index 2d2ce761bc..6d3696acf9 100644 --- a/frontend/src/cards/CardWrapper.tsx +++ b/frontend/src/cards/CardWrapper.tsx @@ -4,11 +4,10 @@ import type { MetricQueryResponse, } from '../data/query/MetricQuery' import { WithMetadataAndMetrics } from '../data/react/WithLoadingOrErrorUI' -import { Sources } from './ui/Sources' import type { MapOfDatasetMetadata } from '../data/utils/DatasetTypes' import type { ScrollableHashId } from '../utils/hooks/useStepObserver' import CardOptionsMenu from './ui/CardOptionsMenu' -import { saveCardImage } from './ui/DownloadCardImageHelpers' +import { Sources } from './ui/Sources' function CardWrapper(props: { // prevent layout shift as component loads @@ -55,9 +54,6 @@ function CardWrapper(props: { tabIndex={-1} > - saveCardImage(props.scrollToHash, props.downloadTitle) - } reportTitle={props.reportTitle} scrollToHash={props.scrollToHash} /> diff --git a/frontend/src/cards/ui/CardOptionsMenu.tsx b/frontend/src/cards/ui/CardOptionsMenu.tsx index c7c77d45bd..97b5a631df 100644 --- a/frontend/src/cards/ui/CardOptionsMenu.tsx +++ b/frontend/src/cards/ui/CardOptionsMenu.tsx @@ -6,13 +6,13 @@ import Popover from '@mui/material/Popover' import type { PopoverOrigin } from '@mui/material/Popover' import { DownloadCardImageButton } from './DownloadCardImageButton' import CopyLinkButton from './CopyLinkButton' -import CardShareIcons from './CardShareIcons' +import CardShareIconButtons from './CardShareIconButtons' import { usePopover } from '../../utils/hooks/usePopover' import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' import { useIsBreakpointAndUp } from '../../utils/hooks/useIsBreakpointAndUp' +import { CopyCardImageToClipboardButton } from './CopyCardImageToClipboardButton' interface CardOptionsMenuProps { - downloadTargetScreenshot: () => Promise reportTitle: string scrollToHash: ScrollableHashId } @@ -21,9 +21,6 @@ export default function CardOptionsMenu(props: CardOptionsMenuProps) { const shareMenu = usePopover() const isSm = useIsBreakpointAndUp('sm') - const urlWithoutHash = window.location.href.split('#')[0] - const urlWithHash = `${urlWithoutHash}#${props.scrollToHash}` - const anchorOrigin: PopoverOrigin = { vertical: 'top', horizontal: 'right', @@ -58,16 +55,19 @@ export default function CardOptionsMenu(props: CardOptionsMenuProps) { + - diff --git a/frontend/src/cards/ui/CardShareIconButtons.tsx b/frontend/src/cards/ui/CardShareIconButtons.tsx new file mode 100644 index 0000000000..28060ca241 --- /dev/null +++ b/frontend/src/cards/ui/CardShareIconButtons.tsx @@ -0,0 +1,104 @@ +import type { ComponentType } from 'react' +import { + EmailIcon, + EmailShareButton, + FacebookIcon, + FacebookShareButton, + LinkedinIcon, + LinkedinShareButton, + TwitterShareButton, + XIcon, +} from 'react-share' +import { het } from '../../styles/DesignTokens' +import { HetCardExportMenuItem } from '../../styles/HetComponents/HetCardExportMenuItem' +import { useCardImage } from '../../utils/hooks/useCardImage' +import type { PopoverElements } from '../../utils/hooks/usePopover' +import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' + +const shareIconAttributes = { + iconFillColor: het.hexShareIconGray, + bgStyle: { fill: 'none' }, + size: 39, +} + +interface CardShareIconButtonsProps { + popover: PopoverElements + reportTitle: string + scrollToHash: ScrollableHashId +} + +interface ShareButtonConfig { + ShareButton: ComponentType + Icon: ComponentType + label: string + options: Record +} + +export default function CardShareIconButtons(props: CardShareIconButtonsProps) { + const title = `Health Equity Tracker - ${props.reportTitle}` + const emailShareBody = `${title}${'\n'}${'\n'}` + + const { cardUrlWithHash, handleClose } = useCardImage( + props.popover, + props.scrollToHash, + ) + + const shareButtons: ShareButtonConfig[] = [ + { + ShareButton: TwitterShareButton, + Icon: XIcon, + label: 'Share on X', + options: { + hashtags: ['healthequity'], + related: ['@SatcherHealth', '@MSMEDU'], + 'aria-label': 'Share to X (formerly Twitter)', + }, + }, + { + ShareButton: FacebookShareButton, + Icon: FacebookIcon, + label: 'Share on Facebook', + options: { + hashtag: '#healthequity', + 'aria-label': 'Post this report to Facebook', + }, + }, + { + ShareButton: LinkedinShareButton, + Icon: LinkedinIcon, + label: 'Share on LinkedIn', + options: { + source: 'Health Equity Tracker', + 'aria-label': 'Share to LinkedIn', + }, + }, + { + ShareButton: EmailShareButton, + Icon: EmailIcon, + label: 'Email card link', + options: { + body: emailShareBody, + subject: 'Sharing from healthequitytracker.org', + 'aria-label': 'Share by email', + }, + }, + ] + + return ( + <> + {shareButtons.map(({ ShareButton, Icon, label, options }) => ( + + + {label} + + + ))} + + ) +} diff --git a/frontend/src/cards/ui/CardShareIcons.tsx b/frontend/src/cards/ui/CardShareIcons.tsx deleted file mode 100644 index 885a703fa2..0000000000 --- a/frontend/src/cards/ui/CardShareIcons.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import MenuItem from '@mui/material/MenuItem' -import { - EmailShareButton, - FacebookShareButton, - LinkedinShareButton, - TwitterShareButton, - EmailIcon, - FacebookIcon, - LinkedinIcon, - XIcon, -} from 'react-share' -import type { PopoverElements } from '../../utils/hooks/usePopover' -import { het } from '../../styles/DesignTokens' - -const shareIconAttributes = { - iconFillColor: het.hexShareIconGray, - bgStyle: { fill: 'none' }, - size: 39, -} - -interface CardShareIconsProps { - popover: PopoverElements - reportTitle: string - urlWithHash: string -} - -export default function CardShareIcons(props: CardShareIconsProps) { - const title = `Health Equity Tracker - ${props.reportTitle}` - const emailShareBody = `${title}${'\n'}${'\n'}` // Add line breaks here if needed - const sharedUrl = props.urlWithHash - - function handleClose() { - props.popover.close() - } - - return ( - <> - - - -
Share on X (Twitter)
-
-
- - - - -
Share on Facebook
-
-
- - - - -
Share on LinkedIn
-
-
- - - - -
Email card link
-
-
- - ) -} diff --git a/frontend/src/cards/ui/CopyCardImageToClipboardButton.tsx b/frontend/src/cards/ui/CopyCardImageToClipboardButton.tsx new file mode 100644 index 0000000000..034f984bf3 --- /dev/null +++ b/frontend/src/cards/ui/CopyCardImageToClipboardButton.tsx @@ -0,0 +1,51 @@ +import { ContentCopy } from '@mui/icons-material' +import type { PopoverElements } from '../../utils/hooks/usePopover' +import HetDialog from '../../styles/HetComponents/HetDialog' +import HetTerm from '../../styles/HetComponents/HetTerm' +import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' +import SimpleBackdrop from '../../pages/ui/SimpleBackdrop' +import { useCardImage } from '../../utils/hooks/useCardImage' +import { HetCardExportMenuItem } from '../../styles/HetComponents/HetCardExportMenuItem' + +interface CopyCardImageToClipboardButtonProps { + popover: PopoverElements + scrollToHash: ScrollableHashId +} + +export function CopyCardImageToClipboardButton( + props: CopyCardImageToClipboardButtonProps, +) { + const { + cardName, + isThinking, + setIsThinking, + imgDataUrl, + hetDialogOpen, + handleCopyImgToClipboard, + handleClose, + } = useCardImage(props.popover, props.scrollToHash) + + return ( + <> + + + Copy Image To Clipboard + + + Copied {cardName} image to clipboard! + {imgDataUrl && ( +
+ {`Preview +
+ )} +
+ + ) +} diff --git a/frontend/src/cards/ui/CopyLinkButton.tsx b/frontend/src/cards/ui/CopyLinkButton.tsx index 00b5916d42..add348feb1 100644 --- a/frontend/src/cards/ui/CopyLinkButton.tsx +++ b/frontend/src/cards/ui/CopyLinkButton.tsx @@ -1,48 +1,30 @@ -import { useState } from 'react' import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' -import ListItemIcon from '@mui/material/ListItemIcon' import LinkIcon from '@mui/icons-material/Link' -import MenuItem from '@mui/material/MenuItem' import type { PopoverElements } from '../../utils/hooks/usePopover' import HetDialog from '../../styles/HetComponents/HetDialog' +import HetTerm from '../../styles/HetComponents/HetTerm' +import { useCardImage } from '../../utils/hooks/useCardImage' +import { HetCardExportMenuItem } from '../../styles/HetComponents/HetCardExportMenuItem' interface CopyLinkButtonProps { popover: PopoverElements scrollToHash: ScrollableHashId - urlWithHash: string } export default function CopyLinkButton(props: CopyLinkButtonProps) { - const [open, setOpen] = useState(false) - - let cardName = props.scrollToHash.replaceAll('-', ' ') ?? 'Card' - cardName = cardName[0].toUpperCase() + cardName.slice(1) - - const title = `Copy direct link to: ${cardName}` - - function handleClick() { - async function asyncHandleClick() { - await navigator.clipboard.writeText(props.urlWithHash) - setOpen(true) - } - asyncHandleClick().catch((error) => error) - } - - function handleClose() { - setOpen(false) - props.popover.close() - } + const { cardName, hetDialogOpen, handleCopyLink, handleClose } = useCardImage( + props.popover, + props.scrollToHash, + ) return ( <> - - - -
Copy card link
-
-
- - + + Copy Card Link + + + Direct link to {cardName} copied to clipboard! + ) } diff --git a/frontend/src/cards/ui/DownloadCardImageButton.tsx b/frontend/src/cards/ui/DownloadCardImageButton.tsx index 2d7e8d79c8..496d50391b 100644 --- a/frontend/src/cards/ui/DownloadCardImageButton.tsx +++ b/frontend/src/cards/ui/DownloadCardImageButton.tsx @@ -1,36 +1,27 @@ -import { useState } from 'react' import { SaveAlt } from '@mui/icons-material' -import ListItemIcon from '@mui/material/ListItemIcon' -import MenuItem from '@mui/material/MenuItem' import SimpleBackdrop from '../../pages/ui/SimpleBackdrop' import type { PopoverElements } from '../../utils/hooks/usePopover' +import { useCardImage } from '../../utils/hooks/useCardImage' +import { HetCardExportMenuItem } from '../../styles/HetComponents/HetCardExportMenuItem' +import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' interface DownloadCardImageButtonProps { - downloadTargetScreenshot: () => Promise - popover?: PopoverElements - isMulti?: boolean + popover: PopoverElements + scrollToHash: ScrollableHashId } export function DownloadCardImageButton(props: DownloadCardImageButtonProps) { - const [isThinking, setIsThinking] = useState(false) - - async function handleClick() { - setIsThinking(true) - setIsThinking(!(await props.downloadTargetScreenshot())) - props.popover?.close() - } + const { isThinking, setIsThinking, handleDownloadImg } = useCardImage( + props.popover, + props.scrollToHash, + ) return ( <> - - - - {!props.isMulti && ( -
Save Image
- )} -
-
+ + Save Image + ) } diff --git a/frontend/src/cards/ui/MultiMapDialog.tsx b/frontend/src/cards/ui/MultiMapDialog.tsx index eb8fc2ecda..67b10e2423 100644 --- a/frontend/src/cards/ui/MultiMapDialog.tsx +++ b/frontend/src/cards/ui/MultiMapDialog.tsx @@ -2,41 +2,40 @@ import { useState } from 'react' // TODO: eventually should make a HetDialog to handle modals import { Dialog, DialogContent } from '@mui/material' import ChoroplethMap from '../../charts/ChoroplethMap' -import { Fips } from '../../data/utils/Fips' import { Legend } from '../../charts/Legend' -import type { - MapOfDatasetMetadata, - HetRow, - FieldRange, -} from '../../data/utils/DatasetTypes' +import { type CountColsMap, RATE_MAP_SCALE } from '../../charts/mapGlobals' import type { DataTypeConfig, MetricConfig, } from '../../data/config/MetricConfigTypes' -import type { - MetricQuery, - MetricQueryResponse, -} from '../../data/query/MetricQuery' +import { + CAWP_METRICS, + getWomenRaceLabel, +} from '../../data/providers/CawpProvider' import { type DemographicType, DEMOGRAPHIC_DISPLAY_TYPES_LOWER_CASE, } from '../../data/query/Breakdowns' +import type { + MetricQuery, + MetricQueryResponse, +} from '../../data/query/MetricQuery' import type { DemographicGroup } from '../../data/utils/Constants' -import { - CAWP_METRICS, - getWomenRaceLabel, -} from '../../data/providers/CawpProvider' -import TerritoryCircles from './TerritoryCircles' -import HetBreadcrumbs from '../../styles/HetComponents/HetBreadcrumbs' -import { type CountColsMap, RATE_MAP_SCALE } from '../../charts/mapGlobals' -import CardOptionsMenu from './CardOptionsMenu' -import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' -import { Sources } from './Sources' +import type { + FieldRange, + HetRow, + MapOfDatasetMetadata, +} from '../../data/utils/DatasetTypes' +import { Fips } from '../../data/utils/Fips' import DataTypeDefinitionsList from '../../pages/ui/DataTypeDefinitionsList' +import HetBreadcrumbs from '../../styles/HetComponents/HetBreadcrumbs' +import HetLinkButton from '../../styles/HetComponents/HetLinkButton' import HetNotice from '../../styles/HetComponents/HetNotice' import HetTerm from '../../styles/HetComponents/HetTerm' -import HetLinkButton from '../../styles/HetComponents/HetLinkButton' -import { saveCardImage } from './DownloadCardImageHelpers' +import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' +import CardOptionsMenu from './CardOptionsMenu' +import { Sources } from './Sources' +import TerritoryCircles from './TerritoryCircles' interface MultiMapDialogProps { dataTypeConfig: DataTypeConfig @@ -129,9 +128,6 @@ export default function MultiMapDialog(props: MultiMapDialogProps) { {/* card options button */}
- saveCardImage('multimap-modal', title) - } reportTitle={props.reportTitle} scrollToHash={props.scrollToHash} /> diff --git a/frontend/src/styles/HetComponents/HetCardExportMenuItem.tsx b/frontend/src/styles/HetComponents/HetCardExportMenuItem.tsx new file mode 100644 index 0000000000..f1fc99aa02 --- /dev/null +++ b/frontend/src/styles/HetComponents/HetCardExportMenuItem.tsx @@ -0,0 +1,30 @@ +import ListItemIcon from '@mui/material/ListItemIcon' +import MenuItem from '@mui/material/MenuItem' +import type { ComponentType } from 'react' + +interface HetCardExportMenuItemProps { + Icon: ComponentType + onClick: () => void + className?: string + children?: React.ReactNode + iconProps?: Record +} + +export function HetCardExportMenuItem({ + Icon, + onClick, + children, + className = '', + iconProps = {}, +}: HetCardExportMenuItemProps) { + return ( + + + + {children && ( + {children} + )} + + + ) +} diff --git a/frontend/src/styles/HetComponents/HetDialog.tsx b/frontend/src/styles/HetComponents/HetDialog.tsx index 62957658d5..0c8864fa13 100644 --- a/frontend/src/styles/HetComponents/HetDialog.tsx +++ b/frontend/src/styles/HetComponents/HetDialog.tsx @@ -1,8 +1,8 @@ import { Snackbar, Alert, Slide } from '@mui/material' -import HetTerm from './HetTerm' +import type { ReactNode } from 'react' interface HetDialogProps { - cardName: string + children?: ReactNode open: boolean handleClose: () => void } @@ -20,7 +20,7 @@ export default function HetDialog(props: HetDialogProps) { className='border border-solid border-barChartLight' role='alert' > - Direct link to {props.cardName} copied to clipboard! + {props.children} ) diff --git a/frontend/src/cards/ui/DownloadCardImageHelpers.ts b/frontend/src/utils/hooks/useCardImage.tsx similarity index 53% rename from frontend/src/cards/ui/DownloadCardImageHelpers.ts rename to frontend/src/utils/hooks/useCardImage.tsx index 74686480f2..cdc46d25fb 100644 --- a/frontend/src/cards/ui/DownloadCardImageHelpers.ts +++ b/frontend/src/utils/hooks/useCardImage.tsx @@ -1,7 +1,80 @@ -import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' import domtoimage from 'dom-to-image-more' -import { CITATION_APA } from './SourcesHelpers' +import { useState } from 'react' +import { CITATION_APA } from '../../cards/ui/SourcesHelpers' +import { reportProviderSteps } from '../../reports/ReportProviderSteps' +import type { PopoverElements } from './usePopover' +import type { ScrollableHashId } from './useStepObserver' + +export function useCardImage( + cardMenuPopover: PopoverElements, + scrollToHash: ScrollableHashId, +) { + // STATE + const [isThinking, setIsThinking] = useState(false) + const [hetDialogOpen, setHetDialogOpen] = useState(false) + const [imgDataUrl, setImgDataUrl] = useState(null) + + // COMPUTED VALUES + const cardName = reportProviderSteps[scrollToHash].label + const urlWithoutHash = window.location.href.split('#')[0] + const cardUrlWithHash = `${urlWithoutHash}#${scrollToHash}` + + // HANDLERS + const handleCopyImgToClipboard = async () => { + setIsThinking(true) + try { + const result = await saveCardImage(scrollToHash, cardName, 'clipboard') + if (typeof result === 'string') { + setImgDataUrl(result) + setHetDialogOpen(true) + } + } finally { + setIsThinking(false) + } + } + + const handleDownloadImg = async () => { + setIsThinking(true) + try { + await saveCardImage(scrollToHash, cardName, 'download') + cardMenuPopover?.close() + } finally { + setIsThinking(false) + } + } + + const handleCopyLink = async () => { + if (cardUrlWithHash) { + await navigator.clipboard.writeText(cardUrlWithHash) + setHetDialogOpen(true) + } + } + function handleClose() { + setIsThinking(false) + setHetDialogOpen(false) + cardMenuPopover.close() + setImgDataUrl(null) + } + + // HOOK RETURN + return { + cardName, + cardUrlWithHash, + isThinking, + setIsThinking, + imgDataUrl, + setImgDataUrl, + hetDialogOpen, + setHetDialogOpen, + handleCopyImgToClipboard, + handleDownloadImg, + handleCopyLink, + handleClose, + } +} + +// INTERNAL HELPERS const SCALE_FACTOR = 3 const UNSAFE_CHAR_REGEX = /[^a-zA-Z0-9_.\-\s]+/g @@ -12,10 +85,11 @@ interface DomToImageOptions { height?: number } -export async function saveCardImage( +async function saveCardImage( cardId: ScrollableHashId, cardTitle: string, -): Promise { + destination: 'clipboard' | 'download', +): Promise { const parentCardNode = document.getElementById(cardId) as HTMLElement const articleChild = parentCardNode?.querySelector( 'article', @@ -57,6 +131,10 @@ export async function saveCardImage( heightToCrop -= getTotalElementHeight(addedParagraph) heightToCrop -= getTotalElementHeight(addedDivider) } + async function dataURLtoBlob(dataURL: string): Promise { + const response = await fetch(dataURL) + return response.blob() + } try { const options: DomToImageOptions = { @@ -67,12 +145,28 @@ export async function saveCardImage( } const dataUrl = await domtoimage.toPng(targetNode, options) - const fileName = createFileName(cardTitle) - const link = document.createElement('a') - link.download = fileName - link.href = dataUrl - link.click() + if (destination === 'clipboard') { + try { + const blob = await dataURLtoBlob(dataUrl) + await navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob, + }), + ]) + return dataUrl + } catch (clipboardError) { + console.error('Failed to write to clipboard:', clipboardError) + return false + } + } else if (destination === 'download') { + const fileName = createFileName(cardTitle) + const link = document.createElement('a') + link.download = fileName + link.href = dataUrl + link.click() + } + return true } catch (error: unknown) { if (error instanceof Error) {