Add richText interpolation for breaking long words with spans

This commit is contained in:
Vesa Luusua 2018-03-22 16:14:57 +02:00
parent 1d6d88bdab
commit c75b1ed0ba
9 changed files with 509 additions and 29 deletions

View file

@ -120,3 +120,11 @@
margin-bottom: 0;
}
}
.longWord {
/* fallback option */
word-break: break-all;
/* use break-word if available */
word-break: break-word;
hyphens: auto;
}

View file

@ -6,12 +6,13 @@ import { NamedLink, ResponsiveImage } from '../../components';
import { propTypes } from '../../util/types';
import { formatMoney } from '../../util/currency';
import { ensureListing, ensureUser } from '../../util/data';
import { richText } from '../../util/richText';
import { createSlug } from '../../util/urlHelpers';
import config from '../../config';
import css from './ListingCard.css';
const MAX_LENGTH_FOR_WORDS_IN_TITLE = 10;
const MIN_LENGTH_FOR_LONG_WORDS = 10;
const priceData = (price, intl) => {
if (price && price.currency === config.currency) {
@ -32,23 +33,6 @@ const priceData = (price, intl) => {
return {};
};
// Cards are not fixed sizes - So, long words in title make flexboxed items to grow too big.
// 1. We split title to an array of words and spaces.
// "foo bar".split(/([^\s]+)/gi) => ["", "foo", " ", "bar", ""]
// 2. Then we break long words by adding a '<span>' with word-break: 'break-all';
const formatTitle = (title, maxLength) => {
const nonWhiteSpaceSequence = /([^\s]+)/gi;
return title.split(nonWhiteSpaceSequence).map((word, index) => {
return word.length > maxLength ? (
<span key={index} style={{ wordBreak: 'break-all' }}>
{word}
</span>
) : (
word
);
});
};
export const ListingCardComponent = props => {
const { className, rootClassName, intl, listing, setActiveListing } = props;
const classes = classNames(rootClassName || css.root, className);
@ -92,7 +76,12 @@ export const ListingCardComponent = props => {
</div>
</div>
<div className={css.mainInfo}>
<div className={css.title}>{formatTitle(title, MAX_LENGTH_FOR_WORDS_IN_TITLE)}</div>
<div className={css.title}>
{richText(title, {
longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS,
longWordClass: css.longWord,
})}
</div>
<div className={css.authorInfo}>
<FormattedMessage
className={css.authorName}

View file

@ -0,0 +1,7 @@
.longWord {
/* fallback option */
word-break: break-all;
/* use break-word if available */
word-break: break-word;
hyphens: auto;
}

View file

@ -8,9 +8,14 @@
*/
import React from 'react';
import { string, oneOfType, node } from 'prop-types';
import { NamedLink } from '../../components';
import { richText } from '../../util/richText';
import { LISTING_STATE_PENDING_APPROVAL, propTypes } from '../../util/types';
import { LISTING_PAGE_PENDING_APPROVAL_VARIANT, createSlug } from '../../util/urlHelpers';
import { NamedLink } from '../../components';
import css from './ListingLink.css';
const MIN_LENGTH_FOR_LONG_WORDS = 16;
const ListingLink = props => {
const { className, listing, children } = props;
@ -21,6 +26,15 @@ const ListingLink = props => {
const id = listing.id.uuid;
const { title, state } = listing.attributes;
const slug = createSlug(title);
const richTitle = (
<span>
{richText(title, {
longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS,
longWordClass: css.longWord,
})}
</span>
);
const isPendingApproval = state === LISTING_STATE_PENDING_APPROVAL;
const linkProps = isPendingApproval
? {
@ -37,7 +51,7 @@ const ListingLink = props => {
};
return (
<NamedLink className={className} {...linkProps}>
{children ? children : listing.attributes.title || ''}
{children ? children : richTitle || ''}
</NamedLink>
);
};

View file

@ -744,3 +744,11 @@
padding: 15px 24px 15px 24px;
text-align: center;
}
.longWord {
/* fallback option */
word-break: break-all;
/* use break-word if available */
word-break: break-word;
hyphens: auto;
}

View file

@ -14,6 +14,7 @@ import { LISTING_PAGE_PENDING_APPROVAL_VARIANT, createSlug, parse } from '../../
import { formatMoney } from '../../util/currency';
import { createResourceLocatorString, findRouteByRouteName } from '../../util/routes';
import { ensureListing, ensureOwnListing, ensureUser, userDisplayName } from '../../util/data';
import { richText } from '../../util/richText';
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
import {
@ -47,6 +48,8 @@ import css from './ListingPage.css';
// This defines when ModalInMobile shows content as Modal
const MODAL_BREAKPOINT = 1023;
const MIN_LENGTH_FOR_LONG_WORDS_IN_TITLE = 16;
const MIN_LENGTH_FOR_LONG_WORDS_IN_DESCRIPTION = 20;
const { UUID } = sdkTypes;
@ -292,6 +295,15 @@ export class ListingPageComponent extends Component {
publicData,
} = currentListing.attributes;
const richTitle = (
<span>
{richText(title, {
longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS_IN_TITLE,
longWordClass: css.longWord,
})}
</span>
);
const category =
publicData && publicData.category ? (
<span>
@ -393,7 +405,7 @@ export class ListingPageComponent extends Component {
const bookingHeading = (
<div className={css.bookingHeading}>
<h2 className={css.bookingTitle}>
<FormattedMessage id="ListingPage.bookingTitle" values={{ title }} />
<FormattedMessage id="ListingPage.bookingTitle" values={{ title: richTitle }} />
</h2>
<div className={css.bookingHelp}>
<FormattedMessage
@ -564,7 +576,7 @@ export class ListingPageComponent extends Component {
</div>
</div>
<div className={css.heading}>
<h1 className={css.title}>{title}</h1>
<h1 className={css.title}>{richTitle}</h1>
<div className={css.author}>
{category}
<FormattedMessage id="ListingPage.hostedBy" values={{ name: hostLink }} />
@ -587,7 +599,12 @@ export class ListingPageComponent extends Component {
<h2 className={css.descriptionTitle}>
<FormattedMessage id="ListingPage.descriptionTitle" />
</h2>
<p className={css.description}>{description}</p>
<p className={css.description}>
{richText(description, {
longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS_IN_DESCRIPTION,
longWordClass: css.longWord,
})}
</p>
</div>
<div className={css.featuresContainer}>
@ -663,7 +680,7 @@ export class ListingPageComponent extends Component {
onManageDisableScrolling={onManageDisableScrolling}
>
<div className={css.modalHeading}>
<h1 className={css.title}>{title}</h1>
<h1 className={css.title}>{richTitle}</h1>
<div className={css.author}>
<span className={css.authorName}>
<FormattedMessage

View file

@ -217,7 +217,11 @@ exports[`ListingPage matches snapshot 1`] = `
</div>
<div>
<h1>
listing1 title
<span>
listing1
title
</span>
</h1>
<div>
<FormattedMessage
@ -267,7 +271,9 @@ exports[`ListingPage matches snapshot 1`] = `
/>
</h2>
<p>
listing1 description
listing1
description
</p>
</div>
<div>
@ -431,7 +437,11 @@ exports[`ListingPage matches snapshot 1`] = `
>
<div>
<h1>
listing1 title
<span>
listing1
title
</span>
</h1>
<div>
<span>
@ -452,7 +462,11 @@ exports[`ListingPage matches snapshot 1`] = `
id="ListingPage.bookingTitle"
values={
Object {
"title": "listing1 title",
"title": <span>
listing1
title
</span>,
}
}
/>

135
src/util/richText.js Normal file
View file

@ -0,0 +1,135 @@
import React from 'react';
import { chain } from 'lodash';
import { ExternalLink } from '../components';
/**
* Add zero width space (zwsp) around given breakchars (default '/') to make word break possible.
* E.g. "one/two/three" => ["one", "/", "two" "/" "three"]
*
* @param {string} wordToBreak word to be broken from special character points.
* @param {string} breakChars string containing possible chars that can be surrounded with zwsp.
* @return {Array<string>} returns an array containing strings-
*/
export const zwspAroundSpecialCharsSplit = (wordToBreak, breakChars = '/') => {
if (typeof wordToBreak !== 'string') {
return wordToBreak;
}
const bcArray = breakChars.split('');
// Escape special regular expression chars
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
const escapedBCArray = bcArray.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const reSplit = new RegExp('([' + escapedBCArray.join('') + '])');
const zwsp = '';
return wordToBreak.split(reSplit).map(w => (bcArray.includes(w) ? `${zwsp}${w}${zwsp}` : w));
};
/**
* Layouts are not fixed sizes - So, long words in text make flexboxed items to grow too big.
* This wraps long words with span and adds given class to it
*
* @param {string} word to be wrapped if requirement (longWordMinLength) is met
* @param {number} key span needs a key in React/JSX
* @param {number} longWordMinLength minimum length when word is considered long
* @param {string} longWordClass class to be added to spans
* @return {node} returns a string or component
*/
export const wrapLongWord = (word, key, options = {}) => {
const { longWordMinLength, longWordClass } = options;
if (typeof word !== 'string' || !(longWordMinLength && longWordClass)) {
return word;
}
const isShortWord = word.length <= longWordMinLength;
return isShortWord ? (
word
) : (
<span key={key} className={longWordClass}>
{word}
</span>
);
};
/**
* Find links from words and surround them with <ExternalLink> component
*
* @param {string} word to be linkified if requirement (link) is met
* @param {number} key span needs a key in React/JSX
* @param {Object} options than can contain keys: linkify, linkClass.
* @return {Array<node>} returns a array containing ExternalLink component or strings
*/
export const linkifyOrWrapLinkSplit = (word, key, options = {}) => {
if (typeof word !== 'string') {
return word;
}
const { linkify, linkClass } = options;
// TODO This can't handle links that contain parenthesis:
// '(http://example.org/path_(etc))'
// Currently extracts:
// '(<a href=\"http://example.org/path_\" ...>http://example.org/path_</a>(etc))'
//
// We need to
// 1) track whether token before link contains parenthesis as a last character
// before link token ("word.split(urlRegex)[linkIndex - 1]") and
// 2) add enough characters to the end of link-token from the next token
// after link ("word.split(urlRegex)[linkIndex + 1]")
// urlRegex modified from examples in
// https://stackoverflow.com/questions/1500260/detect-urls-in-text-with-javascript
// eslint-disable-next-line no-useless-escape
const urlRegex = /(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi;
if (word.match(urlRegex)) {
// Split strings like "(http://www.example.com)" to ["(","http://www.example.com",")"]
return word.split(urlRegex).map(w => {
return !w.match(urlRegex) ? (
w
) : linkify ? (
<ExternalLink key={key} href={w} className={linkClass}>
{w}
</ExternalLink>
) : linkClass ? (
<span key={key} className={linkClass}>
{w}
</span>
) : (
w
);
});
} else {
return word;
}
};
/**
* Scan text to fill in wrappers for long words and add links.
* Wrap long words: options should contain longWordMinLength & longWordClass
* Linkify found links: options should contain "linkify: true" (linkClass is optional)
*
* @param {string} text check text content
* @param {object} options { longWordMinLength, longWordClass, linkify = false, linkClass }
* @return {Array<node>} returns a child array containing strings and inline elements
*/
export const richText = (text, options) => {
if (typeof text !== 'string') {
return text;
}
// longWordMinLength & longWordClass are needed for long words to be spanned
// linkify = true is needed for links to be linkified (linkClass is optional)
const { longWordMinLength, longWordClass, linkify = false, linkClass } = options;
const linkOrLongWordClass = linkClass ? linkClass : longWordClass;
const nonWhiteSpaceSequence = /([^\s]+)/gi;
return text.split(nonWhiteSpaceSequence).reduce((acc, nextChild, i) => {
const parts = chain([nextChild])
.flatMap(w => linkifyOrWrapLinkSplit(w, i, { linkify, linkClass: linkOrLongWordClass }))
.flatMap(w => zwspAroundSpecialCharsSplit(w, '/,'))
.map((w, j) => wrapLongWord(w, `${i}${j}`, { longWordMinLength, longWordClass }))
.value();
return acc.concat(parts);
}, []);
};

288
src/util/richText.test.js Normal file
View file

@ -0,0 +1,288 @@
import React from 'react';
import { shallow } from 'enzyme';
import {
zwspAroundSpecialCharsSplit,
linkifyOrWrapLinkSplit,
wrapLongWord,
richText,
} from './richText';
describe('richText', () => {
// There variables contain zero-width-space on both sides of the visible character.
const slashWithZWSP = '/';
const commaWithZWSP = ',';
const dollarSignWithZWSP = '$';
const backslashWithZWSP = '\\';
describe('zwspAroundSpecialChars(word, optionalBreakingChars)', () => {
it('should not add anything to strings withouth word boundary', () => {
expect(zwspAroundSpecialCharsSplit('word')).toEqual(['word']);
});
it('should add zwsp to a word with slash', () => {
expect(zwspAroundSpecialCharsSplit('word/another')).toEqual([
'word',
slashWithZWSP,
'another',
]);
});
it('should add zwsp to strings with spaces and slashes', () => {
expect(zwspAroundSpecialCharsSplit('word one/another/third word')).toEqual([
'word one',
slashWithZWSP,
'another',
slashWithZWSP,
'third word',
]);
});
it('should add zwsp to strings with spaces and slashes when "/" is given', () => {
expect(zwspAroundSpecialCharsSplit('word one/another/third word', '/')).toEqual([
'word one',
slashWithZWSP,
'another',
slashWithZWSP,
'third word',
]);
});
it('should add zwsp to strings with given char (","), no char', () => {
expect(zwspAroundSpecialCharsSplit('word/another', ',')).toEqual(['word/another']);
});
it('should add zwsp to strings with given char (","), single char', () => {
expect(zwspAroundSpecialCharsSplit('word,another', ',')).toEqual([
'word',
commaWithZWSP,
'another',
]);
});
it('should add zwsp to strings with given chars (","), single char and spaces', () => {
expect(zwspAroundSpecialCharsSplit('word,another/third forth', ',')).toEqual([
'word',
commaWithZWSP,
'another/third forth',
]);
});
it('should add zwsp to strings with given chars (",/"): multiple occurrences', () => {
expect(
zwspAroundSpecialCharsSplit('word/another/third,fourth and fifth,sixth', ',/')
).toEqual([
'word',
slashWithZWSP,
'another',
slashWithZWSP,
'third',
commaWithZWSP,
'fourth and fifth',
commaWithZWSP,
'sixth',
]);
});
it('should add zwsp to strings with given chars (",/:"), when not-found-chars are included', () => {
expect(zwspAroundSpecialCharsSplit('word/another,third', '.,/:')).toEqual([
'word',
slashWithZWSP,
'another',
commaWithZWSP,
'third',
]);
});
it('should add zwsp to strings with given chars ("?\\$") escapable chars not found', () => {
expect(zwspAroundSpecialCharsSplit('word/another,third', '?\\$')).toEqual([
'word/another,third',
]);
});
it('should add zwsp to strings with given chars ("?\\$") escapable chars $ found', () => {
expect(zwspAroundSpecialCharsSplit('word/another$third', '?\\$')).toEqual([
'word/another',
dollarSignWithZWSP,
'third',
]);
});
it('should add zwsp to strings with given chars ("?\\$") escapable chars backslash found', () => {
expect(zwspAroundSpecialCharsSplit('word/another\\third', '?\\$')).toEqual([
'word/another',
backslashWithZWSP,
'third',
]);
});
});
describe('wrapLongWord(word, key, longWordMinLength, longWordClass)', () => {
it('should not add anything to short word', () => {
const wrapper = shallow(
<span>
{wrapLongWord('word', 'key', { longWordMinLength: 10, longWordClass: 'longWord' })}
</span>
);
expect(wrapper.html()).toEqual('<span>word</span>');
});
it('should add span around long word', () => {
const wrapper = shallow(
<span>
{wrapLongWord('Pneumonoultramicroscopicsilicovolcanoconiosis', 'key', {
longWordMinLength: 10,
longWordClass: 'longWord',
})}
</span>
);
expect(wrapper.html()).toEqual(
'<span><span class="longWord">Pneumonoultramicroscopicsilicovolcanoconiosis</span></span>'
);
});
});
describe('linkifyOrWrapLinkSplit(word, key, linkClass)', () => {
it('should not add anything to words without links', () => {
const wrapper = shallow(
<span>
{linkifyOrWrapLinkSplit('word', 'key', { linkify: true, linkClass: 'linkClass' })}
</span>
);
expect(wrapper.html()).toEqual('<span>word</span>');
});
it('should add link around words that are links', () => {
const wrapper = shallow(
<span>
{linkifyOrWrapLinkSplit('http://www.example.com', 'key', {
linkify: true,
linkClass: 'linkClass',
})}
</span>
);
expect(wrapper.html()).toEqual(
'<span><a href="http://www.example.com" class="linkClass" target="_blank" rel="noopener noreferrer">http://www.example.com</a></span>'
);
});
it('should add link around words that are links even inside parenthesis', () => {
const wrapper = shallow(
<span>
{linkifyOrWrapLinkSplit('(http://www.example.com)', 'key', {
linkify: true,
linkClass: 'linkClass',
})}
</span>
);
expect(wrapper.html()).toEqual(
'<span>(<a href="http://www.example.com" class="linkClass" target="_blank" rel="noopener noreferrer">http://www.example.com</a>)</span>'
);
});
it('should add link around words that are links even inside brackets', () => {
const wrapper = shallow(
<span>
{linkifyOrWrapLinkSplit('[http://www.example.com]', 'key', {
linkify: true,
linkClass: 'linkClass',
})}
</span>
);
expect(wrapper.html()).toEqual(
'<span>[<a href="http://www.example.com" class="linkClass" target="_blank" rel="noopener noreferrer">http://www.example.com</a>]</span>'
);
});
});
describe('richText(text, { longWordMinLength, longWordClass })', () => {
const options = { longWordMinLength: 10, longWordClass: 'longWord' };
it('should not add anything to strings with short words', () => {
const wrapper = shallow(<span>{richText('word word word', options)}</span>);
expect(wrapper.html()).toEqual('<span>word word word</span>');
});
it('should add span around a string with a single long word', () => {
const wrapper = shallow(
<span>
{richText(
'word Pneumonoultramicroscopicsilicovolcanoconiosis is the longest word',
options
)}
</span>
);
expect(wrapper.html()).toEqual(
'<span>word <span class="longWord">Pneumonoultramicroscopicsilicovolcanoconiosis</span> is the longest word</span>'
);
});
it('should add span around a string with multiple long words', () => {
const wrapper = shallow(
<span>
{richText(
'word Pneumonoultramicroscopicsilicovolcanoconiosis is the longest word - Pseudopseudohypoparathyroidism is shorter',
options
)}
</span>
);
expect(wrapper.html()).toEqual(
'<span>word <span class="longWord">Pneumonoultramicroscopicsilicovolcanoconiosis</span> is the longest word - <span class="longWord">Pseudopseudohypoparathyroidism</span> is shorter</span>'
);
});
it('should add span around a string with multiple long words and containing slashes', () => {
const wrapper = shallow(
<span>{richText('Chars one/two/three - count until exhaustion…', options)}</span>
);
// <span>
// Chars one/two/three - count until <span class=\"classX\">exhaustion…</span>
// </span>
expect(wrapper.html()).toEqual(
`<span>Chars one${slashWithZWSP}two${slashWithZWSP}three - count until <span class=\"longWord\">exhaustion…</span></span>`
);
});
it('should add span around a string with a long word and containing slashes and commas', () => {
const wrapper = shallow(
<span>{richText('Chars one/two/three, count until exhaustion…', options)}</span>
);
// <span>
// Chars one/two/three, count until <span class=\"classX\">exhaustion…</span>
// </span>
expect(wrapper.html()).toEqual(
`<span>Chars one${slashWithZWSP}two${slashWithZWSP}three${commaWithZWSP} count until <span class=\"longWord\">exhaustion…</span></span>`
);
});
it('should add span around a string with a long word, containing slashes and a link', () => {
const wrapper = shallow(
<span>
{richText(
'Chars one/two/three - count until exhaustion… and a random link: http://www.example.com',
{ ...options, linkify: true, linkClass: 'link' }
)}
</span>
);
// <span>
// Chars one/two/three - count until <span class=\"classX\">exhaustion…</span> and a random link: <a href=\"http://www.example.com\" target=\"_blank\" rel=\"noopener noreferrer\">http://www.example.com</a>
// </span>
expect(wrapper.html()).toEqual(
`<span>Chars one${slashWithZWSP}two${slashWithZWSP}three - count until <span class=\"longWord\">exhaustion…</span> and a random link: <a href=\"http://www.example.com\" class=\"link\" target=\"_blank\" rel=\"noopener noreferrer\">http://www.example.com</a></span>`
);
});
it('should add link inside non-whitespace-sequence (http://example.com)', () => {
const wrapper = shallow(
<span>{richText('Link: (http://example.com)', { ...options, linkify: true })}</span>
);
// <span>
// Link: (<a href=\"http://example.com\" target=\"_blank\" rel=\"noopener noreferrer\">http://example.com</a>)
// </span>
expect(wrapper.html()).toEqual(
`<span>Link: (<a href=\"http://example.com\" class=\"longWord\" target=\"_blank\" rel=\"noopener noreferrer\">http://example.com</a>)</span>`
);
});
it('should not add span around a string if no linkify option is given', () => {
const wrapper = shallow(
<span>
{richText(
'Chars one/two/three - count until exhaustion… and a random link: http://www.example.com',
options
)}
</span>
);
// <span>
// Chars one/two/three - count until <span class=\"classX\">exhaustion…</span> and a random link: http://<span class=\"longWord\">www.example.com</span>
// </span>
expect(wrapper.html()).toEqual(
`<span>Chars one${slashWithZWSP}two${slashWithZWSP}three - count until <span class=\"longWord\">exhaustion…</span> and a random link: <span class=\"longWord\">http://www.example.com</span></span>`
);
});
});
});