diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index c281784349e4..cdadebca30c4 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -71,7 +71,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v1 with: - node-version: "${{ steps.nvm.outputs.NVMRC }}" + node-version: '${{ steps.nvm.outputs.NVMRC }}' - name: Setup npm cache uses: pat-s/always-upload-cache@v1.1.4 @@ -96,8 +96,8 @@ jobs: - name: Annotate Code Linting Results uses: ataylorme/eslint-annotate-action@1.0.4 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - report-json: "build/lint-js-report.json" + repo-token: '${{ secrets.GITHUB_TOKEN }}' + report-json: 'build/lint-js-report.json' markdownlint: name: Markdown Code Style @@ -113,7 +113,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v1 with: - node-version: "${{ steps.nvm.outputs.NVMRC }}" + node-version: '${{ steps.nvm.outputs.NVMRC }}' - name: Setup npm cache uses: pat-s/always-upload-cache@v1.1.4 @@ -148,7 +148,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v1 with: - node-version: "${{ steps.nvm.outputs.NVMRC }}" + node-version: '${{ steps.nvm.outputs.NVMRC }}' - name: Setup npm cache uses: pat-s/always-upload-cache@v1.1.4 @@ -328,7 +328,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v1 with: - node-version: "${{ steps.nvm.outputs.NVMRC }}" + node-version: '${{ steps.nvm.outputs.NVMRC }}' - name: Setup npm cache uses: pat-s/always-upload-cache@v1.1.4 @@ -405,7 +405,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v1 with: - node-version: "${{ steps.nvm.outputs.NVMRC }}" + node-version: '${{ steps.nvm.outputs.NVMRC }}' - name: Setup npm cache uses: pat-s/always-upload-cache@v1.1.4 diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 283c086b7338..c026670a68db 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v1 with: - node-version: "${{ steps.nvm.outputs.NVMRC }}" + node-version: '${{ steps.nvm.outputs.NVMRC }}' - name: Setup npm cache uses: pat-s/always-upload-cache@v1.1.4 diff --git a/assets/src/edit-story/components/colorPicker/colorPicker.js b/assets/src/edit-story/components/colorPicker/colorPicker.js index 14feccc47304..5bcfe7e67fb7 100644 --- a/assets/src/edit-story/components/colorPicker/colorPicker.js +++ b/assets/src/edit-story/components/colorPicker/colorPicker.js @@ -33,6 +33,7 @@ import { __ } from '@wordpress/i18n'; import { PatternPropType } from '../../types'; import { useKeyDownEffect } from '../keyboard'; import useFocusOut from '../../utils/useFocusOut'; +import createSolid from '../../utils/createSolid'; import CurrentColorPicker from './currentColorPicker'; import GradientPicker from './gradientPicker'; import Header from './header'; @@ -102,6 +103,9 @@ function ColorPicker({ useEffect(() => { if (color) { load(color); + } else { + // If no color given, load solid black + load(createSolid(0, 0, 0)); } }, [color, load]); diff --git a/assets/src/edit-story/components/form/color/color.js b/assets/src/edit-story/components/form/color/color.js index 562a76f39149..4d840bf7a74f 100644 --- a/assets/src/edit-story/components/form/color/color.js +++ b/assets/src/edit-story/components/form/color/color.js @@ -64,7 +64,7 @@ function ColorInput({ } ColorInput.propTypes = { - value: PatternPropType, + value: PropTypes.oneOfType([PatternPropType, PropTypes.string]), hasGradient: PropTypes.bool, hasOpacity: PropTypes.bool, onChange: PropTypes.func.isRequired, diff --git a/assets/src/edit-story/components/form/color/colorPreview.js b/assets/src/edit-story/components/form/color/colorPreview.js index 1fc65f09c7aa..4f201115604e 100644 --- a/assets/src/edit-story/components/form/color/colorPreview.js +++ b/assets/src/edit-story/components/form/color/colorPreview.js @@ -172,7 +172,7 @@ function ColorPreview({ diff --git a/assets/src/edit-story/components/form/color/opacityPreview.js b/assets/src/edit-story/components/form/color/opacityPreview.js index 21d24f097af4..135dba69feef 100644 --- a/assets/src/edit-story/components/form/color/opacityPreview.js +++ b/assets/src/edit-story/components/form/color/opacityPreview.js @@ -31,6 +31,7 @@ import { _x, __ } from '@wordpress/i18n'; */ import { PatternPropType } from '../../../types'; import useFocusAndSelect from '../../../utils/useFocusAndSelect'; +import { MULTIPLE_VALUE } from '../'; import getPreviewText from './getPreviewText'; import getPreviewOpacity from './getPreviewOpacity'; import ColorBox from './colorBox'; @@ -47,7 +48,8 @@ const Input = styled(ColorBox).attrs({ `; function OpacityPreview({ value, onChange }) { - const hasPreviewText = Boolean(getPreviewText(value)); + const hasPreviewText = + value !== MULTIPLE_VALUE && Boolean(getPreviewText(value)); const postfix = _x('%', 'Percentage', 'web-stories'); const [inputValue, setInputValue] = useState(''); const ref = useRef(); @@ -91,7 +93,7 @@ function OpacityPreview({ value, onChange }) { } OpacityPreview.propTypes = { - value: PatternPropType, + value: PropTypes.oneOfType([PatternPropType, PropTypes.string]), onChange: PropTypes.func.isRequired, }; diff --git a/assets/src/edit-story/components/library/panes/text/textPane.js b/assets/src/edit-story/components/library/panes/text/textPane.js index b0df064a8873..5c23bfb802e1 100644 --- a/assets/src/edit-story/components/library/panes/text/textPane.js +++ b/assets/src/edit-story/components/library/panes/text/textPane.js @@ -42,9 +42,11 @@ const PRESETS = [ { id: 'heading', title: __('Heading', 'web-stories'), - content: __('Heading', 'web-stories'), + content: `${__( + 'Heading', + 'web-stories' + )}`, fontSize: dataFontEm(2), - fontWeight: 700, font: { family: 'Open Sans', service: 'fonts.google.com', @@ -53,9 +55,11 @@ const PRESETS = [ { id: 'subheading', title: __('Subheading', 'web-stories'), - content: __('Subheading', 'web-stories'), + content: `${__( + 'Subheading', + 'web-stories' + )}`, fontSize: dataFontEm(1.5), - fontWeight: 600, font: { family: 'Open Sans', service: 'fonts.google.com', @@ -69,7 +73,6 @@ const PRESETS = [ 'web-stories' ), fontSize: dataFontEm(1.1), - fontWeight: 400, font: { family: 'Roboto', service: 'fonts.google.com', diff --git a/assets/src/edit-story/components/panels/stylePreset/panel.js b/assets/src/edit-story/components/panels/stylePreset/panel.js index cff39b44fef4..fd82881b84fd 100644 --- a/assets/src/edit-story/components/panels/stylePreset/panel.js +++ b/assets/src/edit-story/components/panels/stylePreset/panel.js @@ -17,18 +17,20 @@ /** * External dependencies */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useRef, useEffect, useState } from 'react'; /** * Internal dependencies */ import { useStory } from '../../../app/story'; import stripHTML from '../../../utils/stripHTML'; +import objectWithout from '../../../utils/objectWithout'; +import { Panel } from '../panel'; +import useRichTextFormatting from '../textStyle/useRichTextFormatting'; import { COLOR_PRESETS_PER_ROW, STYLE_PRESETS_PER_ROW, } from '../../../constants'; -import { Panel } from './../panel'; import { getShapePresets, getTextPresets } from './utils'; import PresetsHeader from './header'; import Presets from './presets'; @@ -123,16 +125,38 @@ function StylePresetPanel() { ] ); + const extraPropsToAdd = useRef(null); + const miniPushUpdate = useCallback( + (updater) => { + updateElementsById({ + elementIds: selectedElementIds, + properties: (oldProps) => ({ + ...updater(oldProps), + ...extraPropsToAdd.current, + }), + }); + extraPropsToAdd.current = null; + }, + [selectedElementIds, updateElementsById] + ); + + const { + handlers: { handleSetColor }, + } = useRichTextFormatting(selectedElements, miniPushUpdate); + const handleApplyPreset = useCallback( (preset) => { if (isText) { // @todo Determine this in a better way. // Only style presets have background text mode set. const isStylePreset = preset.backgroundTextMode !== undefined; - updateElementsById({ - elementIds: selectedElementIds, - properties: isStylePreset ? { ...preset } : { color: preset }, - }); + if (isStylePreset) { + extraPropsToAdd.current = objectWithout(preset, ['color']); + handleSetColor(preset.color); + } else { + extraPropsToAdd.current = null; + handleSetColor(preset); + } } else { updateElementsById({ elementIds: selectedElementIds, @@ -140,7 +164,7 @@ function StylePresetPanel() { }); } }, - [isText, selectedElementIds, updateElementsById] + [isText, handleSetColor, selectedElementIds, updateElementsById] ); const colorPresets = isText ? textColors : fillColors; diff --git a/assets/src/edit-story/components/panels/stylePreset/test/panel.js b/assets/src/edit-story/components/panels/stylePreset/test/panel.js index 8d1f1ee30e3a..3f05c7c7d7fa 100644 --- a/assets/src/edit-story/components/panels/stylePreset/test/panel.js +++ b/assets/src/edit-story/components/panels/stylePreset/test/panel.js @@ -28,6 +28,7 @@ import { BACKGROUND_TEXT_MODE } from '../../../../constants'; import { getShapePresets, getTextPresets } from '../utils'; import { renderWithTheme } from '../../../../testUtils'; import { TEXT_ELEMENT_DEFAULT_FONT } from '../../../../app/font/defaultFonts'; + jest.mock('../utils'); function setupPanel(extraStylePresets, extraStateProps) { @@ -91,7 +92,6 @@ describe('Panels/StylePreset', () => { }, }; const STYLE_PRESET = { - color: TEST_COLOR_2, backgroundTextMode: BACKGROUND_TEXT_MODE.FILL, backgroundColor: TEST_COLOR, }; @@ -149,13 +149,13 @@ describe('Panels/StylePreset', () => { expect(newEditButton).toBeDefined(); }); - it('should add a text color preset', () => { + it('should add a text color preset if other text styles are default or missing', () => { const extraStateProps = { selectedElements: [ { id: '1', type: 'text', - color: [TEST_COLOR_2], + content: 'Content', backgroundTextMode: BACKGROUND_TEXT_MODE.NONE, font: TEXT_ELEMENT_DEFAULT_FONT, }, @@ -193,6 +193,7 @@ describe('Panels/StylePreset', () => { { id: '1', type: 'text', + content: 'Content', ...STYLE_PRESET, }, ], @@ -204,7 +205,7 @@ describe('Panels/StylePreset', () => { getTextPresets.mockImplementation(() => { return { - textStyles: [STYLE_PRESET], + textStyles: [{ color: TEST_COLOR_2, ...STYLE_PRESET }], }; }); @@ -217,7 +218,7 @@ describe('Panels/StylePreset', () => { stylePresets: { textColors: [], fillColors: [], - textStyles: [STYLE_PRESET], + textStyles: [{ color: TEST_COLOR_2, ...STYLE_PRESET }], }, }, }); @@ -365,10 +366,17 @@ describe('Panels/StylePreset', () => { expect(updateElementsById).toHaveBeenCalledTimes(1); expect(updateElementsById).toHaveBeenCalledWith({ elementIds: ['1'], - properties: { - color: TEST_COLOR, - }, + properties: expect.any(Function), }); + const updaterFunction = updateElementsById.mock.calls[0][0].properties; + const partiallyBlueContent = { + content: 'Hello World', + }; + const updatedContent = updaterFunction(partiallyBlueContent); + const expectedContent = { + content: 'Hello World', + }; + expect(updatedContent).toStrictEqual(expectedContent); }); }); }); diff --git a/assets/src/edit-story/components/panels/stylePreset/test/utils.js b/assets/src/edit-story/components/panels/stylePreset/test/utils.js index a42d9a8097cb..ac9c3b8a9162 100644 --- a/assets/src/edit-story/components/panels/stylePreset/test/utils.js +++ b/assets/src/edit-story/components/panels/stylePreset/test/utils.js @@ -24,6 +24,7 @@ import { getTextPresets, } from '../utils'; import { BACKGROUND_TEXT_MODE } from '../../../../constants'; +import objectWithout from '../../../../utils/objectWithout'; import { TEXT_ELEMENT_DEFAULT_FONT } from '../../../../app/font/defaultFonts'; describe('Panels/StylePreset/utils', () => { @@ -164,12 +165,13 @@ describe('Panels/StylePreset/utils', () => { vertical: 0, horizontal: 0, }, - color: TEST_COLOR, + content: 'Content', }, { type: 'text', x: 30, - ...stylePreset, + content: 'Content', + ...objectWithout(stylePreset, ['color']), }, ]; const stylePresets = { @@ -185,6 +187,64 @@ describe('Panels/StylePreset/utils', () => { expect(presets).toStrictEqual(expected); }); + it('should ignore text color presets for multi-color text fields', () => { + const elements = [ + { + type: 'text', + backgroundTextMode: BACKGROUND_TEXT_MODE.NONE, + font: TEXT_ELEMENT_DEFAULT_FONT, + content: + 'OK', + }, + ]; + const stylePresets = { + textStyles: [], + textColors: [], + fillColors: [], + }; + const expected = { + textColors: [], + textStyles: [], + }; + const presets = getTextPresets(elements, stylePresets); + expect(presets).toStrictEqual(expected); + }); + + it('should use black color when adding text style preset for multi-color text fields', () => { + const stylePreset = { + ...STYLE_PRESET, + font: { + family: 'Foo', + fallbacks: ['Bar'], + }, + }; + const elements = [ + { + type: 'text', + x: 30, + content: + 'OK', + ...objectWithout(stylePreset, ['color']), + }, + ]; + const stylePresets = { + textStyles: [], + textColors: [], + fillColors: [], + }; + const expected = { + textColors: [], + textStyles: [ + { + ...stylePreset, + color: { color: { r: 0, g: 0, b: 0 } }, + }, + ], + }; + const presets = getTextPresets(elements, stylePresets); + expect(presets).toStrictEqual(expected); + }); + it('should not consider existing presets as new', () => { const stylePreset = { ...STYLE_PRESET, @@ -199,7 +259,7 @@ describe('Panels/StylePreset/utils', () => { backgroundTextMode: BACKGROUND_TEXT_MODE.NONE, font: TEXT_ELEMENT_DEFAULT_FONT, foo: 'bar', - color: TEST_COLOR, + content: 'Content', padding: { vertical: 0, horizontal: 0, @@ -208,7 +268,8 @@ describe('Panels/StylePreset/utils', () => { { type: 'text', x: 30, - ...stylePreset, + content: 'Content', + ...objectWithout(stylePreset, ['color']), }, ]; const stylePresets = { diff --git a/assets/src/edit-story/components/panels/stylePreset/utils.js b/assets/src/edit-story/components/panels/stylePreset/utils.js index f33266f77c69..bab4d2a27ef5 100644 --- a/assets/src/edit-story/components/panels/stylePreset/utils.js +++ b/assets/src/edit-story/components/panels/stylePreset/utils.js @@ -17,11 +17,15 @@ /** * Internal dependencies */ +import isPatternEqual from '../../../utils/isPatternEqual'; +import convertToCSS from '../../../utils/convertToCSS'; import generatePatternStyles from '../../../utils/generatePatternStyles'; import { generateFontFamily } from '../../../elements/text/util'; import { BACKGROUND_TEXT_MODE } from '../../../constants'; -import convertToCSS from '../../../utils/convertToCSS'; +import createSolid from '../../../utils/createSolid'; import objectPick from '../../../utils/objectPick'; +import { MULTIPLE_VALUE } from '../../form'; +import { getHTMLInfo } from '../../richText/htmlManipulation'; import { TEXT_ELEMENT_DEFAULT_FONT } from '../../../app/font/defaultFonts'; export function findMatchingColor(color, stylePresets, isText) { @@ -29,11 +33,9 @@ export function findMatchingColor(color, stylePresets, isText) { ? stylePresets.textColors : stylePresets.fillColors; const patternType = isText ? 'color' : 'background'; - const toAdd = generatePatternStyles(color, patternType); - return colorsToMatch.find((value) => { - const existing = generatePatternStyles(value, patternType); - return Object.keys(toAdd).every((key) => existing[key] === toAdd[key]); - }); + return colorsToMatch.find((value) => + isPatternEqual(value, color, patternType) + ); } export function findMatchingStylePreset(preset, stylePresets) { @@ -74,17 +76,25 @@ export function getTextPresets(elements, stylePresets) { return { textColors: elements .filter((text) => !hasStylePreset(text)) - .map(({ color }) => color) + .map(({ content }) => getHTMLInfo(content).color) + .filter((color) => color !== MULTIPLE_VALUE) .filter((color) => !findMatchingColor(color, stylePresets, true)), textStyles: elements .filter((text) => hasStylePreset(text)) .map((text) => { - return objectPick(text, [ - 'color', - 'backgroundColor', - 'backgroundTextMode', - 'font', - ]); + const extractedColor = getHTMLInfo(text.content).color; + const color = + extractedColor === MULTIPLE_VALUE + ? createSolid(0, 0, 0) + : extractedColor; + return { + color, + ...objectPick(text, [ + 'backgroundColor', + 'backgroundTextMode', + 'font', + ]), + }; }) .filter((preset) => !findMatchingStylePreset(preset, stylePresets)), }; diff --git a/assets/src/edit-story/components/panels/test/textStyle.js b/assets/src/edit-story/components/panels/test/textStyle.js index 99d04a1690d2..fd070a1de5d4 100644 --- a/assets/src/edit-story/components/panels/test/textStyle.js +++ b/assets/src/edit-story/components/panels/test/textStyle.js @@ -25,6 +25,7 @@ import { act, fireEvent } from '@testing-library/react'; */ import TextStyle from '../textStyle'; import FontContext from '../../../app/font/context'; +import RichTextContext from '../../richText/context'; import { calculateTextHeight } from '../../../utils/textMeasurements'; import calcRotatedResizeOffset from '../../../utils/calcRotatedResizeOffset'; import DropDown from '../../form/dropDown'; @@ -87,7 +88,11 @@ function Wrapper({ children }) { }, }} > - {children} + + {children} + ); } @@ -111,7 +116,6 @@ describe('Panels/TextStyle', () => { id: '1', textAlign: 'normal', fontSize: 30, - fontWeight: 400, font: { family: 'ABeeZee', }, @@ -430,7 +434,6 @@ describe('Panels/TextStyle', () => { ], fallbacks: ['fallback1'], }, - fontWeight: 400, }, true ); @@ -439,7 +442,14 @@ describe('Panels/TextStyle', () => { it('should select font weight', () => { const { pushUpdate } = renderTextStyle([textElement]); act(() => controls['font.weight'].onChange('300')); - expect(pushUpdate).toHaveBeenCalledWith({ fontWeight: 300 }, true); + const updatingFunction = pushUpdate.mock.calls[0][0]; + const resultOfUpdating = updatingFunction({ content: 'Hello world' }); + expect(resultOfUpdating).toStrictEqual( + { + content: 'Hello world', + }, + true + ); }); it('should select font size', () => { @@ -476,27 +486,43 @@ describe('Panels/TextStyle', () => { const { getByTestId, pushUpdate } = renderTextStyle([textElement]); const input = getByTestId('text.letterSpacing'); fireEvent.change(input, { target: { value: '150' } }); - expect(pushUpdate).toHaveBeenCalledWith({ letterSpacing: 1.5 }); + const updatingFunction = pushUpdate.mock.calls[0][0]; + const resultOfUpdating = updatingFunction({ content: 'Hello world' }); + expect(resultOfUpdating).toStrictEqual( + { + content: 'Hello world', + }, + true + ); }); it('should set letterSpacing to empty', () => { const { getByTestId, pushUpdate } = renderTextStyle([textElement]); const input = getByTestId('text.letterSpacing'); fireEvent.change(input, { target: { value: '' } }); - expect(pushUpdate).toHaveBeenCalledWith({ letterSpacing: '' }); + const updatingFunction = pushUpdate.mock.calls[0][0]; + const resultOfUpdating = updatingFunction({ + content: 'Hello world', + }); + expect(resultOfUpdating).toStrictEqual( + { + content: 'Hello world', + }, + true + ); }); }); describe('ColorControls', () => { - it('should render no color', () => { + it('should render default black color', () => { renderTextStyle([textElement]); - expect(controls['text.color'].value).toBeNull(); + expect(controls['text.color'].value).toStrictEqual(createSolid(0, 0, 0)); }); it('should render a color', () => { const textWithColor = { ...textElement, - color: createSolid(255, 0, 0), + content: 'Hello world', }; renderTextStyle([textWithColor]); expect(controls['text.color'].value).toStrictEqual( @@ -507,20 +533,26 @@ describe('Panels/TextStyle', () => { it('should set color', () => { const { pushUpdate } = renderTextStyle([textElement]); act(() => controls['text.color'].onChange(createSolid(0, 255, 0))); - expect(pushUpdate).toHaveBeenCalledWith( - { color: createSolid(0, 255, 0) }, + const updatingFunction = pushUpdate.mock.calls[0][0]; + const resultOfUpdating = updatingFunction({ + content: 'Hello world', + }); + expect(resultOfUpdating).toStrictEqual( + { + content: 'Hello world', + }, true ); }); - it('should set color with multi selection, same values', () => { + it('should detect color with multi selection, same values', () => { const textWithColor1 = { ...textElement, - color: createSolid(0, 0, 255), + content: 'Hello world', }; const textWithColor2 = { ...textElement, - color: createSolid(0, 0, 255), + content: 'Hello world', }; renderTextStyle([textWithColor1, textWithColor2]); expect(controls['text.color'].value).toStrictEqual( @@ -531,11 +563,11 @@ describe('Panels/TextStyle', () => { it('should set color with multi selection, different values', () => { const textWithColor1 = { ...textElement, - color: createSolid(255, 0, 0), + content: 'Hello world', }; const textWithColor2 = { ...textElement, - color: createSolid(0, 255, 0), + content: 'Hello world', }; renderTextStyle([textWithColor1, textWithColor2]); expect(controls['text.color'].value).toStrictEqual(MULTIPLE_VALUE); diff --git a/assets/src/edit-story/components/panels/textStyle/color.js b/assets/src/edit-story/components/panels/textStyle/color.js index 1e9e1518906a..7d16ace3adbb 100644 --- a/assets/src/edit-story/components/panels/textStyle/color.js +++ b/assets/src/edit-story/components/panels/textStyle/color.js @@ -37,6 +37,7 @@ import { Color, Label, Row, ToggleButton } from '../../form'; import { useKeyDownEffect } from '../../keyboard'; import { useCommonColorValue, getCommonValue } from '../utils'; import getColorPickerActions from '../utils/getColorPickerActions'; +import useRichTextFormatting from './useRichTextFormatting'; const FillRow = styled(Row)` align-items: flex-start; @@ -79,7 +80,6 @@ const BUTTONS = [ ]; function ColorControls({ selectedElements, pushUpdate }) { - const color = useCommonColorValue(selectedElements, 'color'); const backgroundColor = useCommonColorValue( selectedElements, 'backgroundColor' @@ -90,6 +90,11 @@ function ColorControls({ selectedElements, pushUpdate }) { ); const fillRow = useRef(); + const { + textInfo: { color }, + handlers: { handleSetColor }, + } = useRichTextFormatting(selectedElements, pushUpdate); + useKeyDownEffect( fillRow, ['left', 'right'], @@ -113,14 +118,7 @@ function ColorControls({ selectedElements, pushUpdate }) { - pushUpdate( - { - color: value, - }, - true - ) - } + onChange={handleSetColor} colorPickerActions={getColorPickerActions} /> diff --git a/assets/src/edit-story/components/panels/textStyle/font.js b/assets/src/edit-story/components/panels/textStyle/font.js index 93e6a1638baa..e199668c26fd 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 useRichTextFormatting from './useRichTextFormatting'; import getFontWeights from './getFontWeights'; const Space = styled.div` @@ -51,7 +52,11 @@ function FontControls({ selectedElements, pushUpdate }) { ({ font }) => font?.family ); const fontSize = getCommonValue(selectedElements, 'fontSize'); - const fontWeight = getCommonValue(selectedElements, 'fontWeight'); + + const { + textInfo: { fontWeight }, + handlers: { handleSelectFontWeight }, + } = useRichTextFormatting(selectedElements, pushUpdate); const { state: { fonts }, @@ -73,15 +78,6 @@ function FontControls({ selectedElements, pushUpdate }) { value={fontFamily} onChange={(value) => { const fontObj = fonts.find((item) => item.value === value); - const { weights } = fontObj; - - // Find the nearest font weight from the available font weight list - const newFontWeight = weights.reduce((a, b) => - Math.abs(parseInt(b) - fontWeight) < - Math.abs(parseInt(a) - fontWeight) - ? b - : a - ); pushUpdate( { @@ -95,7 +91,6 @@ function FontControls({ selectedElements, pushUpdate }) { 'variants', ]), }, - fontWeight: parseInt(newFontWeight), }, true ); @@ -109,11 +104,10 @@ function FontControls({ selectedElements, pushUpdate }) { - pushUpdate({ fontWeight: parseInt(value) }, true) - } + onChange={handleSelectFontWeight} /> diff --git a/assets/src/edit-story/components/panels/textStyle/textStyle.js b/assets/src/edit-story/components/panels/textStyle/textStyle.js index 7512e3cf48be..ba7edbfbb530 100644 --- a/assets/src/edit-story/components/panels/textStyle/textStyle.js +++ b/assets/src/edit-story/components/panels/textStyle/textStyle.js @@ -40,6 +40,7 @@ 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 useRichTextFormatting from './useRichTextFormatting'; const BoxedNumeric = styled(Numeric)` padding: 6px 6px; @@ -62,11 +63,17 @@ const Space = styled.div` function StylePanel({ selectedElements, pushUpdate }) { const textAlign = getCommonValue(selectedElements, 'textAlign'); - const letterSpacing = getCommonValue(selectedElements, 'letterSpacing'); const lineHeight = getCommonValue(selectedElements, 'lineHeight'); - const fontStyle = getCommonValue(selectedElements, 'fontStyle'); - const textDecoration = getCommonValue(selectedElements, 'textDecoration'); - const bold = getCommonValue(selectedElements, 'bold'); + + const { + textInfo: { isBold, isItalic, isUnderline, letterSpacing }, + handlers: { + handleClickBold, + handleClickItalic, + handleClickUnderline, + handleSetLetterSpacing, + }, + } = useRichTextFormatting(selectedElements, pushUpdate); return ( <> @@ -83,18 +90,10 @@ function StylePanel({ selectedElements, pushUpdate }) { } symbol="%" - onChange={(value) => - pushUpdate({ - letterSpacing: typeof value === 'number' ? value / 100 : value, - }) - } + onChange={handleSetLetterSpacing} /> @@ -128,28 +127,24 @@ function StylePanel({ selectedElements, pushUpdate }) { /> } - value={bold === true} + value={isBold} iconWidth={9} iconHeight={10} - onChange={(value) => pushUpdate({ bold: value }, true)} + onChange={handleClickBold} /> } - value={fontStyle === 'italic'} + value={isItalic} iconWidth={10} iconHeight={10} - onChange={(value) => - pushUpdate({ fontStyle: value ? 'italic' : 'normal' }, true) - } + onChange={handleClickItalic} /> } - value={textDecoration === 'underline'} + value={isUnderline} iconWidth={8} iconHeight={21} - onChange={(value) => - pushUpdate({ textDecoration: value ? 'underline' : 'none' }, true) - } + onChange={handleClickUnderline} /> diff --git a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js new file mode 100644 index 000000000000..7e277f26e5d2 --- /dev/null +++ b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js @@ -0,0 +1,142 @@ +/* + * 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 { useMemo, useCallback } from 'react'; + +/** + * Internal dependencies + */ +import isPatternEqual from '../../../utils/isPatternEqual'; +import useRichText from '../../richText/useRichText'; +import { + getHTMLFormatters, + getHTMLInfo, +} from '../../richText/htmlManipulation'; +import { MULTIPLE_VALUE } from '../../form'; + +/** + * Equality function for *primitives and color patterns* only. + * + * @param {any} a First value to compare + * @param {any} b Second value to compare + * @return {boolean} True if equal + */ +function isEqual(a, b) { + // patterns are truthy objects with either a type or a color attribute. + // Note: `null` is a falsy object, that would cause an error if first + // check is removed. + const isPattern = a && typeof a === 'object' && (a.type || a.color); + return !isPattern ? a === b : isPatternEqual(a, b); +} + +/** + * A function to gather the text info for multiple elements into a single + * one. + * + * The text info object contains a number of values that can be either + * primitives or a color object. + * + * If any two objects for the same key have different values, return + * `MULTIPLE_VALUE`. Uses `isEqual` to determine this equality.` + * + * @param {Object} reduced Currently reduced object from previous elements + * - will be empty for first object + * @param {Object} info Info about current object + * @return {Object} Combination of object as described. + */ +function reduceWithMultiple(reduced, info) { + return Object.fromEntries( + Object.keys(info).map((key) => { + const wasMultiple = reduced[key] === MULTIPLE_VALUE; + const hadValue = typeof reduced[key] !== 'undefined'; + const areDifferent = hadValue && !isEqual(reduced[key], info[key]); + if (wasMultiple || areDifferent) { + return [key, MULTIPLE_VALUE]; + } + return [key, info[key]]; + }) + ); +} + +function useRichTextFormatting(selectedElements, pushUpdate) { + const { + state: { hasCurrentEditor, selectionInfo }, + actions: { selectionActions }, + } = useRichText(); + + const textInfo = useMemo(() => { + if (hasCurrentEditor) { + return selectionInfo; + } + + // loop over all elements, find info for content and reduce to common value + // (setting MULTIPLE_VALUE appropriately) + return selectedElements + .map(({ content }) => content) + .map(getHTMLInfo) + .reduce(reduceWithMultiple, {}); + }, [hasCurrentEditor, selectionInfo, selectedElements]); + + const push = useCallback( + (updater, ...args) => + pushUpdate( + ({ content }) => ({ content: updater(content, ...args) }), + true + ), + [pushUpdate] + ); + + const handlers = useMemo(() => { + if (hasCurrentEditor) { + return { + // This particular function ignores the flag argument. + // Bold for inline selection has its own logic for + // determining proper resulting bold weight + handleClickBold: () => selectionActions.toggleBoldInSelection(), + // All these keep their arguments: + handleSelectFontWeight: selectionActions.setFontWeightInSelection, + handleClickItalic: selectionActions.toggleItalicInSelection, + handleClickUnderline: selectionActions.toggleUnderlineInSelection, + handleSetLetterSpacing: selectionActions.setLetterSpacingInSelection, + handleSetColor: selectionActions.setColorInSelection, + }; + } + + const htmlFormatters = getHTMLFormatters(); + + return { + handleClickBold: (flag) => push(htmlFormatters.toggleBold, flag), + handleSelectFontWeight: (weight) => + push(htmlFormatters.setFontWeight, weight), + handleClickItalic: (flag) => push(htmlFormatters.toggleItalic, flag), + handleClickUnderline: (flag) => + push(htmlFormatters.toggleUnderline, flag), + handleSetLetterSpacing: (letterSpacing) => + push(htmlFormatters.setLetterSpacing, letterSpacing), + handleSetColor: (color) => push(htmlFormatters.setColor, color), + }; + }, [hasCurrentEditor, selectionActions, push]); + + return { + textInfo, + handlers, + }; +} + +export default useRichTextFormatting; diff --git a/assets/src/edit-story/components/richText/context.js b/assets/src/edit-story/components/richText/context.js new file mode 100644 index 000000000000..7797a50aab8d --- /dev/null +++ b/assets/src/edit-story/components/richText/context.js @@ -0,0 +1,24 @@ +/* + * 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 { createContext } from 'react'; + +const RichTextContext = createContext({ state: {}, actions: {} }); + +export default RichTextContext; diff --git a/assets/src/edit-story/components/richText/customConstants.js b/assets/src/edit-story/components/richText/customConstants.js new file mode 100644 index 000000000000..996021c120fb --- /dev/null +++ b/assets/src/edit-story/components/richText/customConstants.js @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export const NONE = 'NONE'; +export const ITALIC = 'CUSTOM-ITALIC'; +export const UNDERLINE = 'CUSTOM-UNDERLINE'; +export const WEIGHT = 'CUSTOM-WEIGHT'; +export const COLOR = 'CUSTOM-COLOR'; +export const LETTERSPACING = 'CUSTOM-LETTERSPACING'; diff --git a/assets/src/edit-story/components/richText/customExport.js b/assets/src/edit-story/components/richText/customExport.js new file mode 100644 index 000000000000..9671e28ec037 --- /dev/null +++ b/assets/src/edit-story/components/richText/customExport.js @@ -0,0 +1,56 @@ +/* + * 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 { stateToHTML } from 'draft-js-export-html'; + +/** + * Internal dependencies + */ +import formatters from './formatters'; + +function inlineStyleFn(styles) { + const inlineCSS = formatters.reduce( + (css, { stylesToCSS }) => ({ ...css, ...stylesToCSS(styles) }), + {} + ); + + if (Object.keys(inlineCSS).length === 0) { + return null; + } + + return { + element: 'span', + style: inlineCSS, + }; +} + +function exportHTML(editorState) { + if (!editorState) { + return null; + } + + const html = stateToHTML(editorState.getCurrentContent(), { + inlineStyleFn, + defaultBlockTag: null, + }); + + return html.replace(/
/g, '').replace(/ $/, ''); +} + +export default exportHTML; diff --git a/assets/src/edit-story/components/richText/customImport.js b/assets/src/edit-story/components/richText/customImport.js new file mode 100644 index 000000000000..87dc321c0b24 --- /dev/null +++ b/assets/src/edit-story/components/richText/customImport.js @@ -0,0 +1,52 @@ +/* + * 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 { stateFromHTML } from 'draft-js-import-html'; + +/** + * Internal dependencies + */ +import getValidHTML from '../../utils/getValidHTML'; +import formatters from './formatters'; + +function customInlineFn(element, { Style }) { + const styleStrings = formatters + .map(({ elementToStyle }) => elementToStyle(element)) + .filter((style) => Boolean(style)); + + if (styleStrings.length === 0) { + return null; + } + + return Style(styleStrings); +} + +function importHTML(html) { + const htmlWithBreaks = (html || '') + // Re-insert manual line-breaks for empty lines + .replace(/\n(?=\n)/g, '\n
') + .split('\n') + .map((s) => { + return `

${getValidHTML(s)}

`; + }) + .join(''); + return stateFromHTML(htmlWithBreaks, { customInlineFn }); +} + +export default importHTML; diff --git a/assets/src/edit-story/components/richText/customInlineDisplay.js b/assets/src/edit-story/components/richText/customInlineDisplay.js new file mode 100644 index 000000000000..8e75fb6297a4 --- /dev/null +++ b/assets/src/edit-story/components/richText/customInlineDisplay.js @@ -0,0 +1,35 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import formatters from './formatters'; +import { fauxStylesToCSS } from './fauxSelection'; + +function customInlineDisplay(styles) { + const stylesToCSSConverters = [ + ...formatters.map(({ stylesToCSS }) => stylesToCSS), + fauxStylesToCSS, + ]; + + return stylesToCSSConverters.reduce( + (css, stylesToCSS) => ({ ...css, ...stylesToCSS(styles) }), + {} + ); +} + +export default customInlineDisplay; diff --git a/assets/src/edit-story/components/richText/draftUtils.js b/assets/src/edit-story/components/richText/draftUtils.js new file mode 100644 index 000000000000..356c9a05b85d --- /dev/null +++ b/assets/src/edit-story/components/richText/draftUtils.js @@ -0,0 +1,75 @@ +/* + * 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. + */ + +/* Ignore reason: This is lifted from elsewhere - a combo of these basically: + * + * https://github.com/webdeveloperpr/draft-js-custom-styles/blob/f3e6b533905de8eee6da54f9727b5e5803d53fc4/src/index.js#L8-L52 + * https://github.com/facebook/draft-js/issues/602#issuecomment-584676405 + */ +/* istanbul ignore next */ + +/** + * This returns *an array of sets of styles* for all currently selected + * characters. + * + * If you have the following states with html tags representing styles + * and [] representing selection),you get the following returns: + * + * + * input: Hel[lo w]orld + * output: [Set(), Set(), Set(), Set()] + * + * input: Hel[lo w]orld + * output: [Set("BOLD"), Set("BOLD"), Set(), Set()] + * + * input: Hel[lo w]orld + * output: [Set("BOLD"), Set("BOLD", "ITALIC"), Set(), Set("UNDERLINE")] + * + * + * @param {Object} editorState The current state of the editor including + * selection + * @return {Array.>} list of sets of styles as described + */ +export function getAllStyleSetsInSelection(editorState) { + const styleSets = []; + const contentState = editorState.getCurrentContent(); + const selection = editorState.getSelection(); + let key = selection.getStartKey(); + let startOffset = selection.getStartOffset(); + const endKey = selection.getEndKey(); + const endOffset = selection.getEndOffset(); + let hasMoreRounds = true; + while (hasMoreRounds) { + hasMoreRounds = key !== endKey; + const block = contentState.getBlockForKey(key); + const offsetEnd = hasMoreRounds ? block.getLength() : endOffset; + const characterList = block.getCharacterList(); + for ( + let offsetIndex = startOffset; + offsetIndex < offsetEnd; + offsetIndex++ + ) { + styleSets.push(characterList.get(offsetIndex).getStyle()); + } + if (!hasMoreRounds) { + break; + } + key = contentState.getKeyAfter(key); + startOffset = 0; + } + + return styleSets; +} diff --git a/assets/src/edit-story/components/richText/editor.js b/assets/src/edit-story/components/richText/editor.js new file mode 100644 index 000000000000..47385b1cf830 --- /dev/null +++ b/assets/src/edit-story/components/richText/editor.js @@ -0,0 +1,101 @@ +/* + * 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 { Editor } from 'draft-js'; +import PropTypes from 'prop-types'; +import { useEffect, useRef, useImperativeHandle, forwardRef } from 'react'; + +/** + * Internal dependencies + */ +import useUnmount from '../../utils/useUnmount'; +import useRichText from './useRichText'; +import customInlineDisplay from './customInlineDisplay'; + +function RichTextEditor({ content, onChange }, ref) { + const editorRef = useRef(null); + const { + state: { editorState }, + actions: { + setStateFromContent, + updateEditorState, + getHandleKeyCommand, + getContentFromState, + clearState, + }, + } = useRichText(); + + // Load state from parent when content changes + useEffect(() => { + setStateFromContent(content); + }, [setStateFromContent, content]); + + // Push updates to parent when state changes + useEffect(() => { + if (!editorState) { + return; + } + const newContent = getContentFromState(editorState); + if (newContent) { + onChange(newContent); + } + }, [onChange, getContentFromState, editorState]); + + const hasEditorState = Boolean(editorState); + + // On unmount, clear state in provider + useUnmount(clearState); + + // Allow parent to focus editor and access main node + useImperativeHandle( + ref, + () => ({ + focus: () => editorRef.current?.focus?.(), + getNode: () => editorRef.current?.editorContainer, + }), + [] + ); + + if (!hasEditorState) { + return null; + } + + // Handle basic key commands such as bold, italic and underscore. + const handleKeyCommand = getHandleKeyCommand(); + + return ( + + ); +} + +const RichTextEditorWithRef = forwardRef(RichTextEditor); + +RichTextEditor.propTypes = { + content: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default RichTextEditorWithRef; diff --git a/assets/src/edit-story/components/richText/fauxSelection.js b/assets/src/edit-story/components/richText/fauxSelection.js new file mode 100644 index 000000000000..f749416310c4 --- /dev/null +++ b/assets/src/edit-story/components/richText/fauxSelection.js @@ -0,0 +1,126 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { EditorState, Modifier } from 'draft-js'; + +const FAUX_SELECTION = 'CUSTOM-FAUX'; + +function isEqualSelectionIgnoreFocus(a, b) { + if (!a || !b) { + return false; + } + const aWithoutFocus = a.serialize().replace(/has focus: .*$/i, ''); + const bWithoutFocus = b.serialize().replace(/has focus: .*$/i, ''); + return aWithoutFocus === bWithoutFocus; +} + +/** + * A hook to properly set and remove faux selection style. + * + * If current selection in editor is unfocused, set faux style on current selection + * else, if current selection in editor is focused, remove faux style from entire editor + * + * @param {Object} editorState Current editor state + * @param {Function} setEditorState Callback to update current editor state + * @return {void} + */ +export function useFauxSelection(editorState, setEditorState) { + const [fauxSelection, setFauxSelection] = useState(null); + useEffect(() => { + if (!editorState) { + setFauxSelection(null); + return; + } + const content = editorState.getCurrentContent(); + const currentSelection = editorState.getSelection(); + const isFocused = currentSelection.getHasFocus(); + const hasSelectionChanged = !isEqualSelectionIgnoreFocus( + fauxSelection, + currentSelection + ); + const hasFauxSelection = Boolean(fauxSelection); + + if (!isFocused && !hasFauxSelection) { + // Get new content with style applied to selection + const contentWithFaux = Modifier.applyInlineStyle( + content, + currentSelection, + FAUX_SELECTION + ); + + // Push to get a new state + const stateWithFaux = EditorState.push( + editorState, + contentWithFaux, + 'change-inline-style' + ); + + // Save that as the next editor state + setEditorState(stateWithFaux); + + // And remember what we marked + setFauxSelection(currentSelection); + } + + if (isFocused && hasSelectionChanged && hasFauxSelection) { + setEditorState((oldEditorState) => { + try { + // Get new content with style removed from old selection + const contentWithoutFaux = Modifier.removeInlineStyle( + oldEditorState.getCurrentContent(), + fauxSelection, + FAUX_SELECTION + ); + + // Push to get a new state + const stateWithoutFaux = EditorState.push( + oldEditorState, + contentWithoutFaux, + 'change-inline-style' + ); + + // Force selection + const selectedState = EditorState.forceSelection( + stateWithoutFaux, + oldEditorState.getSelection() + ); + + // Save that as the next editor state + return selectedState; + } catch (e) { + // If the component has unmounted/remounted, some of the above might throw + // if so, just ignore it and return old state + return oldEditorState; + } + }); + + // And forget that we ever marked anything + setFauxSelection(null); + } + }, [fauxSelection, editorState, setEditorState]); +} + +export function fauxStylesToCSS(styles) { + const hasFauxSelection = styles.includes(FAUX_SELECTION); + if (!hasFauxSelection) { + return null; + } + return { backgroundColor: 'rgba(169, 169, 169, 0.7)' }; +} diff --git a/assets/src/edit-story/components/richText/formatters/color.js b/assets/src/edit-story/components/richText/formatters/color.js new file mode 100644 index 000000000000..690b1b310247 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/color.js @@ -0,0 +1,103 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { getHexFromSolid, getSolidFromHex } from '../../../utils/patternUtils'; +import isPatternEqual from '../../../utils/isPatternEqual'; +import createSolidFromString from '../../../utils/createSolidFromString'; +import createSolid from '../../../utils/createSolid'; +import generatePatternStyles from '../../../utils/generatePatternStyles'; +import { MULTIPLE_VALUE } from '../../form'; +import { NONE, COLOR } from '../customConstants'; +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../styleManipulation'; +import { isStyle, getVariable } from './util'; + +/* + * Color uses PREFIX-XXXXXXXX where XXXXXXXX is the 8 digit + * hex represenation of the RGBA color. + */ +const styleToColor = (style) => getSolidFromHex(getVariable(style, COLOR)); + +const colorToStyle = (color) => `${COLOR}-${getHexFromSolid(color)}`; + +function elementToStyle(element) { + const isSpan = element.tagName.toLowerCase() === 'span'; + const rawColor = element.style.color; + const hasColor = Boolean(rawColor); + if (isSpan && hasColor) { + const solid = createSolidFromString(rawColor); + return colorToStyle(solid); + } + + return null; +} + +function stylesToCSS(styles) { + const style = styles.find((someStyle) => isStyle(someStyle, COLOR)); + if (!style) { + return null; + } + let color; + try { + color = styleToColor(style); + } catch (e) { + return null; + } + + return generatePatternStyles(color, 'color'); +} + +function getColor(editorState) { + const styles = getPrefixStylesInSelection(editorState, COLOR); + if (styles.length > 1) { + return MULTIPLE_VALUE; + } + const colorStyle = styles[0]; + if (colorStyle === NONE) { + return createSolid(0, 0, 0); + } + return styleToColor(colorStyle); +} + +function setColor(editorState, color) { + // opaque black is default, and isn't necessary to set + const isBlack = isPatternEqual(createSolid(0, 0, 0), color); + const shouldSetStyle = () => !isBlack; + + // the style util manages conversion + const getStyleToSet = () => colorToStyle(color); + + return togglePrefixStyle(editorState, COLOR, shouldSetStyle, getStyleToSet); +} + +const formatter = { + elementToStyle, + stylesToCSS, + autoFocus: false, + getters: { + color: getColor, + }, + setters: { + setColor, + }, +}; + +export default formatter; diff --git a/assets/src/edit-story/components/richText/formatters/index.js b/assets/src/edit-story/components/richText/formatters/index.js new file mode 100644 index 000000000000..b85d981edc02 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/index.js @@ -0,0 +1,34 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import weightFormatter from './weight'; +import italicFormatter from './italic'; +import underlineFormatter from './underline'; +import colorFormatter from './color'; +import letterSpacingFormatter from './letterSpacing'; + +const formatters = [ + weightFormatter, + italicFormatter, + underlineFormatter, + colorFormatter, + letterSpacingFormatter, +]; + +export default formatters; diff --git a/assets/src/edit-story/components/richText/formatters/italic.js b/assets/src/edit-story/components/richText/formatters/italic.js new file mode 100644 index 000000000000..e829bb1aff42 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/italic.js @@ -0,0 +1,68 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { NONE, ITALIC } from '../customConstants'; +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../styleManipulation'; + +function elementToStyle(element) { + const isSpan = element.tagName.toLowerCase() === 'span'; + const isItalicFontStyle = element.style.fontStyle === 'italic'; + if (isSpan && isItalicFontStyle) { + return ITALIC; + } + + return null; +} + +function stylesToCSS(styles) { + const hasItalic = styles.includes(ITALIC); + if (!hasItalic) { + return null; + } + return { fontStyle: 'italic' }; +} + +function isItalic(editorState) { + const styles = getPrefixStylesInSelection(editorState, ITALIC); + return !styles.includes(NONE); +} + +function toggleItalic(editorState, flag) { + if (typeof flag === 'boolean') { + return togglePrefixStyle(editorState, ITALIC, () => flag); + } + return togglePrefixStyle(editorState, ITALIC); +} + +const formatter = { + elementToStyle, + stylesToCSS, + autoFocus: true, + getters: { + isItalic, + }, + setters: { + toggleItalic, + }, +}; + +export default formatter; diff --git a/assets/src/edit-story/components/richText/formatters/letterSpacing.js b/assets/src/edit-story/components/richText/formatters/letterSpacing.js new file mode 100644 index 000000000000..5176d9b36ec1 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/letterSpacing.js @@ -0,0 +1,99 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { MULTIPLE_VALUE } from '../../form'; +import { NONE, LETTERSPACING } from '../customConstants'; +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../styleManipulation'; +import { isStyle, numericToStyle, styleToNumeric } from './util'; + +function letterSpacingToStyle(weight) { + return numericToStyle(LETTERSPACING, weight); +} + +function styleToLetterSpacing(style) { + return styleToNumeric(LETTERSPACING, style); +} + +function elementToStyle(element) { + const isSpan = element.tagName.toLowerCase() === 'span'; + // This will implicitly strip any trailing unit from the value - it's assumed to be em + const letterSpacing = parseFloat(element.style.letterSpacing); + const hasLetterSpacing = letterSpacing && !isNaN(letterSpacing); + if (isSpan && hasLetterSpacing) { + return letterSpacingToStyle(letterSpacing * 100); + } + + return null; +} + +function stylesToCSS(styles) { + const style = styles.find((someStyle) => isStyle(someStyle, LETTERSPACING)); + if (!style) { + return null; + } + const letterSpacing = styleToLetterSpacing(style); + if (!letterSpacing) { + return null; + } + return { letterSpacing: `${letterSpacing / 100}em` }; +} + +function getLetterSpacing(editorState) { + const styles = getPrefixStylesInSelection(editorState, LETTERSPACING); + if (styles.length > 1) { + return MULTIPLE_VALUE; + } + const spacingStyle = styles[0]; + if (spacingStyle === NONE) { + return 0; + } + return styleToLetterSpacing(spacingStyle); +} + +function setLetterSpacing(editorState, letterSpacing) { + // if the spacing to set to non-0, set a style + const shouldSetStyle = () => letterSpacing !== 0; + + // and if we're setting a style, it's the style for the spacing of course + const getStyleToSet = () => letterSpacingToStyle(letterSpacing); + + return togglePrefixStyle( + editorState, + LETTERSPACING, + shouldSetStyle, + getStyleToSet + ); +} + +const formatter = { + elementToStyle, + stylesToCSS, + autoFocus: false, + getters: { + letterSpacing: getLetterSpacing, + }, + setters: { + setLetterSpacing, + }, +}; + +export default formatter; diff --git a/assets/src/edit-story/components/richText/formatters/test/_utils.js b/assets/src/edit-story/components/richText/formatters/test/_utils.js new file mode 100644 index 000000000000..f07eeab5a5a0 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/test/_utils.js @@ -0,0 +1,26 @@ +/* + * 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 { render } from 'react-dom'; + +export function getDOMElement(jsx) { + const el = document.createElement('div'); + render(jsx, el); + return el.firstChild; +} diff --git a/assets/src/edit-story/components/richText/formatters/test/color.js b/assets/src/edit-story/components/richText/formatters/test/color.js new file mode 100644 index 000000000000..07718fe663aa --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/test/color.js @@ -0,0 +1,177 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import createSolid from '../../../../utils/createSolid'; +import { MULTIPLE_VALUE } from '../../../form'; +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../../styleManipulation'; +import { NONE, COLOR } from '../../customConstants'; +import formatter from '../color'; +import { getDOMElement } from './_utils'; + +jest.mock('../../styleManipulation', () => { + return { + togglePrefixStyle: jest.fn(), + getPrefixStylesInSelection: jest.fn(), + }; +}); + +getPrefixStylesInSelection.mockImplementation(() => [NONE]); + +describe('Color formatter', () => { + const { elementToStyle, stylesToCSS, getters, setters } = formatter; + + beforeEach(() => { + togglePrefixStyle.mockClear(); + getPrefixStylesInSelection.mockClear(); + }); + + describe('elementToStyle', () => { + function setup(element) { + return elementToStyle(getDOMElement(element)); + } + + it('should ignore non-span elements', () => { + const element =
; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should ignore span elements without color style property', () => { + const element = ; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should extract color without opacity from span elements and return correct style', () => { + const element = ; + const style = setup(element); + const expected = `${COLOR}-ff000064`; + + expect(style).toBe(expected); + }); + + it('should extract color with opacity from span elements and return correct style', () => { + const element = ; + const style = setup(element); + const expected = `${COLOR}-00ff0032`; + + expect(style).toBe(expected); + }); + }); + + describe('stylesToCSS', () => { + it('should ignore styles without a color style', () => { + const css = stylesToCSS(['NOT-COLOR', 'ALSO-NOT-COLOR']); + + expect(css).toBeNull(); + }); + + it('should ignore invalid color style', () => { + const css = stylesToCSS([`${COLOR}-invalid`]); + + expect(css).toBeNull(); + }); + + it('should return correct CSS for a valid color style', () => { + const css = stylesToCSS([`${COLOR}-ff000032`]); + + expect(css).toStrictEqual({ color: 'rgba(255,0,0,0.5)' }); + }); + }); + + describe('getters', () => { + it('should contain color property with getter', () => { + expect(getters).toContainAllKeys(['color']); + expect(getters.color).toStrictEqual(expect.any(Function)); + }); + + it('should invoke getPrefixStylesInSelection with given state and correct style prefix', () => { + const state = {}; + getters.color(state); + expect(getPrefixStylesInSelection).toHaveBeenCalledWith(state, COLOR); + }); + + function setup(styleArray) { + getPrefixStylesInSelection.mockImplementationOnce(() => styleArray); + return getters.color({}); + } + + it('should return multiple if more than one style matches', () => { + const styles = [`${COLOR}-ff000064`, `${COLOR}-ffff0064`]; + const result = setup(styles); + expect(result).toBe(MULTIPLE_VALUE); + }); + + it('should return default black if no style matches', () => { + const styles = [NONE]; + const result = setup(styles); + expect(result).toStrictEqual(createSolid(0, 0, 0)); + }); + + it('should return parsed color if exactly one style matches', () => { + const styles = [`${COLOR}-ffff0032`]; + const result = setup(styles); + expect(result).toStrictEqual(createSolid(255, 255, 0, 0.5)); + }); + }); + + describe('setters', () => { + it('should contain setColor property with function', () => { + expect(setters).toContainAllKeys(['setColor']); + expect(setters.setColor).toStrictEqual(expect.any(Function)); + }); + + it('should invoke togglePrefixStyle correctly with non-black color', () => { + const state = {}; + const color = createSolid(255, 0, 255); + setters.setColor(state, color); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + COLOR, + expect.any(Function), + expect.any(Function) + ); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(true); + + // Fourth argument is actual style to set + const styleToSet = togglePrefixStyle.mock.calls[0][3]; + expect(styleToSet()).toStrictEqual(`${COLOR}-ff00ff64`); + }); + + it('should invoke togglePrefixStyle correctly with black color', () => { + const state = {}; + const color = createSolid(0, 0, 0); + setters.setColor(state, color); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(false); + + // Fourth argument is ignored + }); + }); +}); diff --git a/assets/src/edit-story/components/richText/formatters/test/italic.js b/assets/src/edit-story/components/richText/formatters/test/italic.js new file mode 100644 index 000000000000..ba96c8ee1df5 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/test/italic.js @@ -0,0 +1,163 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../../styleManipulation'; +import { NONE, ITALIC } from '../../customConstants'; +import formatter from '../italic'; +import { getDOMElement } from './_utils'; + +jest.mock('../../styleManipulation', () => { + return { + togglePrefixStyle: jest.fn(), + getPrefixStylesInSelection: jest.fn(), + }; +}); + +getPrefixStylesInSelection.mockImplementation(() => [NONE]); + +describe('Italic formatter', () => { + beforeEach(() => { + togglePrefixStyle.mockClear(); + getPrefixStylesInSelection.mockClear(); + }); + + const { elementToStyle, stylesToCSS, getters, setters } = formatter; + + describe('elementToStyle', () => { + function setup(element) { + return elementToStyle(getDOMElement(element)); + } + + it('should ignore non-span elements', () => { + const element =
; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should ignore span elements without italic font style', () => { + const element = ; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should detect italic from span elements and return correct style', () => { + const element = ; + const style = setup(element); + const expected = ITALIC; + + expect(style).toBe(expected); + }); + }); + + describe('stylesToCSS', () => { + it('should ignore styles without italic style', () => { + const css = stylesToCSS(['NOT-ITALIC', 'ALSO-NOT-ITALIC']); + + expect(css).toBeNull(); + }); + + it('should return correct CSS if italic is present', () => { + const css = stylesToCSS([ITALIC]); + + expect(css).toStrictEqual({ fontStyle: 'italic' }); + }); + }); + + describe('getters', () => { + it('should contain isItalic property with getter', () => { + expect(getters).toContainAllKeys(['isItalic']); + expect(getters.isItalic).toStrictEqual(expect.any(Function)); + }); + + it('should invoke getPrefixStylesInSelection with given state and correct style prefix', () => { + const state = {}; + getters.isItalic(state); + expect(getPrefixStylesInSelection).toHaveBeenCalledWith(state, ITALIC); + }); + + function setup(styleArray) { + getPrefixStylesInSelection.mockImplementationOnce(() => styleArray); + return getters.isItalic({}); + } + + it('should return false if both italic and non-italic', () => { + const styles = [NONE, ITALIC]; + const result = setup(styles); + expect(result).toBe(false); + }); + + it('should return false if no style matches', () => { + const styles = [NONE]; + const result = setup(styles); + expect(result).toStrictEqual(false); + }); + + it('should return true if only italic', () => { + const styles = [ITALIC]; + const result = setup(styles); + expect(result).toStrictEqual(true); + }); + }); + + describe('setters', () => { + it('should contain toggleItalic property with function', () => { + expect(setters).toContainAllKeys(['toggleItalic']); + expect(setters.toggleItalic).toStrictEqual(expect.any(Function)); + }); + + it('should invoke togglePrefixStyle with state and prefix', () => { + const state = {}; + setters.toggleItalic(state); + expect(togglePrefixStyle).toHaveBeenCalledWith(state, ITALIC); + }); + + it('should invoke togglePrefixStyle correctly for explicitly setting italic to false', () => { + const state = {}; + setters.toggleItalic(state, false); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + ITALIC, + expect.any(Function) + ); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(false); + }); + + it('should invoke togglePrefixStyle correctly for explicitly setting italic to true', () => { + const state = {}; + setters.toggleItalic(state, true); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + ITALIC, + expect.any(Function) + ); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(true); + }); + }); +}); diff --git a/assets/src/edit-story/components/richText/formatters/test/letterSpacing.js b/assets/src/edit-story/components/richText/formatters/test/letterSpacing.js new file mode 100644 index 000000000000..3fa57246b4dd --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/test/letterSpacing.js @@ -0,0 +1,183 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { MULTIPLE_VALUE } from '../../../form'; +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../../styleManipulation'; +import { NONE, LETTERSPACING } from '../../customConstants'; +import formatter from '../letterSpacing'; +import { getDOMElement } from './_utils'; + +jest.mock('../../styleManipulation', () => { + return { + togglePrefixStyle: jest.fn(), + getPrefixStylesInSelection: jest.fn(), + }; +}); + +getPrefixStylesInSelection.mockImplementation(() => [NONE]); + +describe('Color formatter', () => { + beforeEach(() => { + togglePrefixStyle.mockClear(); + getPrefixStylesInSelection.mockClear(); + }); + + const { elementToStyle, stylesToCSS, getters, setters } = formatter; + + describe('elementToStyle', () => { + function setup(element) { + return elementToStyle(getDOMElement(element)); + } + + it('should ignore non-span elements', () => { + const element =
; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should ignore span elements without letter spacing style property', () => { + const element = ; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should extract letter spacing from span elements and return correct style', () => { + const element = ; + const style = setup(element); + const expected = `${LETTERSPACING}-150`; + + expect(style).toBe(expected); + }); + }); + + describe('stylesToCSS', () => { + it('should ignore styles without a letter spacing style', () => { + const css = stylesToCSS(['NOT-LETTERSPACING', 'ALSO-NOT-LETTERSPACING']); + + expect(css).toBeNull(); + }); + + it('should ignore invalid letter spacing style', () => { + const css = stylesToCSS([`${LETTERSPACING}-invalid`]); + + expect(css).toBeNull(); + }); + + it('should return correct CSS for a positive style', () => { + const css = stylesToCSS([`${LETTERSPACING}-150`]); + + expect(css).toStrictEqual({ letterSpacing: '1.5em' }); + }); + + it('should return correct CSS for a negative style', () => { + const css = stylesToCSS([`${LETTERSPACING}-N250`]); + + expect(css).toStrictEqual({ letterSpacing: '-2.5em' }); + }); + }); + + describe('getters', () => { + it('should contain letterSpacing property with getter', () => { + expect(getters).toContainAllKeys(['letterSpacing']); + expect(getters.letterSpacing).toStrictEqual(expect.any(Function)); + }); + + it('should invoke getPrefixStylesInSelection with given state and correct style prefix', () => { + const state = {}; + getters.letterSpacing(state); + expect(getPrefixStylesInSelection).toHaveBeenCalledWith( + state, + LETTERSPACING + ); + }); + + function setup(styleArray) { + getPrefixStylesInSelection.mockImplementationOnce(() => styleArray); + return getters.letterSpacing({}); + } + + it('should return multiple if more than one style matches', () => { + const styles = [`${LETTERSPACING}-150`, `${LETTERSPACING}-N100`]; + const result = setup(styles); + expect(result).toBe(MULTIPLE_VALUE); + }); + + it('should return default 0 if no style matches', () => { + const styles = [NONE]; + const result = setup(styles); + expect(result).toStrictEqual(0); + }); + + it('should return parsed letter spacing if exactly one style matches', () => { + const styles = [`${LETTERSPACING}-34`]; + const result = setup(styles); + expect(result).toStrictEqual(34); + }); + }); + + describe('setters', () => { + it('should contain setLetterSpacing property with function', () => { + expect(setters).toContainAllKeys(['setLetterSpacing']); + expect(setters.setLetterSpacing).toStrictEqual(expect.any(Function)); + }); + + it('should invoke togglePrefixStyle with state and prefix', () => { + const state = {}; + const letterSpacing = 0; + setters.setLetterSpacing(state, letterSpacing); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + LETTERSPACING, + expect.any(Function), + expect.any(Function) + ); + }); + + it('should invoke togglePrefixStyle correctly for trivial letter spacing', () => { + const state = {}; + // 0 letter spacing is the trivial case, that doesn't need to be added + const letterSpacing = 0; + setters.setLetterSpacing(state, letterSpacing); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(false); + }); + + it('should invoke togglePrefixStyle correctly for non-trivial letter spacing', () => { + const state = {}; + // A non-zero letter spacing should be added as a style + const letterSpacing = -150; + setters.setLetterSpacing(state, letterSpacing); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(true); + + // Fourth argument is actual style to set + const styleToSet = togglePrefixStyle.mock.calls[0][3]; + expect(styleToSet()).toStrictEqual(`${LETTERSPACING}-N150`); + }); + }); +}); diff --git a/assets/src/edit-story/components/richText/formatters/test/underline.js b/assets/src/edit-story/components/richText/formatters/test/underline.js new file mode 100644 index 000000000000..80b143462332 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/test/underline.js @@ -0,0 +1,163 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../../styleManipulation'; +import { NONE, UNDERLINE } from '../../customConstants'; +import formatter from '../underline'; +import { getDOMElement } from './_utils'; + +jest.mock('../../styleManipulation', () => { + return { + togglePrefixStyle: jest.fn(), + getPrefixStylesInSelection: jest.fn(), + }; +}); + +getPrefixStylesInSelection.mockImplementation(() => [NONE]); + +describe('Underline formatter', () => { + beforeEach(() => { + togglePrefixStyle.mockClear(); + getPrefixStylesInSelection.mockClear(); + }); + + const { elementToStyle, stylesToCSS, getters, setters } = formatter; + + describe('elementToStyle', () => { + function setup(element) { + return elementToStyle(getDOMElement(element)); + } + + it('should ignore non-span elements', () => { + const element =
; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should ignore span elements without underline text decoration', () => { + const element = ; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should detect underline from span elements and return correct style', () => { + const element = ; + const style = setup(element); + const expected = UNDERLINE; + + expect(style).toBe(expected); + }); + }); + + describe('stylesToCSS', () => { + it('should ignore styles without underline style', () => { + const css = stylesToCSS(['NOT-UNDERLINE', 'ALSO-NOT-UNDERLINE']); + + expect(css).toBeNull(); + }); + + it('should return correct CSS if underline is present', () => { + const css = stylesToCSS([UNDERLINE]); + + expect(css).toStrictEqual({ textDecoration: 'underline' }); + }); + }); + + describe('getters', () => { + it('should contain isUnderline property with getter', () => { + expect(getters).toContainAllKeys(['isUnderline']); + expect(getters.isUnderline).toStrictEqual(expect.any(Function)); + }); + + it('should invoke getPrefixStylesInSelection with given state and correct style prefix', () => { + const state = {}; + getters.isUnderline(state); + expect(getPrefixStylesInSelection).toHaveBeenCalledWith(state, UNDERLINE); + }); + + function setup(styleArray) { + getPrefixStylesInSelection.mockImplementationOnce(() => styleArray); + return getters.isUnderline({}); + } + + it('should return false if both underline and non-underline', () => { + const styles = [NONE, UNDERLINE]; + const result = setup(styles); + expect(result).toBe(false); + }); + + it('should return false if no style matches', () => { + const styles = [NONE]; + const result = setup(styles); + expect(result).toStrictEqual(false); + }); + + it('should return true if only underline', () => { + const styles = [UNDERLINE]; + const result = setup(styles); + expect(result).toStrictEqual(true); + }); + }); + + describe('setters', () => { + it('should contain toggleUnderline property with function', () => { + expect(setters).toContainAllKeys(['toggleUnderline']); + expect(setters.toggleUnderline).toStrictEqual(expect.any(Function)); + }); + + it('should invoke togglePrefixStyle with state and prefix', () => { + const state = {}; + setters.toggleUnderline(state); + expect(togglePrefixStyle).toHaveBeenCalledWith(state, UNDERLINE); + }); + + it('should invoke togglePrefixStyle correctly for explicitly setting underline to false', () => { + const state = {}; + setters.toggleUnderline(state, false); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + UNDERLINE, + expect.any(Function) + ); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(false); + }); + + it('should invoke togglePrefixStyle correctly for explicitly setting underline to true', () => { + const state = {}; + setters.toggleUnderline(state, true); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + UNDERLINE, + expect.any(Function) + ); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(true); + }); + }); +}); diff --git a/assets/src/edit-story/components/richText/formatters/test/weight.js b/assets/src/edit-story/components/richText/formatters/test/weight.js new file mode 100644 index 000000000000..59f713446626 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/test/weight.js @@ -0,0 +1,259 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { MULTIPLE_VALUE } from '../../../form'; +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../../styleManipulation'; +import { NONE, WEIGHT } from '../../customConstants'; +import formatter from '../weight'; +import { getDOMElement } from './_utils'; + +jest.mock('../../styleManipulation', () => { + return { + togglePrefixStyle: jest.fn(), + getPrefixStylesInSelection: jest.fn(), + }; +}); + +getPrefixStylesInSelection.mockImplementation(() => [NONE]); + +describe('Color formatter', () => { + beforeEach(() => { + togglePrefixStyle.mockClear(); + getPrefixStylesInSelection.mockClear(); + }); + + const { elementToStyle, stylesToCSS, getters, setters } = formatter; + + describe('elementToStyle', () => { + function setup(element) { + return elementToStyle(getDOMElement(element)); + } + + it('should ignore non-span elements', () => { + const element =
; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should ignore span elements without font weight style property', () => { + const element = ; + const style = setup(element); + + expect(style).toBeNull(); + }); + + it('should extract font weight from span elements and return correct style', () => { + const element = ; + const style = setup(element); + const expected = `${WEIGHT}-600`; + + expect(style).toBe(expected); + }); + }); + + describe('stylesToCSS', () => { + it('should ignore styles without a font weight style', () => { + const css = stylesToCSS(['NOT-WEIGHT', 'ALSO-NOT-WEIGHT']); + + expect(css).toBeNull(); + }); + + it('should ignore invalid font weight style', () => { + const css = stylesToCSS([`${WEIGHT}-invalid`]); + + expect(css).toBeNull(); + }); + + it('should return correct CSS for a valid style', () => { + const css = stylesToCSS([`${WEIGHT}-500`]); + + expect(css).toStrictEqual({ fontWeight: 500 }); + }); + }); + + describe('getters', () => { + it('should contain weight and isBold properties with getters', () => { + expect(getters).toContainAllKeys(['fontWeight', 'isBold']); + expect(getters.fontWeight).toStrictEqual(expect.any(Function)); + expect(getters.isBold).toStrictEqual(expect.any(Function)); + }); + + it('should invoke getPrefixStylesInSelection with given state and correct style prefix', () => { + const state = {}; + getters.fontWeight(state); + expect(getPrefixStylesInSelection).toHaveBeenCalledWith(state, WEIGHT); + }); + + function setupFontWeight(styleArray) { + getPrefixStylesInSelection.mockImplementationOnce(() => styleArray); + return getters.fontWeight({}); + } + + it('should return multiple if more than one style matches', () => { + const styles = [`${WEIGHT}-400`, `${WEIGHT}-600`]; + const result = setupFontWeight(styles); + expect(result).toBe(MULTIPLE_VALUE); + }); + + it('should return default 400 if no style matches', () => { + const styles = [NONE]; + const result = setupFontWeight(styles); + expect(result).toStrictEqual(400); + }); + + it('should return parsed font weight if exactly one style matches', () => { + const styles = [`${WEIGHT}-700`]; + const result = setupFontWeight(styles); + expect(result).toStrictEqual(700); + }); + + function setupIsBold(styleArray) { + getPrefixStylesInSelection.mockImplementationOnce(() => styleArray); + return getters.isBold({}); + } + + it('should return false if mix of bold and non-bold', () => { + const styles = [`${WEIGHT}-300`, `${WEIGHT}-700`]; + const result = setupIsBold(styles); + expect(result).toBe(false); + }); + + it('should return false if no style matches', () => { + const styles = [NONE]; + const result = setupIsBold(styles); + expect(result).toStrictEqual(false); + }); + + it('should return true if all are bold', () => { + const styles = [`${WEIGHT}-800`, `${WEIGHT}-600`]; + const result = setupIsBold(styles); + expect(result).toStrictEqual(true); + }); + }); + + describe('setters', () => { + it('should contain setWeight and toggleBold properties with functions', () => { + expect(setters).toContainAllKeys(['setFontWeight', 'toggleBold']); + expect(setters.setFontWeight).toStrictEqual(expect.any(Function)); + expect(setters.toggleBold).toStrictEqual(expect.any(Function)); + }); + + it('should invoke togglePrefixStyle with state and prefix', () => { + const state = {}; + const weight = 0; + setters.setFontWeight(state, weight); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + WEIGHT, + expect.any(Function), + expect.any(Function) + ); + }); + + it('should invoke togglePrefixStyle correctly for trivial font weight', () => { + const state = {}; + // 400 font weight is the trivial case, that doesn't need to be added + const weight = 400; + setters.setFontWeight(state, weight); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(false); + }); + + it('should invoke togglePrefixStyle correctly for non-trivial font weight', () => { + const state = {}; + // A non-400 font weight should be added as a style + const weight = 900; + setters.setFontWeight(state, weight); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(true); + + // Fourth argument is actual style to set + const styleToSet = togglePrefixStyle.mock.calls[0][3]; + expect(styleToSet()).toStrictEqual(`${WEIGHT}-900`); + }); + + it('should invoke togglePrefixStyle correctly for explicitly setting bold to false', () => { + const state = {}; + setters.toggleBold(state, false); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + WEIGHT, + expect.any(Function) + ); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(false); + }); + + it('should invoke togglePrefixStyle correctly for explicitly setting bold to true', () => { + const state = {}; + setters.toggleBold(state, true); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + WEIGHT, + expect.any(Function), + expect.any(Function) + ); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle()).toBe(true); + + // Fourth argument is actual style to set + const styleToSet = togglePrefixStyle.mock.calls[0][3]; + expect(styleToSet()).toStrictEqual(`${WEIGHT}-700`); + }); + + it('should correctly determine if setting bold when toggling without explicit flag', () => { + const state = {}; + setters.toggleBold(state); + expect(togglePrefixStyle).toHaveBeenCalledWith( + state, + WEIGHT, + expect.any(Function), + expect.any(Function) + ); + + // Third argument is tester + const shouldSetStyle = togglePrefixStyle.mock.calls[0][2]; + expect(shouldSetStyle([NONE])).toBe(true); + expect(shouldSetStyle([`${WEIGHT}-300`, `${WEIGHT}-900`])).toBe(true); + expect(shouldSetStyle([`${WEIGHT}-600`, `${WEIGHT}-900`])).toBe(false); + + // Fourth argument is actual style to set + const styleToSet = togglePrefixStyle.mock.calls[0][3]; + expect(styleToSet([NONE])).toBe(`${WEIGHT}-700`); + expect(styleToSet([`${WEIGHT}-300`, `${WEIGHT}-600`])).toBe( + `${WEIGHT}-700` + ); + expect(styleToSet([`${WEIGHT}-300`, `${WEIGHT}-900`])).toBe( + `${WEIGHT}-900` + ); + }); + }); +}); diff --git a/assets/src/edit-story/components/richText/formatters/underline.js b/assets/src/edit-story/components/richText/formatters/underline.js new file mode 100644 index 000000000000..07102776e120 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/underline.js @@ -0,0 +1,68 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { NONE, UNDERLINE } from '../customConstants'; +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../styleManipulation'; + +function elementToStyle(element) { + const isSpan = element.tagName.toLowerCase() === 'span'; + const isUnderlineDecoration = element.style.textDecoration === 'underline'; + if (isSpan && isUnderlineDecoration) { + return UNDERLINE; + } + + return null; +} + +function stylesToCSS(styles) { + const hasUnderline = styles.includes(UNDERLINE); + if (!hasUnderline) { + return null; + } + return { textDecoration: 'underline' }; +} + +function isUnderline(editorState) { + const styles = getPrefixStylesInSelection(editorState, UNDERLINE); + return !styles.includes(NONE); +} + +function toggleUnderline(editorState, flag) { + if (typeof flag === 'boolean') { + return togglePrefixStyle(editorState, UNDERLINE, () => flag); + } + return togglePrefixStyle(editorState, UNDERLINE); +} + +const formatter = { + elementToStyle, + stylesToCSS, + autoFocus: true, + getters: { + isUnderline, + }, + setters: { + toggleUnderline, + }, +}; + +export default formatter; diff --git a/assets/src/edit-story/components/richText/formatters/util.js b/assets/src/edit-story/components/richText/formatters/util.js new file mode 100644 index 000000000000..90d0d0245d6a --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/util.js @@ -0,0 +1,36 @@ +/* + * 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. + */ + +export const isStyle = (style, prefix) => + typeof style === 'string' && style.startsWith(prefix); + +export const getVariable = (style, prefix) => style.slice(prefix.length + 1); + +/* + * Numerics use PREFIX-123 for the number 123 + * and PREFIX-N123 for the number -123. + */ +export const numericToStyle = (prefix, num) => + `${prefix}-${num < 0 ? 'N' : ''}${Math.abs(num)}`; + +export const styleToNumeric = (prefix, style) => { + const raw = getVariable(style, prefix); + // Negative numbers are prefixed with an N: + if (raw.charAt(0) === 'N') { + return -parseInt(raw.slice(1)); + } + return parseInt(raw); +}; diff --git a/assets/src/edit-story/components/richText/formatters/weight.js b/assets/src/edit-story/components/richText/formatters/weight.js new file mode 100644 index 000000000000..3b14d6fff604 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/weight.js @@ -0,0 +1,137 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import { MULTIPLE_VALUE } from '../../form'; +import { NONE, WEIGHT } from '../customConstants'; +import { + togglePrefixStyle, + getPrefixStylesInSelection, +} from '../styleManipulation'; +import { isStyle, numericToStyle, styleToNumeric } from './util'; + +const NORMAL_WEIGHT = 400; +const SMALLEST_BOLD = 600; +const DEFAULT_BOLD = 700; + +function weightToStyle(weight) { + return numericToStyle(WEIGHT, weight); +} + +function styleToWeight(style) { + return styleToNumeric(WEIGHT, style); +} + +function elementToStyle(element) { + const isSpan = element.tagName.toLowerCase() === 'span'; + const fontWeight = parseInt(element.style.fontWeight); + const hasFontWeight = fontWeight && !isNaN(fontWeight); + if (isSpan && hasFontWeight && fontWeight !== 400) { + return weightToStyle(fontWeight); + } + + return null; +} + +function stylesToCSS(styles) { + const style = styles.find((someStyle) => isStyle(someStyle, WEIGHT)); + if (!style) { + return null; + } + const fontWeight = styleToWeight(style); + if (!fontWeight) { + return null; + } + return { fontWeight }; +} + +// convert a set of weight styles to a set of weights +function getWeights(styles) { + return styles.map((style) => + style === NONE ? NORMAL_WEIGHT : styleToWeight(style) + ); +} + +function isBold(editorState) { + const styles = getPrefixStylesInSelection(editorState, WEIGHT); + const weights = getWeights(styles); + const allIsBold = weights.every((w) => w >= SMALLEST_BOLD); + return allIsBold; +} + +function toggleBold(editorState, flag) { + if (typeof flag === 'boolean') { + if (flag) { + const getDefault = () => weightToStyle(DEFAULT_BOLD); + return togglePrefixStyle(editorState, WEIGHT, () => true, getDefault); + } + + // No fourth arg needed as we're not setting a style + return togglePrefixStyle(editorState, WEIGHT, () => false); + } + + // if no flag is set, determine these values from current weights present + + // if any character has weight less than SMALLEST_BOLD, + // everything should be bolded + const shouldSetBold = (styles) => + getWeights(styles).some((w) => w < SMALLEST_BOLD); + + // if setting a bold, it should be the boldest current weight, + // though at least DEFAULT_BOLD + const getBoldToSet = (styles) => + weightToStyle(Math.max(...[DEFAULT_BOLD].concat(getWeights(styles)))); + + return togglePrefixStyle(editorState, WEIGHT, shouldSetBold, getBoldToSet); +} + +function getFontWeight(editorState) { + const styles = getPrefixStylesInSelection(editorState, WEIGHT); + const weights = getWeights(styles); + if (weights.length > 1) { + return MULTIPLE_VALUE; + } + return weights[0]; +} + +function setFontWeight(editorState, weight) { + // if the weight to set is non-400, set a style + // (if 400 is target, all other weights are just removed, and we're good) + const shouldSetStyle = () => weight !== 400; + + // and if we're setting a style, it's the style for the weight of course + const getBoldToSet = () => weightToStyle(weight); + + return togglePrefixStyle(editorState, WEIGHT, shouldSetStyle, getBoldToSet); +} + +const formatter = { + elementToStyle, + stylesToCSS, + autoFocus: true, + getters: { + isBold, + fontWeight: getFontWeight, + }, + setters: { + toggleBold, + setFontWeight, + }, +}; + +export default formatter; diff --git a/assets/src/edit-story/components/richText/getStateInfo.js b/assets/src/edit-story/components/richText/getStateInfo.js new file mode 100644 index 000000000000..99876a30425e --- /dev/null +++ b/assets/src/edit-story/components/richText/getStateInfo.js @@ -0,0 +1,35 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import formatters from './formatters'; + +function getStateInfo(state) { + const stateInfo = formatters.reduce( + (aggr, { getters }) => ({ + ...aggr, + ...Object.fromEntries( + Object.entries(getters).map(([key, getter]) => [key, getter(state)]) + ), + }), + {} + ); + return stateInfo; +} + +export default getStateInfo; diff --git a/assets/src/edit-story/components/richText/htmlManipulation.js b/assets/src/edit-story/components/richText/htmlManipulation.js new file mode 100644 index 000000000000..33d121b49930 --- /dev/null +++ b/assets/src/edit-story/components/richText/htmlManipulation.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 { EditorState } from 'draft-js'; + +/** + * Internal dependencies + */ +import formatters from './formatters'; +import getStateInfo from './getStateInfo'; +import customImport from './customImport'; +import customExport from './customExport'; +import { getSelectionForAll } from './util'; + +/** + * Return an editor state object with content set to parsed HTML + * and selection set to everything. + * + * @param {string} html HTML string to parse into content + * @return {Object} New editor state with selection + */ +function getSelectAllStateFromHTML(html) { + const contentState = customImport(html); + const initialState = EditorState.createWithContent(contentState); + const selection = getSelectionForAll(initialState.getCurrentContent()); + return EditorState.forceSelection(initialState, selection); +} + +/** + * Convert HTML via updater function. As updater function works on the + * current selection in an editor state, first parse HTML to editor state + * with entire body selected, then run updater and then export back to + * HTML again. + * + * @param {string} html HTML string to parse into content + * @param {Function} updater A function converting a state to a new state + * @param {Array} args Extra args to supply to updater other than state + * @return {Object} New HTML with updates applied + */ +function updateAndReturnHTML(html, updater, ...args) { + const stateWithUpdate = updater(getSelectAllStateFromHTML(html), ...args); + const renderedHTML = customExport(stateWithUpdate); + return renderedHTML; +} + +const getHTMLFormatter = (setter) => (html, ...args) => + updateAndReturnHTML(html, setter, ...args); + +export const getHTMLFormatters = () => { + return formatters.reduce( + (aggr, { setters }) => ({ + ...aggr, + ...Object.fromEntries( + Object.entries(setters).map(([key, setter]) => [ + key, + getHTMLFormatter(setter), + ]) + ), + }), + {} + ); +}; + +export function getHTMLInfo(html) { + const htmlStateInfo = getStateInfo(getSelectAllStateFromHTML(html)); + return htmlStateInfo; +} diff --git a/assets/src/edit-story/components/richText/index.js b/assets/src/edit-story/components/richText/index.js new file mode 100644 index 000000000000..4f4f3a503e41 --- /dev/null +++ b/assets/src/edit-story/components/richText/index.js @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +export { default as RichTextProvider } from './provider'; diff --git a/assets/src/edit-story/components/richText/provider.js b/assets/src/edit-story/components/richText/provider.js new file mode 100644 index 000000000000..ab5575d989d4 --- /dev/null +++ b/assets/src/edit-story/components/richText/provider.js @@ -0,0 +1,135 @@ +/* + * 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 PropTypes from 'prop-types'; +import { useState, useCallback, useMemo } from 'react'; +import { EditorState } from 'draft-js'; + +/** + * Internal dependencies + */ +import useCanvas from '../canvas/useCanvas'; +import RichTextContext from './context'; +import { + getSelectionForAll, + getSelectionForOffset, + getFilteredState, + getHandleKeyCommandFromState, +} from './util'; +import getStateInfo from './getStateInfo'; +import { useFauxSelection } from './fauxSelection'; +import customImport from './customImport'; +import customExport from './customExport'; +import useSelectionManipulation from './useSelectionManipulation'; + +function RichTextProvider({ children }) { + const { + state: { editingElementState }, + } = useCanvas(); + + const [editorState, setEditorState] = useState(null); + + const selectionInfo = useMemo(() => { + if (editorState) { + return getStateInfo(editorState); + } + return { isBold: false, isItalic: false, isUnderline: false }; + }, [editorState]); + + const setStateFromContent = useCallback( + (content) => { + const { offset, clearContent, selectAll } = editingElementState || {}; + let state = EditorState.createWithContent(customImport(content)); + if (clearContent) { + // If `clearContent` is specified, push the update to clear content so that + // it can be undone. + state = EditorState.push(state, customImport(''), 'remove-range'); + } + let selection; + if (selectAll) { + selection = getSelectionForAll(state.getCurrentContent()); + } else if (offset) { + selection = getSelectionForOffset(state.getCurrentContent(), offset); + } + if (selection) { + state = EditorState.forceSelection(state, selection); + } + setEditorState(state); + }, + [editingElementState, setEditorState] + ); + + useFauxSelection(editorState, setEditorState); + + // This filters out illegal content (see `getFilteredState`) + // on paste and updates state accordingly. + // Furthermore it also sets initial selection if relevant. + const updateEditorState = useCallback( + (newEditorState) => { + const filteredState = getFilteredState(newEditorState, editorState); + setEditorState(filteredState); + }, + [editorState, setEditorState] + ); + + const getHandleKeyCommand = useCallback( + () => getHandleKeyCommandFromState(updateEditorState), + [updateEditorState] + ); + + const clearState = useCallback(() => { + setEditorState(null); + }, [setEditorState]); + + const hasCurrentEditor = Boolean(editorState); + + const selectionActions = useSelectionManipulation( + editorState, + setEditorState + ); + + const value = { + state: { + editorState, + hasCurrentEditor, + selectionInfo, + }, + actions: { + setStateFromContent, + updateEditorState, + getHandleKeyCommand, + clearState, + selectionActions, + // These actually don't work on the state at all, just pure functions + getContentFromState: customExport, + }, + }; + + return ( + + {children} + + ); +} + +RichTextProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default RichTextProvider; diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js new file mode 100644 index 000000000000..8a142bb90c51 --- /dev/null +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -0,0 +1,205 @@ +/* + * 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 { Modifier, EditorState } from 'draft-js'; + +/** + * Internal dependencies + */ +import { NONE } from './customConstants'; +import { getAllStyleSetsInSelection } from './draftUtils'; + +/** + * Get a first style in the given set that match the given prefix, + * or NONE if there's no match. + * + * @param {Set.} styles Set (ImmutableSet even) of styles to check + * @param {string} prefix Prefix to test styles for + * @return {string} First match or NONE + */ +export function getPrefixStyleForCharacter(styles, prefix) { + const list = styles.toArray().map((style) => style.style ?? style); + const matcher = (style) => style && style.startsWith(prefix); + if (!list.some(matcher)) { + return NONE; + } + return list.find(matcher); +} + +/** + * Get a deduped list of all matching styles for every character in the + * current selection for the current content. + * + * A style is matching, if it matches the current prefix. If for any given + * character in the current selection, there's no prefix match, NONE will + * be part of the returned set. + * + * If selection is collapsed, it'll get the style matching at the current + * insertion point (or NONE). + * + * + * // In all examples below, + * // - first character is WEIGHT-700, ITALIC + * // - second character is WEIGHT-900, ITALIC + * // - third character is ITALIC + * // - all other characters have no styling + * + * // This is the text: Hello + * // Editor state with selection "[He]llo": + * const editorStateEl = {}; + * // Editor state with selection "[Hel]lo": + * const editorStateHel = {}; + * // Editor state with selection "[Hello]": + * const editorStateHello = {}; + * // Editor state with selection "Hel[lo]": + * const editorStateLo = {}; + * + * const styles = getPrefixStylesInSelection(editorStateEl, 'ITALIC'); + * // styles are now: ['ITALIC'] + * + * const styles = getPrefixStylesInSelection(editorStateHello, 'ITALIC'); + * // styles are now: ['ITALIC', 'NONE'] + * + * const styles = getPrefixStylesInSelection(editorStateLo, 'ITALIC'); + * // styles are now: ['NONE'] + * + * const styles = getPrefixStylesInSelection(editorStateHel, 'WEIGHT'); + * // styles are now: ['WEIGHT-700', 'WEIGHT-900', 'NONE'] + * + * const styles = getPrefixStylesInSelection(editorStateLo, 'WEIGHT'); + * // styles are now: ['NONE'] + * + * + * @param {Object} editorState Current editor state + * @param {string} prefix Prefix to test styles for + * @return {Array.} Deduped array of all matching styles + */ +export function getPrefixStylesInSelection(editorState, prefix) { + const selection = editorState.getSelection(); + if (selection.isCollapsed()) { + return [ + getPrefixStyleForCharacter(editorState.getCurrentInlineStyle(), prefix), + ]; + } + + const styleSets = getAllStyleSetsInSelection(editorState); + const styles = new Set(); + styleSets.forEach((styleSet) => + styles.add(getPrefixStyleForCharacter(styleSet, prefix)) + ); + + return [...styles]; +} + +function applyContent(editorState, contentState) { + return EditorState.push(editorState, contentState, 'change-inline-style'); +} + +/** + * Toggle prefix style in selection. This is a pretty complex function and + * it's probably easiest to understand how it works by following the inline + * comments and reading through the corresponding exhaustive unit tests. + * + * @param {Object} editorState Current editor state + * @param {string} prefix Style (prefix) to remove from state and potentially + * replace with different style + * @param {Function} shouldSetStyle Optional function to get if new style + * should be added or not + * @param {Function} getStyleToSet Optional function to get what new style + * should be added + * + * @return {Object} New editor state + */ +export function togglePrefixStyle( + editorState, + prefix, + shouldSetStyle = null, + getStyleToSet = null +) { + if (editorState.getSelection().isCollapsed()) { + // A different set of rules apply here + // First find all styles that apply at cursor - we'll reapply those as override + // with modifications at the end + let inlineStyles = editorState.getCurrentInlineStyle(); + + // See if there's a matching style for our prefix + const foundMatch = getPrefixStyleForCharacter(inlineStyles, prefix); + + // Then remove potentially found style from list + if (foundMatch !== NONE) { + inlineStyles = inlineStyles.remove(foundMatch); + } + + // Then figure out whether to apply new style or not + const willAddStyle = shouldSetStyle + ? shouldSetStyle([foundMatch]) + : foundMatch === NONE; + + // If so, add to list + if (willAddStyle) { + const styleToAdd = getStyleToSet ? getStyleToSet([foundMatch]) : prefix; + inlineStyles = inlineStyles.add(styleToAdd); + } + + // Finally apply to style override + const newState = EditorState.setInlineStyleOverride( + editorState, + inlineStyles + ); + return newState; + } + + const matchingStyles = getPrefixStylesInSelection(editorState, prefix); + + // First remove all old styles matching prefix + // (except NONE, it's not actually a style) + const stylesToRemove = matchingStyles.filter((s) => s !== NONE); + const strippedContentState = stylesToRemove.reduce( + (contentState, styleToRemove) => + Modifier.removeInlineStyle( + contentState, + editorState.getSelection(), + styleToRemove + ), + editorState.getCurrentContent() + ); + + // Should we add a style to everything now? + // If no function is given, we simply add the style if any + // character did not have a match (NONE is in the list) + const willSetStyle = shouldSetStyle + ? shouldSetStyle(matchingStyles) + : matchingStyles.includes(NONE); + + if (!willSetStyle) { + // we're done! + return applyContent(editorState, strippedContentState); + } + + // Add style to entire selection + // If no function is given, we simple add the prefix as a style + const styleToSet = getStyleToSet ? getStyleToSet(matchingStyles) : prefix; + const newContentState = Modifier.applyInlineStyle( + strippedContentState, + editorState.getSelection(), + styleToSet + ); + + return applyContent(editorState, newContentState); +} diff --git a/assets/src/edit-story/components/richText/test/styleManipulation.js b/assets/src/edit-story/components/richText/test/styleManipulation.js new file mode 100644 index 000000000000..3826be93736e --- /dev/null +++ b/assets/src/edit-story/components/richText/test/styleManipulation.js @@ -0,0 +1,383 @@ +/* + * 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 { convertFromRaw, EditorState, SelectionState } from 'draft-js'; + +/** + * Internal dependencies + */ +import { + getPrefixStyleForCharacter, + getPrefixStylesInSelection, + togglePrefixStyle, +} from '../styleManipulation'; + +expect.extend({ + toHaveStyleAtCursor(received, style) { + const styles = received.getCurrentInlineStyle().toArray(); + const pass = styles.includes(style); + if (pass) { + return { + message: () => `expected ${styles} to not include ${style}`, + pass: true, + }; + } else { + return { + message: () => `expected ${styles} to include ${style}`, + pass: false, + }; + } + }, + toHaveStyleInSelection(received, style, stylePrefix = null) { + stylePrefix = stylePrefix ?? style; + const styles = getPrefixStylesInSelection(received, stylePrefix); + const pass = styles.includes(style); + if (pass) { + return { + message: () => `expected selection ${styles} to not include ${style}`, + pass: true, + }; + } else { + return { + message: () => `expected selection ${styles} to include ${style}`, + pass: false, + }; + } + }, + toHaveStyleInEntireSelection(received, style, stylePrefix = null) { + stylePrefix = stylePrefix ?? style; + const styles = getPrefixStylesInSelection(received, stylePrefix); + const pass = styles.includes(style) && styles.length === 1; + if (pass) { + return { + message: () => + `expected selection ${styles} to not only include ${style}`, + pass: true, + }; + } else { + return { + message: () => `expected selection ${styles} to only include ${style}`, + pass: false, + }; + } + }, +}); + +describe('getPrefixStyleForCharacter', () => { + function setup(styleArray, prefix) { + return getPrefixStyleForCharacter({ toArray: () => styleArray }, prefix); + } + + it('should return a direct match', () => { + const match = setup(['ALPHA', 'BRAVO', 'CHARLIE'], 'BRAVO'); + expect(match).toStrictEqual('BRAVO'); + }); + + it('should return a prefix match', () => { + const match = setup(['ALPHA-1', 'BRAVO-2', 'CHARLIE-3'], 'BRAVO'); + expect(match).toStrictEqual('BRAVO-2'); + }); + + it('should return first match if multiple', () => { + const match = setup(['ALPHA-1', 'BRAVO-2', 'BRAVO-3'], 'BRAVO'); + expect(match).toStrictEqual('BRAVO-2'); + }); + + it('should return NONE if no match', () => { + const match = setup(['ALPHA-1', 'BRAVO-2', 'CHARLIE-3'], 'DELTA'); + expect(match).toStrictEqual('NONE'); + }); +}); + +describe('getPrefixStylesInSelection', () => { + it('should return all different matched styles for non-collapsed selection', () => { + // Let's select "World" + const editorState = getEditorState(6, 11); + const matches = getPrefixStylesInSelection(editorState, 'CUSTOM-WEIGHT'); + expect(matches).toIncludeSameMembers([ + 'CUSTOM-WEIGHT-700', + 'CUSTOM-WEIGHT-900', + ]); + }); + + it('should include NONE if at least one character did not match for non-collapsed selection', () => { + // Let's select "Hello World" + const editorState = getEditorState(0, 11); + const matches = getPrefixStylesInSelection(editorState, 'CUSTOM-WEIGHT'); + expect(matches).toIncludeSameMembers([ + 'NONE', + 'CUSTOM-WEIGHT-700', + 'CUSTOM-WEIGHT-900', + ]); + }); + + it('should return matched style for collapsed selection', () => { + // Let's place selection where the * is: "Hello W*orld" + const editorState = getEditorState(7); + const matches = getPrefixStylesInSelection(editorState, 'CUSTOM-WEIGHT'); + expect(matches).toIncludeSameMembers(['CUSTOM-WEIGHT-700']); + }); + + it('should return NONE if no match for collapsed selection', () => { + // Let's place selection where the * is: "H*ello World" + const editorState = getEditorState(1); + const matches = getPrefixStylesInSelection(editorState, 'CUSTOM-WEIGHT'); + expect(matches).toIncludeSameMembers(['NONE']); + }); +}); + +describe('togglePrefixStyle', () => { + describe('on collapsed selection', () => { + it('should toggle simple style off if selection is not inside matching style', () => { + // Let's place selection where the * is: "H*ello World" + // So, inside italic style + const editorState = getEditorState(1); + + // Verify that it doesn't include italic: + expect(editorState).not.toHaveStyleAtCursor('CUSTOM-ITALIC'); + + // Now toggle italic + const newState = togglePrefixStyle(editorState, 'CUSTOM-ITALIC'); + + // And verify that it now has italic + expect(newState).toHaveStyleAtCursor('CUSTOM-ITALIC'); + }); + + it('should toggle simple style off if selection is inside matching style', () => { + // Let's place selection where the * is: "Hel*lo World" + // So, inside italic style + const editorState = getEditorState(3); + + // Verify that it includes italic: + expect(editorState).toHaveStyleAtCursor('CUSTOM-ITALIC'); + + // Now toggle italic + const newState = togglePrefixStyle(editorState, 'CUSTOM-ITALIC'); + + //And verify that it doesn't have italic anymore + expect(newState).not.toHaveStyleAtCursor('CUSTOM-ITALIC'); + }); + + it('should do nothing when toggling simple style *on* in selection *with style*', () => { + // Let's place selection where the * is: "Hel*lo World" + // So, inside italic style + const editorState = getEditorState(3); + + // Verify that it already includes italic: + expect(editorState).toHaveStyleAtCursor('CUSTOM-ITALIC'); + + // Now toggle italic + const newState = togglePrefixStyle( + editorState, + 'CUSTOM-ITALIC', + () => true + ); + + // And verify that it still includes italic + expect(newState).toHaveStyleAtCursor('CUSTOM-ITALIC'); + }); + + it('should do nothing when toggling simple style *off* in selection *without style*', () => { + // Let's place selection where the * is: "H*ello World" + // So, inside italic style + const editorState = getEditorState(1); + + // Verify that it doesn't include italic: + expect(editorState).not.toHaveStyleAtCursor('CUSTOM-ITALIC'); + + // Now toggle italic + const newState = togglePrefixStyle( + editorState, + 'CUSTOM-ITALIC', + () => false + ); + + // And verify that it still doesn't have italic + expect(newState).not.toHaveStyleAtCursor('CUSTOM-ITALIC'); + }); + + it('should toggle complex style based on callbacks', () => { + // Let's place selection where the * is: "Hel*lo World" + // So, inside bold style + const editorState = getEditorState(3); + + // Verify that it has custom weight 700: + expect(editorState).toHaveStyleAtCursor('CUSTOM-WEIGHT-700'); + + // Now toggle according to callbacks: + const shouldSetStyle = jest.fn().mockImplementation(() => true); + const styleToSet = jest + .fn() + .mockImplementation(() => 'CUSTOM-WEIGHT-900'); + const newState = togglePrefixStyle( + editorState, + 'CUSTOM-WEIGHT', + shouldSetStyle, + styleToSet + ); + + // And verify that it now has given style + expect(newState).toHaveStyleAtCursor('CUSTOM-WEIGHT-900'); + + // And verify given callbacks have been invoked correctly + expect(shouldSetStyle).toHaveBeenCalledWith(['CUSTOM-WEIGHT-700']); + expect(styleToSet).toHaveBeenCalledWith(['CUSTOM-WEIGHT-700']); + }); + }); + + describe('on non-collapsed selection', () => { + it('should toggle simple style off if selection contains style throughout', () => { + // Let's place selection here: "Hel[lo] World" + // So, everything is underlined + const editorState = getEditorState(3, 5); + + // Verify that it is underline throughout: + expect(editorState).toHaveStyleInEntireSelection('CUSTOM-UNDERLINE'); + + // Now toggle underline + const newState = togglePrefixStyle(editorState, 'CUSTOM-UNDERLINE'); + + // And verify that it doesn't have underline anywhere + expect(newState).not.toHaveStyleInSelection('CUSTOM-UNDERLINE'); + }); + + it('should toggle simple style on if selection contains style somewhere but not everywhere', () => { + // Let's place selection here: "H[ello] World" + // So, some is underlined, some not + const editorState = getEditorState(1, 5); + + // Verify that it is underline somewhere, but not everywhere: + expect(editorState).not.toHaveStyleInEntireSelection('CUSTOM-UNDERLINE'); + expect(editorState).toHaveStyleInSelection('CUSTOM-UNDERLINE'); + + // Now toggle underline + const newState = togglePrefixStyle(editorState, 'CUSTOM-UNDERLINE'); + + // And verify that now has underline throughout + expect(newState).toHaveStyleInEntireSelection('CUSTOM-UNDERLINE'); + }); + + it('should toggle simple style on if selection does not contain style at all', () => { + // Let's place selection here: "[He]llo World" + // So, no underline at all + const editorState = getEditorState(0, 2); + + // Verify that it is underline nowhere: + expect(editorState).not.toHaveStyleInSelection('CUSTOM-UNDERLINE'); + + // Now toggle underline + const newState = togglePrefixStyle(editorState, 'CUSTOM-UNDERLINE'); + + // And verify that now has underline throughout + expect(newState).toHaveStyleInEntireSelection('CUSTOM-UNDERLINE'); + }); + + it('should toggle complex style according to callbacks', () => { + // Let's place selection here: "Hell[o Worl]d" + // So, some regular weight, some bold, some black + const editorState = getEditorState(4, 10); + + // Verify that it is both none, bold and black somewhere: + expect(editorState).toHaveStyleInSelection('NONE', 'CUSTOM-WEIGHT'); + expect(editorState).toHaveStyleInSelection( + 'CUSTOM-WEIGHT-700', + 'CUSTOM-WEIGHT' + ); + expect(editorState).toHaveStyleInSelection( + 'CUSTOM-WEIGHT-900', + 'CUSTOM-WEIGHT' + ); + + // Now toggle font weight according to callbacks + const shouldSetStyle = jest.fn().mockImplementation(() => true); + const styleToSet = jest + .fn() + .mockImplementation(() => 'CUSTOM-WEIGHT-300'); + const newState = togglePrefixStyle( + editorState, + 'CUSTOM-WEIGHT', + shouldSetStyle, + styleToSet + ); + + // And verify that now has new font weight throughout + expect(newState).toHaveStyleInEntireSelection( + 'CUSTOM-WEIGHT-300', + 'CUSTOM-WEIGHT' + ); + + // And verify given callbacks have been invoked correctly + expect(shouldSetStyle).toHaveBeenCalledWith([ + 'NONE', + 'CUSTOM-WEIGHT-700', + 'CUSTOM-WEIGHT-900', + ]); + expect(styleToSet).toHaveBeenCalledWith([ + 'NONE', + 'CUSTOM-WEIGHT-700', + 'CUSTOM-WEIGHT-900', + ]); + }); + }); +}); + +/* Editor state has this content: + * Hello World + * + * With 7 different sections with styles as follows: + * + * 1. "He" is unstyled + * 2. "l" is bold (700) and italic + * 3. "l" is bold (700), italic and underline + * 4. "o" is underline + * 5. " " is unstyled + * 6. "Wo" is bold (700) + * 7. "rld" is black (900) + */ +function getEditorState(selectionStart, selectionEnd = null) { + const raw = { + blocks: [ + { + key: '65t0d', + text: 'Hello world', + type: 'unstyled', + depth: 0, + inlineStyleRanges: [ + { offset: 2, length: 2, style: 'CUSTOM-WEIGHT-700' }, + { offset: 2, length: 2, style: 'CUSTOM-ITALIC' }, + { offset: 3, length: 2, style: 'CUSTOM-UNDERLINE' }, + { offset: 6, length: 2, style: 'CUSTOM-WEIGHT-700' }, + { offset: 8, length: 3, style: 'CUSTOM-WEIGHT-900' }, + ], + entityRanges: [], + data: {}, + }, + ], + entityMap: {}, + }; + const contentState = convertFromRaw(raw); + const unselectedState = EditorState.createWithContent(contentState); + const selection = new SelectionState({ + anchorKey: raw.blocks[0].key, + anchorOffset: selectionStart, + focusKey: raw.blocks[0].key, + focusOffset: selectionEnd ?? selectionStart, + }); + return EditorState.forceSelection(unselectedState, selection); +} diff --git a/assets/src/edit-story/components/richText/useRichText.js b/assets/src/edit-story/components/richText/useRichText.js new file mode 100644 index 000000000000..4a45e5f5d723 --- /dev/null +++ b/assets/src/edit-story/components/richText/useRichText.js @@ -0,0 +1,31 @@ +/* + * 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 { useContext } from 'react'; + +/** + * Internal dependencies + */ +import RichTextContext from './context'; + +function useRichText() { + return useContext(RichTextContext); +} + +export default useRichText; diff --git a/assets/src/edit-story/components/richText/useSelectionManipulation.js b/assets/src/edit-story/components/richText/useSelectionManipulation.js new file mode 100644 index 000000000000..9f8c933544e1 --- /dev/null +++ b/assets/src/edit-story/components/richText/useSelectionManipulation.js @@ -0,0 +1,84 @@ +/* + * 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 { useCallback, useRef, useEffect, useMemo } from 'react'; +import { EditorState } from 'draft-js'; + +/** + * Internal dependencies + */ +import formatters from './formatters'; + +function useSelectionManipulation(editorState, setEditorState) { + const lastKnownState = useRef(null); + const lastKnownSelection = useRef(null); + useEffect(() => { + lastKnownState.current = editorState; + if (!editorState) { + lastKnownSelection.current = null; + } else if (editorState.getSelection().hasFocus) { + lastKnownSelection.current = editorState.getSelection(); + } + }, [editorState]); + + const updateWhileUnfocused = useCallback( + (updater, shouldForceFocus = true) => { + const oldState = lastKnownState.current; + const selection = lastKnownSelection.current; + const workingState = shouldForceFocus + ? EditorState.acceptSelection(oldState, selection) + : oldState; + const newState = updater(workingState); + setEditorState(newState); + }, + [setEditorState] + ); + + const getSetterName = useCallback( + (setterName) => `${setterName}InSelection`, + [] + ); + + const getSetterCallback = useCallback( + (setter, autoFocus) => (...args) => + updateWhileUnfocused((state) => setter(state, ...args), autoFocus), + [updateWhileUnfocused] + ); + + const selectionFormatters = useMemo( + () => + formatters.reduce( + (aggr, { setters, autoFocus }) => ({ + ...aggr, + ...Object.fromEntries( + Object.entries(setters).map(([key, setter]) => [ + getSetterName(key), + getSetterCallback(setter, autoFocus), + ]) + ), + }), + {} + ), + [getSetterName, getSetterCallback] + ); + + return selectionFormatters; +} + +export default useSelectionManipulation; diff --git a/assets/src/edit-story/components/richText/util.js b/assets/src/edit-story/components/richText/util.js new file mode 100644 index 000000000000..88e0ee7a9b0c --- /dev/null +++ b/assets/src/edit-story/components/richText/util.js @@ -0,0 +1,109 @@ +/* + * 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 { SelectionState } from 'draft-js'; +import { filterEditorState } from 'draftjs-filters'; + +/** + * Internal dependencies + */ +import weightFormatter from './formatters/weight'; +import italicFormatter from './formatters/italic'; +import underlineFormatter from './formatters/underline'; + +export function getFilteredState(editorState, oldEditorState) { + const shouldFilterPaste = + oldEditorState.getCurrentContent() !== editorState.getCurrentContent() && + editorState.getLastChangeType() === 'insert-fragment'; + + if (!shouldFilterPaste) { + return editorState; + } + + return filterEditorState( + { + blocks: [], + styles: ['BOLD', 'ITALIC', 'UNDERLINE'], + entities: [], + maxNesting: 1, + whitespacedCharacters: [], + }, + editorState + ); +} + +function getStateFromCommmand(command, oldEditorState) { + switch (command) { + case 'bold': + return weightFormatter.setters.toggleBold(oldEditorState); + + case 'italic': + return italicFormatter.setters.toggleItalic(oldEditorState); + + case 'underline': + return underlineFormatter.setters.toggleUnderline(oldEditorState); + + default: + return null; + } +} + +export const getHandleKeyCommandFromState = (setEditorState) => ( + command, + currentEditorState +) => { + const newEditorState = getStateFromCommmand(command, currentEditorState); + if (newEditorState) { + setEditorState(newEditorState); + return 'handled'; + } + return 'not-handled'; +}; + +export function getSelectionForAll(content) { + const firstBlock = content.getFirstBlock(); + const lastBlock = content.getLastBlock(); + return new SelectionState({ + anchorKey: firstBlock.getKey(), + anchorOffset: 0, + focusKey: lastBlock.getKey(), + focusOffset: lastBlock.getLength(), + }); +} + +export function getSelectionForOffset(content, offset) { + const blocks = content.getBlocksAsArray(); + let countdown = offset; + for (let i = 0; i < blocks.length && countdown >= 0; i++) { + const block = blocks[i]; + const length = block.getLength(); + if (countdown <= length) { + const selection = new SelectionState({ + anchorKey: block.getKey(), + anchorOffset: countdown, + focusKey: block.getKey(), + focusOffset: countdown, + }); + return selection; + } + // +1 char for the delimiter. + countdown -= length + 1; + } + return null; +} diff --git a/assets/src/edit-story/components/workspace/index.js b/assets/src/edit-story/components/workspace/index.js index 0246c257f31c..3c1e2630e645 100644 --- a/assets/src/edit-story/components/workspace/index.js +++ b/assets/src/edit-story/components/workspace/index.js @@ -21,21 +21,24 @@ import Inspector from '../inspector'; import Canvas from '../canvas'; import { SidebarProvider } from '../sidebar'; import CanvasProvider from '../canvas/canvasProvider'; +import RichTextProvider from '../richText/provider'; import { WorkspaceLayout, CanvasArea, InspectorArea } from './layout'; function Workspace() { return ( - - - - - - - - - - + + + + + + + + + + + + ); } diff --git a/assets/src/edit-story/elements/shared/index.js b/assets/src/edit-story/elements/shared/index.js index 78c42cb040b7..9d98990d286c 100644 --- a/assets/src/edit-story/elements/shared/index.js +++ b/assets/src/edit-story/elements/shared/index.js @@ -54,16 +54,13 @@ export const elementWithBackgroundColor = css` convertToCSS(generatePatternStyles(backgroundColor))}; `; -export const elementWithFontColor = css` - ${({ color }) => convertToCSS(generatePatternStyles(color, 'color'))}; -`; - export const elementWithFont = css` white-space: pre-wrap; font-family: ${({ font }) => font?.family}; font-style: ${({ fontStyle }) => fontStyle}; font-size: ${({ fontSize }) => fontSize}px; font-weight: ${({ fontWeight }) => fontWeight}; + color: #000000; `; /** @@ -73,9 +70,7 @@ export const elementWithTextParagraphStyle = css` margin: ${({ margin }) => margin || 0}; padding: ${({ padding }) => padding || 0}; line-height: ${({ lineHeight }) => lineHeight}; - letter-spacing: ${({ letterSpacing }) => letterSpacing}; text-align: ${({ textAlign }) => textAlign}; - text-decoration: ${({ textDecoration }) => textDecoration}; `; export const SHARED_DEFAULT_ATTRIBUTES = { diff --git a/assets/src/edit-story/elements/text/display.js b/assets/src/edit-story/elements/text/display.js index 03a11ae39093..6c2d177713c1 100644 --- a/assets/src/edit-story/elements/text/display.js +++ b/assets/src/edit-story/elements/text/display.js @@ -18,7 +18,7 @@ * External dependencies */ import styled from 'styled-components'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useMemo } from 'react'; /** * Internal dependencies @@ -29,22 +29,18 @@ import { elementFillContent, elementWithFont, elementWithBackgroundColor, - elementWithFontColor, elementWithTextParagraphStyle, } from '../shared'; import StoryPropTypes from '../../types'; import { BACKGROUND_TEXT_MODE } from '../../constants'; import { useTransformHandler } from '../../components/transform'; -import { - draftMarkupToContent, - getHighlightLineheight, - generateParagraphTextStyle, -} from './util'; +import { getHTMLFormatters } from '../../components/richText/htmlManipulation'; +import createSolid from '../../utils/createSolid'; +import { getHighlightLineheight, generateParagraphTextStyle } from './util'; const HighlightWrapperElement = styled.div` ${elementFillContent} ${elementWithFont} - ${elementWithFontColor} ${elementWithTextParagraphStyle} line-height: ${({ lineHeight, verticalPadding }) => getHighlightLineheight(lineHeight, verticalPadding)}; @@ -90,20 +86,11 @@ const FillElement = styled.p` ${elementFillContent} ${elementWithFont} ${elementWithBackgroundColor} - ${elementWithFontColor} ${elementWithTextParagraphStyle} `; function TextDisplay({ - element: { - id, - bold, - content, - color, - backgroundColor, - backgroundTextMode, - ...rest - }, + element: { id, content, backgroundColor, backgroundTextMode, ...rest }, box: { width }, }) { const ref = useRef(null); @@ -115,7 +102,6 @@ function TextDisplay({ const { font } = rest; const props = { - color, font, ...(backgroundTextMode === BACKGROUND_TEXT_MODE.NONE ? {} @@ -141,6 +127,13 @@ function TextDisplay({ : ''; }); + // Setting the text color of the entire block to black essentially removes all inline + // color styling allowing us to apply transparent to all of them. + const contentWithoutColor = useMemo( + () => getHTMLFormatters().setColor(content, createSolid(0, 0, 0)), + [content] + ); + if (backgroundTextMode === BACKGROUND_TEXT_MODE.HIGHLIGHT) { return ( @@ -149,7 +142,7 @@ function TextDisplay({ @@ -159,7 +152,7 @@ function TextDisplay({ @@ -172,7 +165,7 @@ function TextDisplay({ diff --git a/assets/src/edit-story/elements/text/edit.js b/assets/src/edit-story/elements/text/edit.js index 1a9e2129849c..06fdfc07127f 100644 --- a/assets/src/edit-story/elements/text/edit.js +++ b/assets/src/edit-story/elements/text/edit.js @@ -18,46 +18,26 @@ * External dependencies */ import styled from 'styled-components'; -import { Editor, EditorState } from 'draft-js'; -import { stateFromHTML } from 'draft-js-import-html'; -import { stateToHTML } from 'draft-js-export-html'; -import { - useMemo, - useState, - useEffect, - useLayoutEffect, - useRef, - useCallback, -} from 'react'; +import { useEffect, useLayoutEffect, useRef, useCallback } from 'react'; /** * Internal dependencies */ import { useStory, useFont } from '../../app'; -import { useCanvas } from '../../components/canvas'; +import RichTextEditor from '../../components/richText/editor'; import { useUnits } from '../../units'; import { elementFillContent, elementWithFont, elementWithBackgroundColor, - elementWithFontColor, elementWithTextParagraphStyle, } from '../shared'; import StoryPropTypes from '../../types'; import { BACKGROUND_TEXT_MODE } from '../../constants'; -import useFocusOut from '../../utils/useFocusOut'; import useUnmount from '../../utils/useUnmount'; import createSolid from '../../utils/createSolid'; import calcRotatedResizeOffset from '../../utils/calcRotatedResizeOffset'; -import { - draftMarkupToContent, - getFilteredState, - getHandleKeyCommand, - getSelectionForAll, - getSelectionForOffset, - generateParagraphTextStyle, - getHighlightLineheight, -} from './util'; +import { generateParagraphTextStyle, getHighlightLineheight } from './util'; // Wrapper bounds the text editor within the element bounds. The resize // logic updates the height of this element to show the new height based @@ -85,7 +65,6 @@ const TextBox = styled.div` ${elementWithFont} ${elementWithTextParagraphStyle} ${elementWithBackgroundColor} - ${elementWithFontColor} opacity: ${({ opacity }) => (opacity ? opacity / 100 : null)}; position: absolute; @@ -97,9 +76,7 @@ const TextBox = styled.div` function TextEdit({ element: { id, - bold, content, - color, backgroundColor, backgroundTextMode, opacity, @@ -115,7 +92,6 @@ function TextEdit({ } = useUnits(); const textProps = { ...generateParagraphTextStyle(rest, dataToEditorX, dataToEditorY), - color, font, backgroundColor, opacity, @@ -124,102 +100,53 @@ function TextEdit({ rest.lineHeight, dataToEditorX(rest.padding?.vertical || 0) ), - color: createSolid(0, 0, 0), backgroundColor: createSolid(255, 255, 255), }), ...(backgroundTextMode === BACKGROUND_TEXT_MODE.NONE && { backgroundColor: null, }), }; - const wrapperRef = useRef(null); - const textBoxRef = useRef(null); - const editorRef = useRef(null); const { actions: { maybeEnqueueFontStyle }, } = useFont(); const { actions: { updateElementById }, } = useStory(); - const { - state: { editingElementState }, - } = useCanvas(); + const setProperties = useCallback( (properties) => updateElementById({ elementId: id, properties }), [id, updateElementById] ); - const { offset, clearContent, selectAll } = editingElementState || {}; - const initialState = useMemo(() => { - const contentWithBreaks = (content || '') - // Re-insert manual line-breaks for empty lines - .replace(/\n(?=\n)/g, '\n
') - .split('\n') - .map((s) => { - return `

${draftMarkupToContent(s, bold)}

`; - }) - .join(''); - let state = EditorState.createWithContent(stateFromHTML(contentWithBreaks)); - if (clearContent) { - // If `clearContent` is specified, push the update to clear content so that - // it can be undone. - state = EditorState.push(state, stateFromHTML(''), 'remove-range'); - } - let selection; - if (selectAll) { - selection = getSelectionForAll(state.getCurrentContent()); - } else if (offset) { - selection = getSelectionForOffset(state.getCurrentContent(), offset); - } - if (selection) { - state = EditorState.forceSelection(state, selection); - } - return state; - }, [content, clearContent, selectAll, offset, bold]); - const [editorState, setEditorState] = useState(initialState); + const wrapperRef = useRef(null); + const textBoxRef = useRef(null); + const editorRef = useRef(null); + const contentRef = useRef(); const editorHeightRef = useRef(0); - // This is to allow the finalizing useEffect to *not* depend on editorState, - // as would otherwise be a lint error. - const lastKnownState = useRef(null); - - // This filters out illegal content (see `getFilteredState`) - // on paste and updates state accordingly. - // Furthermore it also sets initial selection if relevant. - const updateEditorState = useCallback( - (newEditorState) => { - const filteredState = getFilteredState(newEditorState, editorState); - lastKnownState.current = filteredState.getCurrentContent(); - setEditorState(filteredState); - }, - [editorState] - ); - - // Handle basic key commands such as bold, italic and underscore. - const handleKeyCommand = getHandleKeyCommand(updateEditorState); - // Make sure to allow the user to click in the text box while working on the text. const onClick = (evt) => { const editor = editorRef.current; // Refocus the editor if the container outside it is clicked. - if (!editor.editorContainer.contains(evt.target)) { + if (!editor.getNode().contains(evt.target)) { editor.focus(); } evt.stopPropagation(); }; + // Set focus when initially rendered. + useLayoutEffect(() => { + if (editorRef.current) { + editorRef.current.focus(); + } + }, []); + const updateContent = useCallback(() => { - const newState = lastKnownState.current; const newHeight = editorHeightRef.current; wrapperRef.current.style.height = ''; - if (newState) { + if (contentRef.current) { // Remove manual line breaks and remember to trim any trailing non-breaking space. - const properties = { - content: stateToHTML(lastKnownState.current, { - defaultBlockTag: null, - }) - .replace(/
/g, '') - .replace(/ $/, ''), - }; + const properties = { content: contentRef.current }; // Recalculate the new height and offset. if (newHeight) { const [dx, dy] = calcRotatedResizeOffset( @@ -245,24 +172,26 @@ function TextEdit({ y, ]); - // Update content for element on focus out. - useFocusOut(textBoxRef, updateContent, [updateContent]); - // Update content for element on unmount. useUnmount(updateContent); - // Set focus when initially rendered. - useLayoutEffect(() => { - editorRef.current.focus(); - }, []); - - // Remeasure the height on each content update. - useEffect(() => { + // A function to remeasure height + const handleResize = useCallback(() => { const wrapper = wrapperRef.current; const textBox = textBoxRef.current; editorHeightRef.current = textBox.offsetHeight; wrapper.style.height = `${editorHeightRef.current}px`; - }, [editorState, elementHeight]); + }, []); + // Invoke on each content update. + const handleUpdate = useCallback( + (newContent) => { + contentRef.current = newContent; + handleResize(); + }, + [handleResize] + ); + // Also invoke if the raw element height ever changes + useEffect(handleResize, [elementHeight]); useEffect(() => { maybeEnqueueFontStyle(font); @@ -271,11 +200,10 @@ function TextEdit({ return ( - diff --git a/assets/src/edit-story/elements/text/index.js b/assets/src/edit-story/elements/text/index.js index 2cdc0839f100..80612d0423b1 100644 --- a/assets/src/edit-story/elements/text/index.js +++ b/assets/src/edit-story/elements/text/index.js @@ -34,17 +34,11 @@ export { default as updateForResizeEvent } from './updateForResizeEvent'; export const defaultAttributes = { ...SHARED_DEFAULT_ATTRIBUTES, backgroundTextMode: BACKGROUND_TEXT_MODE.NONE, - bold: false, font: TEXT_ELEMENT_DEFAULT_FONT, - fontWeight: 400, fontSize: 36, - fontStyle: 'normal', backgroundColor: createSolid(196, 196, 196), - color: createSolid(0, 0, 0), - letterSpacing: 0, lineHeight: 1.3, textAlign: 'initial', - textDecoration: 'none', padding: { vertical: 0, horizontal: 0, diff --git a/assets/src/edit-story/elements/text/output.js b/assets/src/edit-story/elements/text/output.js index fbe8c29eb7af..94504f360e26 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -18,33 +18,24 @@ * External dependencies */ import PropTypes from 'prop-types'; +import { useMemo } from 'react'; /** * Internal dependencies */ import StoryPropTypes from '../../types'; import generatePatternStyles from '../../utils/generatePatternStyles'; +import { getHTMLFormatters } from '../../components/richText/htmlManipulation'; +import createSolid from '../../utils/createSolid'; import { dataToEditorX, dataToEditorY } from '../../units'; import { BACKGROUND_TEXT_MODE } from '../../constants'; -import { - draftMarkupToContent, - generateParagraphTextStyle, - getHighlightLineheight, -} from './util'; +import { generateParagraphTextStyle, getHighlightLineheight } from './util'; /** * Renders DOM for the text output based on the provided unit converters. */ export function TextOutputWithUnits({ - element: { - bold, - content, - color, - backgroundColor, - backgroundTextMode, - padding, - ...rest - }, + element: { content, backgroundColor, backgroundTextMode, padding, ...rest }, dataToStyleX, dataToStyleY, dataToFontSizeY, @@ -78,8 +69,8 @@ export function TextOutputWithUnits({ dataToStyleY, dataToFontSizeY ), - ...generatePatternStyles(color, 'color'), ...bgColor, + color: '#000000', padding: `${paddingStyles.vertical} ${paddingStyles.horizontal}`, }; @@ -138,6 +129,13 @@ export function TextOutputWithUnits({ background: 'none', }; + // Setting the text color of the entire block to black essentially removes all inline + // color styling allowing us to apply transparent to all of them. + const contentWithoutColor = useMemo( + () => getHTMLFormatters().setColor(content, createSolid(0, 0, 0)), + [content] + ); + if (backgroundTextMode === BACKGROUND_TEXT_MODE.HIGHLIGHT) { return ( <> @@ -146,7 +144,7 @@ export function TextOutputWithUnits({ @@ -156,7 +154,7 @@ export function TextOutputWithUnits({ @@ -169,7 +167,7 @@ export function TextOutputWithUnits({

); } diff --git a/assets/src/edit-story/elements/text/test/output.js b/assets/src/edit-story/elements/text/test/output.js index 394543a23e9a..3cf1f71b06db 100644 --- a/assets/src/edit-story/elements/text/test/output.js +++ b/assets/src/edit-story/elements/text/test/output.js @@ -38,14 +38,6 @@ describe('TextOutput', () => { const element = { id: '123', content: 'Content', - color: { - color: { - r: 255, - g: 255, - b: 255, - a: 0.5, - }, - }, backgroundColor: { color: { r: 255, @@ -58,9 +50,7 @@ describe('TextOutput', () => { family: 'Roboto', }, fontSize: 16, - letterSpacing: 1.3, textAlign: 'left', - textDecoration: 'none', type: 'text', x: 10, y: 10, @@ -87,33 +77,21 @@ describe('TextOutput', () => { whiteSpace: 'pre-wrap', padding: '0% 0%', margin: '0px', - color: 'rgba(255, 255, 255, 0.5)', backgroundColor: 'rgba(255, 0, 0, 0.3)', fontSize: '0.242424em', - letterSpacing: '1.3em', textAlign: 'left', - textDecoration: 'none', }); }); it('should convert padding to percent of width', () => { const element = { id: '123', - color: { - color: { - r: 255, - g: 255, - b: 255, - }, - }, content: 'Content', font: { family: 'Roboto', }, fontSize: 16, - letterSpacing: 1.3, textAlign: 'left', - textDecoration: 'none', type: 'text', x: 10, y: 10, @@ -140,55 +118,6 @@ describe('TextOutput', () => { }); }); - it('should apply tags if bold', () => { - const element = { - id: '123', - content: 'Content', - color: { - color: { - r: 255, - g: 255, - b: 255, - a: 0.5, - }, - }, - backgroundColor: { - color: { - r: 255, - g: 0, - b: 0, - a: 0.3, - }, - }, - font: { - family: 'Roboto', - }, - fontSize: 16, - letterSpacing: 1.3, - textAlign: 'left', - textDecoration: 'none', - type: 'text', - x: 10, - y: 10, - width: 50, - height: 50, - rotationAngle: 0, - padding: { - vertical: 0, - horizontal: 0, - }, - bold: true, - }; - - const output = renderViaString( - - ); - expect(output.innerHTML).toBe('Content'); - }); - it('should wrap font-family into quotes', () => { const element = { id: '123', @@ -207,7 +136,6 @@ describe('TextOutput', () => { vertical: 0, horizontal: 0, }, - color: { type: 'solid', color: { r: 255, g: 255, b: 255 } }, }; const output = renderViaString( @@ -238,7 +166,6 @@ describe('TextOutput', () => { vertical: 0, horizontal: 0, }, - color: { type: 'solid', color: { r: 255, g: 255, b: 255 } }, }; const output = renderViaString( diff --git a/assets/src/edit-story/elements/text/test/util.js b/assets/src/edit-story/elements/text/test/util.js index 8d4ffb298b7b..1b286f6a6ee8 100644 --- a/assets/src/edit-story/elements/text/test/util.js +++ b/assets/src/edit-story/elements/text/test/util.js @@ -17,7 +17,7 @@ /** * Internal dependencies */ -import { draftMarkupToContent, generateFontFamily } from '../util'; +import { generateFontFamily } from '../util'; describe('Text/util', () => { describe('Text/util/generateFontFamily', () => { @@ -46,30 +46,4 @@ describe('Text/util', () => { ).toStrictEqual(expected); }); }); - - describe('Text/util/draftMarkupToContent', () => { - it('should return valid HTML for content', () => { - const input = 'Hello World!'; - expect(draftMarkupToContent(input, false)).toStrictEqual( - 'Hello World!' - ); - - const nestedInput = 'Hello World, again!'; - expect(draftMarkupToContent(nestedInput, false)).toStrictEqual( - 'Hello World, again!' - ); - - const invalidInput = 'Hello world { + describe('should parse all text elements', () => { + it('should ignore non-text elements', () => { + expect( + inlineTextProperties({ + pages: [ + { + elements: [ + { + type: 'square', + bold: true, + }, + { + type: 'image', + content: 'Horse', + color: 'red', + }, + ], + }, + ], + }) + ).toStrictEqual({ + pages: [ + { + elements: [ + { + type: 'square', + bold: true, + }, + { + type: 'image', + content: 'Horse', + color: 'red', + }, + ], + }, + ], + }); + }); + + it('should parse multiple text elements on multiple pages', () => { + expect( + inlineTextProperties({ + pages: [ + { + elements: [ + { + type: 'text', + bold: true, + content: 'Hello', + }, + { + type: 'text', + textDecoration: 'underline', + content: 'Hello', + }, + ], + }, + { + elements: [ + { + type: 'text', + fontStyle: 'italic', + content: 'Hello', + }, + { + type: 'text', + fontWeight: 300, + content: 'Hello', + }, + ], + }, + ], + }) + ).toStrictEqual({ + pages: [ + { + elements: [ + { + type: 'text', + content: 'Hello', + }, + { + type: 'text', + content: + 'Hello', + }, + ], + }, + { + elements: [ + { + type: 'text', + content: 'Hello', + }, + { + type: 'text', + content: 'Hello', + }, + ], + }, + ], + }); + }); + + it('should remove all deprecated properties', () => { + const res = inlineTextProperties({ + pages: [ + { + elements: [ + { + type: 'text', + bold: true, + fontWeight: 300, + fontStyle: 'italic', + textDecoration: 'underline', + letterSpacing: 5, + color: { color: { r: 255, g: 0, b: 0 } }, + content: 'Hello', + }, + ], + }, + ], + }); + + const convertedElementKeys = res.pages[0].elements[0]; + expect(convertedElementKeys).toContainKey('content'); + expect(convertedElementKeys).not.toContainKeys([ + 'bold', + 'fontWeight', + 'fontStyle', + 'textDecoration', + 'letterSpacing', + 'color', + ]); + }); + }); + + it('should convert inline elements', () => { + const original = ` + Lorem + ipsum + dolor + sit + amet, + + consectetur + adipiscing + elit + . + `; + const converted = convert(original); + const expected = ` + Lorem + ipsum + dolor + sit + amet, + + consectetur + adipiscing + elit + . + `; + expect(converted).toStrictEqual(expected); + }); + + it('should convert nested elements', () => { + const original = ` + Lorem + ipsum + dolor + sit + amet, + + consectetur + adipiscing + elit + . + `; + const converted = convert(original); + const expected = ` + Lorem + ipsum + dolor + sit + amet, + + consectetur + adipiscing + elit + . + `; + expect(converted).toStrictEqual(expected); + }); + describe('should correctly interpret bold properties', () => { + it('should correctly interpret bold property and ignore inline elements', () => { + const original = 'Lorem ipsum'; + const properties = { bold: true }; + const converted = convert(original, properties); + const expected = 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + + it('should correctly interpret font weight property and ignore inline elements', () => { + const original = 'Lorem ipsum'; + const properties = { fontWeight: 300 }; + const converted = convert(original, properties); + const expected = 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + + it('should ignore font weight set to 400', () => { + const original = 'Lorem ipsum'; + const properties = { fontWeight: 400 }; + const converted = convert(original, properties); + const expected = 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + + it('should use font weight over bold when both set if not 400', () => { + const original = 'Lorem ipsum'; + const properties = { fontWeight: 300, bold: true }; + const converted = convert(original, properties); + const expected = 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + + it('should do nothing globally if font weight is 400 and bold is false', () => { + const original = 'Lorem ipsum'; + const properties = { fontWeight: 400, bold: false }; + const converted = convert(original, properties); + const expected = 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + }); + + describe('should correctly interpret italic property', () => { + it('should correctly interpret font style property and ignore inline elements', () => { + const original = 'Lorem ipsum'; + const properties = { fontStyle: 'italic' }; + const converted = convert(original, properties); + const expected = 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + + it('should ignore font style set to anything but "italic"', () => { + const original = 'Lorem ipsum'; + const properties = { fontStyle: 'oblique' }; + const converted = convert(original, properties); + const expected = 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + }); + + describe('should correctly interpret underline property', () => { + it('should correctly interpret text decoration property and ignore inline elements', () => { + const original = 'Lorem ipsum'; + const properties = { textDecoration: 'underline' }; + const converted = convert(original, properties); + const expected = + 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + + it('should ignore text decoration set to anything but "underline"', () => { + const original = 'Lorem ipsum'; + const properties = { textDecoration: 'line-through' }; + const converted = convert(original, properties); + const expected = + 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + }); + + it('should correctly inline color property', () => { + const original = 'Lorem ipsum'; + const properties = { color: { color: { r: 255, g: 0, b: 0, a: 0.5 } } }; + const converted = convert(original, properties); + const expected = + 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + + it('should correctly inline letter spacing property', () => { + const original = 'Lorem ipsum'; + const properties = { letterSpacing: 20 }; + const converted = convert(original, properties); + const expected = 'Lorem ipsum'; + expect(converted).toStrictEqual(expected); + }); + + it('should all work correctly together', () => { + const original = ` + Lorem + ipsum + dolor + sit + amet, + consectetur + adipiscing + elit. + `; + // Here we have global italic, color and letter spacing, but no global font weight or underline. + const properties = { + letterSpacing: 20, + bold: false, + fontWeight: 400, + fontStyle: 'italic', + color: { color: { r: 255, g: 0, b: 0 } }, + textDecoration: 'line-through', + }; + const converted = convert(original, properties); + const expected = ` + + + + Lorem + ipsum + dolor + sit + amet, + consectetur + adipiscing + elit. + + + + `; + expect(uniformWhitespace(converted)).toStrictEqual( + uniformWhitespace(expected) + ); + + function uniformWhitespace(str) { + return str + .replace(/^|$/g, ' ') // insert single space front and back + .replace(/> <') // and insert space between tags + .replace(/\s+/g, ' '); // and then collapse all multi-whitespace to single space + } + }); +}); diff --git a/assets/src/edit-story/migration/migrations/v0017_inlineTextProperties.js b/assets/src/edit-story/migration/migrations/v0017_inlineTextProperties.js new file mode 100644 index 000000000000..5389e284a953 --- /dev/null +++ b/assets/src/edit-story/migration/migrations/v0017_inlineTextProperties.js @@ -0,0 +1,150 @@ +/* + * 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. + */ + +function inlineTextProperties({ pages, ...rest }) { + return { + pages: pages.map(reducePage), + ...rest, + }; +} + +function reducePage({ elements, ...rest }) { + return { + elements: elements.map(updateElement), + ...rest, + }; +} + +function updateElement(element) { + if (element.type === 'text') { + return updateTextContent(element); + } + + return element; +} + +function updateTextContent({ + bold, + fontWeight, + fontStyle, + textDecoration, + letterSpacing, + color, + content, + ...rest +}) { + // We use an array to chain all the converters more nicely + const convertedContent = [content] + .map((c) => convertInlineBold(c, bold, fontWeight)) + .map((c) => convertInlineItalic(c, fontStyle)) + .map((c) => convertInlineUnderline(c, textDecoration)) + .map((c) => addInlineColor(c, color)) + .map((c) => addInlineLetterSpacing(c, letterSpacing)) + .pop(); + + return { ...rest, content: convertedContent }; +} + +function convertInlineBold(content, isBold, fontWeight) { + // Do we have a specific global weight to apply for entire text field? + const globalWeight = + typeof fontWeight === 'number' && fontWeight != 400 + ? fontWeight + : isBold === true + ? 700 + : null; + + if (globalWeight) { + // In that case, strip any inline bold from the text and wrap everything in a span with correct style + const stripped = stripTag(content, 'strong'); + const fancyBold = `font-weight: ${globalWeight}`; + const wrapped = wrapWithSpan(stripped, fancyBold); + return wrapped; + } + + const justBold = 'font-weight: 700'; + return replaceTagWithSpan(content, 'strong', justBold); +} + +function convertInlineItalic(content, fontStyle) { + // Do we have a specific font style to apply for entire text field? + const globalFontStyle = fontStyle === 'italic' ? fontStyle : null; + const italicStyle = 'font-style: italic'; + + if (globalFontStyle) { + // In that case, strip any inline em from the text and wrap everything in a span with correct style + const stripped = stripTag(content, 'em'); + const wrapped = wrapWithSpan(stripped, italicStyle); + return wrapped; + } + + return replaceTagWithSpan(content, 'em', italicStyle); +} + +function convertInlineUnderline(content, textDecoration) { + // Do we have a specific text decoration to apply for entire text field? + const globalDecoration = + textDecoration === 'underline' ? textDecoration : null; + const underlineStyle = 'text-decoration: underline'; + + if (globalDecoration) { + // In that case, strip any inline underline from the text and wrap everything in a span with correct style + const stripped = stripTag(content, 'u'); + const wrapped = wrapWithSpan(stripped, underlineStyle); + return wrapped; + } + + return replaceTagWithSpan(content, 'u', underlineStyle); +} + +function addInlineColor(content, color) { + // If we don't have a color (should never happen, but if), just return + if (!color) { + return content; + } + + const { + color: { r, g, b, a = 1 }, + } = color; + return wrapWithSpan(content, `color: rgba(${r}, ${g}, ${b}, ${a})`); +} + +function addInlineLetterSpacing(content, letterSpacing) { + // If we don't have letterSpacing, just return + if (!letterSpacing) { + return content; + } + + return wrapWithSpan(content, `letter-spacing: ${letterSpacing / 100}em`); +} + +function stripTag(html, tag) { + // This is a very naive strip. Can only remove non-self-closing tags with attributes, which is sufficent here + return html.replace(new RegExp(``, 'gi'), ''); +} + +function replaceTagWithSpan(html, tag, style) { + // Again, very naive + return html + .replace(new RegExp(`<${tag}>`, 'gi'), ``) + .replace(new RegExp(``, 'gi'), ''); +} + +function wrapWithSpan(html, style) { + return `${html}`; +} + +export default inlineTextProperties; diff --git a/assets/src/edit-story/types.js b/assets/src/edit-story/types.js index 50a91c49ae97..52b0c013eecd 100644 --- a/assets/src/edit-story/types.js +++ b/assets/src/edit-story/types.js @@ -204,14 +204,10 @@ export const FontPropType = PropTypes.shape({ StoryPropTypes.elements.text = PropTypes.shape({ ...StoryElementPropTypes, content: PropTypes.string, - color: PatternPropType.isRequired, backgroundTextMode: PropTypes.oneOf(Object.values(BACKGROUND_TEXT_MODE)), backgroundColor: PatternPropType, font: FontPropType.isRequired, fontSize: PropTypes.number, - fontWeight: PropTypes.number, - fontStyle: PropTypes.string, - letterSpacing: PropTypes.number, lineHeight: PropTypes.number, padding: PropTypes.shape({ horizontal: PropTypes.number, @@ -219,7 +215,6 @@ StoryPropTypes.elements.text = PropTypes.shape({ locked: PropTypes.bool, }), textAlign: PropTypes.string, - textDecoration: PropTypes.string, }); StoryPropTypes.elements.shape = PropTypes.shape({ diff --git a/assets/src/edit-story/utils/getValidHTML.js b/assets/src/edit-story/utils/getValidHTML.js new file mode 100644 index 000000000000..92f7d975ab9c --- /dev/null +++ b/assets/src/edit-story/utils/getValidHTML.js @@ -0,0 +1,22 @@ +/* + * 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. + */ + +const contentBuffer = document.createElement('template'); + +export default function getValidHTML(string) { + contentBuffer.innerHTML = string; + return contentBuffer.innerHTML; +} diff --git a/assets/src/edit-story/utils/isPatternEqual.js b/assets/src/edit-story/utils/isPatternEqual.js new file mode 100644 index 000000000000..831a6868e00a --- /dev/null +++ b/assets/src/edit-story/utils/isPatternEqual.js @@ -0,0 +1,27 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import convertToCSS from './convertToCSS'; +import generatePatternStyles from './generatePatternStyles'; + +export default function isPatternEqual(p1, p2, patternType = undefined) { + const p1CSS = convertToCSS(generatePatternStyles(p1, patternType)); + const p2CSS = convertToCSS(generatePatternStyles(p2, patternType)); + return p1CSS === p2CSS; +} diff --git a/assets/src/edit-story/utils/patternUtils.js b/assets/src/edit-story/utils/patternUtils.js new file mode 100644 index 000000000000..503b32071c25 --- /dev/null +++ b/assets/src/edit-story/utils/patternUtils.js @@ -0,0 +1,45 @@ +/* + * 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. + */ + +/** + * Internal dependencies + */ +import createSolidFromString from './createSolidFromString'; +import createSolid from './createSolid'; + +export function getSolidFromHex(hex) { + // We already have a nice parser for most of this, but we need to + // parse opacity as the last two hex digits as percent, not 1/256th + + const { + color: { r, g, b }, + } = createSolidFromString(`#${hex.slice(0, 6)}`); + + const opacity = parseInt(hex.slice(6), 16); + + return createSolid(r, g, b, opacity / 100); +} + +export function getHexFromSolid(solid) { + const { + color: { r, g, b, a = 1 }, + } = solid; + const dims = [r, g, b, Math.round(a * 100)]; + return dims + .map((n) => n.toString(16)) + .map((s) => s.padStart(2, '0')) + .join(''); +} diff --git a/assets/src/edit-story/utils/test/useLiveRegion.js b/assets/src/edit-story/utils/test/useLiveRegion.js index 58bdeb925b77..993bd40427d9 100644 --- a/assets/src/edit-story/utils/test/useLiveRegion.js +++ b/assets/src/edit-story/utils/test/useLiveRegion.js @@ -31,7 +31,9 @@ describe('useLiveRegion', () => { expect( queryById(document.documentElement, 'web-stories-aria-live-region-polite') - ).toBeEmpty(); + ).toHaveTextContent(''); + // .toBeEmpty() cannot be used, because of + // https://github.com/testing-library/jest-dom/issues/216 act(() => { result.current('Hello World'); @@ -53,7 +55,9 @@ describe('useLiveRegion', () => { document.documentElement, 'web-stories-aria-live-region-assertive' ) - ).toBeEmpty(); + ).toHaveTextContent(''); + // .toBeEmpty() cannot be used, because of + // https://github.com/testing-library/jest-dom/issues/216 act(() => result.current('Hello World')); @@ -73,7 +77,9 @@ describe('useLiveRegion', () => { expect( queryById(document.documentElement, 'web-stories-aria-live-region-polite') - ).toBeEmpty(); + ).toHaveTextContent(''); + // .toBeEmpty() cannot be used, because of + // https://github.com/testing-library/jest-dom/issues/216 unmount(); @@ -87,7 +93,9 @@ describe('useLiveRegion', () => { expect( queryById(document.documentElement, 'web-stories-aria-live-region-polite') - ).toBeEmpty(); + ).toHaveTextContent(''); + // .toBeEmpty() cannot be used, because of + // https://github.com/testing-library/jest-dom/issues/216 act(() => { result.current('Foo'); @@ -110,6 +118,8 @@ describe('useLiveRegion', () => { expect( queryById(document.documentElement, 'web-stories-aria-live-region-polite') - ).toBeEmpty(); + ).toHaveTextContent(''); + // .toBeEmpty() cannot be used, because of + // https://github.com/testing-library/jest-dom/issues/216 }); }); diff --git a/assets/src/edit-story/utils/useFocusOut.js b/assets/src/edit-story/utils/useFocusOut.js index a22fac872ef6..cd853a4ff208 100644 --- a/assets/src/edit-story/utils/useFocusOut.js +++ b/assets/src/edit-story/utils/useFocusOut.js @@ -35,19 +35,23 @@ function useFocusOut(ref, callback, deps) { const onDocumentClick = (evt) => { // If something outside the target node is clicked, callback time! - const isInDocument = node.ownerDocument.contains(evt.target); const isInNode = node.contains(evt.target); - if (!isInNode && isInDocument) { + if (!isInNode) { callback(); } }; node.addEventListener('focusout', onFocusOut); - node.ownerDocument.addEventListener('pointerdown', onDocumentClick); + // Often elements are removed in pointerdown handlers elsewhere, causing them + // to fail the node.contains check regardless of being inside target ref or not. + // By checking the click target in the capture phase, we circumvent that completely. + const opts = { capture: true }; + const doc = node.ownerDocument; + doc.addEventListener('pointerdown', onDocumentClick, opts); return () => { node.removeEventListener('focusout', onFocusOut); - node.ownerDocument.removeEventListener('pointerdown', onDocumentClick); + doc.removeEventListener('pointerdown', onDocumentClick, opts); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps || []); diff --git a/package-lock.json b/package-lock.json index 24540fa069d0..3da39fe0a3b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7975,6 +7975,11 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" + }, "abab": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", @@ -8363,20 +8368,17 @@ "arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", - "dev": true + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=" }, "arr-flatten": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", - "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", - "dev": true + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==" }, "arr-union": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", - "dev": true + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=" }, "array-equal": { "version": "1.0.0", @@ -8419,8 +8421,7 @@ "array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", - "dev": true + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=" }, "array.prototype.find": { "version": "2.1.1", @@ -8528,8 +8529,7 @@ "assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", - "dev": true + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=" }, "ast-types": { "version": "0.12.4", @@ -8578,8 +8578,7 @@ "atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "dev": true + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==" }, "autoprefixer": { "version": "9.7.4", @@ -9565,14 +9564,12 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "base": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", - "dev": true, "requires": { "cache-base": "^1.0.1", "class-utils": "^0.3.5", @@ -9587,7 +9584,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, "requires": { "is-descriptor": "^1.0.0" } @@ -9596,7 +9592,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, "requires": { "kind-of": "^6.0.0" } @@ -9605,7 +9600,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, "requires": { "kind-of": "^6.0.0" } @@ -9614,7 +9608,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, "requires": { "is-accessor-descriptor": "^1.0.0", "is-data-descriptor": "^1.0.0", @@ -9885,7 +9878,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -9895,7 +9887,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, "requires": { "arr-flatten": "^1.1.0", "array-unique": "^0.3.2", @@ -9913,7 +9904,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, "requires": { "is-extendable": "^0.1.0" } @@ -10145,7 +10135,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", - "dev": true, "requires": { "collection-visit": "^1.0.0", "component-emitter": "^1.2.1", @@ -10404,8 +10393,7 @@ "ci-info": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" }, "cipher-base": { "version": "1.0.4", @@ -10421,7 +10409,6 @@ "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", - "dev": true, "requires": { "arr-union": "^3.1.0", "define-property": "^0.2.5", @@ -10433,7 +10420,6 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, "requires": { "is-descriptor": "^0.1.0" } @@ -10735,7 +10721,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", - "dev": true, "requires": { "map-visit": "^1.0.0", "object-visit": "^1.0.0" @@ -10830,14 +10815,12 @@ "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "concat-stream": { "version": "1.6.2", @@ -10967,8 +10950,7 @@ "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", - "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", - "dev": true + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" }, "copy-to-clipboard": { "version": "3.3.1", @@ -11114,7 +11096,6 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, "requires": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -11653,7 +11634,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, "requires": { "is-descriptor": "^1.0.2", "isobject": "^3.0.1" @@ -11663,7 +11643,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, "requires": { "kind-of": "^6.0.0" } @@ -11672,7 +11651,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, "requires": { "kind-of": "^6.0.0" } @@ -11681,7 +11659,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, "requires": { "is-accessor-descriptor": "^1.0.0", "is-data-descriptor": "^1.0.0", @@ -13163,7 +13140,6 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", - "dev": true, "requires": { "debug": "^2.3.3", "define-property": "^0.2.5", @@ -13178,7 +13154,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "requires": { "ms": "2.0.0" } @@ -13187,7 +13162,6 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, "requires": { "is-descriptor": "^0.1.0" } @@ -13196,7 +13170,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, "requires": { "is-extendable": "^0.1.0" } @@ -13204,8 +13177,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" } } }, @@ -13506,7 +13478,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", - "dev": true, "requires": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" @@ -13516,7 +13487,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, "requires": { "is-plain-object": "^2.0.4" } @@ -13538,7 +13508,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, "requires": { "array-unique": "^0.3.2", "define-property": "^1.0.0", @@ -13554,7 +13523,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, "requires": { "is-descriptor": "^1.0.0" } @@ -13563,7 +13531,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, "requires": { "is-extendable": "^0.1.0" } @@ -13572,7 +13539,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, "requires": { "kind-of": "^6.0.0" } @@ -13581,7 +13547,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, "requires": { "kind-of": "^6.0.0" } @@ -13590,7 +13555,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, "requires": { "is-accessor-descriptor": "^1.0.0", "is-data-descriptor": "^1.0.0", @@ -13802,7 +13766,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", - "dev": true, "requires": { "extend-shallow": "^2.0.1", "is-number": "^3.0.0", @@ -13814,7 +13777,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, "requires": { "is-extendable": "^0.1.0" } @@ -13941,6 +13903,35 @@ "semver-regex": "^2.0.0" } }, + "find-yarn-workspace-root": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-1.2.1.tgz", + "integrity": "sha512-dVtfb0WuQG+8Ag2uWkbG79hOUzEsRrhBzgfn86g2sJPkzmcpGdghbNTfUKGTxymFrY/tLIodDzLoW9nOJ4FY8Q==", + "requires": { + "fs-extra": "^4.0.3", + "micromatch": "^3.1.4" + }, + "dependencies": { + "fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "^4.1.6" + } + } + } + }, "findup": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/findup/-/findup-0.1.5.tgz", @@ -14064,8 +14055,7 @@ "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", - "dev": true + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=" }, "for-own": { "version": "0.1.5", @@ -14123,7 +14113,6 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", - "dev": true, "requires": { "map-cache": "^0.2.2" } @@ -14213,8 +14202,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "1.2.11", @@ -14943,8 +14931,7 @@ "get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", - "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", - "dev": true + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=" }, "getpass": { "version": "0.1.7", @@ -14976,7 +14963,6 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -15159,8 +15145,7 @@ "graceful-fs": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", - "dev": true + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==" }, "graceful-readlink": { "version": "1.0.1", @@ -15248,7 +15233,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", - "dev": true, "requires": { "get-value": "^2.0.6", "has-values": "^1.0.0", @@ -15259,7 +15243,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", - "dev": true, "requires": { "is-number": "^3.0.0", "kind-of": "^4.0.0" @@ -15269,7 +15252,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", - "dev": true, "requires": { "is-buffer": "^1.1.5" } @@ -15876,7 +15858,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -15885,8 +15866,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { "version": "1.3.5", @@ -16076,7 +16056,6 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", - "dev": true, "requires": { "kind-of": "^3.0.2" }, @@ -16085,7 +16064,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, "requires": { "is-buffer": "^1.1.5" } @@ -16149,7 +16127,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", - "dev": true, "requires": { "ci-info": "^2.0.0" } @@ -16172,7 +16149,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", - "dev": true, "requires": { "kind-of": "^3.0.2" }, @@ -16181,7 +16157,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, "requires": { "is-buffer": "^1.1.5" } @@ -16203,7 +16178,6 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", - "dev": true, "requires": { "is-accessor-descriptor": "^0.1.6", "is-data-descriptor": "^0.1.4", @@ -16213,8 +16187,7 @@ "kind-of": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==" } } }, @@ -16261,8 +16234,7 @@ "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", - "dev": true + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" }, "is-extglob": { "version": "2.1.1", @@ -16316,7 +16288,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", - "dev": true, "requires": { "kind-of": "^3.0.2" }, @@ -16325,7 +16296,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, "requires": { "is-buffer": "^1.1.5" } @@ -16354,7 +16324,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, "requires": { "isobject": "^3.0.1" } @@ -16450,8 +16419,7 @@ "is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==" }, "is-word-character": { "version": "1.0.4", @@ -16468,20 +16436,17 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, "isomorphic-fetch": { "version": "2.2.1", @@ -21230,8 +21195,7 @@ "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" }, "klaw": { "version": "1.3.1", @@ -21242,6 +21206,14 @@ "graceful-fs": "^4.1.9" } }, + "klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "requires": { + "graceful-fs": "^4.1.11" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -21945,8 +21917,7 @@ "map-cache": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", - "dev": true + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=" }, "map-obj": { "version": "4.1.0", @@ -21964,7 +21935,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", - "dev": true, "requires": { "object-visit": "^1.0.0" } @@ -22373,7 +22343,6 @@ "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, "requires": { "arr-diff": "^4.0.0", "array-unique": "^0.3.2", @@ -22468,7 +22437,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -22476,8 +22444,7 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" }, "minimist-options": { "version": "4.0.2", @@ -22581,7 +22548,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", - "dev": true, "requires": { "for-in": "^1.0.2", "is-extendable": "^1.0.1" @@ -22591,7 +22557,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, "requires": { "is-plain-object": "^2.0.4" } @@ -22694,7 +22659,6 @@ "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", - "dev": true, "requires": { "arr-diff": "^4.0.0", "array-unique": "^0.3.2", @@ -22748,8 +22712,7 @@ "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" }, "no-case": { "version": "3.0.3", @@ -23016,7 +22979,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", - "dev": true, "requires": { "copy-descriptor": "^0.1.0", "define-property": "^0.2.5", @@ -23027,7 +22989,6 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, "requires": { "is-descriptor": "^0.1.0" } @@ -23036,7 +22997,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, "requires": { "is-buffer": "^1.1.5" } @@ -23062,7 +23022,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", - "dev": true, "requires": { "isobject": "^3.0.0" } @@ -23115,7 +23074,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", - "dev": true, "requires": { "isobject": "^3.0.1" } @@ -23149,7 +23107,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "1" } @@ -23252,8 +23209,7 @@ "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" }, "p-defer": { "version": "1.0.0", @@ -23453,8 +23409,46 @@ "pascalcase": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", - "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", - "dev": true + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=" + }, + "patch-package": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-6.2.2.tgz", + "integrity": "sha512-YqScVYkVcClUY0v8fF0kWOjDYopzIM8e3bj/RU1DPeEF14+dCGm6UeOYm4jvCyxqIEQ5/eJzmbWfDWnUleFNMg==", + "requires": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^2.4.2", + "cross-spawn": "^6.0.5", + "find-yarn-workspace-root": "^1.2.1", + "fs-extra": "^7.0.1", + "is-ci": "^2.0.0", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.0", + "rimraf": "^2.6.3", + "semver": "^5.6.0", + "slash": "^2.0.0", + "tmp": "^0.0.33" + }, + "dependencies": { + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "^4.1.6" + } + } + } }, "path-browserify": { "version": "0.0.1", @@ -23477,14 +23471,12 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", - "dev": true + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" }, "path-parse": { "version": "1.0.6", @@ -23674,8 +23666,7 @@ "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", - "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", - "dev": true + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=" }, "postcss": { "version": "7.0.27", @@ -25881,7 +25872,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", - "dev": true, "requires": { "extend-shallow": "^3.0.2", "safe-regex": "^1.1.0" @@ -26281,14 +26271,12 @@ "repeat-element": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", - "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", - "dev": true + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==" }, "repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", - "dev": true + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" }, "replace-ext": { "version": "1.0.0", @@ -26464,8 +26452,7 @@ "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", - "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", - "dev": true + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=" }, "restore-cursor": { "version": "3.1.0", @@ -26480,8 +26467,7 @@ "ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==" }, "reusify": { "version": "1.0.4", @@ -26514,7 +26500,6 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "dev": true, "requires": { "glob": "^7.1.3" } @@ -26655,7 +26640,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", - "dev": true, "requires": { "ret": "~0.1.10" } @@ -26728,8 +26712,7 @@ "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, "semver-compare": { "version": "1.0.0", @@ -26844,7 +26827,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "dev": true, "requires": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", @@ -26856,7 +26838,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, "requires": { "is-extendable": "^0.1.0" } @@ -26928,7 +26909,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "dev": true, "requires": { "shebang-regex": "^1.0.0" } @@ -26936,8 +26916,7 @@ "shebang-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", - "dev": true + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" }, "shell-quote": { "version": "1.7.2", @@ -27037,8 +27016,7 @@ "slash": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", - "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", - "dev": true + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==" }, "slice-ansi": { "version": "2.1.0", @@ -27063,7 +27041,6 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", - "dev": true, "requires": { "base": "^0.11.1", "debug": "^2.2.0", @@ -27079,7 +27056,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "requires": { "ms": "2.0.0" } @@ -27088,7 +27064,6 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, "requires": { "is-descriptor": "^0.1.0" } @@ -27097,7 +27072,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, "requires": { "is-extendable": "^0.1.0" } @@ -27105,8 +27079,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" } } }, @@ -27114,7 +27087,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", - "dev": true, "requires": { "define-property": "^1.0.0", "isobject": "^3.0.0", @@ -27125,7 +27097,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, "requires": { "is-descriptor": "^1.0.0" } @@ -27134,7 +27105,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, "requires": { "kind-of": "^6.0.0" } @@ -27143,7 +27113,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, "requires": { "kind-of": "^6.0.0" } @@ -27152,7 +27121,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, "requires": { "is-accessor-descriptor": "^1.0.0", "is-data-descriptor": "^1.0.0", @@ -27165,7 +27133,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", - "dev": true, "requires": { "kind-of": "^3.2.0" }, @@ -27174,7 +27141,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, "requires": { "is-buffer": "^1.1.5" } @@ -27248,7 +27214,6 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", - "dev": true, "requires": { "atob": "^2.1.2", "decode-uri-component": "^0.2.0", @@ -27278,8 +27243,7 @@ "source-map-url": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", - "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", - "dev": true + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=" }, "space-separated-tokens": { "version": "1.1.5", @@ -27346,7 +27310,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", - "dev": true, "requires": { "extend-shallow": "^3.0.0" } @@ -27403,7 +27366,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", - "dev": true, "requires": { "define-property": "^0.2.5", "object-copy": "^0.1.0" @@ -27413,7 +27375,6 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, "requires": { "is-descriptor": "^0.1.0" } @@ -28867,7 +28828,6 @@ "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, "requires": { "os-tmpdir": "~1.0.2" } @@ -28893,7 +28853,6 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", - "dev": true, "requires": { "kind-of": "^3.0.2" }, @@ -28902,7 +28861,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, "requires": { "is-buffer": "^1.1.5" } @@ -28913,7 +28871,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", - "dev": true, "requires": { "define-property": "^2.0.2", "extend-shallow": "^3.0.2", @@ -28925,7 +28882,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", - "dev": true, "requires": { "is-number": "^3.0.0", "repeat-string": "^1.6.1" @@ -29225,7 +29181,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", - "dev": true, "requires": { "arr-union": "^3.1.0", "get-value": "^2.0.6", @@ -29382,8 +29337,7 @@ "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" }, "unpipe": { "version": "1.0.0", @@ -29401,7 +29355,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", - "dev": true, "requires": { "has-value": "^0.3.1", "isobject": "^3.0.0" @@ -29411,7 +29364,6 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", - "dev": true, "requires": { "get-value": "^2.0.3", "has-values": "^0.1.4", @@ -29422,7 +29374,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", - "dev": true, "requires": { "isarray": "1.0.0" } @@ -29432,8 +29383,7 @@ "has-values": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", - "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", - "dev": true + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=" } } }, @@ -29454,8 +29404,7 @@ "urix": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", - "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", - "dev": true + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=" }, "url": { "version": "0.11.0", @@ -29517,8 +29466,7 @@ "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", - "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", - "dev": true + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, "use-callback-ref": { "version": "1.2.1", @@ -30331,7 +30279,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -30514,8 +30461,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write": { "version": "1.0.3", diff --git a/package.json b/package.json index 58d705ae0590..7f324c085a19 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "history": "^4.10.1", "moment": "^2.25.3", "mousetrap": "^1.6.5", + "patch-package": "^6.2.2", "polished": "^3.6.2", "prop-types": "^15.7.2", "query-string": "^6.12.1", @@ -161,6 +162,7 @@ "lint:php": "vendor/bin/phpcs", "lint:php:fix": "vendor/bin/phpcbf", "lint:md": "markdownlint .", + "postinstall": "npx patch-package", "storybook": "start-storybook --quiet", "test": "npm-run-all --parallel test:*", "test:js": "jest --config=tests/js/jest.config.js", diff --git a/patches/draft-js-import-element+1.4.0.patch b/patches/draft-js-import-element+1.4.0.patch new file mode 100644 index 000000000000..25f49e233fb5 --- /dev/null +++ b/patches/draft-js-import-element+1.4.0.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/draft-js-import-element/esm/stateFromElement.js b/node_modules/draft-js-import-element/esm/stateFromElement.js +index eccabfd..9ec7496 100644 +--- a/node_modules/draft-js-import-element/esm/stateFromElement.js ++++ b/node_modules/draft-js-import-element/esm/stateFromElement.js +@@ -373,7 +373,7 @@ function () { + switch (customInline.type) { + case 'STYLE': + { +- style = style.add(customInline.style); ++ [].concat(customInline.style).forEach(customStyle => { style = style.add(customStyle); }); + break; + } + +diff --git a/node_modules/draft-js-import-element/lib/stateFromElement.js b/node_modules/draft-js-import-element/lib/stateFromElement.js +index e814c05..c469bda 100644 +--- a/node_modules/draft-js-import-element/lib/stateFromElement.js ++++ b/node_modules/draft-js-import-element/lib/stateFromElement.js +@@ -389,7 +389,7 @@ function () { + switch (customInline.type) { + case 'STYLE': + { +- style = style.add(customInline.style); ++ [].concat(customInline.style).forEach(customStyle => { style = style.add(customStyle); }); + break; + } + diff --git a/tests/js/jest.setup.js b/tests/js/jest.setup.js index 711f3eda18bf..3d3330af6691 100644 --- a/tests/js/jest.setup.js +++ b/tests/js/jest.setup.js @@ -21,6 +21,7 @@ // See https://github.com/testing-library/jest-dom. import 'jest-extended'; import '@testing-library/jest-dom'; +import 'jest-extended'; /** * Internal dependencies