Skip to content

Commit

Permalink
Enhance link control UI with rich URL previews (#31464)
Browse files Browse the repository at this point in the history
* Add new helper to utilise new REST endpoint

* Provide docblock

* Provide docblock

* Get title tag contents from remote URL

* Create distinct data object for rich meta rather than rely on overiding suggestion result data.

* Add example of adding rich details to the link preview

* Add icon, image and description

* Add icon, image and description

* Add in description

* Remove url data to hook and prevent setting state on unmounted

* Avoid ref destructure

* Add loading animation

* Clear richdata on url change

* Avoid rich data in search items

* Account for preview layout variation requirements

* Only fetch if fetchRemoteUrlData is available

* Fix text overflow ellipsis

* Improve icon visual scaling

* Manage fetching as state

* Add tests for rich data and resolve exposed bugs

* Fix broken async/await code and restore test running

* Tidy code comments

* Updates to use visually truncated description text

* Clip image height, center and provide border-radius and background

* Apply correct spacing as per design provided

* Allow text to flow (almost) to edge of `Edit` button before ellipsis

* Constrain image height to 140px as per Figma design

* Add tests to cover missing data edge cases

* Fix cancel pending fetch when URL changes.

* Remove isMounted anti pattern in favour of explictly cancelled promise pattern

See https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html

* Update comments to make reason for cancelling on URL change clearer

* Fix broken tests by disabling rich reviews in those tests which do not exercise this feature

* Move make cancelable util into own file

* Allow for passing options to fetchRemoteUrlData

This enables the usage of AbortController signal

* Use AbortController to cancel requests rather than cancellable promise wrapper

* Force remount preview when the URL changes

Previously a URL might change and the component would not remount which could lead to edge cases.

Addresses #31464 (comment)

* Implement simpler hook via abortable fetch

Based on https://codesandbox.io/s/dry-pond-43qy2?file=/src/link-control.js:187-201

* Fix to ensure correct handling of aborted vs standard fetch errors

Avoid situation where requests which 404 do not result in the fetching UI being reset.

* Add test to cover resetting fetching state if rich data requests fails

* Simplify hook implementation

* Guard for legacy browsers

* Refactor to useReducer

* Disable rich previews by default and enable only in inline text by default

* Update packages/editor/src/components/provider/use-block-editor-settings.js

Co-authored-by: Kai Hao <[email protected]>

* Update to hasRichPreviews

Address feedback from #31464 (comment)

* Use correct prop to enable rich previews in rich text

* Fix e2e test to allow unlinking when rich data obscures block toolbar

* Add permenant missing data placeholders with loading animation

* Fix tests

* Update snapshots

* Update doc block

Co-authored-by: Kai Hao <[email protected]>

* Restore makecancellable to a local util

Co-authored-by: Kai Hao <[email protected]>
  • Loading branch information
getdave and kevin940726 authored Jun 10, 2021
1 parent 7709d7b commit 6530dfc
Show file tree
Hide file tree
Showing 10 changed files with 876 additions and 36 deletions.
3 changes: 3 additions & 0 deletions packages/block-editor/src/components/link-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ function LinkControl( {
suggestionsQuery = {},
noURLSuggestion = false,
createSuggestionButtonText,
hasRichPreviews = false,
} ) {
if ( withCreateSuggestion === undefined && createSuggestion ) {
withCreateSuggestion = true;
Expand Down Expand Up @@ -249,8 +250,10 @@ function LinkControl( {

{ value && ! isEditingLink && ! isCreatingPage && (
<LinkPreview
key={ value?.url } // force remount when URL changes to avoid race conditions for rich previews
value={ value }
onEditClick={ () => setIsEditingLink( true ) }
hasRichPreviews={ hasRichPreviews }
/>
) }

Expand Down
120 changes: 96 additions & 24 deletions packages/block-editor/src/components/link-control/link-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,49 +7,121 @@ import classnames from 'classnames';
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, ExternalLink } from '@wordpress/components';
import {
Button,
ExternalLink,
__experimentalText as Text,
} from '@wordpress/components';
import { filterURLForDisplay, safeDecodeURI } from '@wordpress/url';
import { Icon, globe } from '@wordpress/icons';

/**
* Internal dependencies
*/
import { ViewerSlot } from './viewer-slot';

export default function LinkPreview( { value, onEditClick } ) {
import useRemoteUrlData from './use-remote-url-data';

export default function LinkPreview( {
value,
onEditClick,
hasRichPreviews = false,
} ) {
// Avoid fetching if rich previews are not desired.
const maybeRemoteURL = hasRichPreviews ? value?.url : null;

const { richData, isFetching } = useRemoteUrlData( maybeRemoteURL );

// Rich data may be an empty object so test for that.
const hasRichData = richData && Object.keys( richData ).length;

const displayURL =
( value && filterURLForDisplay( safeDecodeURI( value.url ), 16 ) ) ||
'';

return (
<div
aria-label={ __( 'Currently selected' ) }
aria-selected="true"
className={ classnames( 'block-editor-link-control__search-item', {
'is-current': true,
'is-rich': hasRichData,
'is-fetching': !! isFetching,
'is-preview': true,
} ) }
>
<span className="block-editor-link-control__search-item-header">
<ExternalLink
className="block-editor-link-control__search-item-title"
href={ value.url }
>
{ ( value && value.title ) || displayURL }
</ExternalLink>
{ value && value.title && (
<span className="block-editor-link-control__search-item-info">
{ displayURL }
<div className="block-editor-link-control__search-item-top">
<span className="block-editor-link-control__search-item-header">
<span
className={ classnames(
'block-editor-link-control__search-item-icon',
{
'is-image': richData?.icon,
}
) }
>
{ richData?.icon ? (
<img src={ richData?.icon } alt="" />
) : (
<Icon icon={ globe } />
) }
</span>
<span className="block-editor-link-control__search-item-details">
<ExternalLink
className="block-editor-link-control__search-item-title"
href={ value.url }
>
{ richData?.title || value?.title || displayURL }
</ExternalLink>
{ value?.url && (
<span className="block-editor-link-control__search-item-info">
{ displayURL }
</span>
) }
</span>
) }
</span>

<Button
variant="secondary"
onClick={ () => onEditClick() }
className="block-editor-link-control__search-item-action"
>
{ __( 'Edit' ) }
</Button>
<ViewerSlot fillProps={ value } />
</span>

<Button
variant="secondary"
onClick={ () => onEditClick() }
className="block-editor-link-control__search-item-action"
>
{ __( 'Edit' ) }
</Button>
<ViewerSlot fillProps={ value } />
</div>

{ ( hasRichData || isFetching ) && (
<div className="block-editor-link-control__search-item-bottom">
<div
aria-hidden={ ! richData?.image }
className={ classnames(
'block-editor-link-control__search-item-image',
{
'is-placeholder': ! richData?.image,
}
) }
>
{ richData?.image && (
<img src={ richData?.image } alt="" />
) }
</div>
<div
aria-hidden={ ! richData?.description }
className={ classnames(
'block-editor-link-control__search-item-description',
{
'is-placeholder': ! richData?.description,
}
) }
>
{ richData?.description && (
<Text truncate numberOfLines="2">
{ richData.description }
</Text>
) }
</div>
</div>
) }
</div>
);
}
148 changes: 144 additions & 4 deletions packages/block-editor/src/components/link-control/style.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
$block-editor-link-control-number-of-actions: 1;
$preview-image-height: 140px;

@keyframes loadingpulse {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}

.block-editor-link-control {
position: relative;
Expand Down Expand Up @@ -141,31 +154,51 @@ $block-editor-link-control-number-of-actions: 1;
}

&.is-current {
flex-direction: column; // allow for stacking.
background: transparent;
border: 0;
width: 100%;
cursor: default;
padding: $grid-unit-20;
padding-left: $grid-unit-30;
}

.block-editor-link-control__search-item-header {
display: block;
flex-direction: row;
align-items: flex-start;
margin-right: $grid-unit-10;
overflow: hidden;
white-space: nowrap;
}

&.is-preview .block-editor-link-control__search-item-header {
display: flex;
flex: 1; // fill available space.
}

.block-editor-link-control__search-item-details {
overflow: hidden; // clip to force text ellipsis.
}

.block-editor-link-control__search-item-icon {
margin-right: 1em;
min-width: 24px;
position: relative;
top: 0.2em;
margin-right: $grid-unit-10;
width: 18px; // half of 32px to improve perceived resolution.
height: 18px; // half of 32px to improve perceived resolution.

svg,
img {
max-width: none;
width: 18px; // half of 32px to improve perceived resolution.
}
}


.block-editor-link-control__search-item-info,
.block-editor-link-control__search-item-title {
overflow: hidden;
text-overflow: ellipsis;
padding-right: $grid-unit-30;

.components-external-link__icon {
position: absolute;
Expand All @@ -189,6 +222,10 @@ $block-editor-link-control-number-of-actions: 1;
span {
font-weight: normal;
}

svg {
display: none; // specifically requested to be removed visually as well.
}
}

.block-editor-link-control__search-item-info {
Expand All @@ -206,6 +243,109 @@ $block-editor-link-control-number-of-actions: 1;
background-color: $gray-100;
border-radius: 2px;
}

.block-editor-link-control__search-item-description {
padding-top: 12px;
margin: 0;

&.is-placeholder {
margin-top: 12px;
padding-top: 0;
height: 28px;
display: flex;
flex-direction: column;
justify-content: space-around;

&::before,
&::after {
display: block;
content: "";
height: 0.7em;
width: 100%;
background-color: $gray-100;
border-radius: 3px;
}
}

.components-text {
font-size: 0.9em;
}
}

.block-editor-link-control__search-item-image {
display: flex;
width: 100%;
background-color: $gray-100;
justify-content: center;
height: $preview-image-height; // limit height
max-height: $preview-image-height; // limit height
overflow: hidden;
border-radius: 2px;
margin-top: 12px;

&.is-placeholder {
background-color: $gray-100;
border-radius: 3px;
}

img {
display: block; // remove unwanted space below image
max-width: 100%;
height: $preview-image-height; // limit height
max-height: $preview-image-height; // limit height
}
}
}

.block-editor-link-control__search-item-top {
display: flex;
flex-direction: row;
width: 100%; // clip.
}

.block-editor-link-control__search-item-bottom {
transition: opacity 1.5s;
min-height: 100px;
width: 100%;
}


.block-editor-link-control__search-item.is-fetching {

.block-editor-link-control__search-item-description {
&::before,
&::after {
animation: loadingpulse 1s linear infinite;
animation-delay: 0.5s; // avoid animating for fast network responses
}

}

.block-editor-link-control__search-item-image {
animation: loadingpulse 1s linear infinite;
animation-delay: 0.5s; // avoid animating for fast network responses
}

.block-editor-link-control__search-item-icon {
svg,
img {
opacity: 0;
}

&::before {
content: "";
display: block;
background-color: $gray-100;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 100%;
animation: loadingpulse 1s linear infinite;
animation-delay: 0.5s; // avoid animating for fast network responses
}
}
}

.block-editor-link-control__loading {
Expand Down
Loading

0 comments on commit 6530dfc

Please sign in to comment.