diff --git a/assets/src/edit-story/app/story/effects/useLoadStory.js b/assets/src/edit-story/app/story/effects/useLoadStory.js index 11b712e5be22..4b3d4d7cdf0f 100644 --- a/assets/src/edit-story/app/story/effects/useLoadStory.js +++ b/assets/src/edit-story/app/story/effects/useLoadStory.js @@ -98,6 +98,9 @@ function useLoadStory({ storyId, shouldLoad, restore }) { if (!stylePresets.textColors) { stylePresets.textColors = []; } + if (!stylePresets.textStyles) { + stylePresets.textStyles = []; + } // Set story-global variables. const story = { diff --git a/assets/src/edit-story/components/panels/layer/panel.js b/assets/src/edit-story/components/panels/layer/panel.js index 4a6abb3a11ef..8b6cc43f567b 100644 --- a/assets/src/edit-story/components/panels/layer/panel.js +++ b/assets/src/edit-story/components/panels/layer/panel.js @@ -36,7 +36,10 @@ function LayerPanel() { return ( diff --git a/assets/src/edit-story/components/panels/panel/shared/handle.js b/assets/src/edit-story/components/panels/panel/shared/handle.js index b49e5cd89a04..b341a11a16da 100644 --- a/assets/src/edit-story/components/panels/panel/shared/handle.js +++ b/assets/src/edit-story/components/panels/panel/shared/handle.js @@ -20,7 +20,6 @@ import styled from 'styled-components'; import PropTypes from 'prop-types'; import { useRef } from 'react'; -import { rgba } from 'polished'; /** * WordPress dependencies @@ -34,7 +33,6 @@ import useDragHandlers from '../useDragHandlers'; import useKeyboardHandlers from '../useKeyboardHandlers'; const Handle = styled.div` - background-color: ${({ theme }) => rgba(theme.colors.bg.v0, 0.07)}; border: 0; padding: 0; height: 6px; @@ -46,10 +44,10 @@ const Handle = styled.div` user-select: none; `; +// @todo This needs blue outline when in focus. const Bar = styled.div.attrs({ tabIndex: 0, })` - background-color: ${({ theme }) => rgba(theme.colors.fg.v1, 0.1)}; width: 36px; height: 4px; border-radius: 2px; diff --git a/assets/src/edit-story/components/panels/panel/shared/title.js b/assets/src/edit-story/components/panels/panel/shared/title.js index cc4a83ea259a..dd0cb5bc87bb 100644 --- a/assets/src/edit-story/components/panels/panel/shared/title.js +++ b/assets/src/edit-story/components/panels/panel/shared/title.js @@ -63,11 +63,9 @@ const Header = styled.h2` user-select: none; `; -const HeaderButton = styled.button.attrs({ type: 'button' })` +const HeaderButton = styled.div.attrs({ role: 'button' })` color: inherit; - border: 0; padding: 10px 20px; - background: transparent; display: flex; justify-content: space-between; align-items: center; @@ -87,12 +85,15 @@ const HeaderActions = styled.div` align-items: center; `; -const Collapse = styled.span` +const Collapse = styled.button` + border: none; + background: transparent; color: inherit; width: 28px; height: 28px; display: flex; /* removes implicit line-height padding from child element */ - + padding: 0; + cursor: pointer; svg { width: 28px; height: 28px; @@ -155,17 +156,21 @@ function Title({ handleDoubleClick={resetHeight} /> )} - + {children} {secondaryAction} {canCollapse && ( - + { + evt.stopPropagation(); + isCollapsed ? expand() : collapse(); + }} + aria-label={titleLabel} + aria-expanded={!isCollapsed} + aria-controls={panelContentId} + > )} diff --git a/assets/src/edit-story/components/panels/stylePreset/header.js b/assets/src/edit-story/components/panels/stylePreset/header.js new file mode 100644 index 000000000000..12e7cb242c25 --- /dev/null +++ b/assets/src/edit-story/components/panels/stylePreset/header.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. + */ + +/** + * External dependencies + */ +import styled, { css } from 'styled-components'; +import { rgba } from 'polished'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import PropTypes from 'prop-types'; +import { ReactComponent as Edit } from '../../../icons/edit_pencil.svg'; +import { ReactComponent as Add } from '../../../icons/add_page.svg'; +import { PanelTitle } from '../panel'; +import { StylePresetPropType } from '../../../types'; + +const buttonCSS = css` + border: none; + background: transparent; + width: 30px; + height: 28px; + color: ${({ theme }) => rgba(theme.colors.fg.v1, 0.84)}; + cursor: pointer; + padding: 0; +`; + +const AddColorPresetButton = styled.button` + ${buttonCSS} + svg { + width: 26px; + height: 28px; + } +`; + +const ExitEditMode = styled.button` + ${buttonCSS} + color: ${({ theme }) => theme.colors.fg.v1}; + font-size: 12px; + line-height: 14px; + padding: 7px; + height: initial; +`; + +const EditModeButton = styled.button` + ${buttonCSS} + height: 20px; + svg { + width: 16px; + height: 20px; + } +`; + +function PresetsHeader({ + handleAddColorPreset, + isEditMode, + setIsEditMode, + stylePresets, +}) { + const { fillColors, textColors, textStyles } = stylePresets; + const hasPresets = + fillColors.length > 0 || textColors.length > 0 || textStyles.length > 0; + + const getActions = () => { + return !isEditMode ? ( + <> + {hasPresets && ( + { + evt.stopPropagation(); + setIsEditMode(true); + }} + aria-label={__('Edit presets', 'web-stories')} + > + + + )} + + + + + ) : ( + { + evt.stopPropagation(); + setIsEditMode(false); + }} + aria-label={__('Exit edit mode', 'web-stories')} + > + {__('Exit', 'web-stories')} + + ); + }; + + return ( + + {__('Presets', 'web-stories')} + + ); +} + +PresetsHeader.propTypes = { + stylePresets: StylePresetPropType.isRequired, + isEditMode: PropTypes.bool.isRequired, + handleAddColorPreset: PropTypes.func.isRequired, + setIsEditMode: PropTypes.func.isRequired, +}; + +export default PresetsHeader; diff --git a/assets/src/edit-story/components/panels/stylePreset/index.js b/assets/src/edit-story/components/panels/stylePreset/index.js index fb0ef5daba58..2e73c3b2b4fb 100644 --- a/assets/src/edit-story/components/panels/stylePreset/index.js +++ b/assets/src/edit-story/components/panels/stylePreset/index.js @@ -14,325 +14,4 @@ * limitations under the License. */ -/** - * External dependencies - */ -import styled, { css } from 'styled-components'; -import { rgba } from 'polished'; -import { useCallback, useEffect, useState } from 'react'; - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import { ReactComponent as Add } from '../../../icons/add_page.svg'; -import { ReactComponent as Edit } from '../../../icons/edit_pencil.svg'; -import { ReactComponent as Remove } from '../../../icons/remove.svg'; -import { useStory } from '../../../app/story'; -import generatePatternStyles from '../../../utils/generatePatternStyles'; -import { getDefinitionForType } from '../../../elements'; -import { Panel, PanelTitle, PanelContent } from './../panel'; -import { findMatchingColor } from './utils'; - -const COLOR_HEIGHT = 35; - -const buttonCSS = css` - background: transparent; - width: 30px; - height: 28px; - border: none; - color: ${({ theme }) => rgba(theme.colors.fg.v1, 0.84)}; - cursor: pointer; - padding: 0; -`; - -// Since the whole wrapper title is already a button, can't use button directly here. -// @todo Use custom title instead to use buttons directly. -const AddColorPresetButton = styled.div.attrs({ - role: 'button', -})` - ${buttonCSS} - svg { - width: 26px; - height: 28px; - } -`; - -const EditModeButton = styled.div.attrs({ - role: 'button', -})` - ${buttonCSS} - height: 20px; - svg { - width: 16px; - height: 20px; - } -`; - -const ExitEditMode = styled.a` - ${buttonCSS} - color: ${({ theme }) => theme.colors.fg.v1}; - font-size: 12px; - line-height: 14px; - padding: 7px; - height: initial; -`; - -const colorCSS = css` - display: inline-block; - width: 30px; - height: 30px; - border-radius: 15px; - border: 0.5px solid ${({ theme }) => rgba(theme.colors.fg.v1, 0.3)}; - padding: 0; - svg { - width: 18px; - height: 28px; - } -`; - -const Color = styled.button` - ${colorCSS} - ${({ color }) => generatePatternStyles(color)} -`; - -// For max-height: Display 5 extra pixels to show there are more colors. -const Colors = styled.div` - display: flex; - flex-direction: row; - flex-wrap: wrap; - max-height: ${6 * COLOR_HEIGHT + 5}px; - overflow-y: auto; -`; - -const ButtonWrapper = styled.div` - flex-basis: 16%; - height: ${COLOR_HEIGHT}px; -`; - -const ColorGroupLabel = styled.div` - color: ${({ theme }) => theme.colors.fg.v1}; - font-size: 10px; - line-height: 12px; - text-transform: uppercase; - padding: 6px 0; -`; - -function StylePresetPanel() { - const { - state: { - selectedElementIds, - selectedElements, - story: { stylePresets }, - }, - actions: { updateStory, updateElementsById }, - } = useStory(); - - const { fillColors, textColors } = stylePresets; - - const [isEditMode, setIsEditMode] = useState(false); - - const isType = (elType) => { - return ( - selectedElements.length > 0 && - selectedElements.every(({ type }) => elType === type) - ); - }; - - const isText = isType('text'); - const isShape = isType('shape'); - - const handleDeleteColor = useCallback( - (toDelete) => { - updateStory({ - properties: { - stylePresets: { - ...stylePresets, - fillColors: isText - ? fillColors - : fillColors.filter((color) => color !== toDelete), - textColors: !isText - ? textColors - : textColors.filter((color) => color !== toDelete), - }, - }, - }); - }, - [fillColors, isText, stylePresets, textColors, updateStory] - ); - - const handleAddColorPreset = useCallback( - (evt) => { - evt.stopPropagation(); - let addedFillColors = []; - let addedTextColors = []; - if (isText) { - addedTextColors = selectedElements - .map(({ color }) => color) - .filter((color) => !findMatchingColor(color, stylePresets, true)); - } else { - addedFillColors = selectedElements - .map(({ backgroundColor }) => { - return backgroundColor ? backgroundColor : null; - }) - .filter( - (color) => color && !findMatchingColor(color, stylePresets, false) - ); - } - if (addedFillColors.length > 0 || addedTextColors.length > 0) { - updateStory({ - properties: { - stylePresets: { - ...stylePresets, - fillColors: [...fillColors, ...addedFillColors], - textColors: [...textColors, ...addedTextColors], - }, - }, - }); - } - }, - [ - isText, - selectedElements, - updateStory, - stylePresets, - fillColors, - textColors, - ] - ); - - const handleApplyColor = useCallback( - (color) => { - if (isText) { - updateElementsById({ - elementIds: selectedElementIds, - properties: { color }, - }); - } else { - updateElementsById({ - elementIds: selectedElementIds, - properties: (currentProperties) => { - const { type } = currentProperties; - // @todo Is this necessary? - const { isMedia } = getDefinitionForType(type); - return isMedia - ? {} - : { - backgroundColor: color, - }; - }, - }); - } - }, - [isText, selectedElementIds, updateElementsById] - ); - - const colorPresets = isText ? textColors : fillColors; - const groupLabel = isText - ? __('Text colors', 'web-stories') - : __('Colors', 'web-stories'); - const hasColorPresets = colorPresets.length > 0; - - useEffect(() => { - // If there are no colors left, exit edit mode. - if (isEditMode && !hasColorPresets) { - setIsEditMode(false); - } - }, [hasColorPresets, isEditMode]); - - // @todo This is temporary until the presets haven't been implemented fully with multi-selection. - if (!isText && !isShape && selectedElements.length > 1) { - return null; - } - - const getSecondaryActions = () => { - return !isEditMode ? ( - <> - {hasColorPresets && ( - { - evt.stopPropagation(); - setIsEditMode(true); - }} - aria-label={__('Edit presets', 'web-stories')} - > - - - )} - - - - - ) : ( - { - evt.stopPropagation(); - setIsEditMode(false); - }} - aria-label={__('Exit edit mode', 'web-stories')} - > - {__('Exit', 'web-stories')} - - ); - }; - - const handleColorClick = (color) => { - if (isEditMode) { - handleDeleteColor(color); - } else { - handleApplyColor(color); - } - }; - - return ( - - - {__('Style presets', 'web-stories')} - - - {hasColorPresets && ( - <> - {groupLabel} - - {colorPresets.map((color, i) => ( - - { - handleColorClick(color); - }} - onKeyDown={(evt) => { - if (evt.keyCode === 'Enter' || evt.keyCode === 'Space') { - handleColorClick(color); - } - }} - aria-label={ - isEditMode - ? __('Delete preset', 'web-stories') - : __('Apply preset', 'web-stories') - } - > - {isEditMode && } - - - ))} - - - )} - - - ); -} - -export default StylePresetPanel; +export { default } from './panel'; diff --git a/assets/src/edit-story/components/panels/stylePreset/panel.js b/assets/src/edit-story/components/panels/stylePreset/panel.js new file mode 100644 index 000000000000..844566df9fc5 --- /dev/null +++ b/assets/src/edit-story/components/panels/stylePreset/panel.js @@ -0,0 +1,191 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { useCallback, useEffect, useState } from 'react'; + +/** + * Internal dependencies + */ +import { useStory } from '../../../app/story'; +import stripHTML from '../../../utils/stripHTML'; +import { Panel } from './../panel'; +import { getShapePresets, getTextPresets } from './utils'; +import PresetsHeader from './header'; +import Presets from './presets'; +import Resize from './resize'; + +function StylePresetPanel() { + const { + state: { + selectedElementIds, + selectedElements, + story: { stylePresets }, + }, + actions: { updateStory, updateElementsById }, + } = useStory(); + + const { fillColors, textColors, textStyles } = stylePresets; + const [isEditMode, setIsEditMode] = useState(false); + + const areAllType = (elType) => { + return ( + selectedElements.length > 0 && + selectedElements.every(({ type }) => elType === type) + ); + }; + + const isText = areAllType('text'); + const isShape = areAllType('shape'); + + const handleDeletePreset = useCallback( + (toDelete) => { + updateStory({ + properties: { + stylePresets: { + textStyles: textStyles.filter((style) => style !== toDelete), + fillColors: isText + ? fillColors + : fillColors.filter((color) => color !== toDelete), + textColors: !isText + ? textColors + : textColors.filter((color) => color !== toDelete), + }, + }, + }); + }, + [textStyles, fillColors, isText, textColors, updateStory] + ); + + const handleAddColorPreset = useCallback( + (evt) => { + evt.stopPropagation(); + let addedPresets = { + fillColors: [], + textColors: [], + textStyles: [], + }; + if (isText) { + addedPresets = { + ...addedPresets, + ...getTextPresets(selectedElements, stylePresets), + }; + } else { + // Currently, shape only supports fillColors. + addedPresets = { + ...addedPresets, + ...getShapePresets(selectedElements, stylePresets), + }; + } + if ( + addedPresets.fillColors?.length > 0 || + addedPresets.textColors?.length > 0 || + addedPresets.textStyles?.length > 0 + ) { + updateStory({ + properties: { + stylePresets: { + textStyles: [...textStyles, ...addedPresets.textStyles], + fillColors: [...fillColors, ...addedPresets.fillColors], + textColors: [...textColors, ...addedPresets.textColors], + }, + }, + }); + } + }, + [ + fillColors, + textStyles, + textColors, + isText, + selectedElements, + updateStory, + stylePresets, + ] + ); + + 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 }, + }); + } else { + updateElementsById({ + elementIds: selectedElementIds, + properties: { backgroundColor: preset }, + }); + } + }, + [isText, selectedElementIds, updateElementsById] + ); + + const colorPresets = isText ? textColors : fillColors; + const hasColorPresets = colorPresets.length > 0; + const hasPresets = hasColorPresets || textStyles.length > 0; + + useEffect(() => { + // If there are no colors left, exit edit mode. + if (isEditMode && !hasPresets) { + setIsEditMode(false); + } + }, [hasPresets, isEditMode]); + + // Text and shape presets are not compatible. + if (!isText && !isShape && selectedElements.length > 1) { + return null; + } + + const handlePresetClick = (preset) => { + if (isEditMode) { + handleDeletePreset(preset); + } else { + handleApplyPreset(preset); + } + }; + + // @Todo confirm initial height. + return ( + + + + + + ); +} + +export default StylePresetPanel; diff --git a/assets/src/edit-story/components/panels/stylePreset/presetGroup.js b/assets/src/edit-story/components/panels/stylePreset/presetGroup.js new file mode 100644 index 000000000000..8c8f6f9f47ae --- /dev/null +++ b/assets/src/edit-story/components/panels/stylePreset/presetGroup.js @@ -0,0 +1,116 @@ +/* + * 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 styled from 'styled-components'; +import { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import { useKeyDownEffect } from '../../keyboard'; + +const COLORS_PER_ROW = 6; +const STYLES_PER_ROW = 3; +const PRESET_HEIGHT = 35; + +const Group = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; +`; + +const ButtonWrapper = styled.div` + flex-basis: ${({ width }) => width}%; + height: ${PRESET_HEIGHT}px; +`; + +const Label = styled.div` + color: ${({ theme }) => theme.colors.fg.v1}; + font-size: 10px; + line-height: 12px; + text-transform: uppercase; + padding: 6px 0; +`; + +function PresetGroup({ presets, itemRenderer, type, label }) { + const [activeIndex, setActiveIndex] = useState(0); + const groupRef = useRef(null); + + const getIndexDiff = (key, rowLength) => { + switch (key) { + case 'ArrowUp': + return -rowLength; + case 'ArrowDown': + return rowLength; + case 'ArrowLeft': + return -1; + case 'ArrowRight': + return 1; + default: + return 0; + } + }; + + useKeyDownEffect( + groupRef, + { key: ['up', 'down', 'left', 'right'] }, + ({ key }) => { + // When the user navigates in the color presets using the arrow keys, + // Let's change the active index accordingly, to indicate which preset should be focused + if (groupRef.current) { + const rowLength = 'color' === type ? COLORS_PER_ROW : STYLES_PER_ROW; + const diff = getIndexDiff(key, rowLength); + const maxIndex = presets.length - 1; + const val = (activeIndex ?? 0) + diff; + const newIndex = Math.max(0, Math.min(maxIndex, val)); + setActiveIndex(newIndex); + const buttons = groupRef.current.querySelectorAll('button'); + if (buttons[newIndex]) { + buttons[newIndex].focus(); + } + } + }, + [activeIndex] + ); + + const buttonWidth = + 'color' === type ? 100 / COLORS_PER_ROW : 100 / STYLES_PER_ROW; + return ( + <> + + + {presets.map((preset, i) => ( + + {itemRenderer(preset, i, activeIndex)} + + ))} + + + ); +} + +PresetGroup.propTypes = { + presets: PropTypes.array.isRequired, + itemRenderer: PropTypes.func.isRequired, + type: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, +}; + +export default PresetGroup; diff --git a/assets/src/edit-story/components/panels/stylePreset/presets.js b/assets/src/edit-story/components/panels/stylePreset/presets.js new file mode 100644 index 000000000000..4787447c48c0 --- /dev/null +++ b/assets/src/edit-story/components/panels/stylePreset/presets.js @@ -0,0 +1,192 @@ +/* + * 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 styled, { css } from 'styled-components'; +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { ReactComponent as Remove } from '../../../icons/remove.svg'; +import { BACKGROUND_TEXT_MODE } from '../../../constants'; +import generatePatternStyles from '../../../utils/generatePatternStyles'; +import { PanelContent } from '../panel'; +import { StylePresetPropType } from '../../../types'; +import PresetGroup from './presetGroup'; +import { generatePresetStyle } from './utils'; + +const REMOVE_ICON_SIZE = 18; +const PRESET_HEIGHT = 30; + +const presetCSS = css` + display: inline-block; + width: 30px; + height: ${PRESET_HEIGHT}px; + border-radius: 15px; + border-color: transparent; + padding: 0; + font-size: 13px; + position: relative; + cursor: pointer; + svg { + width: ${REMOVE_ICON_SIZE}px; + height: ${REMOVE_ICON_SIZE}px; + position: absolute; + top: calc(50% - ${REMOVE_ICON_SIZE / 2}px); + left: calc(50% - ${REMOVE_ICON_SIZE / 2}px); + } +`; + +const Color = styled.button` + ${presetCSS} + ${({ color }) => generatePatternStyles(color)} +`; + +const Style = styled.button` + ${presetCSS} + padding: 0 3px; + background: transparent; + ${({ styles }) => styles} + width: 72px; + border-radius: 4px; +`; + +const TextWrapper = styled.div` + text-align: left; + line-height: ${PRESET_HEIGHT}px; + max-height: 100%; + overflow: hidden; + white-space: nowrap; +`; + +const HighLight = styled.span` + padding: 0 2px; + ${({ background }) => generatePatternStyles(background)} + box-decoration-break: clone; +`; + +function Presets({ + stylePresets, + handleOnClick, + isEditMode, + isText, + textContent = 'Text', +}) { + const { fillColors, textColors, textStyles } = stylePresets; + + const getStylePresetText = (preset) => { + const isHighLight = + preset.backgroundTextMode === BACKGROUND_TEXT_MODE.HIGHLIGHT; + return ( + + {isHighLight ? ( + + {textContent} + + ) : ( + textContent + )} + + ); + }; + + const colorPresets = isText ? textColors : fillColors; + const hasColorPresets = colorPresets.length > 0; + + const colorPresetRenderer = (color, i, activeIndex) => { + return ( + { + e.preventDefault(); + handleOnClick(color); + }} + aria-label={ + isEditMode + ? __('Delete color preset', 'web-stories') + : __('Apply color preset', 'web-stories') + } + > + {isEditMode && } + + ); + }; + + const stylePresetRenderer = (style, i, activeIndex) => { + return ( + + ); + }; + + const colorLabel = isText + ? __('Text colors', 'web-stories') + : __('Colors', 'web-stories'); + return ( + + {hasColorPresets && ( + + )} + {/* Only texts support style presets currently */} + {textStyles.length > 0 && isText && ( + + )} + + ); +} + +Presets.propTypes = { + stylePresets: StylePresetPropType.isRequired, + handleOnClick: PropTypes.func.isRequired, + isEditMode: PropTypes.bool.isRequired, + isText: PropTypes.bool.isRequired, + textContent: PropTypes.string, +}; + +export default Presets; diff --git a/assets/src/edit-story/components/panels/stylePreset/resize.js b/assets/src/edit-story/components/panels/stylePreset/resize.js new file mode 100644 index 000000000000..985af970c8dd --- /dev/null +++ b/assets/src/edit-story/components/panels/stylePreset/resize.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. + */ + +/** + * External dependencies + */ +import { useCallback, useContext } from 'react'; + +/** + * Internal dependencies + */ +import DragHandle from '../panel/shared/handle'; +import panelContext from '../panel/context'; +import useInspector from '../../inspector/useInspector'; +import { PANEL_COLLAPSED_THRESHOLD } from '../panel/panel'; + +function Resize() { + const { + state: { height }, + actions: { setHeight, setExpandToHeight, resetHeight }, + } = useContext(panelContext); + + const { + state: { inspectorContentHeight }, + } = useInspector(); + + // Max panel height is set to 70% of full available height. + const maxHeight = Math.round(inspectorContentHeight * 0.7); + + const handleHeightChange = useCallback( + (deltaHeight) => + setHeight((value) => + Math.max(0, Math.min(maxHeight, value - deltaHeight)) + ), + [setHeight, maxHeight] + ); + + const handleExpandToHeightChange = useCallback(() => { + if (height >= PANEL_COLLAPSED_THRESHOLD) { + setExpandToHeight(height); + } + }, [setExpandToHeight, height]); + + return ( + + ); +} + +export default Resize; diff --git a/assets/src/edit-story/components/panels/stylePreset/stories/index.js b/assets/src/edit-story/components/panels/stylePreset/stories/index.js new file mode 100644 index 000000000000..592d0e69c531 --- /dev/null +++ b/assets/src/edit-story/components/panels/stylePreset/stories/index.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 { object } from '@storybook/addon-knobs'; +import styled from 'styled-components'; + +/** + * Internal dependencies + */ +import Presets from '../presets'; +import createSolid from '../../../../utils/createSolid'; +import { BACKGROUND_TEXT_MODE } from '../../../../constants'; + +export default { + title: 'Stories Editor/Components/stylePreset/Presets', + component: Presets, + parameters: { + backgrounds: [ + { name: 'dark background', value: 'rgba(0, 0, 0, 0.8)', default: true }, + ], + }, +}; + +const Wrapper = styled.div` + max-width: 300px; +`; + +export const _default = () => { + const stylePresets = object('Presets', { + textColors: [ + createSolid(255, 255, 255, 0.5), + createSolid(255, 0, 0, 0.9), + createSolid(255, 255, 0, 0.8), + createSolid(255, 0, 255, 0.5), + createSolid(0, 255, 0, 1), + createSolid(0, 255, 255, 1), + createSolid(0, 0, 0, 0.7), + createSolid(0, 0, 255, 0.7), + ], + textStyles: [ + { + color: createSolid(0, 0, 0, 1), + backgroundTextMode: BACKGROUND_TEXT_MODE.FILL, + backgroundColor: createSolid(255, 0, 255, 0.5), + }, + { + color: createSolid(255, 255, 0, 0.9), + backgroundTextMode: BACKGROUND_TEXT_MODE.HIGHLIGHT, + backgroundColor: createSolid(0, 0, 0, 1), + fontFamily: 'Princess Sofia', + fontFallback: ['cursive'], + }, + { + color: createSolid(255, 255, 255, 0.9), + backgroundTextMode: BACKGROUND_TEXT_MODE.FILL, + backgroundColor: createSolid(0, 0, 0, 1), + }, + { + color: createSolid(0, 0, 0, 1), + backgroundTextMode: BACKGROUND_TEXT_MODE.FILL, + backgroundColor: createSolid(255, 255, 255, 0.8), + }, + ], + fillColors: [], + }); + + return ( + + {}} + isEditMode={false} + isText={true} + textContent={'Hello, World!'} + /> + + ); +}; diff --git a/assets/src/edit-story/components/panels/stylePreset/test/panel.js b/assets/src/edit-story/components/panels/stylePreset/test/panel.js new file mode 100644 index 000000000000..8d1f1ee30e3a --- /dev/null +++ b/assets/src/edit-story/components/panels/stylePreset/test/panel.js @@ -0,0 +1,374 @@ +/* + * 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 { fireEvent } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import StylePresetPanel from '../index'; +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) { + const updateStory = jest.fn(); + const updateElementsById = jest.fn(); + + const textElement = { + id: '1', + type: 'text', + }; + const storyContextValue = { + state: { + selectedElementIds: ['1'], + selectedElements: [textElement], + ...extraStateProps, + story: { + stylePresets: { + ...{ fillColors: [], textColors: [], textStyles: [] }, + ...extraStylePresets, + }, + }, + }, + actions: { updateStory, updateElementsById }, + }; + const { + getByText, + queryByLabelText, + getByLabelText, + queryByText, + queryAllByLabelText, + } = renderWithTheme( + + + + ); + return { + getByText, + queryByText, + getByLabelText, + queryByLabelText, + queryAllByLabelText, + updateStory, + updateElementsById, + }; +} + +describe('Panels/StylePreset', () => { + const EDIT_BUTTON_LABEL = 'Edit presets'; + const TEST_COLOR = { + color: { + r: 1, + g: 1, + b: 1, + }, + }; + const TEST_COLOR_2 = { + color: { + r: 2, + g: 2, + b: 2, + }, + }; + const STYLE_PRESET = { + color: TEST_COLOR_2, + backgroundTextMode: BACKGROUND_TEXT_MODE.FILL, + backgroundColor: TEST_COLOR, + }; + + it('should render panel', () => { + const { getByText } = setupPanel(); + const element = getByText('Presets'); + expect(element).toBeDefined(); + }); + + it('should not display the panel if mixed types multi-selection', () => { + const extraStateProps = { + selectedElements: [ + { + id: '1', + type: 'text', + }, + { + id: '2', + type: 'shape', + }, + ], + }; + const { queryByText } = setupPanel(null, extraStateProps); + expect(queryByText('Presets')).toBeNull(); + }); + + describe('Panels/StylePreset/Header', () => { + it('should display only Add button if no presets exist', () => { + const { queryByLabelText } = setupPanel(); + const addButton = queryByLabelText('Add preset'); + expect(addButton).toBeDefined(); + + const editButton = queryByLabelText(EDIT_BUTTON_LABEL); + expect(editButton).toBeNull(); + }); + + it('should have functional Edit button if relevant presets exist', () => { + const extraStylePresets = { + textColors: [TEST_COLOR], + }; + const { getByLabelText, queryByLabelText } = setupPanel( + extraStylePresets + ); + const editButton = getByLabelText(EDIT_BUTTON_LABEL); + expect(editButton).toBeDefined(); + + fireEvent.click(editButton); + const exitEditModeButton = getByLabelText('Exit edit mode'); + expect(exitEditModeButton).toBeDefined(); + expect(queryByLabelText(EDIT_BUTTON_LABEL)).toBeNull(); + + fireEvent.click(exitEditModeButton); + const newEditButton = getByLabelText(EDIT_BUTTON_LABEL); + expect(newEditButton).toBeDefined(); + }); + + it('should add a text color preset', () => { + const extraStateProps = { + selectedElements: [ + { + id: '1', + type: 'text', + color: [TEST_COLOR_2], + backgroundTextMode: BACKGROUND_TEXT_MODE.NONE, + font: TEXT_ELEMENT_DEFAULT_FONT, + }, + ], + }; + const { updateStory, queryByLabelText } = setupPanel( + null, + extraStateProps + ); + + getTextPresets.mockImplementation(() => { + return { + textColors: [TEST_COLOR_2], + }; + }); + + const addButton = queryByLabelText('Add preset'); + fireEvent.click(addButton); + + expect(updateStory).toHaveBeenCalledTimes(1); + expect(updateStory).toHaveBeenCalledWith({ + properties: { + stylePresets: { + textColors: [TEST_COLOR_2], + fillColors: [], + textStyles: [], + }, + }, + }); + }); + + it('should add style preset from a Text with correct values', () => { + const extraStateProps = { + selectedElements: [ + { + id: '1', + type: 'text', + ...STYLE_PRESET, + }, + ], + }; + const { updateStory, queryByLabelText } = setupPanel( + null, + extraStateProps + ); + + getTextPresets.mockImplementation(() => { + return { + textStyles: [STYLE_PRESET], + }; + }); + + const addButton = queryByLabelText('Add preset'); + fireEvent.click(addButton); + + expect(updateStory).toHaveBeenCalledTimes(1); + expect(updateStory).toHaveBeenCalledWith({ + properties: { + stylePresets: { + textColors: [], + fillColors: [], + textStyles: [STYLE_PRESET], + }, + }, + }); + }); + + it('should allow adding presets from shapes', () => { + const extraStateProps = { + selectedElements: [ + { + id: '1', + type: 'shape', + backgroundColor: [TEST_COLOR_2], + }, + ], + }; + const { updateStory, queryByLabelText } = setupPanel( + null, + extraStateProps + ); + + getShapePresets.mockImplementation(() => { + return { + fillColors: [TEST_COLOR_2], + }; + }); + + const addButton = queryByLabelText('Add preset'); + fireEvent.click(addButton); + + expect(updateStory).toHaveBeenCalledTimes(1); + expect(updateStory).toHaveBeenCalledWith({ + properties: { + stylePresets: { + textColors: [], + fillColors: [TEST_COLOR_2], + textStyles: [], + }, + }, + }); + }); + }); + + describe('Panels/StylePreset/Colors', () => { + it('should display correct label for Text colors', () => { + const extraStylePresets = { + textColors: [TEST_COLOR], + }; + const { getByText } = setupPanel(extraStylePresets); + const groupLabel = getByText('Text colors'); + expect(groupLabel).toBeDefined(); + }); + + it('should display correct label for Colors', () => { + const extraStylePresets = { + fillColors: [TEST_COLOR], + }; + const extraStateProps = { + selectedElements: [ + { + id: '1', + type: 'shape', + }, + ], + }; + const { getByText, queryByText } = setupPanel( + extraStylePresets, + extraStateProps + ); + const groupLabel = getByText('Colors'); + expect(groupLabel).toBeDefined(); + + const textColorGroupLabel = queryByText('Text colors'); + expect(textColorGroupLabel).toBeNull(); + }); + + it('should allow deleting the relevant color preset', () => { + const extraStylePresets = { + textColors: [TEST_COLOR, TEST_COLOR_2], + fillColors: [TEST_COLOR], + }; + const { getByLabelText, queryAllByLabelText, updateStory } = setupPanel( + extraStylePresets + ); + const editButton = getByLabelText(EDIT_BUTTON_LABEL); + fireEvent.click(editButton); + + const deletePresets = queryAllByLabelText('Delete color preset'); + expect(deletePresets[0]).toBeDefined(); + + fireEvent.click(deletePresets[0]); + expect(updateStory).toHaveBeenCalledTimes(1); + expect(updateStory).toHaveBeenCalledWith({ + properties: { + stylePresets: { + fillColors: [TEST_COLOR], + textColors: [TEST_COLOR_2], + textStyles: [], + }, + }, + }); + }); + + it('should allow applying color presets for shapes', () => { + const extraStylePresets = { + fillColors: [TEST_COLOR], + }; + const extraStateProps = { + selectedElements: [ + { + id: '1', + type: 'shape', + }, + ], + }; + const { getByLabelText, updateElementsById } = setupPanel( + extraStylePresets, + extraStateProps + ); + + const applyPreset = getByLabelText('Apply color preset'); + expect(applyPreset).toBeDefined(); + + fireEvent.click(applyPreset); + expect(updateElementsById).toHaveBeenCalledTimes(1); + expect(updateElementsById).toHaveBeenCalledWith({ + elementIds: ['1'], + properties: { + backgroundColor: TEST_COLOR, + }, + }); + }); + + it('should allow applying color presets for text', () => { + const extraStylePresets = { + textColors: [TEST_COLOR], + }; + const { getByLabelText, updateElementsById } = setupPanel( + extraStylePresets + ); + + const applyPreset = getByLabelText('Apply color preset'); + expect(applyPreset).toBeDefined(); + + fireEvent.click(applyPreset); + expect(updateElementsById).toHaveBeenCalledTimes(1); + expect(updateElementsById).toHaveBeenCalledWith({ + elementIds: ['1'], + properties: { + color: TEST_COLOR, + }, + }); + }); + }); +}); diff --git a/assets/src/edit-story/components/panels/stylePreset/test/presetGroup.js b/assets/src/edit-story/components/panels/stylePreset/test/presetGroup.js new file mode 100644 index 000000000000..15b5414d9617 --- /dev/null +++ b/assets/src/edit-story/components/panels/stylePreset/test/presetGroup.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. + */ +/** + * External dependencies + */ +import { fireEvent } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import PresetGroup from '../presetGroup'; +import createSolid from '../../../../utils/createSolid'; +import { renderWithTheme } from '../../../../testUtils'; + +function setupPresetGroup() { + const itemRenderer = jest.fn(); + const presets = [ + createSolid(1, 1, 1), + createSolid(0, 0, 0), + createSolid(255, 255, 255), + ]; + + itemRenderer.mockImplementation((color, i, index) => { + return