diff --git a/ui/src/copyable_text.test.tsx b/ui/src/copyable_text.test.tsx
index 73eb02b8b66..08f4147da76 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 1d293df1806..6c56b53df69 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 18c9a1fddac..bef040fadf0 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 e39bae85a05..5249feb702c 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
}