From 7bee13c6c3177cefc5b6031f0430a1c85424f1d4 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Wed, 13 Nov 2019 13:45:33 +0800 Subject: [PATCH] feat: make clippy button react --- src/components/Clippy.tsx | 33 +++++++++++++++++ src/content.less | 8 ++--- src/utils/DOMHelper.ts | 74 +++++++++------------------------------ src/utils/general.ts | 56 +++++++++++++++++------------ 4 files changed, 85 insertions(+), 86 deletions(-) create mode 100644 src/components/Clippy.tsx diff --git a/src/components/Clippy.tsx b/src/components/Clippy.tsx new file mode 100644 index 0000000..bd397f1 --- /dev/null +++ b/src/components/Clippy.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/src/content.less b/src/content.less index b5b97c8..b0ac42f 100644 --- a/src/content.less +++ b/src/content.less @@ -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'); } } diff --git a/src/utils/DOMHelper.ts b/src/utils/DOMHelper.ts index 19dab59..46d6c47 100644 --- a/src/utils/DOMHelper.ts +++ b/src/utils/DOMHelper.ts @@ -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 $ any, O extends () => a otherwise?: O, ): E extends never ? O extends never - ? (Element | null) + ? Element | null : ReturnType | null : O extends never - ? (ReturnType | null) + ? ReturnType | null : ReturnType | ReturnType { 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) - } - - /** - *
- * - *
- */ - 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(
)
           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')
+            ) {
               /**
                *  
*
     
@@ -306,7 +261,12 @@ export function attachCopySnippet() {
                *    
                *  
*/ - 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) + } } } }), diff --git a/src/utils/general.ts b/src/utils/general.ts index 9fc3281..cde3a0d 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -1,16 +1,15 @@ +import { ReactElement } from 'react' +import * as ReactDOM from 'react-dom' import { TreeNode } from './VisibleNodesGenerator' export function pick(source: T, keys: string[]): Partial { 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, - ) + 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) } return {} as Partial } @@ -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(resolve => { + const mount = document.createElement('div') + ReactDOM.render(element, mount, () => { + resolve(mount.childNodes[0]) + }) + }) +}