feat: make clippy button react

This commit is contained in:
EnixCoda 2019-11-13 13:45:33 +08:00
parent 943a2ccc4b
commit 7bee13c6c3
No known key found for this signature in database
GPG key ID: FE06F5DFC1C9B8E4
4 changed files with 85 additions and 86 deletions

33
src/components/Clippy.tsx Normal file
View file

@ -0,0 +1,33 @@
import * as React from 'react'
import { cx } from 'utils/cx'
import { copyElementContent } from 'utils/DOMHelper'
type Props = {
codeSnippetElement: Element
}
export function Clippy({ codeSnippetElement }: Props) {
const [status, setStatus] = React.useState<'normal' | 'success' | 'fail'>('normal')
React.useEffect(() => {
const timer = window.setTimeout(() => {
setStatus('normal')
}, 1000)
return () => window.clearTimeout(timer)
}, [status])
const onClippyClick = React.useCallback(function onClippyClick() {
if (copyElementContent(codeSnippetElement)) {
setStatus('success')
} else {
setStatus('fail')
}
}, [])
return (
<div className="clippy-wrapper">
<button className="clippy" onClick={onClippyClick}>
<i className={cx('icon', status)} />
</button>
</div>
)
}

View file

@ -59,14 +59,10 @@
background-image: url('~@primer/octicons/build/svg/clippy.svg?inline');
background-position: center;
background-repeat: no-repeat;
}
&.success {
.icon {
&.success {
background-image: url('~@primer/octicons/build/svg/check.svg?inline');
}
}
&.fail {
.icon {
&.fail {
background-image: url('~@primer/octicons/build/svg/x.svg?inline');
}
}

View file

@ -2,11 +2,13 @@
* this helper helps manipulating DOM
*/
import { raiseError } from 'analytics'
import { Clippy } from 'components/Clippy'
import { CopyFileButton } from 'components/CopyFileButton'
import * as NProgress from 'nprogress'
import * as PJAX from 'pjax'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { renderReact } from './general'
NProgress.configure({ showSpinner: false })
@ -37,10 +39,10 @@ function $<EE extends Element, E extends (element: EE) => any, O extends () => a
otherwise?: O,
): E extends never
? O extends never
? (Element | null)
? Element | null
: ReturnType<O> | null
: O extends never
? (ReturnType<E> | null)
? ReturnType<E> | null
: ReturnType<O> | ReturnType<E> {
const element = document.querySelector(selector)
if (element) {
@ -221,10 +223,8 @@ export function attachCopyFileBtn() {
/**
* copy content of a DOM element to clipboard
* @param {element} element
* @returns {boolean} whether copy is successful
*/
export function copyElementContent(element: Element) {
export function copyElementContent(element: Element): boolean {
let selection = window.getSelection()
if (selection) selection.removeAllRanges()
const range = document.createRange()
@ -237,54 +237,6 @@ export function copyElementContent(element: Element) {
return isCopySuccessful
}
/**
* create a copy file content button `clippy`
* once mouse enters a code snippet of markdown, move clippy into it
* user can copy the snippet's content by click it
*
* TODO: 'reactify' it
*/
function createClippy() {
function setTempClippyIconFeedback(clippy: Element, type: 'success' | 'fail') {
const tempIconClassName = type === 'success' ? 'success' : 'fail'
clippy.classList.add(tempIconClassName)
window.setTimeout(() => {
clippy.classList.remove(tempIconClassName)
}, 1000)
}
/**
* <div class="clippy-wrapper">
* <button class="clippy">
* <i class="octicon octicon-clippy" />
* </button>
* </div>
*/
const clippyWrapper = document.createElement('div')
clippyWrapper.classList.add('clippy-wrapper')
const clippy = document.createElement('button')
clippy.classList.add('clippy')
const clippyIcon = document.createElement('i')
clippyIcon.classList.add('icon')
clippyWrapper.appendChild(clippy)
clippy.appendChild(clippyIcon)
// set clipboard with current code snippet element's content
clippy.addEventListener('click', function onClippyClick() {
if (copyElementContent(currentCodeSnippetElement)) {
setTempClippyIconFeedback(clippy, 'success')
} else {
setTempClippyIconFeedback(clippy, 'fail')
}
})
return clippyWrapper
}
const clippy = createClippy()
let currentCodeSnippetElement: Element
export function attachCopySnippet() {
const readmeSelector = '.repository-content div#readme'
return $(readmeSelector, () => {
@ -292,12 +244,15 @@ export function attachCopySnippet() {
$(
readmeArticleSelector,
readmeElement =>
readmeElement.addEventListener('mouseover', e => {
readmeElement.addEventListener('mouseover', async e => {
// only move clippy when mouse is over a new snippet(<pre>)
const target = e.target as Element
if (target.nodeName === 'PRE') {
if (currentCodeSnippetElement !== target) {
currentCodeSnippetElement = target
if (
target.previousSibling === null ||
!(target.previousSibling instanceof Element) ||
!target.previousSibling.classList.contains('clippy-wrapper')
) {
/**
* <article>
* <pre></pre> <!-- case A -->
@ -306,7 +261,12 @@ export function attachCopySnippet() {
* </div>
* </article>
*/
if (target.parentNode) target.parentNode.insertBefore(clippy, target)
if (target.parentNode) {
const clippyElement = await renderReact(
React.createElement(Clippy, { codeSnippetElement: target }),
)
target.parentNode.insertBefore(clippyElement, target)
}
}
}
}),

View file

@ -1,16 +1,15 @@
import { ReactElement } from 'react'
import * as ReactDOM from 'react-dom'
import { TreeNode } from './VisibleNodesGenerator'
export function pick<T>(source: T, keys: string[]): Partial<T> {
if (keys && typeof keys === 'object') {
return (Array.isArray(keys) ? keys : Object.keys(keys)).reduce(
(copy, key) => {
if (key in source) {
copy[key as keyof T] = source[key as keyof T]
}
return copy
},
{} as Partial<T>,
)
return (Array.isArray(keys) ? keys : Object.keys(keys)).reduce((copy, key) => {
if (key in source) {
copy[key as keyof T] = source[key as keyof T]
}
return copy
}, {} as Partial<T>)
}
return {} as Partial<T>
}
@ -114,20 +113,22 @@ export function parseURLSearch(search: string = window.location.search) {
}
export async function JSONRequest(url: string, data: any, extra: RequestInit = { method: 'post' }) {
return (await fetch(url, {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
method: extra.method || 'post',
body: JSON.stringify(data),
...extra,
})).json()
return (
await fetch(url, {
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
method: extra.method || 'post',
body: JSON.stringify(data),
...extra,
})
).json()
}
export function searchKeyToRegexps(searchKey: string) {
@ -140,3 +141,12 @@ export function searchKeyToRegexps(searchKey: string) {
return [/$^/] // matching nothing if failed transforming regexp
}
}
export async function renderReact(element: ReactElement) {
return new Promise<Node>(resolve => {
const mount = document.createElement('div')
ReactDOM.render(element, mount, () => {
resolve(mount.childNodes[0])
})
})
}