Refactor copy overlay

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-03-27 21:05:44 +02:00
parent c05332e994
commit 30620a60e6
7 changed files with 125 additions and 115 deletions

View file

@ -496,12 +496,14 @@
"avatarOf": "avatar of '{{name}}'",
"why": "Why?",
"loading": "Loading ...",
"successfullyCopied": "Copied!",
"copyError": "Error while copying!",
"errorOccurred": "An error occurred",
"errorWhileLoadingLibrary": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.",
"readForMoreInfo": "Read here for more information"
},
"copyOverlay": {
"error": "Error while copying!",
"success": "Copied!"
},
"login": {
"chooseMethod": "Choose method",
"signInVia": "Sign in via {{service}}",

View file

@ -1,77 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RefObject } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { Overlay, Tooltip } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { v4 as uuid } from 'uuid'
import { ShowIf } from '../show-if/show-if'
import { Logger } from '../../../utils/logger'
import { isClientSideRendering } from '../../../utils/is-client-side-rendering'
export interface CopyOverlayProps {
content: string
clickComponent: RefObject<HTMLElement>
}
const log = new Logger('CopyOverlay')
export const CopyOverlay: React.FC<CopyOverlayProps> = ({ content, clickComponent }) => {
useTranslation()
const [showCopiedTooltip, setShowCopiedTooltip] = useState(false)
const [error, setError] = useState(false)
const [tooltipId] = useState<string>(() => uuid())
const copyToClipboard = useCallback((content: string) => {
if (!isClientSideRendering()) {
log.error('Clipboard not available in server side rendering')
return
}
navigator.clipboard
.writeText(content)
.then(() => {
setError(false)
})
.catch((error: Error) => {
setError(true)
log.error('Copy failed', error)
})
.finally(() => {
setShowCopiedTooltip(true)
setTimeout(() => {
setShowCopiedTooltip(false)
}, 2000)
})
}, [])
useEffect(() => {
if (clickComponent && clickComponent.current) {
clickComponent.current.addEventListener('click', () => copyToClipboard(content))
const clickComponentSaved = clickComponent.current
return () => {
if (clickComponentSaved) {
clickComponentSaved.removeEventListener('click', () => copyToClipboard(content))
}
}
}
}, [clickComponent, copyToClipboard, content])
return (
<Overlay target={clickComponent} show={showCopiedTooltip} placement='top'>
{(props) => (
<Tooltip id={`copied_${tooltipId}`} {...props}>
<ShowIf condition={error}>
<Trans i18nKey={'common.copyError'} />
</ShowIf>
<ShowIf condition={!error}>
<Trans i18nKey={'common.successfullyCopied'} />
</ShowIf>
</Tooltip>
)}
</Overlay>
)
}

View file

@ -9,7 +9,7 @@ import { Button } from 'react-bootstrap'
import type { Variant } from 'react-bootstrap/types'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../fork-awesome/fork-awesome-icon'
import { CopyOverlay } from '../copy-overlay'
import { useCopyOverlay } from '../hooks/use-copy-overlay'
import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
import { cypressId } from '../../../../utils/cypress-attribute'
@ -19,6 +19,14 @@ export interface CopyToClipboardButtonProps extends PropsWithDataCypressId {
variant?: Variant
}
/**
* Shows a button that copies the given content on click.
*
* @param content The content to copy
* @param size The size of the button
* @param variant The bootstrap variant of the button
* @param props Other props that are forwarded to the bootstrap button
*/
export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
content,
size = 'sm',
@ -28,6 +36,8 @@ export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
const { t } = useTranslation()
const button = useRef<HTMLButtonElement>(null)
const [copyToClipboard, overlayElement] = useCopyOverlay(button, content)
return (
<Fragment>
<Button
@ -35,10 +45,11 @@ export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
size={size}
variant={variant}
title={t('renderer.highlightCode.copyCode')}
onClick={copyToClipboard}
{...cypressId(props)}>
<ForkAwesomeIcon icon='files-o' />
</Button>
<CopyOverlay content={content} clickComponent={button} />
{overlayElement}
</Fragment>
)
}

View file

@ -4,62 +4,64 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useMemo, useRef } from 'react'
import React, { useCallback, useMemo } from 'react'
import { Button, FormControl, InputGroup } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../show-if/show-if'
import { CopyOverlay } from '../copy-overlay'
import { Logger } from '../../../../utils/logger'
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
import { CopyToClipboardButton } from '../copy-to-clipboard-button/copy-to-clipboard-button'
export interface CopyableFieldProps {
content: string
nativeShareButton?: boolean
url?: string
shareOriginUrl?: string
}
const log = new Logger('CopyableField')
export const CopyableField: React.FC<CopyableFieldProps> = ({ content, nativeShareButton, url }) => {
/**
* Provides an input field with an attached copy button and a share button (if supported by the browser)
*
* @param content The content to present
* @param shareOriginUrl The URL of the page to which the shared content should be linked. If this value is omitted then the share button won't be shown.
*/
export const CopyableField: React.FC<CopyableFieldProps> = ({ content, shareOriginUrl }) => {
useTranslation()
const copyButton = useRef<HTMLButtonElement>(null)
const sharingSupported = useMemo(
() => shareOriginUrl !== undefined && isClientSideRendering() && typeof navigator.share === 'function',
[shareOriginUrl]
)
const doShareAction = useCallback(() => {
if (!isClientSideRendering()) {
log.error('Native sharing not available in server side rendering')
if (!sharingSupported) {
log.error('Native sharing not available')
return
}
navigator
.share({
text: content,
url: url
url: shareOriginUrl
})
.catch((error: Error) => {
log.error('Native sharing failed', error)
})
}, [content, url])
const sharingSupported = useMemo(() => isClientSideRendering() && typeof navigator.share === 'function', [])
}, [content, shareOriginUrl, sharingSupported])
return (
<Fragment>
<InputGroup className='my-3'>
<FormControl readOnly={true} className={'text-center'} value={content} />
<InputGroup className='my-3'>
<FormControl readOnly={true} className={'text-center'} value={content} />
<InputGroup.Append>
<CopyToClipboardButton variant={'outline-secondary'} content={content} />
</InputGroup.Append>
<ShowIf condition={sharingSupported}>
<InputGroup.Append>
<Button variant='outline-secondary' ref={copyButton} title={'Copy'}>
<ForkAwesomeIcon icon='files-o' />
<Button variant='outline-secondary' title={'Share'} onClick={doShareAction}>
<ForkAwesomeIcon icon='share-alt' />
</Button>
</InputGroup.Append>
<ShowIf condition={!!nativeShareButton && sharingSupported}>
<InputGroup.Append>
<Button variant='outline-secondary' title={'Share'} onClick={doShareAction}>
<ForkAwesomeIcon icon='share-alt' />
</Button>
</InputGroup.Append>
</ShowIf>
</InputGroup>
<CopyOverlay content={content} clickComponent={copyButton} />
</Fragment>
</ShowIf>
</InputGroup>
)
}

View file

@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ReactElement, RefObject } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { Overlay, Tooltip } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { v4 as uuid } from 'uuid'
import { ShowIf } from '../../show-if/show-if'
import { Logger } from '../../../../utils/logger'
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
const log = new Logger('useCopyOverlay')
/**
* Provides a function that writes the given text into the browser clipboard and an {@link Overlay overlay} that is shown when the copy action was successful.
*
* @param clickComponent The component to which the overlay should be attached
* @param content The content that should be copied
* @return the copy function and the overlay
*/
export const useCopyOverlay = (
clickComponent: RefObject<HTMLElement>,
content: string
): [copyToCliphoard: () => void, overlayElement: ReactElement] => {
useTranslation()
const [showCopiedTooltip, setShowCopiedTooltip] = useState(false)
const [error, setError] = useState(false)
const [tooltipId] = useState<string>(() => uuid())
const copyToClipboard = useCallback(() => {
if (!isClientSideRendering()) {
log.error('Clipboard not available in server side rendering')
return
}
navigator.clipboard
.writeText(content)
.then(() => {
setError(false)
})
.catch((error: Error) => {
setError(true)
log.error('Copy failed', error)
})
.finally(() => {
setShowCopiedTooltip(true)
setTimeout(() => {
setShowCopiedTooltip(false)
}, 2000)
})
}, [content])
const overlayElement = useMemo(
() => (
<Overlay target={clickComponent} show={showCopiedTooltip} placement='top'>
{(props) => (
<Tooltip id={`copied_${tooltipId}`} {...props}>
<ShowIf condition={error}>
<Trans i18nKey={'copyOverlay.error'} />
</ShowIf>
<ShowIf condition={!error}>
<Trans i18nKey={'copyOverlay.success'} />
</ShowIf>
</Tooltip>
)}
</Overlay>
),
[clickComponent, error, showCopiedTooltip, tooltipId]
)
return [copyToClipboard, overlayElement]
}

View file

@ -26,18 +26,14 @@ export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) =>
<CommonModal show={show} onHide={onHide} showCloseButton={true} title={'editor.modal.shareLink.title'}>
<Modal.Body>
<Trans i18nKey={'editor.modal.shareLink.editorDescription'} />
<CopyableField
content={`${baseUrl}n/${id}?${editorMode}`}
nativeShareButton={true}
url={`${baseUrl}n/${id}?${editorMode}`}
/>
<CopyableField content={`${baseUrl}n/${id}?${editorMode}`} shareOriginUrl={`${baseUrl}n/${id}?${editorMode}`} />
<ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}>
<Trans i18nKey={'editor.modal.shareLink.slidesDescription'} />
<CopyableField content={`${baseUrl}p/${id}`} nativeShareButton={true} url={`${baseUrl}p/${id}`} />
<CopyableField content={`${baseUrl}p/${id}`} shareOriginUrl={`${baseUrl}p/${id}`} />
</ShowIf>
<ShowIf condition={noteFrontmatter.type === NoteType.DOCUMENT}>
<Trans i18nKey={'editor.modal.shareLink.viewOnlyDescription'} />
<CopyableField content={`${baseUrl}s/${id}`} nativeShareButton={true} url={`${baseUrl}s/${id}`} />
<CopyableField content={`${baseUrl}s/${id}`} shareOriginUrl={`${baseUrl}s/${id}`} />
</ShowIf>
</Modal.Body>
</CommonModal>

View file

@ -142,6 +142,7 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
ref={frameReference}
referrerPolicy={'no-referrer'}
className={`border-0 ${frameClasses ?? ''}`}
allow={'clipboard-write'}
{...cypressAttribute('renderer-ready', rendererReady ? 'true' : 'false')}
{...cypressAttribute('renderer-type', rendererType)}
/>