-
Notifications
You must be signed in to change notification settings - Fork 4.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Link images in media + text #18139
Link images in media + text #18139
Changes from all commits
8f224c9
6160355
f5c05bc
219ddf3
ab7f850
7deb95e
0f98beb
d801786
01d2dde
b4d11ea
018b10f
9a61d11
9a8ba09
46fe0c7
ddffb98
3436fa4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,312 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import { find, isEmpty, each, map } from 'lodash'; | ||
|
||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { __ } from '@wordpress/i18n'; | ||
import { useRef, useState, useCallback } from '@wordpress/element'; | ||
import { | ||
IconButton, | ||
NavigableMenu, | ||
MenuItem, | ||
ToggleControl, | ||
TextControl, | ||
SVG, | ||
Path, | ||
} from '@wordpress/components'; | ||
import { | ||
LEFT, | ||
RIGHT, | ||
UP, | ||
DOWN, | ||
BACKSPACE, | ||
ENTER, | ||
} from '@wordpress/keycodes'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import URLPopover from './index'; | ||
|
||
const LINK_DESTINATION_NONE = 'none'; | ||
const LINK_DESTINATION_CUSTOM = 'custom'; | ||
const LINK_DESTINATION_MEDIA = 'media'; | ||
const LINK_DESTINATION_ATTACHMENT = 'attachment'; | ||
const NEW_TAB_REL = [ 'noreferrer', 'noopener' ]; | ||
|
||
const icon = <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0,0h24v24H0V0z" fill="none" /><Path d="m19 5v14h-14v-14h14m0-2h-14c-1.1 0-2 0.9-2 2v14c0 1.1 0.9 2 2 2h14c1.1 0 2-0.9 2-2v-14c0-1.1-0.9-2-2-2z" /><Path d="m14.14 11.86l-3 3.87-2.14-2.59-3 3.86h12l-3.86-5.14z" /></SVG>; | ||
|
||
const ImageURLInputUI = ( { | ||
linkDestination, | ||
onChangeUrl, | ||
url, | ||
mediaType = 'image', | ||
mediaUrl, | ||
mediaLink, | ||
linkTarget, | ||
linkClass, | ||
rel, | ||
} ) => { | ||
const [ isOpen, setIsOpen ] = useState( false ); | ||
const openLinkUI = useCallback( () => { | ||
setIsOpen( true ); | ||
} ); | ||
|
||
const [ isEditingLink, setIsEditingLink ] = useState( false ); | ||
const [ urlInput, setUrlInput ] = useState( null ); | ||
|
||
const autocompleteRef = useRef( null ); | ||
|
||
const stopPropagation = ( event ) => { | ||
event.stopPropagation(); | ||
}; | ||
|
||
const stopPropagationRelevantKeys = ( event ) => { | ||
if ( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ].indexOf( event.keyCode ) > -1 ) { | ||
// Stop the key event from propagating up to ObserveTyping.startTypingInTextField. | ||
event.stopPropagation(); | ||
} | ||
}; | ||
|
||
const startEditLink = useCallback( () => { | ||
if ( linkDestination === LINK_DESTINATION_MEDIA || | ||
linkDestination === LINK_DESTINATION_ATTACHMENT | ||
) { | ||
setUrlInput( '' ); | ||
} | ||
setIsEditingLink( true ); | ||
} ); | ||
|
||
const stopEditLink = useCallback( () => { | ||
setIsEditingLink( false ); | ||
} ); | ||
|
||
const closeLinkUI = useCallback( () => { | ||
setUrlInput( null ); | ||
stopEditLink(); | ||
setIsOpen( false ); | ||
} ); | ||
|
||
const removeNewTabRel = ( currentRel ) => { | ||
let newRel = currentRel; | ||
|
||
if ( currentRel !== undefined && ! isEmpty( newRel ) ) { | ||
if ( ! isEmpty( newRel ) ) { | ||
each( NEW_TAB_REL, function( relVal ) { | ||
const regExp = new RegExp( '\\b' + relVal + '\\b', 'gi' ); | ||
newRel = newRel.replace( regExp, '' ); | ||
} ); | ||
|
||
// Only trim if NEW_TAB_REL values was replaced. | ||
if ( newRel !== currentRel ) { | ||
newRel = newRel.trim(); | ||
} | ||
|
||
if ( isEmpty( newRel ) ) { | ||
newRel = undefined; | ||
} | ||
} | ||
} | ||
|
||
return newRel; | ||
}; | ||
|
||
const getUpdatedLinkTargetSettings = ( value ) => { | ||
const newLinkTarget = value ? '_blank' : undefined; | ||
|
||
let updatedRel; | ||
if ( ! newLinkTarget && ! rel ) { | ||
updatedRel = undefined; | ||
} else { | ||
updatedRel = removeNewTabRel( rel ); | ||
} | ||
|
||
return { | ||
linkTarget: newLinkTarget, | ||
rel: updatedRel, | ||
}; | ||
}; | ||
|
||
const onFocusOutside = useCallback( () => { | ||
return ( event ) => { | ||
// The autocomplete suggestions list renders in a separate popover (in a portal), | ||
// so onFocusOutside fails to detect that a click on a suggestion occurred in the | ||
// LinkContainer. Detect clicks on autocomplete suggestions using a ref here, and | ||
// return to avoid the popover being closed. | ||
const autocompleteElement = autocompleteRef.current; | ||
if ( autocompleteElement && autocompleteElement.contains( event.target ) ) { | ||
return; | ||
} | ||
setIsOpen( false ); | ||
setUrlInput( null ); | ||
stopEditLink(); | ||
}; | ||
} ); | ||
|
||
const onSubmitLinkChange = useCallback( () => { | ||
return ( event ) => { | ||
if ( urlInput ) { | ||
onChangeUrl( { href: urlInput } ); | ||
} | ||
stopEditLink(); | ||
setUrlInput( null ); | ||
event.preventDefault(); | ||
}; | ||
} ); | ||
|
||
const onLinkRemove = useCallback( () => { | ||
onChangeUrl( { | ||
linkDestination: LINK_DESTINATION_NONE, | ||
href: '', | ||
} ); | ||
} ); | ||
|
||
const getLinkDestinations = () => { | ||
return [ | ||
{ | ||
linkDestination: LINK_DESTINATION_MEDIA, | ||
title: __( 'Media File' ), | ||
url: mediaType === 'image' ? mediaUrl : undefined, | ||
icon, | ||
}, | ||
{ | ||
linkDestination: LINK_DESTINATION_ATTACHMENT, | ||
title: __( 'Attachment Page' ), | ||
url: mediaType === 'image' ? mediaLink : undefined, | ||
icon: <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path d="M0 0h24v24H0V0z" fill="none" /><Path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z" /></SVG>, | ||
}, | ||
]; | ||
}; | ||
|
||
const onSetHref = ( value ) => { | ||
const linkDestinations = getLinkDestinations(); | ||
let linkDestinationInput; | ||
if ( ! value ) { | ||
linkDestinationInput = LINK_DESTINATION_NONE; | ||
} else { | ||
linkDestinationInput = ( | ||
find( linkDestinations, ( destination ) => { | ||
return destination.url === value; | ||
} ) || | ||
{ linkDestination: LINK_DESTINATION_CUSTOM } | ||
).linkDestination; | ||
} | ||
onChangeUrl( { | ||
linkDestination: linkDestinationInput, | ||
href: value, | ||
} ); | ||
}; | ||
|
||
const onSetNewTab = ( value ) => { | ||
const updatedLinkTarget = getUpdatedLinkTargetSettings( value ); | ||
onChangeUrl( updatedLinkTarget ); | ||
}; | ||
|
||
const onSetLinkRel = ( value ) => { | ||
onChangeUrl( { rel: value } ); | ||
}; | ||
|
||
const onSetLinkClass = ( value ) => { | ||
onChangeUrl( { linkClass: value } ); | ||
}; | ||
|
||
const advancedOptions = ( | ||
<> | ||
<ToggleControl | ||
label={ __( 'Open in New Tab' ) } | ||
onChange={ onSetNewTab } | ||
checked={ linkTarget === '_blank' } /> | ||
<TextControl | ||
label={ __( 'Link Rel' ) } | ||
value={ removeNewTabRel( rel ) || '' } | ||
onChange={ onSetLinkRel } | ||
onKeyPress={ stopPropagation } | ||
onKeyDown={ stopPropagationRelevantKeys } | ||
/> | ||
<TextControl | ||
label={ __( 'Link CSS Class' ) } | ||
value={ linkClass || '' } | ||
onKeyPress={ stopPropagation } | ||
onKeyDown={ stopPropagationRelevantKeys } | ||
onChange={ onSetLinkClass } | ||
/> | ||
</> | ||
); | ||
|
||
const linkEditorValue = urlInput !== null ? urlInput : url; | ||
|
||
const urlLabel = ( find( getLinkDestinations(), [ 'linkDestination', linkDestination ] ) || {} ).title; | ||
|
||
return ( | ||
<> | ||
<IconButton | ||
icon="admin-links" | ||
className="components-toolbar__control" | ||
label={ url ? __( 'Edit link' ) : __( 'Insert link' ) } | ||
aria-expanded={ isOpen } | ||
onClick={ openLinkUI } | ||
/> | ||
{ isOpen && ( | ||
<URLPopover | ||
onFocusOutside={ onFocusOutside() } | ||
onClose={ closeLinkUI } | ||
renderSettings={ () => advancedOptions } | ||
additionalControls={ ! linkEditorValue && ( | ||
<NavigableMenu> | ||
{ | ||
map( getLinkDestinations(), ( link ) => ( | ||
<MenuItem | ||
key={ link.linkDestination } | ||
icon={ link.icon } | ||
onClick={ () => { | ||
setUrlInput( null ); | ||
onSetHref( link.url ); | ||
stopEditLink(); | ||
} } | ||
> | ||
{ link.title } | ||
</MenuItem> | ||
) ) | ||
} | ||
</NavigableMenu> | ||
) } | ||
> | ||
{ ( ! url || isEditingLink ) && ( | ||
<URLPopover.LinkEditor | ||
className="editor-format-toolbar__link-container-content block-editor-format-toolbar__link-container-content" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not a valid class name for this component: It violates the guideline to not inherit styles from other compoennts:
It also introduces additional legacy compatibility class names intended to have been removed as part of #19050 (though I also note that this is problematic in the |
||
value={ linkEditorValue } | ||
onChangeInputValue={ setUrlInput } | ||
onKeyDown={ stopPropagationRelevantKeys } | ||
onKeyPress={ stopPropagation } | ||
onSubmit={ onSubmitLinkChange() } | ||
autocompleteRef={ autocompleteRef } | ||
/> | ||
) } | ||
{ ( url && ! isEditingLink ) && ( | ||
<> | ||
<URLPopover.LinkViewer | ||
className="editor-format-toolbar__link-container-content block-editor-format-toolbar__link-container-content" | ||
onKeyPress={ stopPropagation } | ||
url={ url } | ||
onEditLinkClick={ startEditLink } | ||
urlLabel={ urlLabel } | ||
/> | ||
<IconButton | ||
icon="no" | ||
label={ __( 'Remove link' ) } | ||
onClick={ onLinkRemove } | ||
/> | ||
</> | ||
) } | ||
</URLPopover> | ||
) } | ||
</> | ||
); | ||
}; | ||
|
||
export { | ||
ImageURLInputUI as __experimentalImageURLInputUI, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a blocker for this PR, but I think we should create a readme and add some test cases for this component.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this will be deprecated by the new LinkEditor.