Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 2 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/compose/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@
"@wordpress/priority-queue": "file:../priority-queue",
"@wordpress/undo-manager": "file:../undo-manager",
"change-case": "^4.1.2",
"clipboard": "^2.0.11",
"mousetrap": "^1.6.5",
"use-memo-one": "^1.1.1"
},
Expand Down
79 changes: 49 additions & 30 deletions packages/compose/src/hooks/use-copy-on-click/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/**
* External dependencies
* WordPress dependencies
*/
import Clipboard from 'clipboard';
import { useEffect, useState } from '@wordpress/element';
import deprecated from '@wordpress/deprecated';

/**
* WordPress dependencies
* Internal dependencies
*/
import { useRef, useEffect, useState } from '@wordpress/element';
import deprecated from '@wordpress/deprecated';
import { clearSelection, copyToClipboard } from '../use-copy-to-clipboard';

/**
* Copies the text to the clipboard when the element is clicked.
Expand All @@ -28,48 +28,67 @@ export default function useCopyOnClick( ref, text, timeout = 4000 ) {
alternative: 'wp.compose.useCopyToClipboard',
} );

/** @type {React.MutableRefObject<Clipboard | undefined>} */
const clipboardRef = useRef( undefined );
const [ hasCopied, setHasCopied ] = useState( false );

useEffect( () => {
/** @type {number | undefined} */
let timeoutId;

if ( ! ref.current ) {
return;
}

// Clipboard listens to click events.
clipboardRef.current = new Clipboard( ref.current, {
text: () => ( typeof text === 'function' ? text() : text ),
} );

clipboardRef.current.on( 'success', ( { clearSelection, trigger } ) => {
// Clearing selection will move focus back to the triggering button,
// ensuring that it is not reset to the body, and further that it is
// kept within the rendered node.
clearSelection();
let targets;
if ( typeof ref.current === 'string' ) {
targets =
typeof document !== 'undefined'
? Array.from( document.querySelectorAll( ref.current ) )
: [];
} else if (
'length' in ref.current &&
typeof ref.current.length === 'number'
) {
targets = Array.from( ref.current );
} else {
targets = [ /** @type {Element} */ ( ref.current ) ];
}

// Handle ClipboardJS focus bug, see https://github.com/zenorocha/clipboard.js/issues/680
if ( trigger ) {
/** @type {HTMLElement} */ ( trigger ).focus();
}
if ( targets.length === 0 ) {
return;
}

if ( timeout ) {
setHasCopied( true );
clearTimeout( timeoutId );
timeoutId = setTimeout( () => setHasCopied( false ), timeout );
const handleClick = ( /** @type {Event} */ event ) => {
const trigger = /** @type {Element} */ ( event.currentTarget );
if ( ! trigger ) {
return;
}
} );
copyToClipboard(
typeof text === 'function' ? text() : text || '',
trigger
).then( ( success ) => {
if ( success ) {
clearSelection( trigger );
if ( timeout ) {
setHasCopied( true );
clearTimeout( timeoutId );
timeoutId = setTimeout(
() => setHasCopied( false ),
timeout
);
}
}
} );
};

for ( const target of targets ) {
target.addEventListener( 'click', handleClick );
}
return () => {
if ( clipboardRef.current ) {
clipboardRef.current.destroy();
for ( const target of targets ) {
target.removeEventListener( 'click', handleClick );
}
clearTimeout( timeoutId );
};
}, [ text, timeout, setHasCopied ] );
}, [ ref, text, timeout ] );

return hasCopied;
}
87 changes: 62 additions & 25 deletions packages/compose/src/hooks/use-copy-to-clipboard/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
/**
* External dependencies
*/
import Clipboard from 'clipboard';

/**
* WordPress dependencies
*/
Expand All @@ -13,6 +8,56 @@ import { useRef, useLayoutEffect } from '@wordpress/element';
*/
import useRefEffect from '../use-ref-effect';

/**
* Copies text to the clipboard using the Clipboard API when available,
* with a fallback for non-secure contexts (e.g. HTTP) and older browsers.
*
* @param {string} text The text to copy.
* @param {Element|null} trigger The element that triggered the copy. Required for
Comment thread
ciampo marked this conversation as resolved.
Resolved
* @return {Promise<boolean>} Resolves to true if successful, false otherwise.
*/
export async function copyToClipboard( text, trigger ) {
if ( ! text || ! trigger ) {
return false;
}
Comment thread
ciampo marked this conversation as resolved.
Resolved
const { ownerDocument } = trigger;
if ( ! ownerDocument ) {
return false;
}
const { defaultView } = ownerDocument;
try {
if ( defaultView?.navigator?.clipboard?.writeText ) {
await defaultView.navigator.clipboard.writeText( text );
return true;
}
// Fallback for non-secure contexts (HTTP) and older browsers.
const textarea = ownerDocument.createElement( 'textarea' );
textarea.value = text;
textarea.setAttribute( 'readonly', '' );
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
ownerDocument.body.appendChild( textarea );
textarea.select();
const success = ownerDocument.execCommand( 'copy' );
textarea.remove();
return success;
} catch {
return false;
}
}

/**
* Clears the current selection and restores focus to the trigger element.
*
* @param {Element} trigger The element that triggered the copy.
*/
export function clearSelection( trigger ) {
if ( 'focus' in trigger && typeof trigger.focus === 'function' ) {
trigger.focus();
}
trigger.ownerDocument?.defaultView?.getSelection()?.removeAllRanges();
}

/**
* @template T
* @param {T} value
Expand All @@ -37,33 +82,25 @@ function useUpdatedRef( value ) {
* @return {React.Ref<TElementType>} A ref to assign to the target element.
*/
export default function useCopyToClipboard( text, onSuccess ) {
// Store the dependencies as refs and continuously update them so they're
// fresh when the callback is called.
const textRef = useUpdatedRef( text );
const onSuccessRef = useUpdatedRef( onSuccess );
return useRefEffect( ( node ) => {
// Clipboard listens to click events.
const clipboard = new Clipboard( node, {
text() {
return typeof textRef.current === 'function'
const handleClick = async () => {
const textToCopy =
typeof textRef.current === 'function'
? textRef.current()
: textRef.current || '';
},
} );

clipboard.on( 'success', ( { clearSelection } ) => {
// Clearing selection will move focus back to the triggering
// button, ensuring that it is not reset to the body, and
// further that it is kept within the rendered node.
clearSelection();

if ( onSuccessRef.current ) {
onSuccessRef.current();
const success = await copyToClipboard( textToCopy, node );
if ( success ) {
clearSelection( node );
if ( onSuccessRef.current ) {
onSuccessRef.current();
}
}
} );

};
node.addEventListener( 'click', handleClick );
return () => {
clipboard.destroy();
node.removeEventListener( 'click', handleClick );
};
}, [] );
}
Loading