From 54c05530f6fb5ca201a9896eda0e1121254f0885 Mon Sep 17 00:00:00 2001 From: Marek Mihok Date: Wed, 6 Sep 2023 08:29:10 +0200 Subject: [PATCH] feat: Add copy to clipboard button to markdown code block #2025 (#2120) Co-authored-by: Martin Turoci --- ui/src/copyable_text.test.tsx | 1 - ui/src/copyable_text.tsx | 53 ++++++++++++++++++++--------------- ui/src/markdown.test.tsx | 4 ++- ui/src/markdown.tsx | 26 ++++++++++++++++- 4 files changed, 59 insertions(+), 25 deletions(-) diff --git a/ui/src/copyable_text.test.tsx b/ui/src/copyable_text.test.tsx index 73eb02b8b6..08f4147da7 100644 --- a/ui/src/copyable_text.test.tsx +++ b/ui/src/copyable_text.test.tsx @@ -33,5 +33,4 @@ describe('CopyableText.tsx', () => { rerender() expect(getByTestId(name)).toHaveValue('B') }) - }) \ No newline at end of file diff --git a/ui/src/copyable_text.tsx b/ui/src/copyable_text.tsx index 1d293df180..6c56b53df6 100644 --- a/ui/src/copyable_text.tsx +++ b/ui/src/copyable_text.tsx @@ -6,17 +6,16 @@ import { clas, cssVar, pc } from './theme' const css = stylesheet({ - btnMultiline: { - opacity: 0, - transition: 'opacity .5s' - }, btn: { - minWidth: 'initial', position: 'absolute', + minWidth: 'initial', width: 24, height: 24, right: 0, transform: 'translate(-4px, 4px)', + outlineWidth: 1, + outlineStyle: 'solid', + outlineColor: cssVar('$text'), zIndex: 1, }, copiedBtn: { @@ -54,21 +53,19 @@ export interface CopyableText { height?: S } -export const XCopyableText = ({ model }: { model: CopyableText }) => { +export const ClipboardCopyButton = ({ value }: { value: S }) => { const - { name, multiline, label, value, height } = model, - heightStyle = multiline && height === '1' ? fullHeightStyle : undefined, - ref = React.useRef(null), timeoutRef = React.useRef(), [copied, setCopied] = React.useState(false), - onClick = async () => { - const el = ref.current - if (!el) return + onClick = React.useCallback(async () => { try { if (document.queryCommandSupported('copy')) { + const el = document.createElement('textarea') + el.value = value + document.body.appendChild(el) el.select() document.execCommand('copy') - window.getSelection()?.removeAllRanges() + document.body.removeChild(el) } } catch (error) { await window.navigator.clipboard.writeText(value) @@ -76,32 +73,44 @@ export const XCopyableText = ({ model }: { model: CopyableText }) => { setCopied(true) timeoutRef.current = window.setTimeout(() => setCopied(false), 2000) - } + }, [value]) React.useEffect(() => () => window.clearTimeout(timeoutRef.current), []) + return ( + + ) +} + +export const XCopyableText = ({ model }: { model: CopyableText }) => { + const + { name, multiline, label, value, height } = model, + heightStyle = multiline && height === '1' ? fullHeightStyle : undefined + return (
{label} - +
} styles={{ root: { ...heightStyle, textFieldRoot: { position: 'relative', width: pc(100) }, - textFieldMultiline: multiline ? { '&:hover button': { opacity: 1 } } : undefined + textFieldMultiline: multiline ? { '& button': { opacity: 0 }, '&:hover button': { opacity: 1 } } : undefined }, wrapper: heightStyle, fieldGroup: heightStyle || { minHeight: height }, diff --git a/ui/src/markdown.test.tsx b/ui/src/markdown.test.tsx index 18c9a1fdda..bef040fadf 100644 --- a/ui/src/markdown.test.tsx +++ b/ui/src/markdown.test.tsx @@ -16,13 +16,15 @@ import { fireEvent, render } from '@testing-library/react' import React from 'react' import { Markdown } from './markdown' +const source = 'The quick brown [fox](?fox) jumps over the lazy [dog](dog).' + describe('Markdown.tsx', () => { // Jest JSDOM does not support event system, so we can only check if the event was dispatched. it('Dispatches a custom event when link prefixed with "?"', () => { const dispatchEventMock = jest.fn() window.dispatchEvent = dispatchEventMock - const { getByText } = render() + const { getByText } = render() fireEvent.click(getByText('fox')) expect(dispatchEventMock).toHaveBeenCalled() diff --git a/ui/src/markdown.tsx b/ui/src/markdown.tsx index e39bae85a0..5249feb702 100644 --- a/ui/src/markdown.tsx +++ b/ui/src/markdown.tsx @@ -16,7 +16,9 @@ import { Model, Rec, S, unpack } from 'h2o-wave' import hljs from 'highlight.js/lib/core' import MarkdownIt from 'markdown-it' import React from 'react' +import ReactDOM from 'react-dom' import { stylesheet } from 'typestyle' +import { ClipboardCopyButton } from './copyable_text' import { cards, grid, substitute } from './layout' import { border, clas, cssVar, padding, pc } from './theme' import { bond } from './ui' @@ -67,6 +69,24 @@ const }, }, }, + codeblock: { + position: 'relative', + $nest: { + '&:hover button': { + opacity: 1, + }, + } + }, + copyBtnWrapper: { + position: 'absolute', + right: 4, + top: 4, + $nest: { + button: { + opacity: 0, + }, + } + }, }) const highlightSyntax = async (str: S, language: S, codeBlockId: S) => { const codeBlock = document.getElementById(codeBlockId) @@ -91,6 +111,10 @@ const highlightSyntax = async (str: S, language: S, codeBlockId: S) => { : hljs.highlightAuto(str).value codeBlock.innerHTML = highlightedCode + const btnContainer = document.createElement('div') + btnContainer.classList.add(css.copyBtnWrapper) + ReactDOM.render(, codeBlock.appendChild(btnContainer)) + return highlightedCode } @@ -108,7 +132,7 @@ export const Markdown = ({ source }: { source: S }) => { setTimeout(async () => prevHighlights.current[+codeBlockId] = await highlightSyntax(str, lang, codeBlockId), 0) // TODO: Sanitize the HTML. - const ret = `${prevHighlights.current[codeBlockIdx.current] || str}` + const ret = `${prevHighlights.current[codeBlockIdx.current] || str}` codeBlockIdx.current++ return ret }