From bc47950d6f28bd6f17d85b35fe00e9f8dce7f975 Mon Sep 17 00:00:00 2001 From: Beto Muniz Date: Tue, 19 May 2020 17:29:08 -0300 Subject: [PATCH] Add resize support to box when changing font-face on display/edit mode (#1275) * Add resize support to box when changing font-face on display/edit mode * Cover unicode chars, font-weight, font-style * Add ((MULTIPLE)) support proposal * Improve docs. * Fix problematic rest approach. * Add fontSize support. Remove content support. Add a better support for MULTIPLE. Improve useLoadFontFiles.js * Update textStyle tests * Force display=auto on @font-face declaration while loading fonts from Google Fonts. * Fix typo * Add codecov to useLoadFontFiles * Update tests to match new APIs proposed after merge. * Address PR reviews * Address some PR reviews. * Adjustments after #1323 merge. Revert content as parameter to load font (To address #923). * Address recent PR review * Improve code performance/reusability. Thanks @dvoytenko * Clean up promise logic a bit Co-authored-by: Pascal Birchler Co-authored-by: Morten Barklund --- .../app/font/actions/useLoadFontFiles.js | 87 ++++++++++++++----- .../app/font/test/actions/useLoadFontFiles.js | 83 ++++++++++++++++++ .../components/library/text/fontPreview.js | 14 ++- .../components/panels/test/textStyle.js | 17 ++-- .../components/panels/textStyle/font.js | 54 +++++++++--- .../components/panels/textStyle/textStyle.js | 21 ++++- .../src/edit-story/elements/text/display.js | 19 +++- assets/src/edit-story/elements/text/edit.js | 27 +++++- 8 files changed, 268 insertions(+), 54 deletions(-) create mode 100644 assets/src/edit-story/app/font/test/actions/useLoadFontFiles.js diff --git a/assets/src/edit-story/app/font/actions/useLoadFontFiles.js b/assets/src/edit-story/app/font/actions/useLoadFontFiles.js index f3d8c707054f..c4879dc95856 100644 --- a/assets/src/edit-story/app/font/actions/useLoadFontFiles.js +++ b/assets/src/edit-story/app/font/actions/useLoadFontFiles.js @@ -25,38 +25,85 @@ import { useCallback } from 'react'; import cleanForSlug from '../../../utils/cleanForSlug'; import getGoogleFontURL from '../../../utils/getGoogleFontURL'; +/** + * This is a utility ensure that Promise.all return ONLY when all promises are processed. + * + * @param {Promise} promise Promise to be processed + * @return {Promise} Return a rejected or fulfilled Promise + */ +const reflect = (promise) => { + return promise.then( + (v) => ({ v, status: 'fulfilled' }), + (e) => ({ e, status: 'rejected' }) + ); +}; + function useLoadFontFiles() { /** * Adds a element to the for a given font in case there is none yet. * * Allows dynamically enqueuing font styles when needed. * - * @param {string} name Font name. + * @param {Array} fonts An array of fonts properties to create a valid FontFaceSet to inject and preload a font-face + * @return {Promise} Returns fonts loaded promise */ - const maybeEnqueueFontStyle = useCallback(({ family, service, variants }) => { - if (!family || service !== 'fonts.google.com') { - return; - } + const maybeEnqueueFontStyle = useCallback((fonts) => { + return Promise.all( + fonts + .map( + async ({ + font: { family, service, variants }, + fontWeight, + fontStyle, + content, + }) => { + if (!family || service !== 'fonts.google.com') { + return null; + } + + const handle = cleanForSlug(family); + const elementId = `${handle}-css`; + const fontFaceSet = ` + ${fontStyle || ''} ${fontWeight || ''} 0 '${family}' + `.trim(); + + const hasFontLink = () => document.getElementById(elementId); - const handle = cleanForSlug(family); - const id = `${handle}-css`; - const element = document.getElementById(id); + const appendFontLink = () => { + return new Promise((resolve, reject) => { + const src = getGoogleFontURL([{ family, variants }], 'auto'); + const fontStylesheet = document.createElement('link'); + fontStylesheet.id = elementId; + fontStylesheet.href = src; + fontStylesheet.rel = 'stylesheet'; + fontStylesheet.type = 'text/css'; + fontStylesheet.media = 'all'; + fontStylesheet.crossOrigin = 'anonymous'; + fontStylesheet.addEventListener('load', () => resolve()); + fontStylesheet.addEventListener('error', (e) => reject(e)); + document.head.appendChild(fontStylesheet); + }); + }; - if (element) { - return; - } + const ensureFontLoaded = () => { + if (!document?.fonts) { + return Promise.resolve(); + } - const src = getGoogleFontURL([{ family, variants }]); + return document.fonts + .load(fontFaceSet, content || '') + .then(() => document.fonts.check(fontFaceSet, content || '')); + }; - const fontStylesheet = document.createElement('link'); - fontStylesheet.id = id; - fontStylesheet.href = src; - fontStylesheet.rel = 'stylesheet'; - fontStylesheet.type = 'text/css'; - fontStylesheet.media = 'all'; - fontStylesheet.crossOrigin = 'anonymous'; + if (!hasFontLink()) { + await appendFontLink(); + } - document.head.appendChild(fontStylesheet); + return ensureFontLoaded(); + } + ) + .map(reflect) + ); }, []); return maybeEnqueueFontStyle; diff --git a/assets/src/edit-story/app/font/test/actions/useLoadFontFiles.js b/assets/src/edit-story/app/font/test/actions/useLoadFontFiles.js new file mode 100644 index 000000000000..be34c80efb69 --- /dev/null +++ b/assets/src/edit-story/app/font/test/actions/useLoadFontFiles.js @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-hooks'; + +/** + * Internal dependencies + */ +import useLoadFontFiles from '../../actions/useLoadFontFiles'; + +const DEFAULT_FONT = { + font: { + family: 'Font', + service: 'fonts.google.com', + }, + fontWeight: 400, + fontStyle: 'normal', + content: 'Fill in some text', +}; + +describe('useLoadFontFiles', () => { + beforeEach(() => { + const el = document.getElementById('font-css'); + if (el) { + el.remove(); + } + }); + + it('maybeEnqueueFontStyle', () => { + expect(document.getElementById('font-css')).toBeNull(); + + renderHook(async () => { + const maybeEnqueueFontStyle = useLoadFontFiles(); + + await maybeEnqueueFontStyle([DEFAULT_FONT]); + }); + + expect(document.getElementById('font-css')).toBeDefined(); + }); + + it('maybeEnqueueFontStyle skip', () => { + expect(document.getElementById('font-css')).toBeNull(); + + renderHook(async () => { + const maybeEnqueueFontStyle = useLoadFontFiles(); + + await maybeEnqueueFontStyle([ + { ...DEFAULT_FONT, font: { ...DEFAULT_FONT.font, service: 'abcd' } }, + ]); + }); + + expect(document.getElementById('font-css')).toBeNull(); + }); + + it('maybeEnqueueFontStyle reflect', () => { + expect(document.getElementById('font-css')).toBeNull(); + + renderHook(async () => { + const maybeEnqueueFontStyle = useLoadFontFiles(); + + await maybeEnqueueFontStyle([{}, DEFAULT_FONT]); + }); + + expect(document.querySelectorAll('link')).toHaveLength(1); + expect(document.getElementById('font-css')).toBeDefined(); + }); +}); diff --git a/assets/src/edit-story/components/library/text/fontPreview.js b/assets/src/edit-story/components/library/text/fontPreview.js index 91c1b323b0b3..972da748fb7b 100644 --- a/assets/src/edit-story/components/library/text/fontPreview.js +++ b/assets/src/edit-story/components/library/text/fontPreview.js @@ -28,6 +28,7 @@ import { useEffect } from 'react'; import { useFont } from '../../../app'; import { ALLOWED_EDITOR_PAGE_WIDTHS, PAGE_WIDTH } from '../../../constants'; import { FontPropType } from '../../../types'; +import stripHTML from '../../../utils/stripHTML'; const PREVIEW_EM_SCALE = ALLOWED_EDITOR_PAGE_WIDTHS[0] / PAGE_WIDTH; @@ -53,14 +54,20 @@ const Text = styled.span` color: ${({ theme }) => theme.colors.fg.v1}; `; -function FontPreview({ title, font, fontSize, fontWeight, onClick }) { +function FontPreview({ title, font, fontSize, fontWeight, content, onClick }) { const { actions: { maybeEnqueueFontStyle }, } = useFont(); useEffect(() => { - maybeEnqueueFontStyle(font); - }, [font, maybeEnqueueFontStyle]); + maybeEnqueueFontStyle([ + { + font, + fontWeight, + content: stripHTML(content), + }, + ]); + }, [font, fontWeight, content, maybeEnqueueFontStyle]); return ( @@ -80,6 +87,7 @@ FontPreview.propTypes = { font: FontPropType, fontSize: PropTypes.number, fontWeight: PropTypes.number, + content: PropTypes.string, onClick: PropTypes.func, }; diff --git a/assets/src/edit-story/components/panels/test/textStyle.js b/assets/src/edit-story/components/panels/test/textStyle.js index bfa5dde71a9d..22fc5f57c0e2 100644 --- a/assets/src/edit-story/components/panels/test/textStyle.js +++ b/assets/src/edit-story/components/panels/test/textStyle.js @@ -73,6 +73,7 @@ function Wrapper({ children }) { ], }, actions: { + maybeEnqueueFontStyle: () => Promise.resolve(), getFontByName: () => ({ name: 'Neu Font', value: 'Neu Font', @@ -420,9 +421,9 @@ describe('Panels/TextStyle', () => { }); describe('FontControls', () => { - it('should select font', () => { + it('should select font', async () => { const { pushUpdate } = renderTextStyle([textElement]); - act(() => controls.font.onChange('Neu Font')); + await act(() => controls.font.onChange('Neu Font')); expect(pushUpdate).toHaveBeenCalledWith( { font: { @@ -441,9 +442,9 @@ describe('Panels/TextStyle', () => { ); }); - it('should select font weight', () => { + it('should select font weight', async () => { const { pushUpdate } = renderTextStyle([textElement]); - act(() => controls['font.weight'].onChange('300')); + await act(() => controls['font.weight'].onChange('300')); const updatingFunction = pushUpdate.mock.calls[0][0]; const resultOfUpdating = updatingFunction({ content: 'Hello world' }); expect(resultOfUpdating).toStrictEqual( @@ -454,17 +455,17 @@ describe('Panels/TextStyle', () => { ); }); - it('should select font size', () => { + it('should select font size', async () => { const { getByTestId, pushUpdate } = renderTextStyle([textElement]); const input = getByTestId('font.size'); - fireEvent.change(input, { target: { value: '32' } }); + await fireEvent.change(input, { target: { value: '32' } }); expect(pushUpdate).toHaveBeenCalledWith({ fontSize: 32 }); }); - it('should select font size to empty value', () => { + it('should select font size to empty value', async () => { const { getByTestId, pushUpdate } = renderTextStyle([textElement]); const input = getByTestId('font.size'); - fireEvent.change(input, { target: { value: '' } }); + await fireEvent.change(input, { target: { value: '' } }); expect(pushUpdate).toHaveBeenCalledWith({ fontSize: '' }); }); }); diff --git a/assets/src/edit-story/components/panels/textStyle/font.js b/assets/src/edit-story/components/panels/textStyle/font.js index e199668c26fd..f47553fb85c6 100644 --- a/assets/src/edit-story/components/panels/textStyle/font.js +++ b/assets/src/edit-story/components/panels/textStyle/font.js @@ -34,6 +34,7 @@ import { PAGE_HEIGHT } from '../../../constants'; import { useFont } from '../../../app/font'; import { getCommonValue } from '../utils'; import objectPick from '../../../utils/objectPick'; +import stripHTML from '../../../utils/stripHTML'; import useRichTextFormatting from './useRichTextFormatting'; import getFontWeights from './getFontWeights'; @@ -54,18 +55,19 @@ function FontControls({ selectedElements, pushUpdate }) { const fontSize = getCommonValue(selectedElements, 'fontSize'); const { - textInfo: { fontWeight }, + textInfo: { fontWeight, isItalic }, handlers: { handleSelectFontWeight }, } = useRichTextFormatting(selectedElements, pushUpdate); const { state: { fonts }, - actions: { getFontByName }, + actions: { maybeEnqueueFontStyle, getFontByName }, } = useFont(); const fontWeights = useMemo(() => getFontWeights(getFontByName(fontFamily)), [ getFontByName, fontFamily, ]); + const fontStyle = isItalic ? 'italic' : 'normal'; return ( <> @@ -76,21 +78,33 @@ function FontControls({ selectedElements, pushUpdate }) { ariaLabel={__('Font family', 'web-stories')} options={fonts} value={fontFamily} - onChange={(value) => { + onChange={async (value) => { const fontObj = fonts.find((item) => item.value === value); + const newFont = { + family: value, + ...objectPick(fontObj, [ + 'service', + 'fallbacks', + 'weights', + 'styles', + 'variants', + ]), + }; + + await maybeEnqueueFontStyle( + selectedElements.map(({ content }) => { + return { + font: newFont, + fontStyle, + fontWeight, + content: stripHTML(content), + }; + }) + ); pushUpdate( { - font: { - family: value, - ...objectPick(fontObj, [ - 'service', - 'fallbacks', - 'weights', - 'styles', - 'variants', - ]), - }, + font: newFont, }, true ); @@ -107,7 +121,19 @@ function FontControls({ selectedElements, pushUpdate }) { placeholder={__('(multiple)', 'web-stories')} options={fontWeights} value={fontWeight} - onChange={handleSelectFontWeight} + onChange={async (value) => { + await maybeEnqueueFontStyle( + selectedElements.map(({ font, content }) => { + return { + font, + fontStyle, + fontWeight: parseInt(value), + content: stripHTML(content), + }; + }) + ); + handleSelectFontWeight(value); + }} /> diff --git a/assets/src/edit-story/components/panels/textStyle/textStyle.js b/assets/src/edit-story/components/panels/textStyle/textStyle.js index ba7edbfbb530..2504a5a35890 100644 --- a/assets/src/edit-story/components/panels/textStyle/textStyle.js +++ b/assets/src/edit-story/components/panels/textStyle/textStyle.js @@ -40,6 +40,8 @@ import { ReactComponent as BoldIcon } from '../../../icons/bold_icon.svg'; import { ReactComponent as ItalicIcon } from '../../../icons/italic_icon.svg'; import { ReactComponent as UnderlineIcon } from '../../../icons/underline_icon.svg'; import { getCommonValue } from '../utils'; +import { useFont } from '../../../app/font'; +import stripHTML from '../../../utils/stripHTML'; import useRichTextFormatting from './useRichTextFormatting'; const BoxedNumeric = styled(Numeric)` @@ -62,11 +64,14 @@ const Space = styled.div` `; function StylePanel({ selectedElements, pushUpdate }) { + const { + actions: { maybeEnqueueFontStyle }, + } = useFont(); const textAlign = getCommonValue(selectedElements, 'textAlign'); const lineHeight = getCommonValue(selectedElements, 'lineHeight'); const { - textInfo: { isBold, isItalic, isUnderline, letterSpacing }, + textInfo: { isBold, isItalic, isUnderline, letterSpacing, fontWeight }, handlers: { handleClickBold, handleClickItalic, @@ -137,7 +142,19 @@ function StylePanel({ selectedElements, pushUpdate }) { value={isItalic} iconWidth={10} iconHeight={10} - onChange={handleClickItalic} + onChange={async (value) => { + await maybeEnqueueFontStyle( + selectedElements.map(({ font, content }) => { + return { + font, + fontStyle: value ? 'italic' : 'normal', + fontWeight, + content: stripHTML(content), + }; + }) + ); + handleClickItalic(value); + }} /> } diff --git a/assets/src/edit-story/elements/text/display.js b/assets/src/edit-story/elements/text/display.js index b22752b391bf..a9e4b2ba09d5 100644 --- a/assets/src/edit-story/elements/text/display.js +++ b/assets/src/edit-story/elements/text/display.js @@ -34,8 +34,12 @@ import { import StoryPropTypes from '../../types'; import { BACKGROUND_TEXT_MODE } from '../../constants'; import { useTransformHandler } from '../../components/transform'; -import { getHTMLFormatters } from '../../components/richText/htmlManipulation'; +import { + getHTMLFormatters, + getHTMLInfo, +} from '../../components/richText/htmlManipulation'; import createSolid from '../../utils/createSolid'; +import stripHTML from '../../utils/stripHTML'; import { getHighlightLineheight, generateParagraphTextStyle } from './util'; const HighlightWrapperElement = styled.div` @@ -101,6 +105,14 @@ function TextDisplay({ } = useUnits(); const { font } = rest; + const fontFaceSetConfigs = useMemo(() => { + const htmlInfo = getHTMLInfo(content); + return { + fontStyle: htmlInfo.isItalic ? 'italic' : 'normal', + fontWeight: htmlInfo.fontWeight, + content: stripHTML(content), + }; + }, [content]); const props = { font, @@ -115,10 +127,9 @@ function TextDisplay({ const { actions: { maybeEnqueueFontStyle }, } = useFont(); - useEffect(() => { - maybeEnqueueFontStyle(font); - }, [font, maybeEnqueueFontStyle]); + maybeEnqueueFontStyle([{ ...fontFaceSetConfigs, font }]); + }, [font, fontFaceSetConfigs, maybeEnqueueFontStyle]); useTransformHandler(id, (transform) => { const target = ref.current; diff --git a/assets/src/edit-story/elements/text/edit.js b/assets/src/edit-story/elements/text/edit.js index 9a3bf0446cbe..198290669400 100644 --- a/assets/src/edit-story/elements/text/edit.js +++ b/assets/src/edit-story/elements/text/edit.js @@ -18,13 +18,20 @@ * External dependencies */ import styled from 'styled-components'; -import { useEffect, useLayoutEffect, useRef, useCallback } from 'react'; +import { + useEffect, + useLayoutEffect, + useRef, + useCallback, + useMemo, +} from 'react'; /** * Internal dependencies */ import { useStory, useFont } from '../../app'; import RichTextEditor from '../../components/richText/editor'; +import { getHTMLInfo } from '../../components/richText/htmlManipulation'; import { useUnits } from '../../units'; import { elementFillContent, @@ -36,6 +43,7 @@ import StoryPropTypes from '../../types'; import { BACKGROUND_TEXT_MODE } from '../../constants'; import useUnmount from '../../utils/useUnmount'; import createSolid from '../../utils/createSolid'; +import stripHTML from '../../utils/stripHTML'; import calcRotatedResizeOffset from '../../utils/calcRotatedResizeOffset'; import { generateParagraphTextStyle, getHighlightLineheight } from './util'; @@ -86,6 +94,14 @@ function TextEdit({ box: { x, y, height, rotationAngle }, }) { const { font } = rest; + const fontFaceSetConfigs = useMemo(() => { + const htmlInfo = getHTMLInfo(content); + return { + fontStyle: htmlInfo.isItalic ? 'italic' : 'normal', + fontWeight: htmlInfo.fontWeight, + content: stripHTML(content), + }; + }, [content]); const { actions: { dataToEditorX, dataToEditorY, editorToDataX, editorToDataY }, @@ -193,8 +209,13 @@ function TextEdit({ useEffect(handleResize, [elementHeight]); useEffect(() => { - maybeEnqueueFontStyle(font); - }, [font, maybeEnqueueFontStyle]); + maybeEnqueueFontStyle([ + { + ...fontFaceSetConfigs, + font, + }, + ]); + }, [font, fontFaceSetConfigs, maybeEnqueueFontStyle]); return (