From 536ac785548b4d77ecf9a7a07a589a8225c90d24 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 23 Apr 2020 15:33:41 -0400 Subject: [PATCH 01/51] WIP: First partially working version of inline text formatting --- .../components/panels/textStyle/textStyle.js | 57 +++-- .../edit-story/components/richText/context.js | 24 +++ .../components/richText/customConstants.js | 45 ++++ .../components/richText/customExport.js | 81 ++++++++ .../components/richText/customImport.js | 77 +++++++ .../richText/customInlineDisplay.js | 47 +++++ .../edit-story/components/richText/editor.js | 115 ++++++++++ .../edit-story/components/richText/index.js | 20 ++ .../components/richText/provider.js | 196 ++++++++++++++++++ .../components/richText/styleManipulation.js | 179 ++++++++++++++++ .../components/richText/useRichText.js | 31 +++ .../edit-story/components/richText/util.js | 121 +++++++++++ .../edit-story/components/workspace/index.js | 23 +- .../src/edit-story/elements/text/display.js | 7 +- assets/src/edit-story/elements/text/edit.js | 127 +++--------- assets/src/edit-story/elements/text/output.js | 7 +- .../src/edit-story/elements/text/test/util.js | 28 +-- assets/src/edit-story/elements/text/util.js | 92 -------- 18 files changed, 1026 insertions(+), 251 deletions(-) create mode 100644 assets/src/edit-story/components/richText/context.js create mode 100644 assets/src/edit-story/components/richText/customConstants.js create mode 100644 assets/src/edit-story/components/richText/customExport.js create mode 100644 assets/src/edit-story/components/richText/customImport.js create mode 100644 assets/src/edit-story/components/richText/customInlineDisplay.js create mode 100644 assets/src/edit-story/components/richText/editor.js create mode 100644 assets/src/edit-story/components/richText/index.js create mode 100644 assets/src/edit-story/components/richText/provider.js create mode 100644 assets/src/edit-story/components/richText/styleManipulation.js create mode 100644 assets/src/edit-story/components/richText/useRichText.js create mode 100644 assets/src/edit-story/components/richText/util.js diff --git a/assets/src/edit-story/components/panels/textStyle/textStyle.js b/assets/src/edit-story/components/panels/textStyle/textStyle.js index 7512e3cf48be..ff6d255476d5 100644 --- a/assets/src/edit-story/components/panels/textStyle/textStyle.js +++ b/assets/src/edit-story/components/panels/textStyle/textStyle.js @@ -30,6 +30,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { Numeric, Row, ToggleButton } from '../../form'; +import useRichText from '../../richText/useRichText'; import { ReactComponent as VerticalOffset } from '../../../icons/offset_vertical.svg'; import { ReactComponent as HorizontalOffset } from '../../../icons/offset_horizontal.svg'; import { ReactComponent as LeftAlign } from '../../../icons/left_align.svg'; @@ -61,12 +62,46 @@ const Space = styled.div` `; function StylePanel({ selectedElements, pushUpdate }) { + const { + state: { + hasCurrentEditor, + selectionIsBold, + selectionIsItalic, + selectionIsUnderline, + }, + actions: { + toggleBoldInSelection, + toggleItalicInSelection, + toggleUnderlineInSelection, + }, + } = useRichText(); + 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 isBold = hasCurrentEditor + ? selectionIsBold + : getCommonValue(selectedElements, 'bold'); + const isItalic = hasCurrentEditor + ? selectionIsItalic + : getCommonValue(selectedElements, 'fontStyle') === 'italic'; + const isUnderline = hasCurrentEditor + ? selectionIsUnderline + : getCommonValue(selectedElements, 'textDecoration') === 'underline'; + + const handleClickBold = hasCurrentEditor + ? toggleBoldInSelection + : (value) => pushUpdate({ bold: value }, true); + + const handleClickItalic = hasCurrentEditor + ? toggleItalicInSelection + : (value) => pushUpdate({ fontStyle: value ? 'italic' : 'normal' }, true); + + const handleClickUnderline = hasCurrentEditor + ? toggleUnderlineInSelection + : (value) => + pushUpdate({ textDecoration: value ? 'underline' : 'none' }, true); return ( <> @@ -128,28 +163,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/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..49569d617c24 --- /dev/null +++ b/assets/src/edit-story/components/richText/customConstants.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. + */ + +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'; + +export const SMALLEST_BOLD = 600; +export const DEFAULT_BOLD = 700; + +export const weightToStyle = (weight) => `${WEIGHT}-${weight}`; +export const colorToStyle = (color) => `${COLOR}-${color}`; +export const lsToStyle = (ls) => `${LETTERSPACING}-${ls}`; + +export const isStyle = (style, prefix) => + typeof style === 'string' && style.startsWith(prefix); +export const getVariable = (style, prefix) => style.slice(prefix.length + 1); + +export const styleToWeight = (style) => + isStyle(style, WEIGHT) ? parseInt(getVariable(style, WEIGHT)) : null; +export const styleToColor = (style) => + isStyle(style, COLOR) ? getVariable(style, COLOR) : null; +export const styleToLs = (style) => + isStyle(style, LETTERSPACING) + ? parseInt(getVariable(style, LETTERSPACING)) + : null; + +export const isBold = (style) => styleToWeight(style) >= 700; +export const hasBold = (styles) => styles.some(isBold); 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..c09baf64d961 --- /dev/null +++ b/assets/src/edit-story/components/richText/customExport.js @@ -0,0 +1,81 @@ +/* + * 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 { styleToWeight, ITALIC, UNDERLINE } from './customConstants'; + +function inlineStyleFn(styles) { + const inline = styles.toArray().reduce( + ({ classes, css }, style) => { + // Italic + if (style === ITALIC) { + return { + classes: [...classes, 'italic'], + css: { ...css, fontStyle: 'italic' }, + }; + } + + // Underline + if (style === UNDERLINE) { + return { + classes: [...classes, 'underline'], + css: { ...css, textDecoration: 'underline' }, + }; + } + + // Weight + const weight = styleToWeight(style); + if (weight) { + return { + classes: [...classes, 'weight'], + css: { ...css, fontWeight: weight }, + }; + } + + // TODO: Color + // TODO: Letter spacing + + return { classes, css }; + }, + { classes: [], css: {} } + ); + + if (inline.classes.length === 0) { + return null; + } + + return { + element: 'span', + attributes: { class: inline.classes.join(' ') }, + style: inline.css, + }; +} + +function exportHTML(editorState) { + return stateToHTML(editorState.getCurrentContent(), { + inlineStyleFn, + defaultBlockTag: null, + }); +} + +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..643d8e5ef5be --- /dev/null +++ b/assets/src/edit-story/components/richText/customImport.js @@ -0,0 +1,77 @@ +/* + * 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 { weightToStyle, ITALIC, UNDERLINE } from './customConstants'; +import { draftMarkupToContent } from './util'; + +function customInlineFn(element, { Style }) { + switch (element.tagName.toLowerCase()) { + case 'span': { + const styles = [...element.classList] + .map((className) => { + switch (className) { + case 'weight': { + const fontWeight = parseInt(element.style.fontWeight) || 400; + return weightToStyle(fontWeight); + } + + case 'italic': + return ITALIC; + + case 'underline': + return UNDERLINE; + + default: + return null; + } + }) + .filter((style) => Boolean(style)); + + if (styles.length) { + // This is the reason we need a fork, as multiple styles aren't supported by published package + // and maintainer clearly doesn't care about it enough to merge. + return Style(styles[0]); + } + + return null; + } + + default: + return null; + } +} + +function importHTML(html) { + const htmlWithBreaks = (html || '') + // Re-insert manual line-breaks for empty lines + .replace(/\n(?=\n)/g, '\n
') + .split('\n') + .map((s) => { + return `

${draftMarkupToContent(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..5d1567c6517c --- /dev/null +++ b/assets/src/edit-story/components/richText/customInlineDisplay.js @@ -0,0 +1,47 @@ +/* + * 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 { styleToWeight, ITALIC, UNDERLINE } from './customConstants'; + +function customInlineDisplay(styles) { + return styles.toArray().reduce((css, style) => { + // Italic + if (style === ITALIC) { + return { ...css, fontStyle: 'italic' }; + } + + // Underline + if (style === UNDERLINE) { + return { ...css, textDecoration: 'underline' }; + } + + // Weight + const weight = styleToWeight(style); + if (weight) { + return { ...css, fontWeight: weight }; + } + + // TODO: Color + // TODO: Letter spacing + + return css; + }, {}); +} + +export default customInlineDisplay; 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..2faf8cf147a4 --- /dev/null +++ b/assets/src/edit-story/components/richText/editor.js @@ -0,0 +1,115 @@ +/* + * 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, + useLayoutEffect, + 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, forceFocus }, + actions: { + setStateFromContent, + updateEditorState, + getHandleKeyCommand, + getContentFromState, + clearState, + clearForceFocus, + }, + } = useRichText(); + + // Load state from parent when content changes + useEffect(() => { + setStateFromContent(content); + }, [setStateFromContent, content]); + + // Push updates to parent when state changes + useEffect(() => { + const newContent = getContentFromState(editorState); + if (newContent) { + onChange(newContent); + } + }, [onChange, getContentFromState, editorState]); + + // Set focus when initially rendered. + useLayoutEffect(() => { + if (editorRef.current) { + editorRef.current.focus(); + } + }, []); + + // Set focus when forced to, then clear + useLayoutEffect(() => { + if (editorRef.current && forceFocus) { + editorRef.current.focus(); + clearForceFocus(); + } + }, [forceFocus, clearForceFocus]); + + 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/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..63c90cce9cdf --- /dev/null +++ b/assets/src/edit-story/components/richText/provider.js @@ -0,0 +1,196 @@ +/* + * 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, useRef, useMemo, useEffect } 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 { + toggleBold, + toggleItalic, + toggleUnderline, + isBold, + isItalic, + isUnderline, +} from './styleManipulation'; +import customImport from './customImport'; +import customExport from './customExport'; + +function RichTextProvider({ children }) { + const { + state: { editingElementState }, + } = useCanvas(); + + const [forceFocus, setForceFocus] = useState(false); + const [editorState, setEditorState] = useState(null); + + const [ + selectionIsBold, + selectionIsItalic, + selectionIsUnderline, + ] = useMemo(() => { + if (editorState) { + return [ + isBold(editorState), + isItalic(editorState), + isUnderline(editorState), + ]; + } + return [false, false, 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] + ); + + // This is to allow the getContentFromState to be stable and *not* + // depend on editorState, as would otherwise be a lint error. + const lastKnownState = useRef(null); + const lastKnownSelection = useRef(null); + useEffect(() => { + lastKnownState.current = editorState; + if (editorState?.getSelection()?.hasFocus) { + lastKnownSelection.current = editorState.getSelection(); + } + }, [editorState]); + + const getContentFromState = useCallback((someEditorState) => { + if (!someEditorState) { + return null; + } + + return customExport(someEditorState) + .replace(/
/g, '') + .replace(/ $/, ''); + }, []); + + // 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); + lastKnownSelection.current = null; + }, [setEditorState]); + + const hasCurrentEditor = Boolean(editorState); + + const updateWhileUnfocused = useCallback((updater) => { + const oldState = lastKnownState.current; + const selection = lastKnownSelection.current; + const workingState = EditorState.forceSelection(oldState, selection); + const newState = updater(workingState); + setEditorState(newState); + setForceFocus(true); + }, []); + + const toggleBoldInSelection = useCallback( + () => updateWhileUnfocused(toggleBold), + [updateWhileUnfocused] + ); + const toggleItalicInSelection = useCallback( + () => updateWhileUnfocused(toggleItalic), + [updateWhileUnfocused] + ); + const toggleUnderlineInSelection = useCallback( + () => updateWhileUnfocused(toggleUnderline), + [updateWhileUnfocused] + ); + + const clearForceFocus = useCallback(() => setForceFocus(false), []); + + const value = { + state: { + editorState, + hasCurrentEditor, + selectionIsBold, + selectionIsItalic, + selectionIsUnderline, + forceFocus, + }, + actions: { + setStateFromContent, + updateEditorState, + getHandleKeyCommand, + getContentFromState, + clearState, + clearForceFocus, + toggleBoldInSelection, + toggleItalicInSelection, + toggleUnderlineInSelection, + }, + }; + + return ( + + {children} + + ); +} + +RichTextProvider.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + 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..3458dc9602c3 --- /dev/null +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -0,0 +1,179 @@ +/* + * 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 { + weightToStyle, + styleToWeight, + NONE, + WEIGHT, + ITALIC, + UNDERLINE, + SMALLEST_BOLD, + DEFAULT_BOLD, +} from './customConstants'; + +function getPrefixStyleForCharacter(styles, prefix) { + const list = styles.toArray().map((style) => style.style ?? style); + if (!list.some((style) => style && style.startsWith(prefix))) { + return NONE; + } + return list.find((style) => style.startsWith(prefix)); +} + +function getPrefixStylesInSelection(editorState, prefix) { + const selection = editorState.getSelection(); + const styles = new Set(); + if (selection.isCollapsed()) { + styles.add( + getPrefixStyleForCharacter(editorState.getCurrentInlineStyle(), prefix) + ); + return [...styles]; + } + + const contentState = editorState.getCurrentContent(); + 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++ + ) { + styles.add( + getPrefixStyleForCharacter( + characterList.get(offsetIndex).getStyle(), + prefix + ) + ); + } + if (!hasMoreRounds) { + break; + } + key = contentState.getKeyAfter(key); + startOffset = 0; + } + + return [...styles]; +} + +function applyContent(editorState, contentState) { + return EditorState.push(editorState, contentState, 'change-inline-style'); +} + +function togglePrefixStyle( + editorState, + prefix, + shouldSetStyle = null, + getStyleToSet = null +) { + const matchingStyles = getPrefixStylesInSelection(editorState, prefix); + + // never the less, remove all old styles (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); +} + +// convert a set of weight styles to a set of weights +function getWeights(styles) { + return styles.map((style) => (style === NONE ? 400 : styleToWeight(style))); +} + +export function isBold(editorState) { + const styles = getPrefixStylesInSelection(editorState, WEIGHT); + const weights = getWeights(styles); + const allIsBold = weights.every((w) => w >= SMALLEST_BOLD); + return allIsBold; +} + +export function toggleBold(editorState) { + // 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.apply(null, [DEFAULT_BOLD].concat(getWeights(styles))) + ); + + return togglePrefixStyle(editorState, WEIGHT, shouldSetBold, getBoldToSet); +} + +export function isItalic(editorState) { + const styles = getPrefixStylesInSelection(editorState, ITALIC); + return !styles.includes(NONE); +} + +export function toggleItalic(editorState) { + return togglePrefixStyle(editorState, ITALIC); +} + +export function isUnderline(editorState) { + const styles = getPrefixStylesInSelection(editorState, UNDERLINE); + return !styles.includes(NONE); +} + +export function toggleUnderline(editorState) { + return togglePrefixStyle(editorState, UNDERLINE); +} 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/util.js b/assets/src/edit-story/components/richText/util.js new file mode 100644 index 000000000000..83c5e357bd4d --- /dev/null +++ b/assets/src/edit-story/components/richText/util.js @@ -0,0 +1,121 @@ +/* + * 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 { toggleBold, toggleUnderline, toggleItalic } from './styleManipulation'; + +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 toggleBold(oldEditorState); + + case 'italic': + return toggleItalic(oldEditorState); + + case 'underline': + return 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; +} + +let contentBuffer = null; +export const draftMarkupToContent = (content, bold) => { + // @todo This logic is temporary and will change with selecting part + marking bold/italic/underline. + if (bold) { + content = `${content}`; + } + if (!contentBuffer) { + contentBuffer = document.createElement('template'); + } + // Ensures the content is valid HTML. + contentBuffer.innerHTML = content; + return contentBuffer.innerHTML; +}; 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/text/display.js b/assets/src/edit-story/elements/text/display.js index 171c0017081e..406bfc6fdfb3 100644 --- a/assets/src/edit-story/elements/text/display.js +++ b/assets/src/edit-story/elements/text/display.js @@ -35,11 +35,8 @@ import { import StoryPropTypes from '../../types'; import { BACKGROUND_TEXT_MODE } from '../../constants'; import { useTransformHandler } from '../../components/transform'; -import { - draftMarkupToContent, - getHighlightLineheight, - generateParagraphTextStyle, -} from './util'; +import { draftMarkupToContent } from '../../components/richText/util'; +import { getHighlightLineheight, generateParagraphTextStyle } from './util'; const HighlightWrapperElement = styled.div` ${elementFillContent} diff --git a/assets/src/edit-story/elements/text/edit.js b/assets/src/edit-story/elements/text/edit.js index 05034160e37c..38a736862754 100644 --- a/assets/src/edit-story/elements/text/edit.js +++ b/assets/src/edit-story/elements/text/edit.js @@ -18,23 +18,13 @@ * 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, 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, @@ -45,19 +35,10 @@ import { } 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 @@ -97,7 +78,6 @@ const TextBox = styled.div` function TextEdit({ element: { id, - bold, content, color, backgroundColor, @@ -128,95 +108,40 @@ function TextEdit({ 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(); }; 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( @@ -243,23 +168,28 @@ function TextEdit({ ]); // Update content for element on focus out. - useFocusOut(textBoxRef, updateContent, [updateContent]); + //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]); const { fontFamily } = rest; useEffect(() => { @@ -269,11 +199,10 @@ function TextEdit({ return ( - diff --git a/assets/src/edit-story/elements/text/output.js b/assets/src/edit-story/elements/text/output.js index fbe8c29eb7af..329497a7854d 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -26,11 +26,8 @@ import StoryPropTypes from '../../types'; import generatePatternStyles from '../../utils/generatePatternStyles'; import { dataToEditorX, dataToEditorY } from '../../units'; import { BACKGROUND_TEXT_MODE } from '../../constants'; -import { - draftMarkupToContent, - generateParagraphTextStyle, - getHighlightLineheight, -} from './util'; +import { draftMarkupToContent } from '../../components/richText/util'; +import { generateParagraphTextStyle, getHighlightLineheight } from './util'; /** * Renders DOM for the text output based on the provided unit converters. diff --git a/assets/src/edit-story/elements/text/test/util.js b/assets/src/edit-story/elements/text/test/util.js index 6f7ec93f852b..69fcbd01970d 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', () => { @@ -34,30 +34,4 @@ describe('Text/util', () => { ); }); }); - - 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 { 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; + } + From 6fdac4cf251a4173196863d8a008bba6151bbe99 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 23 Apr 2020 19:12:28 -0400 Subject: [PATCH 03/51] Added the option to toggle b/i/u for entire text field --- .../components/panels/textStyle/textStyle.js | 44 +++++++----- .../components/richText/customExport.js | 4 +- .../components/richText/provider.js | 71 ++++++++++++++----- 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/assets/src/edit-story/components/panels/textStyle/textStyle.js b/assets/src/edit-story/components/panels/textStyle/textStyle.js index ff6d255476d5..feb92e22bd17 100644 --- a/assets/src/edit-story/components/panels/textStyle/textStyle.js +++ b/assets/src/edit-story/components/panels/textStyle/textStyle.js @@ -18,6 +18,7 @@ * External dependencies */ import PropTypes from 'prop-types'; +import { useMemo, useCallback } from 'react'; import styled from 'styled-components'; import { rgba } from 'polished'; @@ -63,16 +64,15 @@ const Space = styled.div` function StylePanel({ selectedElements, pushUpdate }) { const { - state: { - hasCurrentEditor, - selectionIsBold, - selectionIsItalic, - selectionIsUnderline, - }, + state: { hasCurrentEditor, selectionInfo }, actions: { toggleBoldInSelection, toggleItalicInSelection, toggleUnderlineInSelection, + toggleBoldInHTML, + toggleItalicInHTML, + toggleUnderlineInHTML, + getHTMLInfo, }, } = useRichText(); @@ -80,28 +80,34 @@ function StylePanel({ selectedElements, pushUpdate }) { const letterSpacing = getCommonValue(selectedElements, 'letterSpacing'); const lineHeight = getCommonValue(selectedElements, 'lineHeight'); - const isBold = hasCurrentEditor - ? selectionIsBold - : getCommonValue(selectedElements, 'bold'); - const isItalic = hasCurrentEditor - ? selectionIsItalic - : getCommonValue(selectedElements, 'fontStyle') === 'italic'; - const isUnderline = hasCurrentEditor - ? selectionIsUnderline - : getCommonValue(selectedElements, 'textDecoration') === 'underline'; + const content = selectedElements[0].content; + + const { isBold, isItalic, isUnderline } = useMemo(() => { + if (hasCurrentEditor) { + return selectionInfo; + } + + return getHTMLInfo(content); + }, [hasCurrentEditor, selectionInfo, getHTMLInfo, content]); + + const pushContentUpdate = useCallback( + (updater) => { + pushUpdate({ content: updater(content) }, true); + }, + [content, pushUpdate] + ); const handleClickBold = hasCurrentEditor ? toggleBoldInSelection - : (value) => pushUpdate({ bold: value }, true); + : () => pushContentUpdate(toggleBoldInHTML); const handleClickItalic = hasCurrentEditor ? toggleItalicInSelection - : (value) => pushUpdate({ fontStyle: value ? 'italic' : 'normal' }, true); + : () => pushContentUpdate(toggleItalicInHTML); const handleClickUnderline = hasCurrentEditor ? toggleUnderlineInSelection - : (value) => - pushUpdate({ textDecoration: value ? 'underline' : 'none' }, true); + : () => pushContentUpdate(toggleUnderlineInHTML); return ( <> diff --git a/assets/src/edit-story/components/richText/customExport.js b/assets/src/edit-story/components/richText/customExport.js index c09baf64d961..c3d8f7b5be18 100644 --- a/assets/src/edit-story/components/richText/customExport.js +++ b/assets/src/edit-story/components/richText/customExport.js @@ -72,10 +72,12 @@ function inlineStyleFn(styles) { } function exportHTML(editorState) { - return stateToHTML(editorState.getCurrentContent(), { + 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/provider.js b/assets/src/edit-story/components/richText/provider.js index 63c90cce9cdf..d12c1685d77e 100644 --- a/assets/src/edit-story/components/richText/provider.js +++ b/assets/src/edit-story/components/richText/provider.js @@ -51,20 +51,21 @@ function RichTextProvider({ children }) { const [forceFocus, setForceFocus] = useState(false); const [editorState, setEditorState] = useState(null); - const [ - selectionIsBold, - selectionIsItalic, - selectionIsUnderline, - ] = useMemo(() => { + const getStateInfo = useCallback( + (state) => ({ + isBold: isBold(state), + isItalic: isItalic(state), + isUnderline: isUnderline(state), + }), + [] + ); + + const selectionInfo = useMemo(() => { if (editorState) { - return [ - isBold(editorState), - isItalic(editorState), - isUnderline(editorState), - ]; + return getStateInfo(editorState); } - return [false, false, false]; - }, [editorState]); + return { isBold: false, isItalic: false, isUnderline: false }; + }, [getStateInfo, editorState]); const setStateFromContent = useCallback( (content) => { @@ -105,9 +106,7 @@ function RichTextProvider({ children }) { return null; } - return customExport(someEditorState) - .replace(/
/g, '') - .replace(/ $/, ''); + return customExport(someEditorState); }, []); // This filters out illegal content (see `getFilteredState`) @@ -155,15 +154,47 @@ function RichTextProvider({ children }) { [updateWhileUnfocused] ); + const getSelectAllStateFromHTML = useCallback((html) => { + const contentState = customImport(html); + const initialState = EditorState.createWithContent(contentState); + const selection = getSelectionForAll(initialState.getCurrentContent()); + return EditorState.forceSelection(initialState, selection); + }, []); + + const updateAndReturnHTML = useCallback( + (html, updater) => { + const stateWithUpdate = updater(getSelectAllStateFromHTML(html)); + const renderedHTML = customExport(stateWithUpdate); + return renderedHTML; + }, + [getSelectAllStateFromHTML] + ); + + const toggleBoldInHTML = useCallback( + (html) => updateAndReturnHTML(html, toggleBold), + [updateAndReturnHTML] + ); + const toggleItalicInHTML = useCallback( + (html) => updateAndReturnHTML(html, toggleItalic), + [updateAndReturnHTML] + ); + const toggleUnderlineInHTML = useCallback( + (html) => updateAndReturnHTML(html, toggleUnderline), + [updateAndReturnHTML] + ); + + const getHTMLInfo = useCallback( + (html) => getStateInfo(getSelectAllStateFromHTML(html)), + [getSelectAllStateFromHTML, getStateInfo] + ); + const clearForceFocus = useCallback(() => setForceFocus(false), []); const value = { state: { editorState, hasCurrentEditor, - selectionIsBold, - selectionIsItalic, - selectionIsUnderline, + selectionInfo, forceFocus, }, actions: { @@ -176,6 +207,10 @@ function RichTextProvider({ children }) { toggleBoldInSelection, toggleItalicInSelection, toggleUnderlineInSelection, + getHTMLInfo, + toggleBoldInHTML, + toggleItalicInHTML, + toggleUnderlineInHTML, }, }; From e0506bf0226dea4f96de949a29b5e955e4d3067f Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 23 Apr 2020 19:46:29 -0400 Subject: [PATCH 04/51] Cleaned up the structure a bit --- .../components/richText/customExport.js | 4 ++ .../components/richText/htmlManipulation.js | 60 ++++++++++++++++ .../components/richText/provider.js | 68 +++---------------- .../components/richText/styleManipulation.js | 8 +++ 4 files changed, 82 insertions(+), 58 deletions(-) create mode 100644 assets/src/edit-story/components/richText/htmlManipulation.js diff --git a/assets/src/edit-story/components/richText/customExport.js b/assets/src/edit-story/components/richText/customExport.js index c3d8f7b5be18..f606a505164f 100644 --- a/assets/src/edit-story/components/richText/customExport.js +++ b/assets/src/edit-story/components/richText/customExport.js @@ -72,6 +72,10 @@ function inlineStyleFn(styles) { } function exportHTML(editorState) { + if (!editorState) { + return null; + } + const html = stateToHTML(editorState.getCurrentContent(), { inlineStyleFn, defaultBlockTag: null, 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..c6fe61439691 --- /dev/null +++ b/assets/src/edit-story/components/richText/htmlManipulation.js @@ -0,0 +1,60 @@ +/* + * 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 { + toggleBold, + toggleItalic, + toggleUnderline, + getStateInfo, +} from './styleManipulation'; +import customImport from './customImport'; +import customExport from './customExport'; +import { getSelectionForAll } from './util'; + +function getSelectAllStateFromHTML(html) { + const contentState = customImport(html); + const initialState = EditorState.createWithContent(contentState); + const selection = getSelectionForAll(initialState.getCurrentContent()); + return EditorState.forceSelection(initialState, selection); +} + +function updateAndReturnHTML(html, updater) { + const stateWithUpdate = updater(getSelectAllStateFromHTML(html)); + const renderedHTML = customExport(stateWithUpdate); + return renderedHTML; +} + +export function toggleBoldInHTML(html) { + updateAndReturnHTML(html, toggleBold); +} +export function toggleItalicInHTML(html) { + updateAndReturnHTML(html, toggleItalic); +} +export function toggleUnderlineInHTML(html) { + updateAndReturnHTML(html, toggleUnderline); +} + +export function getHTMLInfo(html) { + return getStateInfo(getSelectAllStateFromHTML(html)); +} diff --git a/assets/src/edit-story/components/richText/provider.js b/assets/src/edit-story/components/richText/provider.js index d12c1685d77e..7701896aa338 100644 --- a/assets/src/edit-story/components/richText/provider.js +++ b/assets/src/edit-story/components/richText/provider.js @@ -36,12 +36,16 @@ import { toggleBold, toggleItalic, toggleUnderline, - isBold, - isItalic, - isUnderline, + getStateInfo, } from './styleManipulation'; import customImport from './customImport'; import customExport from './customExport'; +import { + toggleBoldInHTML, + toggleItalicInHTML, + toggleUnderlineInHTML, + getHTMLInfo, +} from './htmlManipulation'; function RichTextProvider({ children }) { const { @@ -51,21 +55,12 @@ function RichTextProvider({ children }) { const [forceFocus, setForceFocus] = useState(false); const [editorState, setEditorState] = useState(null); - const getStateInfo = useCallback( - (state) => ({ - isBold: isBold(state), - isItalic: isItalic(state), - isUnderline: isUnderline(state), - }), - [] - ); - const selectionInfo = useMemo(() => { if (editorState) { return getStateInfo(editorState); } return { isBold: false, isItalic: false, isUnderline: false }; - }, [getStateInfo, editorState]); + }, [editorState]); const setStateFromContent = useCallback( (content) => { @@ -90,8 +85,6 @@ function RichTextProvider({ children }) { [editingElementState, setEditorState] ); - // This is to allow the getContentFromState to be stable and *not* - // depend on editorState, as would otherwise be a lint error. const lastKnownState = useRef(null); const lastKnownSelection = useRef(null); useEffect(() => { @@ -101,14 +94,6 @@ function RichTextProvider({ children }) { } }, [editorState]); - const getContentFromState = useCallback((someEditorState) => { - if (!someEditorState) { - return null; - } - - return customExport(someEditorState); - }, []); - // This filters out illegal content (see `getFilteredState`) // on paste and updates state accordingly. // Furthermore it also sets initial selection if relevant. @@ -154,40 +139,6 @@ function RichTextProvider({ children }) { [updateWhileUnfocused] ); - const getSelectAllStateFromHTML = useCallback((html) => { - const contentState = customImport(html); - const initialState = EditorState.createWithContent(contentState); - const selection = getSelectionForAll(initialState.getCurrentContent()); - return EditorState.forceSelection(initialState, selection); - }, []); - - const updateAndReturnHTML = useCallback( - (html, updater) => { - const stateWithUpdate = updater(getSelectAllStateFromHTML(html)); - const renderedHTML = customExport(stateWithUpdate); - return renderedHTML; - }, - [getSelectAllStateFromHTML] - ); - - const toggleBoldInHTML = useCallback( - (html) => updateAndReturnHTML(html, toggleBold), - [updateAndReturnHTML] - ); - const toggleItalicInHTML = useCallback( - (html) => updateAndReturnHTML(html, toggleItalic), - [updateAndReturnHTML] - ); - const toggleUnderlineInHTML = useCallback( - (html) => updateAndReturnHTML(html, toggleUnderline), - [updateAndReturnHTML] - ); - - const getHTMLInfo = useCallback( - (html) => getStateInfo(getSelectAllStateFromHTML(html)), - [getSelectAllStateFromHTML, getStateInfo] - ); - const clearForceFocus = useCallback(() => setForceFocus(false), []); const value = { @@ -201,12 +152,13 @@ function RichTextProvider({ children }) { setStateFromContent, updateEditorState, getHandleKeyCommand, - getContentFromState, clearState, clearForceFocus, toggleBoldInSelection, toggleItalicInSelection, toggleUnderlineInSelection, + // These actually don't work on the state at all, just pure functions + getContentFromState: customExport, getHTMLInfo, toggleBoldInHTML, toggleItalicInHTML, diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js index 3458dc9602c3..7e19e99f9d70 100644 --- a/assets/src/edit-story/components/richText/styleManipulation.js +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -174,6 +174,14 @@ export function isUnderline(editorState) { return !styles.includes(NONE); } +export function getStateInfo(state) { + return { + isBold: isBold(state), + isItalic: isItalic(state), + isUnderline: isUnderline(state), + }; +} + export function toggleUnderline(editorState) { return togglePrefixStyle(editorState, UNDERLINE); } From 323ec6c8fe86170f6224ffd8168d23906f2ebc43 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 23 Apr 2020 21:06:00 -0400 Subject: [PATCH 05/51] Added support for font weight and multi-select --- .../components/panels/textStyle/font.js | 11 +- .../components/panels/textStyle/textStyle.js | 48 +------- .../panels/textStyle/useRichTextFormatting.js | 113 ++++++++++++++++++ .../components/richText/customConstants.js | 4 +- .../components/richText/htmlManipulation.js | 20 ++-- .../components/richText/provider.js | 20 ++-- .../components/richText/styleManipulation.js | 63 ++++++++-- assets/src/edit-story/elements/text/util.js | 6 - 8 files changed, 197 insertions(+), 88 deletions(-) create mode 100644 assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js diff --git a/assets/src/edit-story/components/panels/textStyle/font.js b/assets/src/edit-story/components/panels/textStyle/font.js index 098ee6a2c084..7c37ad7e7850 100644 --- a/assets/src/edit-story/components/panels/textStyle/font.js +++ b/assets/src/edit-story/components/panels/textStyle/font.js @@ -33,6 +33,7 @@ import { Numeric, Row, DropDown } from '../../form'; import { PAGE_HEIGHT } from '../../../constants'; import { useFont } from '../../../app/font'; import { getCommonValue } from '../utils'; +import useRichTextFormatting from './useRichTextFormatting'; const Space = styled.div` flex: 0 0 10px; @@ -46,7 +47,11 @@ const BoxedNumeric = styled(Numeric)` function FontControls({ selectedElements, pushUpdate }) { const fontFamily = getCommonValue(selectedElements, 'fontFamily'); const fontSize = getCommonValue(selectedElements, 'fontSize'); - const fontWeight = getCommonValue(selectedElements, 'fontWeight'); + + const { + textInfo: { fontWeight }, + handlers: { handleSelectFontWeight }, + } = useRichTextFormatting(selectedElements, pushUpdate); const { state: { fonts }, @@ -103,9 +108,7 @@ function FontControls({ selectedElements, pushUpdate }) { ariaLabel={__('Font weight', 'web-stories')} options={fontWeights} value={fontWeight} - onChange={(value) => - 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 feb92e22bd17..929211dd7207 100644 --- a/assets/src/edit-story/components/panels/textStyle/textStyle.js +++ b/assets/src/edit-story/components/panels/textStyle/textStyle.js @@ -18,7 +18,6 @@ * External dependencies */ import PropTypes from 'prop-types'; -import { useMemo, useCallback } from 'react'; import styled from 'styled-components'; import { rgba } from 'polished'; @@ -31,7 +30,6 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { Numeric, Row, ToggleButton } from '../../form'; -import useRichText from '../../richText/useRichText'; import { ReactComponent as VerticalOffset } from '../../../icons/offset_vertical.svg'; import { ReactComponent as HorizontalOffset } from '../../../icons/offset_horizontal.svg'; import { ReactComponent as LeftAlign } from '../../../icons/left_align.svg'; @@ -42,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; @@ -63,51 +62,14 @@ const Space = styled.div` `; function StylePanel({ selectedElements, pushUpdate }) { - const { - state: { hasCurrentEditor, selectionInfo }, - actions: { - toggleBoldInSelection, - toggleItalicInSelection, - toggleUnderlineInSelection, - toggleBoldInHTML, - toggleItalicInHTML, - toggleUnderlineInHTML, - getHTMLInfo, - }, - } = useRichText(); - const textAlign = getCommonValue(selectedElements, 'textAlign'); const letterSpacing = getCommonValue(selectedElements, 'letterSpacing'); const lineHeight = getCommonValue(selectedElements, 'lineHeight'); - const content = selectedElements[0].content; - - const { isBold, isItalic, isUnderline } = useMemo(() => { - if (hasCurrentEditor) { - return selectionInfo; - } - - return getHTMLInfo(content); - }, [hasCurrentEditor, selectionInfo, getHTMLInfo, content]); - - const pushContentUpdate = useCallback( - (updater) => { - pushUpdate({ content: updater(content) }, true); - }, - [content, pushUpdate] - ); - - const handleClickBold = hasCurrentEditor - ? toggleBoldInSelection - : () => pushContentUpdate(toggleBoldInHTML); - - const handleClickItalic = hasCurrentEditor - ? toggleItalicInSelection - : () => pushContentUpdate(toggleItalicInHTML); - - const handleClickUnderline = hasCurrentEditor - ? toggleUnderlineInSelection - : () => pushContentUpdate(toggleUnderlineInHTML); + const { + textInfo: { isBold, isItalic, isUnderline }, + handlers: { handleClickBold, handleClickItalic, handleClickUnderline }, + } = useRichTextFormatting(selectedElements, pushUpdate); return ( <> 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..d47dec6e00f1 --- /dev/null +++ b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js @@ -0,0 +1,113 @@ +/* + * 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 useRichText from '../../richText/useRichText'; +import { + getHTMLInfo, + toggleBoldInHTML, + setFontWeightInHTML, + toggleItalicInHTML, + toggleUnderlineInHTML, +} from '../../richText/htmlManipulation'; +import { MULTIPLE_VALUE } from '../../form'; + +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 && reduced[key] !== info[key]; + if (wasMultiple || areDifferent) { + return [key, MULTIPLE_VALUE]; + } + return [key, info[key]]; + }) + ); +} + +function useRichTextFormatting(selectedElements, pushUpdate) { + const { + state: { hasCurrentEditor, selectionInfo }, + actions: { + toggleBoldInSelection, + setFontWeightInSelection, + toggleItalicInSelection, + toggleUnderlineInSelection, + }, + } = 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 { + handleClickBold: toggleBoldInSelection, + handleSelectFontWeight: setFontWeightInSelection, + handleClickItalic: toggleItalicInSelection, + handleClickUnderline: toggleUnderlineInSelection, + }; + } + + return { + handleClickBold: (flag) => push(toggleBoldInHTML, flag), + handleSelectFontWeight: (weight) => push(setFontWeightInHTML, weight), + handleClickItalic: (flag) => push(toggleItalicInHTML, flag), + handleClickUnderline: (flag) => push(toggleUnderlineInHTML, flag), + }; + }, [ + hasCurrentEditor, + toggleBoldInSelection, + setFontWeightInSelection, + toggleItalicInSelection, + toggleUnderlineInSelection, + push, + ]); + + return { + textInfo, + handlers, + }; +} + +export default useRichTextFormatting; diff --git a/assets/src/edit-story/components/richText/customConstants.js b/assets/src/edit-story/components/richText/customConstants.js index 49569d617c24..37283cbee039 100644 --- a/assets/src/edit-story/components/richText/customConstants.js +++ b/assets/src/edit-story/components/richText/customConstants.js @@ -21,6 +21,7 @@ export const WEIGHT = 'CUSTOM-WEIGHT'; export const COLOR = 'CUSTOM-COLOR'; export const LETTERSPACING = 'CUSTOM-LETTERSPACING'; +export const NORMAL_WEIGHT = 400; export const SMALLEST_BOLD = 600; export const DEFAULT_BOLD = 700; @@ -40,6 +41,3 @@ export const styleToLs = (style) => isStyle(style, LETTERSPACING) ? parseInt(getVariable(style, LETTERSPACING)) : null; - -export const isBold = (style) => styleToWeight(style) >= 700; -export const hasBold = (styles) => styles.some(isBold); diff --git a/assets/src/edit-story/components/richText/htmlManipulation.js b/assets/src/edit-story/components/richText/htmlManipulation.js index c6fe61439691..b42aa0678736 100644 --- a/assets/src/edit-story/components/richText/htmlManipulation.js +++ b/assets/src/edit-story/components/richText/htmlManipulation.js @@ -26,6 +26,7 @@ import { toggleBold, toggleItalic, toggleUnderline, + setFontWeight, getStateInfo, } from './styleManipulation'; import customImport from './customImport'; @@ -39,20 +40,23 @@ function getSelectAllStateFromHTML(html) { return EditorState.forceSelection(initialState, selection); } -function updateAndReturnHTML(html, updater) { - const stateWithUpdate = updater(getSelectAllStateFromHTML(html)); +function updateAndReturnHTML(html, updater, ...args) { + const stateWithUpdate = updater(getSelectAllStateFromHTML(html), ...args); const renderedHTML = customExport(stateWithUpdate); return renderedHTML; } -export function toggleBoldInHTML(html) { - updateAndReturnHTML(html, toggleBold); +export function toggleBoldInHTML(html, flag) { + return updateAndReturnHTML(html, toggleBold, flag); } -export function toggleItalicInHTML(html) { - updateAndReturnHTML(html, toggleItalic); +export function setFontWeightInHTML(html, weight) { + return updateAndReturnHTML(html, setFontWeight, weight); } -export function toggleUnderlineInHTML(html) { - updateAndReturnHTML(html, toggleUnderline); +export function toggleItalicInHTML(html, flag) { + return updateAndReturnHTML(html, toggleItalic, flag); +} +export function toggleUnderlineInHTML(html, flag) { + return updateAndReturnHTML(html, toggleUnderline, flag); } export function getHTMLInfo(html) { diff --git a/assets/src/edit-story/components/richText/provider.js b/assets/src/edit-story/components/richText/provider.js index 7701896aa338..59170365ff55 100644 --- a/assets/src/edit-story/components/richText/provider.js +++ b/assets/src/edit-story/components/richText/provider.js @@ -36,16 +36,11 @@ import { toggleBold, toggleItalic, toggleUnderline, + setFontWeight, getStateInfo, } from './styleManipulation'; import customImport from './customImport'; import customExport from './customExport'; -import { - toggleBoldInHTML, - toggleItalicInHTML, - toggleUnderlineInHTML, - getHTMLInfo, -} from './htmlManipulation'; function RichTextProvider({ children }) { const { @@ -117,11 +112,11 @@ function RichTextProvider({ children }) { const hasCurrentEditor = Boolean(editorState); - const updateWhileUnfocused = useCallback((updater) => { + const updateWhileUnfocused = useCallback((updater, ...args) => { const oldState = lastKnownState.current; const selection = lastKnownSelection.current; const workingState = EditorState.forceSelection(oldState, selection); - const newState = updater(workingState); + const newState = updater(workingState, ...args); setEditorState(newState); setForceFocus(true); }, []); @@ -130,6 +125,10 @@ function RichTextProvider({ children }) { () => updateWhileUnfocused(toggleBold), [updateWhileUnfocused] ); + const setFontWeightInSelection = useCallback( + (weight) => updateWhileUnfocused(setFontWeight, weight), + [updateWhileUnfocused] + ); const toggleItalicInSelection = useCallback( () => updateWhileUnfocused(toggleItalic), [updateWhileUnfocused] @@ -155,14 +154,11 @@ function RichTextProvider({ children }) { clearState, clearForceFocus, toggleBoldInSelection, + setFontWeightInSelection, toggleItalicInSelection, toggleUnderlineInSelection, // These actually don't work on the state at all, just pure functions getContentFromState: customExport, - getHTMLInfo, - toggleBoldInHTML, - toggleItalicInHTML, - toggleUnderlineInHTML, }, }; diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js index 7e19e99f9d70..d8f697249664 100644 --- a/assets/src/edit-story/components/richText/styleManipulation.js +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -22,6 +22,7 @@ import { Modifier, EditorState } from 'draft-js'; /** * Internal dependencies */ +import { MULTIPLE_VALUE } from '../form'; import { weightToStyle, styleToWeight, @@ -29,6 +30,7 @@ import { WEIGHT, ITALIC, UNDERLINE, + NORMAL_WEIGHT, SMALLEST_BOLD, DEFAULT_BOLD, } from './customConstants'; @@ -134,7 +136,9 @@ function togglePrefixStyle( // convert a set of weight styles to a set of weights function getWeights(styles) { - return styles.map((style) => (style === NONE ? 400 : styleToWeight(style))); + return styles.map((style) => + style === NONE ? NORMAL_WEIGHT : styleToWeight(style) + ); } export function isBold(editorState) { @@ -144,29 +148,59 @@ export function isBold(editorState) { return allIsBold; } -export function toggleBold(editorState) { - // if any character has weight less than SMALLEST_BOLD, +export function toggleBold(editorState, flag) { + // if flag set, use flag + // otherwise if any character has weight less than SMALLEST_BOLD, // everything should be bolded const shouldSetBold = (styles) => - getWeights(styles).some((w) => w < SMALLEST_BOLD); + typeof flag === 'boolean' + ? flag + : getWeights(styles).some((w) => w < SMALLEST_BOLD); - // if setting a bold, it should be the boldest current weight, + // if flag set, toggle to either 400 or 700, + // otherwise if setting a bold, it should be the boldest current weight, // though at least DEFAULT_BOLD const getBoldToSet = (styles) => - weightToStyle( - Math.max.apply(null, [DEFAULT_BOLD].concat(getWeights(styles))) - ); + typeof flag === 'boolean' + ? weightToStyle(flag ? DEFAULT_BOLD : NORMAL_WEIGHT) + : weightToStyle( + Math.max.apply(null, [DEFAULT_BOLD].concat(getWeights(styles))) + ); return togglePrefixStyle(editorState, WEIGHT, shouldSetBold, getBoldToSet); } +export function getFontWeight(editorState) { + const styles = getPrefixStylesInSelection(editorState, WEIGHT); + const weights = getWeights(styles); + if (weights.length > 1) { + return MULTIPLE_VALUE; + } + return weights[0]; +} + +export 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); +} + export function isItalic(editorState) { const styles = getPrefixStylesInSelection(editorState, ITALIC); return !styles.includes(NONE); } -export function toggleItalic(editorState) { - return togglePrefixStyle(editorState, ITALIC); +export function toggleItalic(editorState, flag) { + return togglePrefixStyle( + editorState, + ITALIC, + typeof flag === 'boolean' && (() => flag) + ); } export function isUnderline(editorState) { @@ -176,12 +210,17 @@ export function isUnderline(editorState) { export function getStateInfo(state) { return { + fontWeight: getFontWeight(state), isBold: isBold(state), isItalic: isItalic(state), isUnderline: isUnderline(state), }; } -export function toggleUnderline(editorState) { - return togglePrefixStyle(editorState, UNDERLINE); +export function toggleUnderline(editorState, flag) { + return togglePrefixStyle( + editorState, + UNDERLINE, + typeof flag === 'boolean' && (() => flag) + ); } diff --git a/assets/src/edit-story/elements/text/util.js b/assets/src/edit-story/elements/text/util.js index 079647adee66..7a9bc77f9f26 100644 --- a/assets/src/edit-story/elements/text/util.js +++ b/assets/src/edit-story/elements/text/util.js @@ -32,25 +32,19 @@ export function generateParagraphTextStyle( fontFamily, fontFallback, fontSize, - fontStyle, - fontWeight, lineHeight, letterSpacing, padding, textAlign, - textDecoration, } = element; return { whiteSpace: 'pre-wrap', margin: 0, fontFamily: generateFontFamily(fontFamily, fontFallback), fontSize: dataToFontSizeY(fontSize), - fontStyle, - fontWeight, lineHeight, letterSpacing: `${typeof letterSpacing === 'number' ? letterSpacing : 0}em`, textAlign, - textDecoration, padding: `${dataToStyleY(padding?.vertical || 0)}px ${dataToStyleX( padding?.horizontal || 0 )}px`, From 6377f5a4bab931eb2e13c5b328113e8430a4fc41 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 23 Apr 2020 21:14:31 -0400 Subject: [PATCH 06/51] Update tests now that some values are not in use --- .../components/panels/test/textStyle.js | 10 +++- .../edit-story/elements/text/test/output.js | 49 ------------------- 2 files changed, 9 insertions(+), 50 deletions(-) diff --git a/assets/src/edit-story/components/panels/test/textStyle.js b/assets/src/edit-story/components/panels/test/textStyle.js index 112705e60949..4828d64673e3 100644 --- a/assets/src/edit-story/components/panels/test/textStyle.js +++ b/assets/src/edit-story/components/panels/test/textStyle.js @@ -391,7 +391,15 @@ 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', () => { diff --git a/assets/src/edit-story/elements/text/test/output.js b/assets/src/edit-story/elements/text/test/output.js index efad9c587ebd..65317910c4c9 100644 --- a/assets/src/edit-story/elements/text/test/output.js +++ b/assets/src/edit-story/elements/text/test/output.js @@ -57,7 +57,6 @@ describe('TextOutput', () => { fontSize: 16, letterSpacing: 1.3, textAlign: 'left', - textDecoration: 'none', type: 'text', x: 10, y: 10, @@ -89,7 +88,6 @@ describe('TextOutput', () => { fontSize: '0.242424em', letterSpacing: '1.3em', textAlign: 'left', - textDecoration: 'none', }); }); @@ -107,7 +105,6 @@ describe('TextOutput', () => { fontSize: 16, letterSpacing: 1.3, textAlign: 'left', - textDecoration: 'none', type: 'text', x: 10, y: 10, @@ -134,52 +131,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, - }, - }, - 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', From 48c765daaec9b9b085d3fddee6a956cf717f0261 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 23 Apr 2020 21:16:20 -0400 Subject: [PATCH 07/51] Adding `npx` to postinstall to see if CI approves --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d033f8d2e94b..e92a0bab5a1d 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "lint:md": "npm-run-all --parallel lint:md:*", "lint:md:js": "eslint **/*.md", "lint:md:docs": "markdownlint **/*.md", - "postinstall": "patch-package", + "postinstall": "npx patch-package", "storybook": "start-storybook --quiet", "test": "npm-run-all --parallel test:*", "test:js": "jest --config=tests/js/jest.config.js", From b0ba3011269837d793ef4ac72d43076990eb40c5 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 24 Apr 2020 11:34:29 -0400 Subject: [PATCH 08/51] Removed `postinstall-postinstall` --- package-lock.json | 5 ----- package.json | 1 - 2 files changed, 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4ab0efd43fa0..1460367fb61b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23565,11 +23565,6 @@ "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", "dev": true }, - "postinstall-postinstall": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postinstall-postinstall/-/postinstall-postinstall-2.1.0.tgz", - "integrity": "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ==" - }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", diff --git a/package.json b/package.json index e92a0bab5a1d..6850d2611e71 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "mousetrap": "^1.6.5", "patch-package": "^6.2.2", "polished": "^3.5.2", - "postinstall-postinstall": "^2.1.0", "prop-types": "^15.7.2", "query-string": "^6.12.1", "react": "^16.13.1", From 1656c0a27029632fb83f46355cf4a96f85e317c8 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 24 Apr 2020 12:13:24 -0400 Subject: [PATCH 09/51] If selection is collapsed when applying style change, apply to inline override --- .../components/richText/styleManipulation.js | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js index d8f697249664..7144985c531f 100644 --- a/assets/src/edit-story/components/richText/styleManipulation.js +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -96,9 +96,43 @@ function togglePrefixStyle( 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); - // never the less, remove all old styles (except NONE, it's not actually a style) + // First remove all old styles natching prefix + // (except NONE, it's not actually a style) const stylesToRemove = matchingStyles.filter((s) => s !== NONE); const strippedContentState = stylesToRemove.reduce( (contentState, styleToRemove) => From ad34f87676357049afeb7bc4f17ad72ccdb9a663 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 24 Apr 2020 13:27:30 -0400 Subject: [PATCH 10/51] Reorganised selection manip and added letter spacing --- .../components/panels/textStyle/textStyle.js | 22 ++--- .../panels/textStyle/useRichTextFormatting.js | 15 ++- .../components/richText/customConstants.js | 25 +++-- .../components/richText/customExport.js | 16 +++- .../components/richText/customImport.js | 14 ++- .../richText/customInlineDisplay.js | 13 ++- .../components/richText/htmlManipulation.js | 4 + .../components/richText/provider.js | 59 ++---------- .../components/richText/styleManipulation.js | 31 ++++++ .../richText/useSelectionManipulation.js | 94 +++++++++++++++++++ assets/src/edit-story/elements/text/util.js | 2 - 11 files changed, 214 insertions(+), 81 deletions(-) create mode 100644 assets/src/edit-story/components/richText/useSelectionManipulation.js diff --git a/assets/src/edit-story/components/panels/textStyle/textStyle.js b/assets/src/edit-story/components/panels/textStyle/textStyle.js index 929211dd7207..ba7edbfbb530 100644 --- a/assets/src/edit-story/components/panels/textStyle/textStyle.js +++ b/assets/src/edit-story/components/panels/textStyle/textStyle.js @@ -63,12 +63,16 @@ const Space = styled.div` function StylePanel({ selectedElements, pushUpdate }) { const textAlign = getCommonValue(selectedElements, 'textAlign'); - const letterSpacing = getCommonValue(selectedElements, 'letterSpacing'); const lineHeight = getCommonValue(selectedElements, 'lineHeight'); const { - textInfo: { isBold, isItalic, isUnderline }, - handlers: { handleClickBold, handleClickItalic, handleClickUnderline }, + textInfo: { isBold, isItalic, isUnderline, letterSpacing }, + handlers: { + handleClickBold, + handleClickItalic, + handleClickUnderline, + handleSetLetterSpacing, + }, } = useRichTextFormatting(selectedElements, pushUpdate); return ( @@ -86,18 +90,10 @@ function StylePanel({ selectedElements, pushUpdate }) { } symbol="%" - onChange={(value) => - pushUpdate({ - letterSpacing: typeof value === 'number' ? value / 100 : value, - }) - } + onChange={handleSetLetterSpacing} /> diff --git a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js index d47dec6e00f1..91793233f34f 100644 --- a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js +++ b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js @@ -29,6 +29,7 @@ import { setFontWeightInHTML, toggleItalicInHTML, toggleUnderlineInHTML, + setLetterSpacingInHTML, } from '../../richText/htmlManipulation'; import { MULTIPLE_VALUE } from '../../form'; @@ -50,10 +51,13 @@ function useRichTextFormatting(selectedElements, pushUpdate) { const { state: { hasCurrentEditor, selectionInfo }, actions: { - toggleBoldInSelection, - setFontWeightInSelection, - toggleItalicInSelection, - toggleUnderlineInSelection, + selectionActions: { + toggleBoldInSelection, + setFontWeightInSelection, + toggleItalicInSelection, + toggleUnderlineInSelection, + setLetterSpacingInSelection, + }, }, } = useRichText(); @@ -86,6 +90,7 @@ function useRichTextFormatting(selectedElements, pushUpdate) { handleSelectFontWeight: setFontWeightInSelection, handleClickItalic: toggleItalicInSelection, handleClickUnderline: toggleUnderlineInSelection, + handleSetLetterSpacing: setLetterSpacingInSelection, }; } @@ -94,6 +99,7 @@ function useRichTextFormatting(selectedElements, pushUpdate) { handleSelectFontWeight: (weight) => push(setFontWeightInHTML, weight), handleClickItalic: (flag) => push(toggleItalicInHTML, flag), handleClickUnderline: (flag) => push(toggleUnderlineInHTML, flag), + handleSetLetterSpacing: (ls) => push(setLetterSpacingInHTML, ls), }; }, [ hasCurrentEditor, @@ -101,6 +107,7 @@ function useRichTextFormatting(selectedElements, pushUpdate) { setFontWeightInSelection, toggleItalicInSelection, toggleUnderlineInSelection, + setLetterSpacingInSelection, push, ]); diff --git a/assets/src/edit-story/components/richText/customConstants.js b/assets/src/edit-story/components/richText/customConstants.js index 37283cbee039..f1208a859dbf 100644 --- a/assets/src/edit-story/components/richText/customConstants.js +++ b/assets/src/edit-story/components/richText/customConstants.js @@ -25,19 +25,28 @@ export const NORMAL_WEIGHT = 400; export const SMALLEST_BOLD = 600; export const DEFAULT_BOLD = 700; -export const weightToStyle = (weight) => `${WEIGHT}-${weight}`; -export const colorToStyle = (color) => `${COLOR}-${color}`; -export const lsToStyle = (ls) => `${LETTERSPACING}-${ls}`; - export const isStyle = (style, prefix) => typeof style === 'string' && style.startsWith(prefix); export const getVariable = (style, prefix) => style.slice(prefix.length + 1); +export const weightToStyle = (weight) => `${WEIGHT}-${weight}`; export const styleToWeight = (style) => isStyle(style, WEIGHT) ? parseInt(getVariable(style, WEIGHT)) : null; + +export const letterSpacingToStyle = (ls) => + `${LETTERSPACING}-${ls < 0 ? 'N' : ''}${Math.abs(ls)}`; +export const styleToLetterSpacing = (style) => { + if (!isStyle(style, LETTERSPACING)) { + return null; + } + const raw = getVariable(style, LETTERSPACING); + // Negative numbers are prefixed with an N: + if (raw.charAt(0) === 'N') { + return -parseInt(raw.slice(1)); + } + return parseInt(raw); +}; + export const styleToColor = (style) => isStyle(style, COLOR) ? getVariable(style, COLOR) : null; -export const styleToLs = (style) => - isStyle(style, LETTERSPACING) - ? parseInt(getVariable(style, LETTERSPACING)) - : null; +export const colorToStyle = (color) => `${COLOR}-${color}`; diff --git a/assets/src/edit-story/components/richText/customExport.js b/assets/src/edit-story/components/richText/customExport.js index f606a505164f..447474d5e3ee 100644 --- a/assets/src/edit-story/components/richText/customExport.js +++ b/assets/src/edit-story/components/richText/customExport.js @@ -22,7 +22,12 @@ import { stateToHTML } from 'draft-js-export-html'; /** * Internal dependencies */ -import { styleToWeight, ITALIC, UNDERLINE } from './customConstants'; +import { + ITALIC, + UNDERLINE, + styleToWeight, + styleToLetterSpacing, +} from './customConstants'; function inlineStyleFn(styles) { const inline = styles.toArray().reduce( @@ -51,9 +56,16 @@ function inlineStyleFn(styles) { css: { ...css, fontWeight: weight }, }; } + // Letter spacing + const letterSpacing = styleToLetterSpacing(style); + if (letterSpacing) { + return { + classes: [...classes, 'letterspacing'], + css: { ...css, letterSpacing: `${letterSpacing / 100}em` }, + }; + } // TODO: Color - // TODO: Letter spacing return { classes, css }; }, diff --git a/assets/src/edit-story/components/richText/customImport.js b/assets/src/edit-story/components/richText/customImport.js index b1c6b66c9a74..41840b2e9bfd 100644 --- a/assets/src/edit-story/components/richText/customImport.js +++ b/assets/src/edit-story/components/richText/customImport.js @@ -22,7 +22,12 @@ import { stateFromHTML } from 'draft-js-import-html'; /** * Internal dependencies */ -import { weightToStyle, ITALIC, UNDERLINE } from './customConstants'; +import { + ITALIC, + UNDERLINE, + weightToStyle, + letterSpacingToStyle, +} from './customConstants'; import { draftMarkupToContent } from './util'; function customInlineFn(element, { Style }) { @@ -42,6 +47,13 @@ function customInlineFn(element, { Style }) { case 'underline': return UNDERLINE; + case 'letterspacing': { + const ls = element.style.letterSpacing; + const lsNumber = ls.split(/[a-z%]/)[0] || 0; + const lsScaled = Math.round(lsNumber * 100); + return letterSpacingToStyle(lsScaled); + } + default: return null; } diff --git a/assets/src/edit-story/components/richText/customInlineDisplay.js b/assets/src/edit-story/components/richText/customInlineDisplay.js index 5d1567c6517c..b0a89d1671b4 100644 --- a/assets/src/edit-story/components/richText/customInlineDisplay.js +++ b/assets/src/edit-story/components/richText/customInlineDisplay.js @@ -17,7 +17,12 @@ /** * Internal dependencies */ -import { styleToWeight, ITALIC, UNDERLINE } from './customConstants'; +import { + ITALIC, + UNDERLINE, + styleToWeight, + styleToLetterSpacing, +} from './customConstants'; function customInlineDisplay(styles) { return styles.toArray().reduce((css, style) => { @@ -37,6 +42,12 @@ function customInlineDisplay(styles) { return { ...css, fontWeight: weight }; } + // Letter spacing + const letterSpacing = styleToLetterSpacing(style); + if (letterSpacing) { + return { ...css, letterSpacing: `${letterSpacing / 100}em` }; + } + // TODO: Color // TODO: Letter spacing diff --git a/assets/src/edit-story/components/richText/htmlManipulation.js b/assets/src/edit-story/components/richText/htmlManipulation.js index b42aa0678736..37e8bb272ce2 100644 --- a/assets/src/edit-story/components/richText/htmlManipulation.js +++ b/assets/src/edit-story/components/richText/htmlManipulation.js @@ -27,6 +27,7 @@ import { toggleItalic, toggleUnderline, setFontWeight, + setLetterSpacing, getStateInfo, } from './styleManipulation'; import customImport from './customImport'; @@ -52,6 +53,9 @@ export function toggleBoldInHTML(html, flag) { export function setFontWeightInHTML(html, weight) { return updateAndReturnHTML(html, setFontWeight, weight); } +export function setLetterSpacingInHTML(html, letterSpacing) { + return updateAndReturnHTML(html, setLetterSpacing, letterSpacing); +} export function toggleItalicInHTML(html, flag) { return updateAndReturnHTML(html, toggleItalic, flag); } diff --git a/assets/src/edit-story/components/richText/provider.js b/assets/src/edit-story/components/richText/provider.js index 59170365ff55..870786496217 100644 --- a/assets/src/edit-story/components/richText/provider.js +++ b/assets/src/edit-story/components/richText/provider.js @@ -18,7 +18,7 @@ * External dependencies */ import PropTypes from 'prop-types'; -import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { EditorState } from 'draft-js'; /** @@ -32,22 +32,16 @@ import { getFilteredState, getHandleKeyCommandFromState, } from './util'; -import { - toggleBold, - toggleItalic, - toggleUnderline, - setFontWeight, - getStateInfo, -} from './styleManipulation'; +import { getStateInfo } from './styleManipulation'; import customImport from './customImport'; import customExport from './customExport'; +import useSelectionManipulation from './useSelectionManipulation'; function RichTextProvider({ children }) { const { state: { editingElementState }, } = useCanvas(); - const [forceFocus, setForceFocus] = useState(false); const [editorState, setEditorState] = useState(null); const selectionInfo = useMemo(() => { @@ -80,15 +74,6 @@ function RichTextProvider({ children }) { [editingElementState, setEditorState] ); - const lastKnownState = useRef(null); - const lastKnownSelection = useRef(null); - useEffect(() => { - lastKnownState.current = editorState; - if (editorState?.getSelection()?.hasFocus) { - lastKnownSelection.current = editorState.getSelection(); - } - }, [editorState]); - // This filters out illegal content (see `getFilteredState`) // on paste and updates state accordingly. // Furthermore it also sets initial selection if relevant. @@ -107,38 +92,15 @@ function RichTextProvider({ children }) { const clearState = useCallback(() => { setEditorState(null); - lastKnownSelection.current = null; }, [setEditorState]); const hasCurrentEditor = Boolean(editorState); - const updateWhileUnfocused = useCallback((updater, ...args) => { - const oldState = lastKnownState.current; - const selection = lastKnownSelection.current; - const workingState = EditorState.forceSelection(oldState, selection); - const newState = updater(workingState, ...args); - setEditorState(newState); - setForceFocus(true); - }, []); - - const toggleBoldInSelection = useCallback( - () => updateWhileUnfocused(toggleBold), - [updateWhileUnfocused] - ); - const setFontWeightInSelection = useCallback( - (weight) => updateWhileUnfocused(setFontWeight, weight), - [updateWhileUnfocused] - ); - const toggleItalicInSelection = useCallback( - () => updateWhileUnfocused(toggleItalic), - [updateWhileUnfocused] - ); - const toggleUnderlineInSelection = useCallback( - () => updateWhileUnfocused(toggleUnderline), - [updateWhileUnfocused] - ); - - const clearForceFocus = useCallback(() => setForceFocus(false), []); + const { + forceFocus, + clearForceFocus, + selectionActions, + } = useSelectionManipulation(editorState, setEditorState); const value = { state: { @@ -153,10 +115,7 @@ function RichTextProvider({ children }) { getHandleKeyCommand, clearState, clearForceFocus, - toggleBoldInSelection, - setFontWeightInSelection, - toggleItalicInSelection, - toggleUnderlineInSelection, + selectionActions, // These actually don't work on the state at all, just pure functions getContentFromState: customExport, }, diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js index 7144985c531f..18aa6f402921 100644 --- a/assets/src/edit-story/components/richText/styleManipulation.js +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -26,8 +26,11 @@ import { MULTIPLE_VALUE } from '../form'; import { weightToStyle, styleToWeight, + letterSpacingToStyle, + styleToLetterSpacing, NONE, WEIGHT, + LETTERSPACING, ITALIC, UNDERLINE, NORMAL_WEIGHT, @@ -244,6 +247,7 @@ export function isUnderline(editorState) { export function getStateInfo(state) { return { + letterSpacing: getLetterSpacing(state), fontWeight: getFontWeight(state), isBold: isBold(state), isItalic: isItalic(state), @@ -258,3 +262,30 @@ export function toggleUnderline(editorState, flag) { typeof flag === 'boolean' && (() => flag) ); } + +export function getLetterSpacing(editorState) { + const styles = getPrefixStylesInSelection(editorState, LETTERSPACING); + if (styles.length > 1) { + return MULTIPLE_VALUE; + } + const spacing = styles[0]; + if (spacing === NONE) { + return 0; + } + return styleToLetterSpacing(spacing); +} + +export 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 + ); +} 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..b55e9dfc71c3 --- /dev/null +++ b/assets/src/edit-story/components/richText/useSelectionManipulation.js @@ -0,0 +1,94 @@ +/* + * 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 { useState, useCallback, useRef, useEffect } from 'react'; +import { EditorState } from 'draft-js'; + +/** + * Internal dependencies + */ +import { + toggleBold, + toggleItalic, + toggleUnderline, + setFontWeight, + setLetterSpacing, +} from './styleManipulation'; + +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 [forceFocus, setForceFocus] = useState(false); + const updateWhileUnfocused = useCallback( + (updater, ...args) => { + const oldState = lastKnownState.current; + const selection = lastKnownSelection.current; + const workingState = EditorState.forceSelection(oldState, selection); + const newState = updater(workingState, ...args); + setEditorState(newState); + setForceFocus(true); + }, + [setEditorState] + ); + + const toggleBoldInSelection = useCallback( + () => updateWhileUnfocused(toggleBold), + [updateWhileUnfocused] + ); + const setFontWeightInSelection = useCallback( + (weight) => updateWhileUnfocused(setFontWeight, weight), + [updateWhileUnfocused] + ); + const toggleItalicInSelection = useCallback( + () => updateWhileUnfocused(toggleItalic), + [updateWhileUnfocused] + ); + const toggleUnderlineInSelection = useCallback( + () => updateWhileUnfocused(toggleUnderline), + [updateWhileUnfocused] + ); + const setLetterSpacingInSelection = useCallback( + (ls) => updateWhileUnfocused(setLetterSpacing, ls), + [updateWhileUnfocused] + ); + + const clearForceFocus = useCallback(() => setForceFocus(false), []); + return { + forceFocus, + clearForceFocus, + selectionActions: { + toggleBoldInSelection, + setFontWeightInSelection, + toggleItalicInSelection, + toggleUnderlineInSelection, + setLetterSpacingInSelection, + }, + }; +} + +export default useSelectionManipulation; diff --git a/assets/src/edit-story/elements/text/util.js b/assets/src/edit-story/elements/text/util.js index 7a9bc77f9f26..4b6798b76003 100644 --- a/assets/src/edit-story/elements/text/util.js +++ b/assets/src/edit-story/elements/text/util.js @@ -33,7 +33,6 @@ export function generateParagraphTextStyle( fontFallback, fontSize, lineHeight, - letterSpacing, padding, textAlign, } = element; @@ -43,7 +42,6 @@ export function generateParagraphTextStyle( fontFamily: generateFontFamily(fontFamily, fontFallback), fontSize: dataToFontSizeY(fontSize), lineHeight, - letterSpacing: `${typeof letterSpacing === 'number' ? letterSpacing : 0}em`, textAlign, padding: `${dataToStyleY(padding?.vertical || 0)}px ${dataToStyleX( padding?.horizontal || 0 From 4a28da7decc036792f72f8d7e327991f16083f5c Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 24 Apr 2020 14:39:52 -0400 Subject: [PATCH 11/51] Disable force focus when setting letter spacing Actually the whole `forceFocus` variable was superfluous - `EditorState.forceSelection` autimatically forces focus to the editor. And selection was remembered, just not visible while unfocused, so things still work as expected. --- .../edit-story/components/richText/editor.js | 11 +----- .../components/richText/provider.js | 11 +++--- .../richText/useSelectionManipulation.js | 35 +++++++++---------- 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/assets/src/edit-story/components/richText/editor.js b/assets/src/edit-story/components/richText/editor.js index 2faf8cf147a4..3e5eac96c951 100644 --- a/assets/src/edit-story/components/richText/editor.js +++ b/assets/src/edit-story/components/richText/editor.js @@ -37,14 +37,13 @@ import customInlineDisplay from './customInlineDisplay'; function RichTextEditor({ content, onChange }, ref) { const editorRef = useRef(null); const { - state: { editorState, forceFocus }, + state: { editorState }, actions: { setStateFromContent, updateEditorState, getHandleKeyCommand, getContentFromState, clearState, - clearForceFocus, }, } = useRichText(); @@ -68,14 +67,6 @@ function RichTextEditor({ content, onChange }, ref) { } }, []); - // Set focus when forced to, then clear - useLayoutEffect(() => { - if (editorRef.current && forceFocus) { - editorRef.current.focus(); - clearForceFocus(); - } - }, [forceFocus, clearForceFocus]); - const hasEditorState = Boolean(editorState); // On unmount, clear state in provider diff --git a/assets/src/edit-story/components/richText/provider.js b/assets/src/edit-story/components/richText/provider.js index 870786496217..116f551913d5 100644 --- a/assets/src/edit-story/components/richText/provider.js +++ b/assets/src/edit-story/components/richText/provider.js @@ -96,25 +96,22 @@ function RichTextProvider({ children }) { const hasCurrentEditor = Boolean(editorState); - const { - forceFocus, - clearForceFocus, - selectionActions, - } = useSelectionManipulation(editorState, setEditorState); + const selectionActions = useSelectionManipulation( + editorState, + setEditorState + ); const value = { state: { editorState, hasCurrentEditor, selectionInfo, - forceFocus, }, actions: { setStateFromContent, updateEditorState, getHandleKeyCommand, clearState, - clearForceFocus, selectionActions, // These actually don't work on the state at all, just pure functions getContentFromState: customExport, diff --git a/assets/src/edit-story/components/richText/useSelectionManipulation.js b/assets/src/edit-story/components/richText/useSelectionManipulation.js index b55e9dfc71c3..68782a0998be 100644 --- a/assets/src/edit-story/components/richText/useSelectionManipulation.js +++ b/assets/src/edit-story/components/richText/useSelectionManipulation.js @@ -17,7 +17,7 @@ /** * External dependencies */ -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useCallback, useRef, useEffect } from 'react'; import { EditorState } from 'draft-js'; /** @@ -43,15 +43,15 @@ function useSelectionManipulation(editorState, setEditorState) { } }, [editorState]); - const [forceFocus, setForceFocus] = useState(false); const updateWhileUnfocused = useCallback( - (updater, ...args) => { + (updater, shouldForceFocus = true) => { const oldState = lastKnownState.current; const selection = lastKnownSelection.current; - const workingState = EditorState.forceSelection(oldState, selection); - const newState = updater(workingState, ...args); + const workingState = shouldForceFocus + ? EditorState.forceSelection(oldState, selection) + : oldState; + const newState = updater(workingState); setEditorState(newState); - setForceFocus(true); }, [setEditorState] ); @@ -61,7 +61,7 @@ function useSelectionManipulation(editorState, setEditorState) { [updateWhileUnfocused] ); const setFontWeightInSelection = useCallback( - (weight) => updateWhileUnfocused(setFontWeight, weight), + (weight) => updateWhileUnfocused((s) => setFontWeight(s, weight)), [updateWhileUnfocused] ); const toggleItalicInSelection = useCallback( @@ -73,21 +73,20 @@ function useSelectionManipulation(editorState, setEditorState) { [updateWhileUnfocused] ); const setLetterSpacingInSelection = useCallback( - (ls) => updateWhileUnfocused(setLetterSpacing, ls), + (ls) => + updateWhileUnfocused( + (s) => setLetterSpacing(s, ls), + /* shouldForceFocus */ false + ), [updateWhileUnfocused] ); - const clearForceFocus = useCallback(() => setForceFocus(false), []); return { - forceFocus, - clearForceFocus, - selectionActions: { - toggleBoldInSelection, - setFontWeightInSelection, - toggleItalicInSelection, - toggleUnderlineInSelection, - setLetterSpacingInSelection, - }, + toggleBoldInSelection, + setFontWeightInSelection, + toggleItalicInSelection, + toggleUnderlineInSelection, + setLetterSpacingInSelection, }; } From 6346fa2d3ec10ebcbc3dfd9835a9f4440c8e5c97 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 24 Apr 2020 15:21:06 -0400 Subject: [PATCH 12/51] Added inline text color formatting --- .../components/panels/textStyle/color.js | 16 +++--- .../panels/textStyle/useRichTextFormatting.js | 5 ++ .../components/richText/customConstants.js | 17 +++++- .../components/richText/customExport.js | 11 +++- .../components/richText/customImport.js | 8 +++ .../richText/customInlineDisplay.js | 9 ++- .../components/richText/htmlManipulation.js | 10 +++- .../components/richText/styleManipulation.js | 56 ++++++++++++++----- .../richText/useSelectionManipulation.js | 10 ++++ .../src/edit-story/elements/shared/index.js | 6 -- .../src/edit-story/elements/text/display.js | 20 ++----- assets/src/edit-story/elements/text/edit.js | 4 -- assets/src/edit-story/elements/text/output.js | 2 - assets/src/edit-story/utils/patternUtils.js | 45 +++++++++++++++ 14 files changed, 160 insertions(+), 59 deletions(-) create mode 100644 assets/src/edit-story/utils/patternUtils.js 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/useRichTextFormatting.js b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js index 91793233f34f..6908e0c73e74 100644 --- a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js +++ b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js @@ -30,6 +30,7 @@ import { toggleItalicInHTML, toggleUnderlineInHTML, setLetterSpacingInHTML, + setColorInHTML, } from '../../richText/htmlManipulation'; import { MULTIPLE_VALUE } from '../../form'; @@ -57,6 +58,7 @@ function useRichTextFormatting(selectedElements, pushUpdate) { toggleItalicInSelection, toggleUnderlineInSelection, setLetterSpacingInSelection, + setColorInSelection, }, }, } = useRichText(); @@ -91,6 +93,7 @@ function useRichTextFormatting(selectedElements, pushUpdate) { handleClickItalic: toggleItalicInSelection, handleClickUnderline: toggleUnderlineInSelection, handleSetLetterSpacing: setLetterSpacingInSelection, + handleSetColor: setColorInSelection, }; } @@ -100,6 +103,7 @@ function useRichTextFormatting(selectedElements, pushUpdate) { handleClickItalic: (flag) => push(toggleItalicInHTML, flag), handleClickUnderline: (flag) => push(toggleUnderlineInHTML, flag), handleSetLetterSpacing: (ls) => push(setLetterSpacingInHTML, ls), + handleSetColor: (c) => push(setColorInHTML, c), }; }, [ hasCurrentEditor, @@ -108,6 +112,7 @@ function useRichTextFormatting(selectedElements, pushUpdate) { toggleItalicInSelection, toggleUnderlineInSelection, setLetterSpacingInSelection, + setColorInSelection, push, ]); diff --git a/assets/src/edit-story/components/richText/customConstants.js b/assets/src/edit-story/components/richText/customConstants.js index f1208a859dbf..64d89339105c 100644 --- a/assets/src/edit-story/components/richText/customConstants.js +++ b/assets/src/edit-story/components/richText/customConstants.js @@ -14,6 +14,11 @@ * limitations under the License. */ +/** + * Internal dependencies + */ +import { getHexFromSolid, getSolidFromHex } from '../../utils/patternUtils'; + export const NONE = 'NONE'; export const ITALIC = 'CUSTOM-ITALIC'; export const UNDERLINE = 'CUSTOM-UNDERLINE'; @@ -33,6 +38,10 @@ export const weightToStyle = (weight) => `${WEIGHT}-${weight}`; export const styleToWeight = (style) => isStyle(style, WEIGHT) ? parseInt(getVariable(style, WEIGHT)) : null; +/* + * Letter spacing uses PREFIX-123 for the number 123 + * and PREFIX-N123 for the number -123. + */ export const letterSpacingToStyle = (ls) => `${LETTERSPACING}-${ls < 0 ? 'N' : ''}${Math.abs(ls)}`; export const styleToLetterSpacing = (style) => { @@ -47,6 +56,10 @@ export const styleToLetterSpacing = (style) => { return parseInt(raw); }; +/* + * Color uses PREFIX-XXXXXXXX where XXXXXXXX is the 8 digit + * hex represenation of the RGBA color. + */ export const styleToColor = (style) => - isStyle(style, COLOR) ? getVariable(style, COLOR) : null; -export const colorToStyle = (color) => `${COLOR}-${color}`; + isStyle(style, COLOR) ? getSolidFromHex(getVariable(style, COLOR)) : null; +export const colorToStyle = (color) => `${COLOR}-${getHexFromSolid(color)}`; diff --git a/assets/src/edit-story/components/richText/customExport.js b/assets/src/edit-story/components/richText/customExport.js index 447474d5e3ee..a18ac41f9ceb 100644 --- a/assets/src/edit-story/components/richText/customExport.js +++ b/assets/src/edit-story/components/richText/customExport.js @@ -22,11 +22,13 @@ import { stateToHTML } from 'draft-js-export-html'; /** * Internal dependencies */ +import generatePatternStyles from '../../utils/generatePatternStyles'; import { ITALIC, UNDERLINE, styleToWeight, styleToLetterSpacing, + styleToColor, } from './customConstants'; function inlineStyleFn(styles) { @@ -65,7 +67,14 @@ function inlineStyleFn(styles) { }; } - // TODO: Color + // Color + const color = styleToColor(style); + if (color) { + return { + classes: [...classes, 'color'], + css: { ...css, ...generatePatternStyles(color, 'color') }, + }; + } return { classes, css }; }, diff --git a/assets/src/edit-story/components/richText/customImport.js b/assets/src/edit-story/components/richText/customImport.js index 41840b2e9bfd..1729f7bea4b5 100644 --- a/assets/src/edit-story/components/richText/customImport.js +++ b/assets/src/edit-story/components/richText/customImport.js @@ -22,11 +22,13 @@ import { stateFromHTML } from 'draft-js-import-html'; /** * Internal dependencies */ +import createSolidFromString from '../../utils/createSolidFromString'; import { ITALIC, UNDERLINE, weightToStyle, letterSpacingToStyle, + colorToStyle, } from './customConstants'; import { draftMarkupToContent } from './util'; @@ -54,6 +56,12 @@ function customInlineFn(element, { Style }) { return letterSpacingToStyle(lsScaled); } + case 'color': { + const rawColor = element.style.color; + const solid = createSolidFromString(rawColor); + return colorToStyle(solid); + } + default: return null; } diff --git a/assets/src/edit-story/components/richText/customInlineDisplay.js b/assets/src/edit-story/components/richText/customInlineDisplay.js index b0a89d1671b4..9467f9e29938 100644 --- a/assets/src/edit-story/components/richText/customInlineDisplay.js +++ b/assets/src/edit-story/components/richText/customInlineDisplay.js @@ -17,11 +17,13 @@ /** * Internal dependencies */ +import generatePatternStyles from '../../utils/generatePatternStyles'; import { ITALIC, UNDERLINE, styleToWeight, styleToLetterSpacing, + styleToColor, } from './customConstants'; function customInlineDisplay(styles) { @@ -48,8 +50,11 @@ function customInlineDisplay(styles) { return { ...css, letterSpacing: `${letterSpacing / 100}em` }; } - // TODO: Color - // TODO: Letter spacing + // Color + const color = styleToColor(style); + if (color) { + return { ...css, ...generatePatternStyles(color, 'color') }; + } return css; }, {}); diff --git a/assets/src/edit-story/components/richText/htmlManipulation.js b/assets/src/edit-story/components/richText/htmlManipulation.js index 37e8bb272ce2..feec68f958ec 100644 --- a/assets/src/edit-story/components/richText/htmlManipulation.js +++ b/assets/src/edit-story/components/richText/htmlManipulation.js @@ -28,6 +28,7 @@ import { toggleUnderline, setFontWeight, setLetterSpacing, + setColor, getStateInfo, } from './styleManipulation'; import customImport from './customImport'; @@ -53,15 +54,18 @@ export function toggleBoldInHTML(html, flag) { export function setFontWeightInHTML(html, weight) { return updateAndReturnHTML(html, setFontWeight, weight); } -export function setLetterSpacingInHTML(html, letterSpacing) { - return updateAndReturnHTML(html, setLetterSpacing, letterSpacing); -} export function toggleItalicInHTML(html, flag) { return updateAndReturnHTML(html, toggleItalic, flag); } export function toggleUnderlineInHTML(html, flag) { return updateAndReturnHTML(html, toggleUnderline, flag); } +export function setLetterSpacingInHTML(html, letterSpacing) { + return updateAndReturnHTML(html, setLetterSpacing, letterSpacing); +} +export function setColorInHTML(html, color) { + return updateAndReturnHTML(html, setColor, color); +} export function getHTMLInfo(html) { return getStateInfo(getSelectAllStateFromHTML(html)); diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js index 18aa6f402921..92a4d8af2e29 100644 --- a/assets/src/edit-story/components/richText/styleManipulation.js +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -23,16 +23,20 @@ import { Modifier, EditorState } from 'draft-js'; * Internal dependencies */ import { MULTIPLE_VALUE } from '../form'; +import createSolid from '../../utils/createSolid'; import { weightToStyle, styleToWeight, letterSpacingToStyle, styleToLetterSpacing, + colorToStyle, + styleToColor, NONE, WEIGHT, - LETTERSPACING, ITALIC, UNDERLINE, + LETTERSPACING, + COLOR, NORMAL_WEIGHT, SMALLEST_BOLD, DEFAULT_BOLD, @@ -245,16 +249,6 @@ export function isUnderline(editorState) { return !styles.includes(NONE); } -export function getStateInfo(state) { - return { - letterSpacing: getLetterSpacing(state), - fontWeight: getFontWeight(state), - isBold: isBold(state), - isItalic: isItalic(state), - isUnderline: isUnderline(state), - }; -} - export function toggleUnderline(editorState, flag) { return togglePrefixStyle( editorState, @@ -268,11 +262,11 @@ export function getLetterSpacing(editorState) { if (styles.length > 1) { return MULTIPLE_VALUE; } - const spacing = styles[0]; - if (spacing === NONE) { + const spacingStyle = styles[0]; + if (spacingStyle === NONE) { return 0; } - return styleToLetterSpacing(spacing); + return styleToLetterSpacing(spacingStyle); } export function setLetterSpacing(editorState, letterSpacing) { @@ -289,3 +283,37 @@ export function setLetterSpacing(editorState, letterSpacing) { getStyleToSet ); } + +export 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); +} + +export function setColor(editorState, color) { + // we set all colors - one could argue that opaque black + // was default, and wasn't necessary, but it's probably not worth the trouble + const shouldSetStyle = () => true; + + // the style util manages conversion + const getStyleToSet = () => colorToStyle(color); + + return togglePrefixStyle(editorState, COLOR, shouldSetStyle, getStyleToSet); +} + +export function getStateInfo(state) { + return { + fontWeight: getFontWeight(state), + isBold: isBold(state), + isItalic: isItalic(state), + isUnderline: isUnderline(state), + letterSpacing: getLetterSpacing(state), + color: getColor(state), + }; +} diff --git a/assets/src/edit-story/components/richText/useSelectionManipulation.js b/assets/src/edit-story/components/richText/useSelectionManipulation.js index 68782a0998be..3a298676436c 100644 --- a/assets/src/edit-story/components/richText/useSelectionManipulation.js +++ b/assets/src/edit-story/components/richText/useSelectionManipulation.js @@ -29,6 +29,7 @@ import { toggleUnderline, setFontWeight, setLetterSpacing, + setColor, } from './styleManipulation'; function useSelectionManipulation(editorState, setEditorState) { @@ -80,6 +81,14 @@ function useSelectionManipulation(editorState, setEditorState) { ), [updateWhileUnfocused] ); + const setColorInSelection = useCallback( + (color) => + updateWhileUnfocused( + (s) => setColor(s, color), + /* shouldForceFocus */ false + ), + [updateWhileUnfocused] + ); return { toggleBoldInSelection, @@ -87,6 +96,7 @@ function useSelectionManipulation(editorState, setEditorState) { toggleItalicInSelection, toggleUnderlineInSelection, setLetterSpacingInSelection, + setColorInSelection, }; } diff --git a/assets/src/edit-story/elements/shared/index.js b/assets/src/edit-story/elements/shared/index.js index b76ba64cd4ce..b02b2d3196f2 100644 --- a/assets/src/edit-story/elements/shared/index.js +++ b/assets/src/edit-story/elements/shared/index.js @@ -54,10 +54,6 @@ 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: ${({ fontFamily }) => fontFamily}; @@ -73,9 +69,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 406bfc6fdfb3..ea6d8434620f 100644 --- a/assets/src/edit-story/elements/text/display.js +++ b/assets/src/edit-story/elements/text/display.js @@ -29,7 +29,6 @@ import { elementFillContent, elementWithFont, elementWithBackgroundColor, - elementWithFontColor, elementWithTextParagraphStyle, } from '../shared'; import StoryPropTypes from '../../types'; @@ -41,7 +40,6 @@ import { getHighlightLineheight, generateParagraphTextStyle } from './util'; const HighlightWrapperElement = styled.div` ${elementFillContent} ${elementWithFont} - ${elementWithFontColor} ${elementWithTextParagraphStyle} line-height: ${({ lineHeight, verticalPadding }) => getHighlightLineheight(lineHeight, verticalPadding)}; @@ -87,20 +85,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); @@ -110,7 +99,6 @@ function TextDisplay({ } = useUnits(); const props = { - color, ...(backgroundTextMode === BACKGROUND_TEXT_MODE.NONE ? {} : { backgroundColor }), @@ -144,7 +132,7 @@ function TextDisplay({ @@ -154,7 +142,7 @@ function TextDisplay({ @@ -167,7 +155,7 @@ function TextDisplay({ diff --git a/assets/src/edit-story/elements/text/edit.js b/assets/src/edit-story/elements/text/edit.js index 38a736862754..2403f4b8a0a5 100644 --- a/assets/src/edit-story/elements/text/edit.js +++ b/assets/src/edit-story/elements/text/edit.js @@ -30,7 +30,6 @@ import { elementFillContent, elementWithFont, elementWithBackgroundColor, - elementWithFontColor, elementWithTextParagraphStyle, } from '../shared'; import StoryPropTypes from '../../types'; @@ -66,7 +65,6 @@ const TextBox = styled.div` ${elementWithFont} ${elementWithTextParagraphStyle} ${elementWithBackgroundColor} - ${elementWithFontColor} opacity: ${({ opacity }) => (opacity ? opacity / 100 : null)}; position: absolute; @@ -79,7 +77,6 @@ function TextEdit({ element: { id, content, - color, backgroundColor, backgroundTextMode, opacity, @@ -93,7 +90,6 @@ function TextEdit({ } = useUnits(); const textProps = { ...generateParagraphTextStyle(rest, dataToEditorX, dataToEditorY), - color, backgroundColor, opacity, ...(backgroundTextMode === BACKGROUND_TEXT_MODE.HIGHLIGHT && { diff --git a/assets/src/edit-story/elements/text/output.js b/assets/src/edit-story/elements/text/output.js index 329497a7854d..6e00280c8232 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -36,7 +36,6 @@ export function TextOutputWithUnits({ element: { bold, content, - color, backgroundColor, backgroundTextMode, padding, @@ -75,7 +74,6 @@ export function TextOutputWithUnits({ dataToStyleY, dataToFontSizeY ), - ...generatePatternStyles(color, 'color'), ...bgColor, padding: `${paddingStyles.vertical} ${paddingStyles.horizontal}`, }; 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(''); +} From ebf8f26fd11daac0c30d14999cc7c1c3454a5f18 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 24 Apr 2020 16:45:15 -0400 Subject: [PATCH 13/51] Fix multiple reducer for patterns, and fix tests --- .../components/panels/test/textStyle.js | 60 +++++++++++++++---- .../panels/textStyle/useRichTextFormatting.js | 15 ++++- .../edit-story/elements/text/test/output.js | 22 ------- 3 files changed, 61 insertions(+), 36 deletions(-) diff --git a/assets/src/edit-story/components/panels/test/textStyle.js b/assets/src/edit-story/components/panels/test/textStyle.js index 4828d64673e3..d6e9038d95fc 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'; @@ -50,7 +51,11 @@ function Wrapper({ children }) { }, }} > - {children} + + {children} + ); } @@ -436,27 +441,46 @@ 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( @@ -467,20 +491,28 @@ 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( @@ -491,11 +523,13 @@ 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/useRichTextFormatting.js b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js index 6908e0c73e74..475fc0cefab8 100644 --- a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js +++ b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js @@ -22,6 +22,7 @@ import { useMemo, useCallback } from 'react'; /** * Internal dependencies */ +import generatePatternStyles from '../../../utils/generatePatternStyles'; import useRichText from '../../richText/useRichText'; import { getHTMLInfo, @@ -34,12 +35,24 @@ import { } from '../../richText/htmlManipulation'; import { MULTIPLE_VALUE } from '../../form'; +function isEqual(a, b) { + const isPattern = typeof a === 'object' && (a.type || a.color); + if (!isPattern) { + return a === b; + } + + const aStyle = generatePatternStyles(a); + const bStyle = generatePatternStyles(b); + const keys = Object.keys(aStyle); + return keys.every((key) => aStyle[key] === bStyle[key]); +} + 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 && reduced[key] !== info[key]; + const areDifferent = hadValue && !isEqual(reduced[key], info[key]); if (wasMultiple || areDifferent) { return [key, MULTIPLE_VALUE]; } diff --git a/assets/src/edit-story/elements/text/test/output.js b/assets/src/edit-story/elements/text/test/output.js index 65317910c4c9..f0cc6dbb1b78 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, @@ -55,7 +47,6 @@ describe('TextOutput', () => { }, }, fontSize: 16, - letterSpacing: 1.3, textAlign: 'left', type: 'text', x: 10, @@ -83,10 +74,8 @@ 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', }); }); @@ -94,16 +83,8 @@ describe('TextOutput', () => { it('should convert padding to percent of width', () => { const element = { id: '123', - color: { - color: { - r: 255, - g: 255, - b: 255, - }, - }, content: 'Content', fontSize: 16, - letterSpacing: 1.3, textAlign: 'left', type: 'text', x: 10, @@ -147,7 +128,6 @@ describe('TextOutput', () => { horizontal: 0, }, fontSize: 16, - color: { type: 'solid', color: { r: 255, g: 255, b: 255 } }, }; const output = renderViaString( @@ -176,7 +156,6 @@ describe('TextOutput', () => { horizontal: 0, }, fontSize: 16, - color: { type: 'solid', color: { r: 255, g: 255, b: 255 } }, }; const output = renderViaString( @@ -199,7 +178,6 @@ describe('TextOutput', () => { width: 1080, rotationAngle: 0, content: 'Hello World', - color: { type: 'solid', color: { r: 255, g: 255, b: 255 } }, padding: { horizontal: 0, vertical: 0, From 04af4f50a7cb59bb81fcc576272c06e38d0c69bc Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 24 Apr 2020 17:04:47 -0400 Subject: [PATCH 14/51] Fixed font and font weight dropdowns Font weight dropdown simply gets a new placeholder, as the placeholder will only be shown, when there's multiple font weights selected. For now, font weight logic is removed from font selector (but must be readded in a new version later), and tests are updated. --- .../components/panels/test/textStyle.js | 2 -- .../components/panels/textStyle/font.js | 17 +---------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/assets/src/edit-story/components/panels/test/textStyle.js b/assets/src/edit-story/components/panels/test/textStyle.js index d6e9038d95fc..63991bda5f45 100644 --- a/assets/src/edit-story/components/panels/test/textStyle.js +++ b/assets/src/edit-story/components/panels/test/textStyle.js @@ -80,7 +80,6 @@ describe('Panels/TextStyle', () => { textAlign: 'normal', fontSize: 30, fontFamily: 'ABeeZee', - fontWeight: 400, x: 0, y: 0, height: 100, @@ -387,7 +386,6 @@ describe('Panels/TextStyle', () => { { fontFamily: 'Neu Font', fontFallback: 'fallback1', - fontWeight: 400, }, true ); diff --git a/assets/src/edit-story/components/panels/textStyle/font.js b/assets/src/edit-story/components/panels/textStyle/font.js index 7c37ad7e7850..8d7aa5d53415 100644 --- a/assets/src/edit-story/components/panels/textStyle/font.js +++ b/assets/src/edit-story/components/panels/textStyle/font.js @@ -72,26 +72,10 @@ function FontControls({ selectedElements, pushUpdate }) { options={fonts} value={fontFamily} onChange={(value) => { - const currentFontWeights = getFontWeight(value); const currentFontFallback = getFontFallback(value); - const fontWeightsArr = currentFontWeights.map( - ({ value: weight }) => weight - ); - - // Find the nearest font weight from the available font weight list - // If no fontweightsArr available then will return undefined - const newFontWeight = - fontWeightsArr && - fontWeightsArr.reduce((a, b) => - Math.abs(parseInt(b) - fontWeight) < - Math.abs(parseInt(a) - fontWeight) - ? b - : a - ); pushUpdate( { fontFamily: value, - fontWeight: parseInt(newFontWeight), fontFallback: currentFontFallback, }, true @@ -106,6 +90,7 @@ function FontControls({ selectedElements, pushUpdate }) { Date: Fri, 24 Apr 2020 17:20:58 -0400 Subject: [PATCH 15/51] Remove deprecated types from text element --- assets/src/edit-story/types.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/assets/src/edit-story/types.js b/assets/src/edit-story/types.js index d45413020e3a..d82485f7ceb1 100644 --- a/assets/src/edit-story/types.js +++ b/assets/src/edit-story/types.js @@ -193,15 +193,11 @@ StoryPropTypes.elements.media = PropTypes.oneOfType([ StoryPropTypes.elements.text = PropTypes.shape({ ...StoryElementPropTypes, content: PropTypes.string, - color: PatternPropType.isRequired, backgroundTextMode: PropTypes.oneOf(Object.values(BACKGROUND_TEXT_MODE)), backgroundColor: PatternPropType, fontFamily: PropTypes.string, fontFallback: PropTypes.array, fontSize: PropTypes.number, - fontWeight: PropTypes.number, - fontStyle: PropTypes.string, - letterSpacing: PropTypes.number, lineHeight: PropTypes.number, padding: PropTypes.shape({ horizontal: PropTypes.number, @@ -209,7 +205,6 @@ StoryPropTypes.elements.text = PropTypes.shape({ locked: PropTypes.bool, }), textAlign: PropTypes.string, - textDecoration: PropTypes.string, }); StoryPropTypes.elements.shape = PropTypes.shape({ From 47df628514fefc5c55a10424e0c0e1585edd6520 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 24 Apr 2020 17:22:35 -0400 Subject: [PATCH 16/51] Some extra cleanup of deprecated properties --- assets/src/edit-story/elements/text/display.js | 2 +- assets/src/edit-story/elements/text/edit.js | 1 - assets/src/edit-story/elements/text/index.js | 5 ----- assets/src/edit-story/elements/text/output.js | 17 +++++------------ 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/assets/src/edit-story/elements/text/display.js b/assets/src/edit-story/elements/text/display.js index ea6d8434620f..9c86509a98d5 100644 --- a/assets/src/edit-story/elements/text/display.js +++ b/assets/src/edit-story/elements/text/display.js @@ -73,7 +73,7 @@ const Span = styled.span` `; const BackgroundSpan = styled(Span)` - color: transparent; + color: transparent !important; `; const ForegroundSpan = styled(Span)` diff --git a/assets/src/edit-story/elements/text/edit.js b/assets/src/edit-story/elements/text/edit.js index 2403f4b8a0a5..5eaaec305bde 100644 --- a/assets/src/edit-story/elements/text/edit.js +++ b/assets/src/edit-story/elements/text/edit.js @@ -97,7 +97,6 @@ function TextEdit({ rest.lineHeight, dataToEditorX(rest.padding?.vertical || 0) ), - color: createSolid(0, 0, 0), backgroundColor: createSolid(255, 255, 255), }), ...(backgroundTextMode === BACKGROUND_TEXT_MODE.NONE && { diff --git a/assets/src/edit-story/elements/text/index.js b/assets/src/edit-story/elements/text/index.js index 9b0b70eaf2e5..d4ca0af26ab7 100644 --- a/assets/src/edit-story/elements/text/index.js +++ b/assets/src/edit-story/elements/text/index.js @@ -36,15 +36,10 @@ export const defaultAttributes = { bold: false, fontFamily: 'Roboto', fontFallback: ['Helvetica Neue', 'Helvetica', 'sans-serif'], - 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 6e00280c8232..90f2ced33f93 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -33,14 +33,7 @@ import { generateParagraphTextStyle, getHighlightLineheight } from './util'; * Renders DOM for the text output based on the provided unit converters. */ export function TextOutputWithUnits({ - element: { - bold, - content, - backgroundColor, - backgroundTextMode, - padding, - ...rest - }, + element: { content, backgroundColor, backgroundTextMode, padding, ...rest }, dataToStyleX, dataToStyleY, dataToFontSizeY, @@ -125,7 +118,7 @@ export function TextOutputWithUnits({ const backgroundTextStyle = { ...textStyle, - color: 'transparent', + color: 'transparent !important', }; const foregroundTextStyle = { @@ -141,7 +134,7 @@ export function TextOutputWithUnits({ @@ -151,7 +144,7 @@ export function TextOutputWithUnits({ @@ -164,7 +157,7 @@ export function TextOutputWithUnits({

); } From 5dcaf4e9eaf67430d9a3265835f26d1a5777fc12 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Mon, 27 Apr 2020 15:43:23 -0400 Subject: [PATCH 17/51] Added new migration with test (also added `jest-extended`) --- assets/src/edit-story/migration/migrate.js | 2 + .../test/v0015_inlineTextProperties.js | 352 ++++++++++++++++++ .../migrations/v0015_inlineTextProperties.js | 157 ++++++++ package-lock.json | 126 +++++++ package.json | 1 + tests/js/jest.setup.js | 1 + 6 files changed, 639 insertions(+) create mode 100644 assets/src/edit-story/migration/migrations/test/v0015_inlineTextProperties.js create mode 100644 assets/src/edit-story/migration/migrations/v0015_inlineTextProperties.js diff --git a/assets/src/edit-story/migration/migrate.js b/assets/src/edit-story/migration/migrate.js index c9b0795b28c3..0a819a077650 100644 --- a/assets/src/edit-story/migration/migrate.js +++ b/assets/src/edit-story/migration/migrate.js @@ -32,6 +32,7 @@ import pageAdvancement from './migrations/v0011_pageAdvancement'; import setBackgroundTextMode from './migrations/v0012_setBackgroundTextMode'; import videoIdToId from './migrations/v0013_videoIdToId'; import oneTapLinkDeprecate from './migrations/v0014_oneTapLinkDeprecate'; +import inlineTextProperties from './migrations/v0015_inlineTextProperties'; const MIGRATIONS = { 1: [storyDataArrayToObject], @@ -48,6 +49,7 @@ const MIGRATIONS = { 12: [setBackgroundTextMode], 13: [videoIdToId], 14: [oneTapLinkDeprecate], + 15: [inlineTextProperties], }; export const DATA_VERSION = Math.max.apply( diff --git a/assets/src/edit-story/migration/migrations/test/v0015_inlineTextProperties.js b/assets/src/edit-story/migration/migrations/test/v0015_inlineTextProperties.js new file mode 100644 index 000000000000..c35f67f14fd9 --- /dev/null +++ b/assets/src/edit-story/migration/migrations/test/v0015_inlineTextProperties.js @@ -0,0 +1,352 @@ +/* + * 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 inlineTextProperties from '../v0015_inlineTextProperties'; + +describe('inlineTextProperties', () => { + 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', + ]); + }); + }); + + function convert(content, properties) { + const converted = inlineTextProperties({ + pages: [{ elements: [{ type: 'text', content, ...properties }] }], + }); + return converted.pages[0].elements[0].content; + } + + 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); + }); + + 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/v0015_inlineTextProperties.js b/assets/src/edit-story/migration/migrations/v0015_inlineTextProperties.js new file mode 100644 index 000000000000..4b6805c59a5b --- /dev/null +++ b/assets/src/edit-story/migration/migrations/v0015_inlineTextProperties.js @@ -0,0 +1,157 @@ +/* + * 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, 'weight', fancyBold); + return wrapped; + } + + const justBold = 'font-weight: 700'; + return replaceTagWithSpan(content, 'strong', 'weight', 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, 'italic', italicStyle); + return wrapped; + } + + return replaceTagWithSpan(content, 'em', 'italic', 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, 'underline', underlineStyle); + return wrapped; + } + + return replaceTagWithSpan(content, 'u', 'underline', 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', `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, + 'letterspacing', + `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, className, style) { + // Again, very naive + return html + .replace( + new RegExp(`<${tag}>`, 'gi'), + `` + ) + .replace(new RegExp(``, 'gi'), ''); +} + +function wrapWithSpan(html, className, style) { + return `${html}`; +} + +export default inlineTextProperties; diff --git a/package-lock.json b/package-lock.json index 1460367fb61b..fcf24fb8d2e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17931,6 +17931,132 @@ } } }, + "jest-extended": { + "version": "0.11.5", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-0.11.5.tgz", + "integrity": "sha512-3RsdFpLWKScpsLD6hJuyr/tV5iFOrw7v6YjA3tPdda9sJwoHwcMROws5gwiIZfcwhHlJRwFJB2OUvGmF3evV/Q==", + "dev": true, + "requires": { + "expect": "^24.1.0", + "jest-get-type": "^22.4.3", + "jest-matcher-utils": "^22.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "diff-sequences": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz", + "integrity": "sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew==", + "dev": true + }, + "expect": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-24.9.0.tgz", + "integrity": "sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "ansi-styles": "^3.2.0", + "jest-get-type": "^24.9.0", + "jest-matcher-utils": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-regex-util": "^24.9.0" + }, + "dependencies": { + "jest-get-type": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz", + "integrity": "sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q==", + "dev": true + }, + "jest-matcher-utils": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz", + "integrity": "sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-diff": "^24.9.0", + "jest-get-type": "^24.9.0", + "pretty-format": "^24.9.0" + } + } + } + }, + "jest-diff": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", + "integrity": "sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "diff-sequences": "^24.9.0", + "jest-get-type": "^24.9.0", + "pretty-format": "^24.9.0" + }, + "dependencies": { + "jest-get-type": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz", + "integrity": "sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q==", + "dev": true + } + } + }, + "jest-get-type": { + "version": "22.4.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-22.4.3.tgz", + "integrity": "sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w==", + "dev": true + }, + "jest-matcher-utils": { + "version": "22.4.3", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-22.4.3.tgz", + "integrity": "sha512-lsEHVaTnKzdAPR5t4B6OcxXo9Vy4K+kRRbG5gtddY8lBEC+Mlpvm1CJcsMESRjzUhzkz568exMV1hTB76nAKbA==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-get-type": "^22.4.3", + "pretty-format": "^22.4.3" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "pretty-format": { + "version": "22.4.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-22.4.3.tgz", + "integrity": "sha512-S4oT9/sT6MN7/3COoOy+ZJeA92VmOnveLHgrwBE3Z1W5N9S2A1QGNYiE1z75DAENbJrXXUb+OWXhpJcg05QKQQ==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } + } + }, + "pretty-format": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz", + "integrity": "sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "ansi-regex": "^4.0.0", + "ansi-styles": "^3.2.0", + "react-is": "^16.8.4" + } + } + } + }, "jest-fetch-mock": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", diff --git a/package.json b/package.json index 6850d2611e71..5759895c211c 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "husky": "^4.2.5", "jest": "^25.4.0", "jest-canvas-mock": "^2.2.0", + "jest-extended": "^0.11.5", "jest-fetch-mock": "^3.0.3", "jest-matcher-deep-close-to": "^1.3.0", "jest-puppeteer": "^4.4.0", diff --git a/tests/js/jest.setup.js b/tests/js/jest.setup.js index e690574b36f8..a493a798210e 100644 --- a/tests/js/jest.setup.js +++ b/tests/js/jest.setup.js @@ -20,6 +20,7 @@ // Extend Jest matchers. // See https://github.com/testing-library/jest-dom. import '@testing-library/jest-dom'; +import 'jest-extended'; /** * Internal dependencies From 9739dfbf2c6888660ab1d12a38a8fa113142344f Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 28 Apr 2020 22:10:12 -0400 Subject: [PATCH 18/51] Refactored formatters to verticals rather than horizontals - much cleaner result --- .../panels/textStyle/useRichTextFormatting.js | 62 +++---- .../components/richText/customConstants.js | 43 ----- .../components/richText/customExport.js | 64 +------ .../components/richText/customImport.js | 62 +------ .../richText/customInlineDisplay.js | 44 +---- .../components/richText/formatters/color.js | 99 ++++++++++ .../components/richText/formatters/index.js | 34 ++++ .../components/richText/formatters/italic.js | 69 +++++++ .../richText/formatters/letterSpacing.js | 99 ++++++++++ .../richText/formatters/underline.js | 69 +++++++ .../components/richText/formatters/util.js | 39 ++++ .../components/richText/formatters/weight.js | 134 ++++++++++++++ .../components/richText/getStateInfo.js | 35 ++++ .../components/richText/htmlManipulation.js | 49 +++-- .../components/richText/provider.js | 2 +- .../components/richText/styleManipulation.js | 171 +----------------- .../richText/useSelectionManipulation.js | 69 +++---- .../edit-story/components/richText/util.js | 10 +- 18 files changed, 679 insertions(+), 475 deletions(-) create mode 100644 assets/src/edit-story/components/richText/formatters/color.js create mode 100644 assets/src/edit-story/components/richText/formatters/index.js create mode 100644 assets/src/edit-story/components/richText/formatters/italic.js create mode 100644 assets/src/edit-story/components/richText/formatters/letterSpacing.js create mode 100644 assets/src/edit-story/components/richText/formatters/underline.js create mode 100644 assets/src/edit-story/components/richText/formatters/util.js create mode 100644 assets/src/edit-story/components/richText/formatters/weight.js create mode 100644 assets/src/edit-story/components/richText/getStateInfo.js diff --git a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js index 475fc0cefab8..da7c6442c09d 100644 --- a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js +++ b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js @@ -25,13 +25,8 @@ import { useMemo, useCallback } from 'react'; import generatePatternStyles from '../../../utils/generatePatternStyles'; import useRichText from '../../richText/useRichText'; import { + getHTMLFormatters, getHTMLInfo, - toggleBoldInHTML, - setFontWeightInHTML, - toggleItalicInHTML, - toggleUnderlineInHTML, - setLetterSpacingInHTML, - setColorInHTML, } from '../../richText/htmlManipulation'; import { MULTIPLE_VALUE } from '../../form'; @@ -64,16 +59,7 @@ function reduceWithMultiple(reduced, info) { function useRichTextFormatting(selectedElements, pushUpdate) { const { state: { hasCurrentEditor, selectionInfo }, - actions: { - selectionActions: { - toggleBoldInSelection, - setFontWeightInSelection, - toggleItalicInSelection, - toggleUnderlineInSelection, - setLetterSpacingInSelection, - setColorInSelection, - }, - }, + actions: { selectionActions }, } = useRichText(); const textInfo = useMemo(() => { @@ -101,33 +87,33 @@ function useRichTextFormatting(selectedElements, pushUpdate) { const handlers = useMemo(() => { if (hasCurrentEditor) { return { - handleClickBold: toggleBoldInSelection, - handleSelectFontWeight: setFontWeightInSelection, - handleClickItalic: toggleItalicInSelection, - handleClickUnderline: toggleUnderlineInSelection, - handleSetLetterSpacing: setLetterSpacingInSelection, - handleSetColor: setColorInSelection, + // 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(toggleBoldInHTML, flag), - handleSelectFontWeight: (weight) => push(setFontWeightInHTML, weight), - handleClickItalic: (flag) => push(toggleItalicInHTML, flag), - handleClickUnderline: (flag) => push(toggleUnderlineInHTML, flag), - handleSetLetterSpacing: (ls) => push(setLetterSpacingInHTML, ls), - handleSetColor: (c) => push(setColorInHTML, c), + 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, - toggleBoldInSelection, - setFontWeightInSelection, - toggleItalicInSelection, - toggleUnderlineInSelection, - setLetterSpacingInSelection, - setColorInSelection, - push, - ]); + }, [hasCurrentEditor, selectionActions, push]); return { textInfo, diff --git a/assets/src/edit-story/components/richText/customConstants.js b/assets/src/edit-story/components/richText/customConstants.js index 64d89339105c..996021c120fb 100644 --- a/assets/src/edit-story/components/richText/customConstants.js +++ b/assets/src/edit-story/components/richText/customConstants.js @@ -14,52 +14,9 @@ * limitations under the License. */ -/** - * Internal dependencies - */ -import { getHexFromSolid, getSolidFromHex } from '../../utils/patternUtils'; - 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'; - -export const NORMAL_WEIGHT = 400; -export const SMALLEST_BOLD = 600; -export const DEFAULT_BOLD = 700; - -export const isStyle = (style, prefix) => - typeof style === 'string' && style.startsWith(prefix); -export const getVariable = (style, prefix) => style.slice(prefix.length + 1); - -export const weightToStyle = (weight) => `${WEIGHT}-${weight}`; -export const styleToWeight = (style) => - isStyle(style, WEIGHT) ? parseInt(getVariable(style, WEIGHT)) : null; - -/* - * Letter spacing uses PREFIX-123 for the number 123 - * and PREFIX-N123 for the number -123. - */ -export const letterSpacingToStyle = (ls) => - `${LETTERSPACING}-${ls < 0 ? 'N' : ''}${Math.abs(ls)}`; -export const styleToLetterSpacing = (style) => { - if (!isStyle(style, LETTERSPACING)) { - return null; - } - const raw = getVariable(style, LETTERSPACING); - // Negative numbers are prefixed with an N: - if (raw.charAt(0) === 'N') { - return -parseInt(raw.slice(1)); - } - return parseInt(raw); -}; - -/* - * Color uses PREFIX-XXXXXXXX where XXXXXXXX is the 8 digit - * hex represenation of the RGBA color. - */ -export const styleToColor = (style) => - isStyle(style, COLOR) ? getSolidFromHex(getVariable(style, COLOR)) : null; -export const colorToStyle = (color) => `${COLOR}-${getHexFromSolid(color)}`; diff --git a/assets/src/edit-story/components/richText/customExport.js b/assets/src/edit-story/components/richText/customExport.js index a18ac41f9ceb..9671e28ec037 100644 --- a/assets/src/edit-story/components/richText/customExport.js +++ b/assets/src/edit-story/components/richText/customExport.js @@ -22,73 +22,21 @@ import { stateToHTML } from 'draft-js-export-html'; /** * Internal dependencies */ -import generatePatternStyles from '../../utils/generatePatternStyles'; -import { - ITALIC, - UNDERLINE, - styleToWeight, - styleToLetterSpacing, - styleToColor, -} from './customConstants'; +import formatters from './formatters'; function inlineStyleFn(styles) { - const inline = styles.toArray().reduce( - ({ classes, css }, style) => { - // Italic - if (style === ITALIC) { - return { - classes: [...classes, 'italic'], - css: { ...css, fontStyle: 'italic' }, - }; - } - - // Underline - if (style === UNDERLINE) { - return { - classes: [...classes, 'underline'], - css: { ...css, textDecoration: 'underline' }, - }; - } - - // Weight - const weight = styleToWeight(style); - if (weight) { - return { - classes: [...classes, 'weight'], - css: { ...css, fontWeight: weight }, - }; - } - // Letter spacing - const letterSpacing = styleToLetterSpacing(style); - if (letterSpacing) { - return { - classes: [...classes, 'letterspacing'], - css: { ...css, letterSpacing: `${letterSpacing / 100}em` }, - }; - } - - // Color - const color = styleToColor(style); - if (color) { - return { - classes: [...classes, 'color'], - css: { ...css, ...generatePatternStyles(color, 'color') }, - }; - } - - return { classes, css }; - }, - { classes: [], css: {} } + const inlineCSS = formatters.reduce( + (css, { stylesToCSS }) => ({ ...css, ...stylesToCSS(styles) }), + {} ); - if (inline.classes.length === 0) { + if (Object.keys(inlineCSS).length === 0) { return null; } return { element: 'span', - attributes: { class: inline.classes.join(' ') }, - style: inline.css, + style: inlineCSS, }; } diff --git a/assets/src/edit-story/components/richText/customImport.js b/assets/src/edit-story/components/richText/customImport.js index 1729f7bea4b5..241944d0a103 100644 --- a/assets/src/edit-story/components/richText/customImport.js +++ b/assets/src/edit-story/components/richText/customImport.js @@ -22,65 +22,19 @@ import { stateFromHTML } from 'draft-js-import-html'; /** * Internal dependencies */ -import createSolidFromString from '../../utils/createSolidFromString'; -import { - ITALIC, - UNDERLINE, - weightToStyle, - letterSpacingToStyle, - colorToStyle, -} from './customConstants'; +import formatters from './formatters'; import { draftMarkupToContent } from './util'; function customInlineFn(element, { Style }) { - switch (element.tagName.toLowerCase()) { - case 'span': { - const styles = [...element.classList] - .map((className) => { - switch (className) { - case 'weight': { - const fontWeight = parseInt(element.style.fontWeight) || 400; - return weightToStyle(fontWeight); - } + const styleStrings = formatters + .map(({ elementToStyle }) => elementToStyle(element)) + .filter((style) => Boolean(style)); - case 'italic': - return ITALIC; - - case 'underline': - return UNDERLINE; - - case 'letterspacing': { - const ls = element.style.letterSpacing; - const lsNumber = ls.split(/[a-z%]/)[0] || 0; - const lsScaled = Math.round(lsNumber * 100); - return letterSpacingToStyle(lsScaled); - } - - case 'color': { - const rawColor = element.style.color; - const solid = createSolidFromString(rawColor); - return colorToStyle(solid); - } - - default: - return null; - } - }) - .filter((style) => Boolean(style)); - - if (styles.length) { - // This is the reason we need a patch, as multiple styles aren't supported by published package - // and maintainer clearly doesn't care about it enough to merge. - // see: /patches/draft-js-import-element+1.4.0.patch - return Style(styles); - } - - return null; - } - - default: - return null; + if (styleStrings.length === 0) { + return null; } + + return Style(styleStrings); } function importHTML(html) { diff --git a/assets/src/edit-story/components/richText/customInlineDisplay.js b/assets/src/edit-story/components/richText/customInlineDisplay.js index 9467f9e29938..1860831f21e4 100644 --- a/assets/src/edit-story/components/richText/customInlineDisplay.js +++ b/assets/src/edit-story/components/richText/customInlineDisplay.js @@ -17,47 +17,13 @@ /** * Internal dependencies */ -import generatePatternStyles from '../../utils/generatePatternStyles'; -import { - ITALIC, - UNDERLINE, - styleToWeight, - styleToLetterSpacing, - styleToColor, -} from './customConstants'; +import formatters from './formatters'; function customInlineDisplay(styles) { - return styles.toArray().reduce((css, style) => { - // Italic - if (style === ITALIC) { - return { ...css, fontStyle: 'italic' }; - } - - // Underline - if (style === UNDERLINE) { - return { ...css, textDecoration: 'underline' }; - } - - // Weight - const weight = styleToWeight(style); - if (weight) { - return { ...css, fontWeight: weight }; - } - - // Letter spacing - const letterSpacing = styleToLetterSpacing(style); - if (letterSpacing) { - return { ...css, letterSpacing: `${letterSpacing / 100}em` }; - } - - // Color - const color = styleToColor(style); - if (color) { - return { ...css, ...generatePatternStyles(color, 'color') }; - } - - return css; - }, {}); + return formatters.reduce( + (css, { stylesToCSS }) => ({ ...css, ...stylesToCSS(styles) }), + {} + ); } export default customInlineDisplay; 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..382c8f17a9dc --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/color.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 { getHexFromSolid, getSolidFromHex } from '../../../utils/patternUtils'; +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; + } + const color = styleToColor(style); + if (!color) { + 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) { + // we set all colors - one could argue that opaque black + // was default, and wasn't necessary, but it's probably not worth the trouble + const shouldSetStyle = () => true; + + // the style util manages conversion + const getStyleToSet = () => colorToStyle(color); + + return togglePrefixStyle(editorState, COLOR, shouldSetStyle, getStyleToSet); +} + +const formatter = { + elementToStyle, + stylesToCSS, + autoFocus: true, + 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..4d56bd52f184 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/italic.js @@ -0,0 +1,69 @@ +/* + * 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 style = styles.find((someStyle) => someStyle === ITALIC); + if (!style) { + return null; + } + return { fontStyle: 'italic' }; +} + +function isItalic(editorState) { + const styles = getPrefixStylesInSelection(editorState, ITALIC); + return !styles.includes(NONE); +} + +function toggleItalic(editorState, flag) { + return togglePrefixStyle( + editorState, + ITALIC, + typeof flag === 'boolean' && (() => flag) + ); +} + +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..ffac2eacd860 --- /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, 10); + 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/underline.js b/assets/src/edit-story/components/richText/formatters/underline.js new file mode 100644 index 000000000000..12bcb233c404 --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/underline.js @@ -0,0 +1,69 @@ +/* + * 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 style = styles.find((someStyle) => someStyle === UNDERLINE); + if (!style) { + return null; + } + return { textDecoration: 'underline' }; +} + +function isUnderline(editorState) { + const styles = getPrefixStylesInSelection(editorState, UNDERLINE); + return !styles.includes(NONE); +} + +function toggleUnderline(editorState, flag) { + return togglePrefixStyle( + editorState, + UNDERLINE, + typeof flag === 'boolean' && (() => flag) + ); +} + +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..db85f65f421e --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/util.js @@ -0,0 +1,39 @@ +/* + * 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) => { + if (!isStyle(style, prefix)) { + return null; + } + 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..738df4de242c --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/weight.js @@ -0,0 +1,134 @@ +/* + * 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) { + const hasFlag = typeof flag === 'boolean'; + // if flag set, use flag + // otherwise if any character has weight less than SMALLEST_BOLD, + // everything should be bolded + const shouldSetBold = (styles) => + hasFlag ? flag : getWeights(styles).some((w) => w < SMALLEST_BOLD); + + // if flag set, toggle to either 400 or 700, + // otherwise if setting a bold, it should be the boldest current weight, + // though at least DEFAULT_BOLD + const getBoldToSet = (styles) => { + const weight = hasFlag + ? flag + ? DEFAULT_BOLD + : NORMAL_WEIGHT + : Math.max(...[DEFAULT_BOLD].concat(getWeights(styles))); + return weightToStyle(weight); + }; + + 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 index feec68f958ec..3c7c4ad098f8 100644 --- a/assets/src/edit-story/components/richText/htmlManipulation.js +++ b/assets/src/edit-story/components/richText/htmlManipulation.js @@ -22,15 +22,8 @@ import { EditorState } from 'draft-js'; /** * Internal dependencies */ -import { - toggleBold, - toggleItalic, - toggleUnderline, - setFontWeight, - setLetterSpacing, - setColor, - getStateInfo, -} from './styleManipulation'; +import formatters from './formatters'; +import getStateInfo from './getStateInfo'; import customImport from './customImport'; import customExport from './customExport'; import { getSelectionForAll } from './util'; @@ -48,25 +41,25 @@ function updateAndReturnHTML(html, updater, ...args) { return renderedHTML; } -export function toggleBoldInHTML(html, flag) { - return updateAndReturnHTML(html, toggleBold, flag); -} -export function setFontWeightInHTML(html, weight) { - return updateAndReturnHTML(html, setFontWeight, weight); -} -export function toggleItalicInHTML(html, flag) { - return updateAndReturnHTML(html, toggleItalic, flag); -} -export function toggleUnderlineInHTML(html, flag) { - return updateAndReturnHTML(html, toggleUnderline, flag); -} -export function setLetterSpacingInHTML(html, letterSpacing) { - return updateAndReturnHTML(html, setLetterSpacing, letterSpacing); -} -export function setColorInHTML(html, color) { - return updateAndReturnHTML(html, setColor, color); -} +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) { - return getStateInfo(getSelectAllStateFromHTML(html)); + const htmlStateInfo = getStateInfo(getSelectAllStateFromHTML(html)); + return htmlStateInfo; } diff --git a/assets/src/edit-story/components/richText/provider.js b/assets/src/edit-story/components/richText/provider.js index 116f551913d5..3d6ea1d16ac1 100644 --- a/assets/src/edit-story/components/richText/provider.js +++ b/assets/src/edit-story/components/richText/provider.js @@ -32,7 +32,7 @@ import { getFilteredState, getHandleKeyCommandFromState, } from './util'; -import { getStateInfo } from './styleManipulation'; +import getStateInfo from './getStateInfo'; import customImport from './customImport'; import customExport from './customExport'; import useSelectionManipulation from './useSelectionManipulation'; diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js index 92a4d8af2e29..b20c43084eb5 100644 --- a/assets/src/edit-story/components/richText/styleManipulation.js +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -22,27 +22,9 @@ import { Modifier, EditorState } from 'draft-js'; /** * Internal dependencies */ -import { MULTIPLE_VALUE } from '../form'; -import createSolid from '../../utils/createSolid'; -import { - weightToStyle, - styleToWeight, - letterSpacingToStyle, - styleToLetterSpacing, - colorToStyle, - styleToColor, - NONE, - WEIGHT, - ITALIC, - UNDERLINE, - LETTERSPACING, - COLOR, - NORMAL_WEIGHT, - SMALLEST_BOLD, - DEFAULT_BOLD, -} from './customConstants'; +import { NONE } from './customConstants'; -function getPrefixStyleForCharacter(styles, prefix) { +export function getPrefixStyleForCharacter(styles, prefix) { const list = styles.toArray().map((style) => style.style ?? style); if (!list.some((style) => style && style.startsWith(prefix))) { return NONE; @@ -50,7 +32,7 @@ function getPrefixStyleForCharacter(styles, prefix) { return list.find((style) => style.startsWith(prefix)); } -function getPrefixStylesInSelection(editorState, prefix) { +export function getPrefixStylesInSelection(editorState, prefix) { const selection = editorState.getSelection(); const styles = new Set(); if (selection.isCollapsed()) { @@ -97,7 +79,7 @@ function applyContent(editorState, contentState) { return EditorState.push(editorState, contentState, 'change-inline-style'); } -function togglePrefixStyle( +export function togglePrefixStyle( editorState, prefix, shouldSetStyle = null, @@ -138,7 +120,7 @@ function togglePrefixStyle( const matchingStyles = getPrefixStylesInSelection(editorState, prefix); - // First remove all old styles natching 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( @@ -174,146 +156,3 @@ function togglePrefixStyle( return applyContent(editorState, newContentState); } - -// convert a set of weight styles to a set of weights -function getWeights(styles) { - return styles.map((style) => - style === NONE ? NORMAL_WEIGHT : styleToWeight(style) - ); -} - -export function isBold(editorState) { - const styles = getPrefixStylesInSelection(editorState, WEIGHT); - const weights = getWeights(styles); - const allIsBold = weights.every((w) => w >= SMALLEST_BOLD); - return allIsBold; -} - -export function toggleBold(editorState, flag) { - // if flag set, use flag - // otherwise if any character has weight less than SMALLEST_BOLD, - // everything should be bolded - const shouldSetBold = (styles) => - typeof flag === 'boolean' - ? flag - : getWeights(styles).some((w) => w < SMALLEST_BOLD); - - // if flag set, toggle to either 400 or 700, - // otherwise if setting a bold, it should be the boldest current weight, - // though at least DEFAULT_BOLD - const getBoldToSet = (styles) => - typeof flag === 'boolean' - ? weightToStyle(flag ? DEFAULT_BOLD : NORMAL_WEIGHT) - : weightToStyle( - Math.max.apply(null, [DEFAULT_BOLD].concat(getWeights(styles))) - ); - - return togglePrefixStyle(editorState, WEIGHT, shouldSetBold, getBoldToSet); -} - -export function getFontWeight(editorState) { - const styles = getPrefixStylesInSelection(editorState, WEIGHT); - const weights = getWeights(styles); - if (weights.length > 1) { - return MULTIPLE_VALUE; - } - return weights[0]; -} - -export 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); -} - -export function isItalic(editorState) { - const styles = getPrefixStylesInSelection(editorState, ITALIC); - return !styles.includes(NONE); -} - -export function toggleItalic(editorState, flag) { - return togglePrefixStyle( - editorState, - ITALIC, - typeof flag === 'boolean' && (() => flag) - ); -} - -export function isUnderline(editorState) { - const styles = getPrefixStylesInSelection(editorState, UNDERLINE); - return !styles.includes(NONE); -} - -export function toggleUnderline(editorState, flag) { - return togglePrefixStyle( - editorState, - UNDERLINE, - typeof flag === 'boolean' && (() => flag) - ); -} - -export 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); -} - -export 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 - ); -} - -export 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); -} - -export function setColor(editorState, color) { - // we set all colors - one could argue that opaque black - // was default, and wasn't necessary, but it's probably not worth the trouble - const shouldSetStyle = () => true; - - // the style util manages conversion - const getStyleToSet = () => colorToStyle(color); - - return togglePrefixStyle(editorState, COLOR, shouldSetStyle, getStyleToSet); -} - -export function getStateInfo(state) { - return { - fontWeight: getFontWeight(state), - isBold: isBold(state), - isItalic: isItalic(state), - isUnderline: isUnderline(state), - letterSpacing: getLetterSpacing(state), - color: getColor(state), - }; -} diff --git a/assets/src/edit-story/components/richText/useSelectionManipulation.js b/assets/src/edit-story/components/richText/useSelectionManipulation.js index 3a298676436c..60738a6913c9 100644 --- a/assets/src/edit-story/components/richText/useSelectionManipulation.js +++ b/assets/src/edit-story/components/richText/useSelectionManipulation.js @@ -17,20 +17,13 @@ /** * External dependencies */ -import { useCallback, useRef, useEffect } from 'react'; +import { useCallback, useRef, useEffect, useMemo } from 'react'; import { EditorState } from 'draft-js'; /** * Internal dependencies */ -import { - toggleBold, - toggleItalic, - toggleUnderline, - setFontWeight, - setLetterSpacing, - setColor, -} from './styleManipulation'; +import formatters from './formatters'; function useSelectionManipulation(editorState, setEditorState) { const lastKnownState = useRef(null); @@ -57,47 +50,35 @@ function useSelectionManipulation(editorState, setEditorState) { [setEditorState] ); - const toggleBoldInSelection = useCallback( - () => updateWhileUnfocused(toggleBold), - [updateWhileUnfocused] - ); - const setFontWeightInSelection = useCallback( - (weight) => updateWhileUnfocused((s) => setFontWeight(s, weight)), - [updateWhileUnfocused] + const getSetterName = useCallback( + (setterName) => `${setterName}InSelection`, + [] ); - const toggleItalicInSelection = useCallback( - () => updateWhileUnfocused(toggleItalic), - [updateWhileUnfocused] - ); - const toggleUnderlineInSelection = useCallback( - () => updateWhileUnfocused(toggleUnderline), - [updateWhileUnfocused] - ); - const setLetterSpacingInSelection = useCallback( - (ls) => - updateWhileUnfocused( - (s) => setLetterSpacing(s, ls), - /* shouldForceFocus */ false - ), + + const getSetterCallback = useCallback( + (setter, autoFocus) => (...args) => + updateWhileUnfocused((state) => setter(state, ...args), autoFocus), [updateWhileUnfocused] ); - const setColorInSelection = useCallback( - (color) => - updateWhileUnfocused( - (s) => setColor(s, color), - /* shouldForceFocus */ false + + const selectionFormatters = useMemo( + () => + formatters.reduce( + (aggr, { setters, autoFocus }) => ({ + ...aggr, + ...Object.fromEntries( + Object.entries(setters).map(([key, setter]) => [ + getSetterName(key), + getSetterCallback(setter, autoFocus), + ]) + ), + }), + {} ), - [updateWhileUnfocused] + [getSetterName, getSetterCallback] ); - return { - toggleBoldInSelection, - setFontWeightInSelection, - toggleItalicInSelection, - toggleUnderlineInSelection, - setLetterSpacingInSelection, - setColorInSelection, - }; + 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 index 83c5e357bd4d..4bef153066d4 100644 --- a/assets/src/edit-story/components/richText/util.js +++ b/assets/src/edit-story/components/richText/util.js @@ -23,7 +23,9 @@ import { filterEditorState } from 'draftjs-filters'; /** * Internal dependencies */ -import { toggleBold, toggleUnderline, toggleItalic } from './styleManipulation'; +import weightFormatter from './formatters/weight'; +import italicFormatter from './formatters/italic'; +import underlineFormatter from './formatters/underline'; export function getFilteredState(editorState, oldEditorState) { const shouldFilterPaste = @@ -49,13 +51,13 @@ export function getFilteredState(editorState, oldEditorState) { function getStateFromCommmand(command, oldEditorState) { switch (command) { case 'bold': - return toggleBold(oldEditorState); + return weightFormatter.setters.toggleBold(oldEditorState); case 'italic': - return toggleItalic(oldEditorState); + return italicFormatter.setters.toggleItalic(oldEditorState); case 'underline': - return toggleUnderline(oldEditorState); + return underlineFormatter.setters.toggleUnderline(oldEditorState); default: return null; From 41e6954828ccd3e1b21cd9aebc28ee2353f5112a Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 28 Apr 2020 22:21:15 -0400 Subject: [PATCH 19/51] Fixed reducer and added comments --- .../panels/textStyle/useRichTextFormatting.js | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js index da7c6442c09d..0552c741bc68 100644 --- a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js +++ b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js @@ -23,6 +23,7 @@ import { useMemo, useCallback } from 'react'; * Internal dependencies */ import generatePatternStyles from '../../../utils/generatePatternStyles'; +import convertToCSS from '../../../utils/convertToCSS'; import useRichText from '../../richText/useRichText'; import { getHTMLFormatters, @@ -30,18 +31,42 @@ import { } 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) { - const isPattern = typeof a === 'object' && (a.type || a.color); + // 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); if (!isPattern) { return a === b; } - const aStyle = generatePatternStyles(a); - const bStyle = generatePatternStyles(b); - const keys = Object.keys(aStyle); - return keys.every((key) => aStyle[key] === bStyle[key]); + const aStyle = convertToCSS(generatePatternStyles(a)); + const bStyle = convertToCSS(generatePatternStyles(b)); + return aStyle === bStyle; } +/** + * 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) => { From 97a91cf38acf45e89ad225ad185a429b75b007f4 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 28 Apr 2020 22:36:34 -0400 Subject: [PATCH 20/51] Added jsdocs to html manipulation --- .../components/richText/htmlManipulation.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/assets/src/edit-story/components/richText/htmlManipulation.js b/assets/src/edit-story/components/richText/htmlManipulation.js index 3c7c4ad098f8..33d121b49930 100644 --- a/assets/src/edit-story/components/richText/htmlManipulation.js +++ b/assets/src/edit-story/components/richText/htmlManipulation.js @@ -28,6 +28,13 @@ 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); @@ -35,6 +42,17 @@ function getSelectAllStateFromHTML(html) { 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); From c4f4c18781f16ddcccc1cc86eb5d49e423eec123 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 28 Apr 2020 22:47:13 -0400 Subject: [PATCH 21/51] Removed classes from migration and added test for nested tags --- .../test/v0015_inlineTextProperties.js | 105 ++++++++++-------- .../migrations/v0015_inlineTextProperties.js | 31 ++---- 2 files changed, 73 insertions(+), 63 deletions(-) diff --git a/assets/src/edit-story/migration/migrations/test/v0015_inlineTextProperties.js b/assets/src/edit-story/migration/migrations/test/v0015_inlineTextProperties.js index c35f67f14fd9..c8ac948711b3 100644 --- a/assets/src/edit-story/migration/migrations/test/v0015_inlineTextProperties.js +++ b/assets/src/edit-story/migration/migrations/test/v0015_inlineTextProperties.js @@ -19,6 +19,13 @@ */ import inlineTextProperties from '../v0015_inlineTextProperties'; +function convert(content, properties) { + const converted = inlineTextProperties({ + pages: [{ elements: [{ type: 'text', content, ...properties }] }], + }); + return converted.pages[0].elements[0].content; +} + describe('inlineTextProperties', () => { describe('should parse all text elements', () => { it('should ignore non-text elements', () => { @@ -99,13 +106,12 @@ describe('inlineTextProperties', () => { elements: [ { type: 'text', - content: - 'Hello', + content: 'Hello', }, { type: 'text', content: - 'Hello', + 'Hello', }, ], }, @@ -113,13 +119,11 @@ describe('inlineTextProperties', () => { elements: [ { type: 'text', - content: - 'Hello', + content: 'Hello', }, { type: 'text', - content: - 'Hello', + content: 'Hello', }, ], }, @@ -160,13 +164,6 @@ describe('inlineTextProperties', () => { }); }); - function convert(content, properties) { - const converted = inlineTextProperties({ - pages: [{ elements: [{ type: 'text', content, ...properties }] }], - }); - return converted.pages[0].elements[0].content; - } - it('should convert inline elements', () => { const original = ` Lorem @@ -183,26 +180,53 @@ describe('inlineTextProperties', () => { const converted = convert(original); const expected = ` Lorem - ipsum - dolor - sit + ipsum + dolor + sit amet, - + consectetur - adipiscing + 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'; + const expected = 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); @@ -210,8 +234,7 @@ describe('inlineTextProperties', () => { const original = 'Lorem ipsum'; const properties = { fontWeight: 300 }; const converted = convert(original, properties); - const expected = - 'Lorem ipsum'; + const expected = 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); @@ -219,8 +242,7 @@ describe('inlineTextProperties', () => { const original = 'Lorem ipsum'; const properties = { fontWeight: 400 }; const converted = convert(original, properties); - const expected = - 'Lorem ipsum'; + const expected = 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); @@ -228,8 +250,7 @@ describe('inlineTextProperties', () => { const original = 'Lorem ipsum'; const properties = { fontWeight: 300, bold: true }; const converted = convert(original, properties); - const expected = - 'Lorem ipsum'; + const expected = 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); @@ -237,8 +258,7 @@ describe('inlineTextProperties', () => { const original = 'Lorem ipsum'; const properties = { fontWeight: 400, bold: false }; const converted = convert(original, properties); - const expected = - 'Lorem ipsum'; + const expected = 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); }); @@ -248,8 +268,7 @@ describe('inlineTextProperties', () => { const original = 'Lorem ipsum'; const properties = { fontStyle: 'italic' }; const converted = convert(original, properties); - const expected = - 'Lorem ipsum'; + const expected = 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); @@ -257,8 +276,7 @@ describe('inlineTextProperties', () => { const original = 'Lorem ipsum'; const properties = { fontStyle: 'oblique' }; const converted = convert(original, properties); - const expected = - 'Lorem ipsum'; + const expected = 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); }); @@ -269,7 +287,7 @@ describe('inlineTextProperties', () => { const properties = { textDecoration: 'underline' }; const converted = convert(original, properties); const expected = - 'Lorem ipsum'; + 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); @@ -278,7 +296,7 @@ describe('inlineTextProperties', () => { const properties = { textDecoration: 'line-through' }; const converted = convert(original, properties); const expected = - 'Lorem ipsum'; + 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); }); @@ -288,7 +306,7 @@ describe('inlineTextProperties', () => { const properties = { color: { color: { r: 255, g: 0, b: 0, a: 0.5 } } }; const converted = convert(original, properties); const expected = - 'Lorem ipsum'; + 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); @@ -296,8 +314,7 @@ describe('inlineTextProperties', () => { const original = 'Lorem ipsum'; const properties = { letterSpacing: 20 }; const converted = convert(original, properties); - const expected = - 'Lorem ipsum'; + const expected = 'Lorem ipsum'; expect(converted).toStrictEqual(expected); }); @@ -323,16 +340,16 @@ describe('inlineTextProperties', () => { }; const converted = convert(original, properties); const expected = ` - - - + + + Lorem - ipsum + ipsum dolor - sit + sit amet, consectetur - adipiscing + adipiscing elit. diff --git a/assets/src/edit-story/migration/migrations/v0015_inlineTextProperties.js b/assets/src/edit-story/migration/migrations/v0015_inlineTextProperties.js index 4b6805c59a5b..5389e284a953 100644 --- a/assets/src/edit-story/migration/migrations/v0015_inlineTextProperties.js +++ b/assets/src/edit-story/migration/migrations/v0015_inlineTextProperties.js @@ -71,12 +71,12 @@ function convertInlineBold(content, isBold, fontWeight) { // 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, 'weight', fancyBold); + const wrapped = wrapWithSpan(stripped, fancyBold); return wrapped; } const justBold = 'font-weight: 700'; - return replaceTagWithSpan(content, 'strong', 'weight', justBold); + return replaceTagWithSpan(content, 'strong', justBold); } function convertInlineItalic(content, fontStyle) { @@ -87,11 +87,11 @@ function convertInlineItalic(content, fontStyle) { 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, 'italic', italicStyle); + const wrapped = wrapWithSpan(stripped, italicStyle); return wrapped; } - return replaceTagWithSpan(content, 'em', 'italic', italicStyle); + return replaceTagWithSpan(content, 'em', italicStyle); } function convertInlineUnderline(content, textDecoration) { @@ -103,11 +103,11 @@ function convertInlineUnderline(content, textDecoration) { 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, 'underline', underlineStyle); + const wrapped = wrapWithSpan(stripped, underlineStyle); return wrapped; } - return replaceTagWithSpan(content, 'u', 'underline', underlineStyle); + return replaceTagWithSpan(content, 'u', underlineStyle); } function addInlineColor(content, color) { @@ -119,7 +119,7 @@ function addInlineColor(content, color) { const { color: { r, g, b, a = 1 }, } = color; - return wrapWithSpan(content, 'color', `color: rgba(${r}, ${g}, ${b}, ${a})`); + return wrapWithSpan(content, `color: rgba(${r}, ${g}, ${b}, ${a})`); } function addInlineLetterSpacing(content, letterSpacing) { @@ -128,11 +128,7 @@ function addInlineLetterSpacing(content, letterSpacing) { return content; } - return wrapWithSpan( - content, - 'letterspacing', - `letter-spacing: ${letterSpacing / 100}em` - ); + return wrapWithSpan(content, `letter-spacing: ${letterSpacing / 100}em`); } function stripTag(html, tag) { @@ -140,18 +136,15 @@ function stripTag(html, tag) { return html.replace(new RegExp(``, 'gi'), ''); } -function replaceTagWithSpan(html, tag, className, style) { +function replaceTagWithSpan(html, tag, style) { // Again, very naive return html - .replace( - new RegExp(`<${tag}>`, 'gi'), - `` - ) + .replace(new RegExp(`<${tag}>`, 'gi'), ``) .replace(new RegExp(``, 'gi'), ''); } -function wrapWithSpan(html, className, style) { - return `${html}`; +function wrapWithSpan(html, style) { + return `${html}`; } export default inlineTextProperties; From 057f33c4abcf32500dcb03d8f87949d1087919aa Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 28 Apr 2020 22:50:10 -0400 Subject: [PATCH 22/51] Fix tests for text style now that export does not have classes anymore --- .../components/panels/test/textStyle.js | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/assets/src/edit-story/components/panels/test/textStyle.js b/assets/src/edit-story/components/panels/test/textStyle.js index 63991bda5f45..28f4cfc912a0 100644 --- a/assets/src/edit-story/components/panels/test/textStyle.js +++ b/assets/src/edit-story/components/panels/test/textStyle.js @@ -398,8 +398,7 @@ describe('Panels/TextStyle', () => { const resultOfUpdating = updatingFunction({ content: 'Hello world' }); expect(resultOfUpdating).toStrictEqual( { - content: - 'Hello world', + content: 'Hello world', }, true ); @@ -443,8 +442,7 @@ describe('Panels/TextStyle', () => { const resultOfUpdating = updatingFunction({ content: 'Hello world' }); expect(resultOfUpdating).toStrictEqual( { - content: - 'Hello world', + content: 'Hello world', }, true ); @@ -456,8 +454,7 @@ describe('Panels/TextStyle', () => { fireEvent.change(input, { target: { value: '' } }); const updatingFunction = pushUpdate.mock.calls[0][0]; const resultOfUpdating = updatingFunction({ - content: - 'Hello world', + content: 'Hello world', }); expect(resultOfUpdating).toStrictEqual( { @@ -477,8 +474,7 @@ describe('Panels/TextStyle', () => { it('should render a color', () => { const textWithColor = { ...textElement, - content: - 'Hello world', + content: 'Hello world', }; renderTextStyle([textWithColor]); expect(controls['text.color'].value).toStrictEqual( @@ -495,7 +491,7 @@ describe('Panels/TextStyle', () => { }); expect(resultOfUpdating).toStrictEqual( { - content: 'Hello world', + content: 'Hello world', }, true ); @@ -504,13 +500,11 @@ describe('Panels/TextStyle', () => { it('should detect color with multi selection, same values', () => { const textWithColor1 = { ...textElement, - content: - 'Hello world', + content: 'Hello world', }; const textWithColor2 = { ...textElement, - content: - 'Hello world', + content: 'Hello world', }; renderTextStyle([textWithColor1, textWithColor2]); expect(controls['text.color'].value).toStrictEqual( @@ -521,13 +515,11 @@ describe('Panels/TextStyle', () => { it('should set color with multi selection, different values', () => { const textWithColor1 = { ...textElement, - content: - 'Hello world', + content: 'Hello world', }; const textWithColor2 = { ...textElement, - content: - 'Hello world', + content: 'Hello world', }; renderTextStyle([textWithColor1, textWithColor2]); expect(controls['text.color'].value).toStrictEqual(MULTIPLE_VALUE); From 8097c3fb20d71ea90c5f5d2c48ae69c0f82eb43c Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 28 Apr 2020 22:53:13 -0400 Subject: [PATCH 23/51] Moved focus management to edit component --- .../src/edit-story/components/richText/editor.js | 15 +-------------- assets/src/edit-story/elements/text/edit.js | 12 ++++++++---- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/assets/src/edit-story/components/richText/editor.js b/assets/src/edit-story/components/richText/editor.js index 3e5eac96c951..81a11e60fb22 100644 --- a/assets/src/edit-story/components/richText/editor.js +++ b/assets/src/edit-story/components/richText/editor.js @@ -19,13 +19,7 @@ */ import { Editor } from 'draft-js'; import PropTypes from 'prop-types'; -import { - useEffect, - useLayoutEffect, - useRef, - useImperativeHandle, - forwardRef, -} from 'react'; +import { useEffect, useRef, useImperativeHandle, forwardRef } from 'react'; /** * Internal dependencies @@ -60,13 +54,6 @@ function RichTextEditor({ content, onChange }, ref) { } }, [onChange, getContentFromState, editorState]); - // Set focus when initially rendered. - useLayoutEffect(() => { - if (editorRef.current) { - editorRef.current.focus(); - } - }, []); - const hasEditorState = Boolean(editorState); // On unmount, clear state in provider diff --git a/assets/src/edit-story/elements/text/edit.js b/assets/src/edit-story/elements/text/edit.js index 5eaaec305bde..49455d3e3fc2 100644 --- a/assets/src/edit-story/elements/text/edit.js +++ b/assets/src/edit-story/elements/text/edit.js @@ -18,7 +18,7 @@ * External dependencies */ import styled from 'styled-components'; -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useLayoutEffect, useRef, useCallback } from 'react'; /** * Internal dependencies @@ -131,6 +131,13 @@ function TextEdit({ evt.stopPropagation(); }; + // Set focus when initially rendered. + useLayoutEffect(() => { + if (editorRef.current) { + editorRef.current.focus(); + } + }, []); + const updateContent = useCallback(() => { const newHeight = editorHeightRef.current; wrapperRef.current.style.height = ''; @@ -162,9 +169,6 @@ function TextEdit({ y, ]); - // Update content for element on focus out. - //useFocusOut(textBoxRef, updateContent, [updateContent]); - // Update content for element on unmount. useUnmount(updateContent); From 8685d11d46039330b6f3655959914b03f04c4da1 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 28 Apr 2020 23:03:27 -0400 Subject: [PATCH 24/51] Create general `getValidHTML` util --- .../components/richText/customImport.js | 4 ++-- .../edit-story/components/richText/util.js | 14 ------------ .../src/edit-story/elements/text/display.js | 8 +++---- assets/src/edit-story/elements/text/output.js | 8 +++---- assets/src/edit-story/utils/getValidHTML.js | 22 +++++++++++++++++++ 5 files changed, 32 insertions(+), 24 deletions(-) create mode 100644 assets/src/edit-story/utils/getValidHTML.js diff --git a/assets/src/edit-story/components/richText/customImport.js b/assets/src/edit-story/components/richText/customImport.js index 241944d0a103..87dc321c0b24 100644 --- a/assets/src/edit-story/components/richText/customImport.js +++ b/assets/src/edit-story/components/richText/customImport.js @@ -22,8 +22,8 @@ import { stateFromHTML } from 'draft-js-import-html'; /** * Internal dependencies */ +import getValidHTML from '../../utils/getValidHTML'; import formatters from './formatters'; -import { draftMarkupToContent } from './util'; function customInlineFn(element, { Style }) { const styleStrings = formatters @@ -43,7 +43,7 @@ function importHTML(html) { .replace(/\n(?=\n)/g, '\n
') .split('\n') .map((s) => { - return `

${draftMarkupToContent(s)}

`; + return `

${getValidHTML(s)}

`; }) .join(''); return stateFromHTML(htmlWithBreaks, { customInlineFn }); diff --git a/assets/src/edit-story/components/richText/util.js b/assets/src/edit-story/components/richText/util.js index 4bef153066d4..88e0ee7a9b0c 100644 --- a/assets/src/edit-story/components/richText/util.js +++ b/assets/src/edit-story/components/richText/util.js @@ -107,17 +107,3 @@ export function getSelectionForOffset(content, offset) { } return null; } - -let contentBuffer = null; -export const draftMarkupToContent = (content, bold) => { - // @todo This logic is temporary and will change with selecting part + marking bold/italic/underline. - if (bold) { - content = `${content}`; - } - if (!contentBuffer) { - contentBuffer = document.createElement('template'); - } - // Ensures the content is valid HTML. - contentBuffer.innerHTML = content; - return contentBuffer.innerHTML; -}; diff --git a/assets/src/edit-story/elements/text/display.js b/assets/src/edit-story/elements/text/display.js index 9c86509a98d5..dbbebc46da8d 100644 --- a/assets/src/edit-story/elements/text/display.js +++ b/assets/src/edit-story/elements/text/display.js @@ -34,7 +34,7 @@ import { import StoryPropTypes from '../../types'; import { BACKGROUND_TEXT_MODE } from '../../constants'; import { useTransformHandler } from '../../components/transform'; -import { draftMarkupToContent } from '../../components/richText/util'; +import getValidHTML from '../../utils/getValidHTML'; import { getHighlightLineheight, generateParagraphTextStyle } from './util'; const HighlightWrapperElement = styled.div` @@ -132,7 +132,7 @@ function TextDisplay({ @@ -142,7 +142,7 @@ function TextDisplay({ @@ -155,7 +155,7 @@ function TextDisplay({ diff --git a/assets/src/edit-story/elements/text/output.js b/assets/src/edit-story/elements/text/output.js index 90f2ced33f93..ef2ba2636429 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -24,9 +24,9 @@ import PropTypes from 'prop-types'; */ import StoryPropTypes from '../../types'; import generatePatternStyles from '../../utils/generatePatternStyles'; +import getValidHTML from '../../utils/getValidHTML'; import { dataToEditorX, dataToEditorY } from '../../units'; import { BACKGROUND_TEXT_MODE } from '../../constants'; -import { draftMarkupToContent } from '../../components/richText/util'; import { generateParagraphTextStyle, getHighlightLineheight } from './util'; /** @@ -134,7 +134,7 @@ export function TextOutputWithUnits({ @@ -144,7 +144,7 @@ export function TextOutputWithUnits({ @@ -157,7 +157,7 @@ export function TextOutputWithUnits({

); } 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; +} From 3a809040b97848a833936299b566718e0c5663db Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Wed, 29 Apr 2020 14:54:50 -0400 Subject: [PATCH 25/51] Added deps to `useImperativeHandle` --- assets/src/edit-story/components/richText/editor.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/assets/src/edit-story/components/richText/editor.js b/assets/src/edit-story/components/richText/editor.js index 81a11e60fb22..239809f0815d 100644 --- a/assets/src/edit-story/components/richText/editor.js +++ b/assets/src/edit-story/components/richText/editor.js @@ -60,10 +60,14 @@ function RichTextEditor({ content, onChange }, ref) { useUnmount(clearState); // Allow parent to focus editor and access main node - useImperativeHandle(ref, () => ({ - focus: () => editorRef.current?.focus?.(), - getNode: () => editorRef.current?.editorContainer, - })); + useImperativeHandle( + ref, + () => ({ + focus: () => editorRef.current?.focus?.(), + getNode: () => editorRef.current?.editorContainer, + }), + [] + ); if (!hasEditorState) { return null; From e2d2f236186358c89c0af7ded5fa618ae365ce6e Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Wed, 29 Apr 2020 15:02:28 -0400 Subject: [PATCH 26/51] Fixed `parseFloat` args --- .../edit-story/components/richText/formatters/letterSpacing.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/src/edit-story/components/richText/formatters/letterSpacing.js b/assets/src/edit-story/components/richText/formatters/letterSpacing.js index ffac2eacd860..5176d9b36ec1 100644 --- a/assets/src/edit-story/components/richText/formatters/letterSpacing.js +++ b/assets/src/edit-story/components/richText/formatters/letterSpacing.js @@ -36,7 +36,7 @@ function styleToLetterSpacing(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, 10); + const letterSpacing = parseFloat(element.style.letterSpacing); const hasLetterSpacing = letterSpacing && !isNaN(letterSpacing); if (isSpan && hasLetterSpacing) { return letterSpacingToStyle(letterSpacing * 100); From a0b3259fd2a64bd37821198cfa798b6b32559367 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Wed, 29 Apr 2020 19:00:33 -0400 Subject: [PATCH 27/51] Added faux-selection style and fixed various bugs --- .../richText/customInlineDisplay.js | 10 +- .../components/richText/fauxSelection.js | 120 ++++++++++++++++++ .../components/richText/formatters/color.js | 2 +- .../components/richText/formatters/italic.js | 4 +- .../richText/formatters/underline.js | 4 +- .../components/richText/provider.js | 3 + .../richText/useSelectionManipulation.js | 2 +- 7 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 assets/src/edit-story/components/richText/fauxSelection.js diff --git a/assets/src/edit-story/components/richText/customInlineDisplay.js b/assets/src/edit-story/components/richText/customInlineDisplay.js index 1860831f21e4..8e75fb6297a4 100644 --- a/assets/src/edit-story/components/richText/customInlineDisplay.js +++ b/assets/src/edit-story/components/richText/customInlineDisplay.js @@ -18,10 +18,16 @@ * Internal dependencies */ import formatters from './formatters'; +import { fauxStylesToCSS } from './fauxSelection'; function customInlineDisplay(styles) { - return formatters.reduce( - (css, { stylesToCSS }) => ({ ...css, ...stylesToCSS(styles) }), + const stylesToCSSConverters = [ + ...formatters.map(({ stylesToCSS }) => stylesToCSS), + fauxStylesToCSS, + ]; + + return stylesToCSSConverters.reduce( + (css, stylesToCSS) => ({ ...css, ...stylesToCSS(styles) }), {} ); } 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..9c0bb8a66cec --- /dev/null +++ b/assets/src/edit-story/components/richText/fauxSelection.js @@ -0,0 +1,120 @@ +/* + * 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) => { + // 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; + }); + + // 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 index 382c8f17a9dc..0f12cacda2b9 100644 --- a/assets/src/edit-story/components/richText/formatters/color.js +++ b/assets/src/edit-story/components/richText/formatters/color.js @@ -87,7 +87,7 @@ function setColor(editorState, color) { const formatter = { elementToStyle, stylesToCSS, - autoFocus: true, + autoFocus: false, getters: { color: getColor, }, diff --git a/assets/src/edit-story/components/richText/formatters/italic.js b/assets/src/edit-story/components/richText/formatters/italic.js index 4d56bd52f184..99d770307213 100644 --- a/assets/src/edit-story/components/richText/formatters/italic.js +++ b/assets/src/edit-story/components/richText/formatters/italic.js @@ -34,8 +34,8 @@ function elementToStyle(element) { } function stylesToCSS(styles) { - const style = styles.find((someStyle) => someStyle === ITALIC); - if (!style) { + const hasItalic = styles.includes(ITALIC); + if (!hasItalic) { return null; } return { fontStyle: 'italic' }; diff --git a/assets/src/edit-story/components/richText/formatters/underline.js b/assets/src/edit-story/components/richText/formatters/underline.js index 12bcb233c404..193e70b1c37c 100644 --- a/assets/src/edit-story/components/richText/formatters/underline.js +++ b/assets/src/edit-story/components/richText/formatters/underline.js @@ -34,8 +34,8 @@ function elementToStyle(element) { } function stylesToCSS(styles) { - const style = styles.find((someStyle) => someStyle === UNDERLINE); - if (!style) { + const hasUnderline = styles.includes(UNDERLINE); + if (!hasUnderline) { return null; } return { textDecoration: 'underline' }; diff --git a/assets/src/edit-story/components/richText/provider.js b/assets/src/edit-story/components/richText/provider.js index 3d6ea1d16ac1..ca23104fccea 100644 --- a/assets/src/edit-story/components/richText/provider.js +++ b/assets/src/edit-story/components/richText/provider.js @@ -33,6 +33,7 @@ import { getHandleKeyCommandFromState, } from './util'; import getStateInfo from './getStateInfo'; +import { useFauxSelection } from './fauxSelection'; import customImport from './customImport'; import customExport from './customExport'; import useSelectionManipulation from './useSelectionManipulation'; @@ -74,6 +75,8 @@ function RichTextProvider({ children }) { [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. diff --git a/assets/src/edit-story/components/richText/useSelectionManipulation.js b/assets/src/edit-story/components/richText/useSelectionManipulation.js index 60738a6913c9..9f8c933544e1 100644 --- a/assets/src/edit-story/components/richText/useSelectionManipulation.js +++ b/assets/src/edit-story/components/richText/useSelectionManipulation.js @@ -42,7 +42,7 @@ function useSelectionManipulation(editorState, setEditorState) { const oldState = lastKnownState.current; const selection = lastKnownSelection.current; const workingState = shouldForceFocus - ? EditorState.forceSelection(oldState, selection) + ? EditorState.acceptSelection(oldState, selection) : oldState; const newState = updater(workingState); setEditorState(newState); From 59134bcc97559084ac3434d0095382969486b4b0 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Wed, 29 Apr 2020 19:02:48 -0400 Subject: [PATCH 28/51] Fixed typo --- assets/src/edit-story/components/richText/fauxSelection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/src/edit-story/components/richText/fauxSelection.js b/assets/src/edit-story/components/richText/fauxSelection.js index 9c0bb8a66cec..0fa41960cec6 100644 --- a/assets/src/edit-story/components/richText/fauxSelection.js +++ b/assets/src/edit-story/components/richText/fauxSelection.js @@ -116,5 +116,5 @@ export function fauxStylesToCSS(styles) { if (!hasFauxSelection) { return null; } - return { backgroundColor: 'rgba(169, 169, 169, 0.7' }; + return { backgroundColor: 'rgba(169, 169, 169, 0.7)' }; } From ab85df5afef8bd871dc41118fcac66347bc6ecbe Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Wed, 29 Apr 2020 19:30:00 -0400 Subject: [PATCH 29/51] For now, strip formatting when pasting --- assets/src/edit-story/components/richText/editor.js | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/src/edit-story/components/richText/editor.js b/assets/src/edit-story/components/richText/editor.js index 239809f0815d..31a02a8e1af9 100644 --- a/assets/src/edit-story/components/richText/editor.js +++ b/assets/src/edit-story/components/richText/editor.js @@ -83,6 +83,7 @@ function RichTextEditor({ content, onChange }, ref) { editorState={editorState} handleKeyCommand={handleKeyCommand} customStyleFn={customInlineDisplay} + stripPastedStyles /> ); } From 2f09bc67120848c21dc79690f22f34a2fce32db8 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Wed, 29 Apr 2020 19:41:35 -0400 Subject: [PATCH 30/51] Remove weird npm scripts --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index e47b2a3ff299..bc1742d04b7c 100644 --- a/package.json +++ b/package.json @@ -161,9 +161,6 @@ "lint:js:report": "eslint --output-file build/lint-js-report.json --format json .", "lint:php": "vendor/bin/phpcs", "lint:php:fix": "vendor/bin/phpcbf", - "lint:md": "npm-run-all --parallel lint:md:*", - "lint:md:js": "eslint **/*.md", - "lint:md:docs": "markdownlint **/*.md", "lint:md": "markdownlint .", "postinstall": "npx patch-package", "storybook": "start-storybook --quiet", From 7e2ed36ef4d6bc44e137fd1b7333650e59907962 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Wed, 29 Apr 2020 19:58:04 -0400 Subject: [PATCH 31/51] Using a less nice matcher --- .../edit-story/utils/test/useLiveRegion.js | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 }); }); From 102c5af00f3413ba0fd8b62bd4d26b4fefd799b3 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 30 Apr 2020 00:02:08 -0400 Subject: [PATCH 32/51] Added tests for formatters --- .../components/richText/formatters/color.js | 7 +- .../components/richText/formatters/italic.js | 9 +- .../richText/formatters/test/_utils.js | 26 ++ .../richText/formatters/test/color.js | 160 +++++++++++ .../richText/formatters/test/italic.js | 163 +++++++++++ .../richText/formatters/test/letterSpacing.js | 183 +++++++++++++ .../richText/formatters/test/underline.js | 163 +++++++++++ .../richText/formatters/test/weight.js | 259 ++++++++++++++++++ .../richText/formatters/underline.js | 9 +- .../components/richText/formatters/util.js | 3 - .../components/richText/formatters/weight.js | 31 ++- .../richText/test/styleManipulation.js | 34 +++ 12 files changed, 1018 insertions(+), 29 deletions(-) create mode 100644 assets/src/edit-story/components/richText/formatters/test/_utils.js create mode 100644 assets/src/edit-story/components/richText/formatters/test/color.js create mode 100644 assets/src/edit-story/components/richText/formatters/test/italic.js create mode 100644 assets/src/edit-story/components/richText/formatters/test/letterSpacing.js create mode 100644 assets/src/edit-story/components/richText/formatters/test/underline.js create mode 100644 assets/src/edit-story/components/richText/formatters/test/weight.js create mode 100644 assets/src/edit-story/components/richText/test/styleManipulation.js diff --git a/assets/src/edit-story/components/richText/formatters/color.js b/assets/src/edit-story/components/richText/formatters/color.js index 0f12cacda2b9..eeaca8677bb0 100644 --- a/assets/src/edit-story/components/richText/formatters/color.js +++ b/assets/src/edit-story/components/richText/formatters/color.js @@ -54,10 +54,13 @@ function stylesToCSS(styles) { if (!style) { return null; } - const color = styleToColor(style); - if (!color) { + let color; + try { + color = styleToColor(style); + } catch (e) { return null; } + return generatePatternStyles(color, 'color'); } diff --git a/assets/src/edit-story/components/richText/formatters/italic.js b/assets/src/edit-story/components/richText/formatters/italic.js index 99d770307213..e829bb1aff42 100644 --- a/assets/src/edit-story/components/richText/formatters/italic.js +++ b/assets/src/edit-story/components/richText/formatters/italic.js @@ -47,11 +47,10 @@ function isItalic(editorState) { } function toggleItalic(editorState, flag) { - return togglePrefixStyle( - editorState, - ITALIC, - typeof flag === 'boolean' && (() => flag) - ); + if (typeof flag === 'boolean') { + return togglePrefixStyle(editorState, ITALIC, () => flag); + } + return togglePrefixStyle(editorState, ITALIC); } const 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..e44a63e74d8f --- /dev/null +++ b/assets/src/edit-story/components/richText/formatters/test/color.js @@ -0,0 +1,160 @@ +/* + * 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; + + 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 with correct parameters', () => { + 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`); + }); + }); +}); 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 index 193e70b1c37c..07102776e120 100644 --- a/assets/src/edit-story/components/richText/formatters/underline.js +++ b/assets/src/edit-story/components/richText/formatters/underline.js @@ -47,11 +47,10 @@ function isUnderline(editorState) { } function toggleUnderline(editorState, flag) { - return togglePrefixStyle( - editorState, - UNDERLINE, - typeof flag === 'boolean' && (() => flag) - ); + if (typeof flag === 'boolean') { + return togglePrefixStyle(editorState, UNDERLINE, () => flag); + } + return togglePrefixStyle(editorState, UNDERLINE); } const formatter = { diff --git a/assets/src/edit-story/components/richText/formatters/util.js b/assets/src/edit-story/components/richText/formatters/util.js index db85f65f421e..90d0d0245d6a 100644 --- a/assets/src/edit-story/components/richText/formatters/util.js +++ b/assets/src/edit-story/components/richText/formatters/util.js @@ -27,9 +27,6 @@ export const numericToStyle = (prefix, num) => `${prefix}-${num < 0 ? 'N' : ''}${Math.abs(num)}`; export const styleToNumeric = (prefix, style) => { - if (!isStyle(style, prefix)) { - return null; - } const raw = getVariable(style, prefix); // Negative numbers are prefixed with an N: if (raw.charAt(0) === 'N') { diff --git a/assets/src/edit-story/components/richText/formatters/weight.js b/assets/src/edit-story/components/richText/formatters/weight.js index 738df4de242c..3b14d6fff604 100644 --- a/assets/src/edit-story/components/richText/formatters/weight.js +++ b/assets/src/edit-story/components/richText/formatters/weight.js @@ -75,24 +75,27 @@ function isBold(editorState) { } function toggleBold(editorState, flag) { - const hasFlag = typeof flag === 'boolean'; - // if flag set, use flag - // otherwise if any character has weight less than SMALLEST_BOLD, + 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) => - hasFlag ? flag : getWeights(styles).some((w) => w < SMALLEST_BOLD); + getWeights(styles).some((w) => w < SMALLEST_BOLD); - // if flag set, toggle to either 400 or 700, - // otherwise if setting a bold, it should be the boldest current weight, + // if setting a bold, it should be the boldest current weight, // though at least DEFAULT_BOLD - const getBoldToSet = (styles) => { - const weight = hasFlag - ? flag - ? DEFAULT_BOLD - : NORMAL_WEIGHT - : Math.max(...[DEFAULT_BOLD].concat(getWeights(styles))); - return weightToStyle(weight); - }; + const getBoldToSet = (styles) => + weightToStyle(Math.max(...[DEFAULT_BOLD].concat(getWeights(styles)))); return togglePrefixStyle(editorState, WEIGHT, shouldSetBold, getBoldToSet); } 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..08e4e0844cd7 --- /dev/null +++ b/assets/src/edit-story/components/richText/test/styleManipulation.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 { + getPrefixStyleForCharacter, + getPrefixStylesInSelection, + togglePrefixStyle, +} from '../styleManipulation';*/ + +describe('getPrefixStyleForCharacter', () => { + it('should be true', () => { + expect(true).toBe(true); + }); +}); + +describe('getPrefixStylesInSelection', () => {}); + +describe('togglePrefixStyle', () => {}); From 44d7a7744005c673948d01dc2bc80439d1b4c277 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 30 Apr 2020 15:53:23 -0400 Subject: [PATCH 33/51] Set default font color to black - all other colors are fixed inline --- assets/src/edit-story/elements/shared/index.js | 1 + assets/src/edit-story/elements/text/output.js | 1 + 2 files changed, 2 insertions(+) diff --git a/assets/src/edit-story/elements/shared/index.js b/assets/src/edit-story/elements/shared/index.js index e5823481c26b..9d98990d286c 100644 --- a/assets/src/edit-story/elements/shared/index.js +++ b/assets/src/edit-story/elements/shared/index.js @@ -60,6 +60,7 @@ export const elementWithFont = css` font-style: ${({ fontStyle }) => fontStyle}; font-size: ${({ fontSize }) => fontSize}px; font-weight: ${({ fontWeight }) => fontWeight}; + color: #000000; `; /** diff --git a/assets/src/edit-story/elements/text/output.js b/assets/src/edit-story/elements/text/output.js index ef2ba2636429..5ed7a3fc853f 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -68,6 +68,7 @@ export function TextOutputWithUnits({ dataToFontSizeY ), ...bgColor, + color: '#000000', padding: `${paddingStyles.vertical} ${paddingStyles.horizontal}`, }; From b5a0c76b6208e9dd8521243fda9c1083d7732262 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 30 Apr 2020 23:02:52 -0400 Subject: [PATCH 34/51] Added unit tests for style manipulation These are slightly integrated with draft-js' editor state, as it was too complex too mock and this is actually a better test IMHO. --- .../components/richText/draftUtils.js | 52 +++ .../edit-story/components/richText/editor.js | 3 + .../components/richText/styleManipulation.js | 48 +-- .../richText/test/styleManipulation.js | 361 +++++++++++++++++- 4 files changed, 422 insertions(+), 42 deletions(-) create mode 100644 assets/src/edit-story/components/richText/draftUtils.js 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..c9b2515af3b6 --- /dev/null +++ b/assets/src/edit-story/components/richText/draftUtils.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. + */ + +/* 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 */ +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 index 31a02a8e1af9..47385b1cf830 100644 --- a/assets/src/edit-story/components/richText/editor.js +++ b/assets/src/edit-story/components/richText/editor.js @@ -48,6 +48,9 @@ function RichTextEditor({ content, onChange }, ref) { // Push updates to parent when state changes useEffect(() => { + if (!editorState) { + return; + } const newContent = getContentFromState(editorState); if (newContent) { onChange(newContent); diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js index b20c43084eb5..bb8487b7a22f 100644 --- a/assets/src/edit-story/components/richText/styleManipulation.js +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -23,54 +23,30 @@ import { Modifier, EditorState } from 'draft-js'; * Internal dependencies */ import { NONE } from './customConstants'; +import { getAllStyleSetsInSelection } from './draftUtils'; export function getPrefixStyleForCharacter(styles, prefix) { const list = styles.toArray().map((style) => style.style ?? style); - if (!list.some((style) => style && style.startsWith(prefix))) { + const matcher = (style) => style && style.startsWith(prefix); + if (!list.some(matcher)) { return NONE; } - return list.find((style) => style.startsWith(prefix)); + return list.find(matcher); } export function getPrefixStylesInSelection(editorState, prefix) { const selection = editorState.getSelection(); - const styles = new Set(); if (selection.isCollapsed()) { - styles.add( - getPrefixStyleForCharacter(editorState.getCurrentInlineStyle(), prefix) - ); - return [...styles]; + return [ + getPrefixStyleForCharacter(editorState.getCurrentInlineStyle(), prefix), + ]; } - const contentState = editorState.getCurrentContent(); - 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++ - ) { - styles.add( - getPrefixStyleForCharacter( - characterList.get(offsetIndex).getStyle(), - prefix - ) - ); - } - if (!hasMoreRounds) { - break; - } - key = contentState.getKeyAfter(key); - startOffset = 0; - } + const styleSets = getAllStyleSetsInSelection(editorState); + const styles = new Set(); + styleSets.forEach((styleSet) => + styles.add(getPrefixStyleForCharacter(styleSet, prefix)) + ); return [...styles]; } diff --git a/assets/src/edit-story/components/richText/test/styleManipulation.js b/assets/src/edit-story/components/richText/test/styleManipulation.js index 08e4e0844cd7..3826be93736e 100644 --- a/assets/src/edit-story/components/richText/test/styleManipulation.js +++ b/assets/src/edit-story/components/richText/test/styleManipulation.js @@ -14,21 +14,370 @@ * limitations under the License. */ +/** + * External dependencies + */ +import { convertFromRaw, EditorState, SelectionState } from 'draft-js'; + /** * Internal dependencies */ -/*import { +import { getPrefixStyleForCharacter, getPrefixStylesInSelection, togglePrefixStyle, -} from '../styleManipulation';*/ +} 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', () => { - it('should be true', () => { - expect(true).toBe(true); + 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', () => {}); +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); -describe('togglePrefixStyle', () => {}); + // 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); +} From 85edb587d6365779c365d1a47b13387ff2775df9 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 30 Apr 2020 23:26:34 -0400 Subject: [PATCH 35/51] Fixed color input (allow multiple value string) --- assets/src/edit-story/components/form/color/color.js | 2 +- .../src/edit-story/components/form/color/opacityPreview.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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/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, }; From e8f626646dd1ffda5dae707b22725e0f1ef9f198 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Thu, 30 Apr 2020 23:31:34 -0400 Subject: [PATCH 36/51] Fixed color picker to default to solid black (rather than transparent black) --- assets/src/edit-story/components/colorPicker/colorPicker.js | 4 ++++ 1 file changed, 4 insertions(+) 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]); From f1dcb73f7f2f790ec7c122df1c092d30e71b7f6d Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 1 May 2020 00:02:18 -0400 Subject: [PATCH 37/51] Fixed null value for input --- assets/src/edit-story/components/form/color/colorPreview.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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({ From 03aa64bceb1c7524a8e56e58235400c67f2bdc88 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 1 May 2020 00:02:53 -0400 Subject: [PATCH 38/51] Fix color preset to work for new ITF formatting --- .../components/panels/stylePreset/panel.js | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/assets/src/edit-story/components/panels/stylePreset/panel.js b/assets/src/edit-story/components/panels/stylePreset/panel.js index 844566df9fc5..4e9de7a453b4 100644 --- a/assets/src/edit-story/components/panels/stylePreset/panel.js +++ b/assets/src/edit-story/components/panels/stylePreset/panel.js @@ -24,7 +24,8 @@ import { useCallback, useEffect, useState } from 'react'; */ import { useStory } from '../../../app/story'; import stripHTML from '../../../utils/stripHTML'; -import { Panel } from './../panel'; +import { Panel } from '../panel'; +import useRichTextFormatting from '../textStyle/useRichTextFormatting'; import { getShapePresets, getTextPresets } from './utils'; import PresetsHeader from './header'; import Presets from './presets'; @@ -119,16 +120,34 @@ function StylePresetPanel() { ] ); + const miniPushUpdate = useCallback( + (updater) => { + updateElementsById({ + elementIds: selectedElementIds, + properties: updater, + }); + }, + [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) { + updateElementsById({ + elementIds: selectedElementIds, + properties: { ...preset }, + }); + } else { + handleSetColor(preset); + } } else { updateElementsById({ elementIds: selectedElementIds, @@ -136,7 +155,7 @@ function StylePresetPanel() { }); } }, - [isText, selectedElementIds, updateElementsById] + [isText, handleSetColor, selectedElementIds, updateElementsById] ); const colorPresets = isText ? textColors : fillColors; From 1b4f9defd454650dbd43f567703ba9177b88f535 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 1 May 2020 00:40:56 -0400 Subject: [PATCH 39/51] Added full support for text color and text style presets --- .../components/panels/stylePreset/panel.js | 17 +++-- .../panels/stylePreset/test/panel.js | 24 ++++--- .../panels/stylePreset/test/utils.js | 69 +++++++++++++++++-- .../components/panels/stylePreset/utils.js | 25 +++++-- 4 files changed, 108 insertions(+), 27 deletions(-) diff --git a/assets/src/edit-story/components/panels/stylePreset/panel.js b/assets/src/edit-story/components/panels/stylePreset/panel.js index 4e9de7a453b4..a2a5ce6d9c85 100644 --- a/assets/src/edit-story/components/panels/stylePreset/panel.js +++ b/assets/src/edit-story/components/panels/stylePreset/panel.js @@ -17,13 +17,14 @@ /** * 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 { getShapePresets, getTextPresets } from './utils'; @@ -120,12 +121,17 @@ function StylePresetPanel() { ] ); + const extraPropsToAdd = useRef(null); const miniPushUpdate = useCallback( (updater) => { updateElementsById({ elementIds: selectedElementIds, - properties: updater, + properties: (oldProps) => ({ + ...updater(oldProps), + ...extraPropsToAdd.current, + }), }); + extraPropsToAdd.current = null; }, [selectedElementIds, updateElementsById] ); @@ -141,11 +147,10 @@ function StylePresetPanel() { // Only style presets have background text mode set. const isStylePreset = preset.backgroundTextMode !== undefined; if (isStylePreset) { - updateElementsById({ - elementIds: selectedElementIds, - properties: { ...preset }, - }); + extraPropsToAdd.current = objectWithout(preset, ['color']); + handleSetColor(preset.color); } else { + extraPropsToAdd.current = null; handleSetColor(preset); } } else { 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..492846fad777 100644 --- a/assets/src/edit-story/components/panels/stylePreset/test/panel.js +++ b/assets/src/edit-story/components/panels/stylePreset/test/panel.js @@ -27,7 +27,6 @@ import StoryContext from '../../../../app/story/context'; 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 +90,6 @@ describe('Panels/StylePreset', () => { }, }; const STYLE_PRESET = { - color: TEST_COLOR_2, backgroundTextMode: BACKGROUND_TEXT_MODE.FILL, backgroundColor: TEST_COLOR, }; @@ -155,9 +153,7 @@ describe('Panels/StylePreset', () => { { id: '1', type: 'text', - color: [TEST_COLOR_2], - backgroundTextMode: BACKGROUND_TEXT_MODE.NONE, - font: TEXT_ELEMENT_DEFAULT_FONT, + content: 'Content', }, ], }; @@ -193,6 +189,7 @@ describe('Panels/StylePreset', () => { { id: '1', type: 'text', + content: 'Content', ...STYLE_PRESET, }, ], @@ -204,7 +201,7 @@ describe('Panels/StylePreset', () => { getTextPresets.mockImplementation(() => { return { - textStyles: [STYLE_PRESET], + textStyles: [{ color: TEST_COLOR_2, ...STYLE_PRESET }], }; }); @@ -217,7 +214,7 @@ describe('Panels/StylePreset', () => { stylePresets: { textColors: [], fillColors: [], - textStyles: [STYLE_PRESET], + textStyles: [{ color: TEST_COLOR_2, ...STYLE_PRESET }], }, }, }); @@ -365,10 +362,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..0ceb8355dce5 100644 --- a/assets/src/edit-story/components/panels/stylePreset/utils.js +++ b/assets/src/edit-story/components/panels/stylePreset/utils.js @@ -20,8 +20,11 @@ import generatePatternStyles from '../../../utils/generatePatternStyles'; import { generateFontFamily } from '../../../elements/text/util'; import { BACKGROUND_TEXT_MODE } from '../../../constants'; +import createSolid from '../../../utils/createSolid'; import convertToCSS from '../../../utils/convertToCSS'; 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) { @@ -74,17 +77,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)), }; From f0dd127765345d1ec2514b705e519e44cc7f7151 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 1 May 2020 01:17:30 -0400 Subject: [PATCH 40/51] Inlined font weights for text panel presets --- .../components/library/panes/text/textPane.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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', From d411b08de7ed648cf5f4184a262b8e955ed7a9a9 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Fri, 1 May 2020 11:23:52 -0400 Subject: [PATCH 41/51] Fix focus out to check click target in capture phase --- assets/src/edit-story/utils/useFocusOut.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 || []); From 16dbf27f078d44aa56f08f19f228bba3f117e26d Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Mon, 4 May 2020 14:30:26 -0400 Subject: [PATCH 42/51] Re-add default props to test --- .../edit-story/components/panels/stylePreset/test/panel.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 492846fad777..3f05c7c7d7fa 100644 --- a/assets/src/edit-story/components/panels/stylePreset/test/panel.js +++ b/assets/src/edit-story/components/panels/stylePreset/test/panel.js @@ -27,6 +27,8 @@ import StoryContext from '../../../../app/story/context'; 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) { @@ -147,13 +149,15 @@ 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', content: 'Content', + backgroundTextMode: BACKGROUND_TEXT_MODE.NONE, + font: TEXT_ELEMENT_DEFAULT_FONT, }, ], }; From e295b37f03134be15061dbf2e68ea7f71035ba28 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Mon, 4 May 2020 14:40:57 -0400 Subject: [PATCH 43/51] Added jsdoc for draftUtils --- .../components/richText/draftUtils.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/assets/src/edit-story/components/richText/draftUtils.js b/assets/src/edit-story/components/richText/draftUtils.js index c9b2515af3b6..356c9a05b85d 100644 --- a/assets/src/edit-story/components/richText/draftUtils.js +++ b/assets/src/edit-story/components/richText/draftUtils.js @@ -20,6 +20,29 @@ * 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(); From 870f2bdfaaf8427aa0862224b4dd99172c6af6e3 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Mon, 4 May 2020 14:42:38 -0400 Subject: [PATCH 44/51] Fix children proptype --- assets/src/edit-story/components/richText/provider.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/assets/src/edit-story/components/richText/provider.js b/assets/src/edit-story/components/richText/provider.js index ca23104fccea..ab5575d989d4 100644 --- a/assets/src/edit-story/components/richText/provider.js +++ b/assets/src/edit-story/components/richText/provider.js @@ -129,10 +129,7 @@ function RichTextProvider({ children }) { } RichTextProvider.propTypes = { - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node, - ]).isRequired, + children: PropTypes.node.isRequired, }; export default RichTextProvider; From e03baf3e3593a5b9267e1b5264dab5d8a1b09227 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Mon, 4 May 2020 15:04:26 -0400 Subject: [PATCH 45/51] Add better error handlign to faux selection removal --- .../components/richText/fauxSelection.js | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/assets/src/edit-story/components/richText/fauxSelection.js b/assets/src/edit-story/components/richText/fauxSelection.js index 0fa41960cec6..f749416310c4 100644 --- a/assets/src/edit-story/components/richText/fauxSelection.js +++ b/assets/src/edit-story/components/richText/fauxSelection.js @@ -81,28 +81,34 @@ export function useFauxSelection(editorState, setEditorState) { if (isFocused && hasSelectionChanged && hasFauxSelection) { setEditorState((oldEditorState) => { - // Get new content with style removed from old selection - const contentWithoutFaux = Modifier.removeInlineStyle( - oldEditorState.getCurrentContent(), - fauxSelection, - FAUX_SELECTION - ); + 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' - ); + // Push to get a new state + const stateWithoutFaux = EditorState.push( + oldEditorState, + contentWithoutFaux, + 'change-inline-style' + ); - // Force selection - const selectedState = EditorState.forceSelection( - stateWithoutFaux, - oldEditorState.getSelection() - ); + // Force selection + const selectedState = EditorState.forceSelection( + stateWithoutFaux, + oldEditorState.getSelection() + ); - // Save that as the next editor state - return selectedState; + // 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 From 0081a79c9cf9a0c7a02b59038e63d741f396aaee Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Mon, 4 May 2020 15:26:32 -0400 Subject: [PATCH 46/51] Added jsdocs for complex style manipulation functions --- .../components/richText/styleManipulation.js | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/assets/src/edit-story/components/richText/styleManipulation.js b/assets/src/edit-story/components/richText/styleManipulation.js index bb8487b7a22f..8a142bb90c51 100644 --- a/assets/src/edit-story/components/richText/styleManipulation.js +++ b/assets/src/edit-story/components/richText/styleManipulation.js @@ -25,6 +25,14 @@ import { Modifier, EditorState } from 'draft-js'; 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); @@ -34,6 +42,54 @@ export function getPrefixStyleForCharacter(styles, prefix) { 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()) { @@ -55,6 +111,21 @@ 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, From c5adfecfe732f70eca9e0874dd5aabfcd3ccdfa3 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Mon, 4 May 2020 17:22:39 -0400 Subject: [PATCH 47/51] Added function to strip relevant inline styling --- .../src/edit-story/elements/text/display.js | 11 +++++-- assets/src/edit-story/elements/text/output.js | 11 +++++-- assets/src/edit-story/utils/getValidHTML.js | 7 +++-- .../src/edit-story/utils/removeInlineStyle.js | 30 +++++++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 assets/src/edit-story/utils/removeInlineStyle.js diff --git a/assets/src/edit-story/elements/text/display.js b/assets/src/edit-story/elements/text/display.js index e11bea90cfa3..ae961463220f 100644 --- a/assets/src/edit-story/elements/text/display.js +++ b/assets/src/edit-story/elements/text/display.js @@ -34,6 +34,7 @@ import { import StoryPropTypes from '../../types'; import { BACKGROUND_TEXT_MODE } from '../../constants'; import { useTransformHandler } from '../../components/transform'; +import removeInlineStyle from '../../utils/removeInlineStyle'; import getValidHTML from '../../utils/getValidHTML'; import { getHighlightLineheight, generateParagraphTextStyle } from './util'; @@ -73,7 +74,7 @@ const Span = styled.span` `; const BackgroundSpan = styled(Span)` - color: transparent !important; + color: transparent; `; const ForegroundSpan = styled(Span)` @@ -127,6 +128,10 @@ function TextDisplay({ }); if (backgroundTextMode === BACKGROUND_TEXT_MODE.HIGHLIGHT) { + const foregroundContent = getValidHTML(content); + const backgroundContent = getValidHTML(content, (node) => + removeInlineStyle(node, 'color') + ); return ( @@ -134,7 +139,7 @@ function TextDisplay({ @@ -144,7 +149,7 @@ function TextDisplay({ diff --git a/assets/src/edit-story/elements/text/output.js b/assets/src/edit-story/elements/text/output.js index 5ed7a3fc853f..e64dddef0ff8 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -23,6 +23,7 @@ import PropTypes from 'prop-types'; * Internal dependencies */ import StoryPropTypes from '../../types'; +import removeInlineStyle from '../../utils/removeInlineStyle'; import generatePatternStyles from '../../utils/generatePatternStyles'; import getValidHTML from '../../utils/getValidHTML'; import { dataToEditorX, dataToEditorY } from '../../units'; @@ -119,7 +120,7 @@ export function TextOutputWithUnits({ const backgroundTextStyle = { ...textStyle, - color: 'transparent !important', + color: 'transparent', }; const foregroundTextStyle = { @@ -128,6 +129,10 @@ export function TextOutputWithUnits({ }; if (backgroundTextMode === BACKGROUND_TEXT_MODE.HIGHLIGHT) { + const foregroundContent = getValidHTML(content); + const backgroundContent = getValidHTML(content, (node) => + removeInlineStyle(node, 'color') + ); return ( <>

@@ -135,7 +140,7 @@ export function TextOutputWithUnits({ @@ -145,7 +150,7 @@ export function TextOutputWithUnits({ diff --git a/assets/src/edit-story/utils/getValidHTML.js b/assets/src/edit-story/utils/getValidHTML.js index 92f7d975ab9c..a766f0d5374f 100644 --- a/assets/src/edit-story/utils/getValidHTML.js +++ b/assets/src/edit-story/utils/getValidHTML.js @@ -14,9 +14,12 @@ * limitations under the License. */ -const contentBuffer = document.createElement('template'); +const contentBuffer = document.createElement('div'); -export default function getValidHTML(string) { +export default function getValidHTML(string, callback = null) { contentBuffer.innerHTML = string; + if (callback) { + callback(contentBuffer); + } return contentBuffer.innerHTML; } diff --git a/assets/src/edit-story/utils/removeInlineStyle.js b/assets/src/edit-story/utils/removeInlineStyle.js new file mode 100644 index 000000000000..e967c9ef133e --- /dev/null +++ b/assets/src/edit-story/utils/removeInlineStyle.js @@ -0,0 +1,30 @@ +/* + * 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 default function removeInlineStyle(node, style) { + // if it's not an element nor fragment, skip + if (!node || ![1, 11].includes(node.nodeType)) { + return; + } + + if (node.style) { + node.style[style] = ''; + } + + Array.from(node.childNodes).forEach((childNode) => + removeInlineStyle(childNode, style) + ); +} From ba711d79d0c9a5815fb1ffa2e12c25bbcf4cbcc5 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 5 May 2020 18:53:07 -0400 Subject: [PATCH 48/51] Added new function to compare patterns and used where applicable --- .../components/panels/stylePreset/utils.js | 11 ++++---- .../panels/textStyle/useRichTextFormatting.js | 11 ++------ assets/src/edit-story/utils/isPatternEqual.js | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 assets/src/edit-story/utils/isPatternEqual.js diff --git a/assets/src/edit-story/components/panels/stylePreset/utils.js b/assets/src/edit-story/components/panels/stylePreset/utils.js index 0ceb8355dce5..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,12 @@ /** * 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 createSolid from '../../../utils/createSolid'; -import convertToCSS from '../../../utils/convertToCSS'; import objectPick from '../../../utils/objectPick'; import { MULTIPLE_VALUE } from '../../form'; import { getHTMLInfo } from '../../richText/htmlManipulation'; @@ -32,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) { diff --git a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js index 0552c741bc68..7e277f26e5d2 100644 --- a/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js +++ b/assets/src/edit-story/components/panels/textStyle/useRichTextFormatting.js @@ -22,8 +22,7 @@ import { useMemo, useCallback } from 'react'; /** * Internal dependencies */ -import generatePatternStyles from '../../../utils/generatePatternStyles'; -import convertToCSS from '../../../utils/convertToCSS'; +import isPatternEqual from '../../../utils/isPatternEqual'; import useRichText from '../../richText/useRichText'; import { getHTMLFormatters, @@ -43,13 +42,7 @@ function isEqual(a, b) { // 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); - if (!isPattern) { - return a === b; - } - - const aStyle = convertToCSS(generatePatternStyles(a)); - const bStyle = convertToCSS(generatePatternStyles(b)); - return aStyle === bStyle; + return !isPattern ? a === b : isPatternEqual(a, b); } /** 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; +} From b63842a4d9c0f9961bf44a6b046580988de43e67 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 5 May 2020 20:31:16 -0400 Subject: [PATCH 49/51] Added ignore black to set color style as it is default --- .../components/richText/formatters/color.js | 7 ++++--- .../richText/formatters/test/color.js | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/assets/src/edit-story/components/richText/formatters/color.js b/assets/src/edit-story/components/richText/formatters/color.js index eeaca8677bb0..690b1b310247 100644 --- a/assets/src/edit-story/components/richText/formatters/color.js +++ b/assets/src/edit-story/components/richText/formatters/color.js @@ -18,6 +18,7 @@ * 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'; @@ -77,9 +78,9 @@ function getColor(editorState) { } function setColor(editorState, color) { - // we set all colors - one could argue that opaque black - // was default, and wasn't necessary, but it's probably not worth the trouble - const shouldSetStyle = () => true; + // 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); diff --git a/assets/src/edit-story/components/richText/formatters/test/color.js b/assets/src/edit-story/components/richText/formatters/test/color.js index e44a63e74d8f..07718fe663aa 100644 --- a/assets/src/edit-story/components/richText/formatters/test/color.js +++ b/assets/src/edit-story/components/richText/formatters/test/color.js @@ -39,6 +39,11 @@ 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)); @@ -137,7 +142,7 @@ describe('Color formatter', () => { expect(setters.setColor).toStrictEqual(expect.any(Function)); }); - it('should invoke togglePrefixStyle with correct parameters', () => { + it('should invoke togglePrefixStyle correctly with non-black color', () => { const state = {}; const color = createSolid(255, 0, 255); setters.setColor(state, color); @@ -156,5 +161,17 @@ describe('Color formatter', () => { 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 + }); }); }); From b6468b0d62e4e2ae652035d038d452e082155fcf Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Tue, 5 May 2020 20:39:25 -0400 Subject: [PATCH 50/51] Changed highlight to use existing style manipulation Reverted changes to `getValidHTML` and reused the existing HTML manipulation functions. --- .../src/edit-story/elements/text/display.js | 17 ++++++----- assets/src/edit-story/elements/text/output.js | 16 ++++++---- assets/src/edit-story/utils/getValidHTML.js | 7 ++--- .../src/edit-story/utils/removeInlineStyle.js | 30 ------------------- 4 files changed, 22 insertions(+), 48 deletions(-) delete mode 100644 assets/src/edit-story/utils/removeInlineStyle.js diff --git a/assets/src/edit-story/elements/text/display.js b/assets/src/edit-story/elements/text/display.js index ae961463220f..7cb93b24731f 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 @@ -34,8 +34,8 @@ import { import StoryPropTypes from '../../types'; import { BACKGROUND_TEXT_MODE } from '../../constants'; import { useTransformHandler } from '../../components/transform'; -import removeInlineStyle from '../../utils/removeInlineStyle'; -import getValidHTML from '../../utils/getValidHTML'; +import { getHTMLFormatters } from '../../components/richText/htmlManipulation'; +import createSolid from '../../utils/createSolid'; import { getHighlightLineheight, generateParagraphTextStyle } from './util'; const HighlightWrapperElement = styled.div` @@ -128,9 +128,12 @@ function TextDisplay({ }); if (backgroundTextMode === BACKGROUND_TEXT_MODE.HIGHLIGHT) { - const foregroundContent = getValidHTML(content); - const backgroundContent = getValidHTML(content, (node) => - removeInlineStyle(node, 'color') + const foregroundContent = content; + // 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 backgroundContent = useMemo( + () => getHTMLFormatters().setColor(content, createSolid(0, 0, 0)), + [content] ); return ( @@ -162,7 +165,7 @@ function TextDisplay({ diff --git a/assets/src/edit-story/elements/text/output.js b/assets/src/edit-story/elements/text/output.js index e64dddef0ff8..9e3b6297b503 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -18,14 +18,15 @@ * External dependencies */ import PropTypes from 'prop-types'; +import { useMemo } from 'react'; /** * Internal dependencies */ import StoryPropTypes from '../../types'; -import removeInlineStyle from '../../utils/removeInlineStyle'; import generatePatternStyles from '../../utils/generatePatternStyles'; -import getValidHTML from '../../utils/getValidHTML'; +import { getHTMLFormatters } from '../../components/richText/htmlManipulation'; +import createSolid from '../../utils/createSolid'; import { dataToEditorX, dataToEditorY } from '../../units'; import { BACKGROUND_TEXT_MODE } from '../../constants'; import { generateParagraphTextStyle, getHighlightLineheight } from './util'; @@ -129,9 +130,12 @@ export function TextOutputWithUnits({ }; if (backgroundTextMode === BACKGROUND_TEXT_MODE.HIGHLIGHT) { - const foregroundContent = getValidHTML(content); - const backgroundContent = getValidHTML(content, (node) => - removeInlineStyle(node, 'color') + const foregroundContent = content; + // 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 backgroundContent = useMemo( + () => getHTMLFormatters().setColor(content, createSolid(0, 0, 0)), + [content] ); return ( <> @@ -163,7 +167,7 @@ export function TextOutputWithUnits({

); } diff --git a/assets/src/edit-story/utils/getValidHTML.js b/assets/src/edit-story/utils/getValidHTML.js index a766f0d5374f..92f7d975ab9c 100644 --- a/assets/src/edit-story/utils/getValidHTML.js +++ b/assets/src/edit-story/utils/getValidHTML.js @@ -14,12 +14,9 @@ * limitations under the License. */ -const contentBuffer = document.createElement('div'); +const contentBuffer = document.createElement('template'); -export default function getValidHTML(string, callback = null) { +export default function getValidHTML(string) { contentBuffer.innerHTML = string; - if (callback) { - callback(contentBuffer); - } return contentBuffer.innerHTML; } diff --git a/assets/src/edit-story/utils/removeInlineStyle.js b/assets/src/edit-story/utils/removeInlineStyle.js deleted file mode 100644 index e967c9ef133e..000000000000 --- a/assets/src/edit-story/utils/removeInlineStyle.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 default function removeInlineStyle(node, style) { - // if it's not an element nor fragment, skip - if (!node || ![1, 11].includes(node.nodeType)) { - return; - } - - if (node.style) { - node.style[style] = ''; - } - - Array.from(node.childNodes).forEach((childNode) => - removeInlineStyle(childNode, style) - ); -} From 13b6fdac30657b964dfd430c747bc2e1b56dd062 Mon Sep 17 00:00:00 2001 From: Morten Barklund Date: Wed, 6 May 2020 13:33:37 -0400 Subject: [PATCH 51/51] Removed conditional hook --- assets/src/edit-story/elements/text/display.js | 18 +++++++++--------- assets/src/edit-story/elements/text/output.js | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/assets/src/edit-story/elements/text/display.js b/assets/src/edit-story/elements/text/display.js index 7cb93b24731f..6c2d177713c1 100644 --- a/assets/src/edit-story/elements/text/display.js +++ b/assets/src/edit-story/elements/text/display.js @@ -127,14 +127,14 @@ 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) { - const foregroundContent = content; - // 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 backgroundContent = useMemo( - () => getHTMLFormatters().setColor(content, createSolid(0, 0, 0)), - [content] - ); return ( @@ -142,7 +142,7 @@ function TextDisplay({ @@ -152,7 +152,7 @@ function TextDisplay({ diff --git a/assets/src/edit-story/elements/text/output.js b/assets/src/edit-story/elements/text/output.js index 9e3b6297b503..94504f360e26 100644 --- a/assets/src/edit-story/elements/text/output.js +++ b/assets/src/edit-story/elements/text/output.js @@ -129,14 +129,14 @@ 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) { - const foregroundContent = content; - // 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 backgroundContent = useMemo( - () => getHTMLFormatters().setColor(content, createSolid(0, 0, 0)), - [content] - ); return ( <>

@@ -144,7 +144,7 @@ export function TextOutputWithUnits({ @@ -154,7 +154,7 @@ export function TextOutputWithUnits({