mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-20 10:15:17 -04:00
Refactor copy overlay
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
c05332e994
commit
30620a60e6
7 changed files with 125 additions and 115 deletions
|
@ -496,12 +496,14 @@
|
||||||
"avatarOf": "avatar of '{{name}}'",
|
"avatarOf": "avatar of '{{name}}'",
|
||||||
"why": "Why?",
|
"why": "Why?",
|
||||||
"loading": "Loading ...",
|
"loading": "Loading ...",
|
||||||
"successfullyCopied": "Copied!",
|
|
||||||
"copyError": "Error while copying!",
|
|
||||||
"errorOccurred": "An error occurred",
|
"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.",
|
"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"
|
"readForMoreInfo": "Read here for more information"
|
||||||
},
|
},
|
||||||
|
"copyOverlay": {
|
||||||
|
"error": "Error while copying!",
|
||||||
|
"success": "Copied!"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"chooseMethod": "Choose method",
|
"chooseMethod": "Choose method",
|
||||||
"signInVia": "Sign in via {{service}}",
|
"signInVia": "Sign in via {{service}}",
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -9,7 +9,7 @@ import { Button } from 'react-bootstrap'
|
||||||
import type { Variant } from 'react-bootstrap/types'
|
import type { Variant } from 'react-bootstrap/types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../fork-awesome/fork-awesome-icon'
|
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 type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
|
||||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||||
|
|
||||||
|
@ -19,6 +19,14 @@ export interface CopyToClipboardButtonProps extends PropsWithDataCypressId {
|
||||||
variant?: Variant
|
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> = ({
|
export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
|
||||||
content,
|
content,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
|
@ -28,6 +36,8 @@ export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const button = useRef<HTMLButtonElement>(null)
|
const button = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [copyToClipboard, overlayElement] = useCopyOverlay(button, content)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Button
|
<Button
|
||||||
|
@ -35,10 +45,11 @@ export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
|
||||||
size={size}
|
size={size}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
title={t('renderer.highlightCode.copyCode')}
|
title={t('renderer.highlightCode.copyCode')}
|
||||||
|
onClick={copyToClipboard}
|
||||||
{...cypressId(props)}>
|
{...cypressId(props)}>
|
||||||
<ForkAwesomeIcon icon='files-o' />
|
<ForkAwesomeIcon icon='files-o' />
|
||||||
</Button>
|
</Button>
|
||||||
<CopyOverlay content={content} clickComponent={button} />
|
{overlayElement}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,62 +4,64 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { Button, FormControl, InputGroup } from 'react-bootstrap'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../fork-awesome/fork-awesome-icon'
|
||||||
import { ShowIf } from '../../show-if/show-if'
|
import { ShowIf } from '../../show-if/show-if'
|
||||||
import { CopyOverlay } from '../copy-overlay'
|
|
||||||
import { Logger } from '../../../../utils/logger'
|
import { Logger } from '../../../../utils/logger'
|
||||||
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
|
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
|
||||||
|
import { CopyToClipboardButton } from '../copy-to-clipboard-button/copy-to-clipboard-button'
|
||||||
|
|
||||||
export interface CopyableFieldProps {
|
export interface CopyableFieldProps {
|
||||||
content: string
|
content: string
|
||||||
nativeShareButton?: boolean
|
shareOriginUrl?: string
|
||||||
url?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = new Logger('CopyableField')
|
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()
|
useTranslation()
|
||||||
const copyButton = useRef<HTMLButtonElement>(null)
|
|
||||||
|
const sharingSupported = useMemo(
|
||||||
|
() => shareOriginUrl !== undefined && isClientSideRendering() && typeof navigator.share === 'function',
|
||||||
|
[shareOriginUrl]
|
||||||
|
)
|
||||||
|
|
||||||
const doShareAction = useCallback(() => {
|
const doShareAction = useCallback(() => {
|
||||||
if (!isClientSideRendering()) {
|
if (!sharingSupported) {
|
||||||
log.error('Native sharing not available in server side rendering')
|
log.error('Native sharing not available')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
navigator
|
navigator
|
||||||
.share({
|
.share({
|
||||||
text: content,
|
text: content,
|
||||||
url: url
|
url: shareOriginUrl
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
log.error('Native sharing failed', error)
|
log.error('Native sharing failed', error)
|
||||||
})
|
})
|
||||||
}, [content, url])
|
}, [content, shareOriginUrl, sharingSupported])
|
||||||
|
|
||||||
const sharingSupported = useMemo(() => isClientSideRendering() && typeof navigator.share === 'function', [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<InputGroup className='my-3'>
|
||||||
<InputGroup className='my-3'>
|
<FormControl readOnly={true} className={'text-center'} value={content} />
|
||||||
<FormControl readOnly={true} className={'text-center'} value={content} />
|
<InputGroup.Append>
|
||||||
|
<CopyToClipboardButton variant={'outline-secondary'} content={content} />
|
||||||
|
</InputGroup.Append>
|
||||||
|
<ShowIf condition={sharingSupported}>
|
||||||
<InputGroup.Append>
|
<InputGroup.Append>
|
||||||
<Button variant='outline-secondary' ref={copyButton} title={'Copy'}>
|
<Button variant='outline-secondary' title={'Share'} onClick={doShareAction}>
|
||||||
<ForkAwesomeIcon icon='files-o' />
|
<ForkAwesomeIcon icon='share-alt' />
|
||||||
</Button>
|
</Button>
|
||||||
</InputGroup.Append>
|
</InputGroup.Append>
|
||||||
<ShowIf condition={!!nativeShareButton && sharingSupported}>
|
</ShowIf>
|
||||||
<InputGroup.Append>
|
</InputGroup>
|
||||||
<Button variant='outline-secondary' title={'Share'} onClick={doShareAction}>
|
|
||||||
<ForkAwesomeIcon icon='share-alt' />
|
|
||||||
</Button>
|
|
||||||
</InputGroup.Append>
|
|
||||||
</ShowIf>
|
|
||||||
</InputGroup>
|
|
||||||
<CopyOverlay content={content} clickComponent={copyButton} />
|
|
||||||
</Fragment>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
75
src/components/common/copyable/hooks/use-copy-overlay.tsx
Normal file
75
src/components/common/copyable/hooks/use-copy-overlay.tsx
Normal 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]
|
||||||
|
}
|
|
@ -26,18 +26,14 @@ export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) =>
|
||||||
<CommonModal show={show} onHide={onHide} showCloseButton={true} title={'editor.modal.shareLink.title'}>
|
<CommonModal show={show} onHide={onHide} showCloseButton={true} title={'editor.modal.shareLink.title'}>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<Trans i18nKey={'editor.modal.shareLink.editorDescription'} />
|
<Trans i18nKey={'editor.modal.shareLink.editorDescription'} />
|
||||||
<CopyableField
|
<CopyableField content={`${baseUrl}n/${id}?${editorMode}`} shareOriginUrl={`${baseUrl}n/${id}?${editorMode}`} />
|
||||||
content={`${baseUrl}n/${id}?${editorMode}`}
|
|
||||||
nativeShareButton={true}
|
|
||||||
url={`${baseUrl}n/${id}?${editorMode}`}
|
|
||||||
/>
|
|
||||||
<ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}>
|
<ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}>
|
||||||
<Trans i18nKey={'editor.modal.shareLink.slidesDescription'} />
|
<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>
|
||||||
<ShowIf condition={noteFrontmatter.type === NoteType.DOCUMENT}>
|
<ShowIf condition={noteFrontmatter.type === NoteType.DOCUMENT}>
|
||||||
<Trans i18nKey={'editor.modal.shareLink.viewOnlyDescription'} />
|
<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>
|
</ShowIf>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</CommonModal>
|
</CommonModal>
|
||||||
|
|
|
@ -142,6 +142,7 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
|
||||||
ref={frameReference}
|
ref={frameReference}
|
||||||
referrerPolicy={'no-referrer'}
|
referrerPolicy={'no-referrer'}
|
||||||
className={`border-0 ${frameClasses ?? ''}`}
|
className={`border-0 ${frameClasses ?? ''}`}
|
||||||
|
allow={'clipboard-write'}
|
||||||
{...cypressAttribute('renderer-ready', rendererReady ? 'true' : 'false')}
|
{...cypressAttribute('renderer-ready', rendererReady ? 'true' : 'false')}
|
||||||
{...cypressAttribute('renderer-type', rendererType)}
|
{...cypressAttribute('renderer-type', rendererType)}
|
||||||
/>
|
/>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue