diff --git a/assets/src/edit-story/components/canvas/displayElement.js b/assets/src/edit-story/components/canvas/displayElement.js index acd53be59395..3c35b2c3503b 100644 --- a/assets/src/edit-story/components/canvas/displayElement.js +++ b/assets/src/edit-story/components/canvas/displayElement.js @@ -36,6 +36,7 @@ import { import StoryPropTypes from '../../types'; import { useTransformHandler } from '../transform'; import { useUnits } from '../../units'; +import { WithElementMask } from '../../masks'; const Wrapper = styled.div` ${elementWithPosition} @@ -72,7 +73,9 @@ function DisplayElement({ element }) { return ( - + + + ); } diff --git a/assets/src/edit-story/components/canvas/frameElement.js b/assets/src/edit-story/components/canvas/frameElement.js index 9a4c035d2527..4e784cb7d943 100644 --- a/assets/src/edit-story/components/canvas/frameElement.js +++ b/assets/src/edit-story/components/canvas/frameElement.js @@ -36,8 +36,13 @@ import { elementWithRotation, } from '../../elements/shared'; import { useUnits } from '../../units'; +import { WithElementMask } from '../../masks'; import useCanvas from './useCanvas'; +// @todo: should the frame borders follow clip lines? + +// Pointer events are disabled in the display mode to ensure that selection +// can be limited to the mask. const Wrapper = styled.div` ${elementWithPosition} ${elementWithSize} @@ -85,7 +90,9 @@ function FrameElement({ element }) { evt.stopPropagation(); }} > - {Frame && } + + {Frame && } + ); } diff --git a/assets/src/edit-story/components/panels/index.js b/assets/src/edit-story/components/panels/index.js index eb12200eeea2..824f146d3752 100644 --- a/assets/src/edit-story/components/panels/index.js +++ b/assets/src/edit-story/components/panels/index.js @@ -24,6 +24,7 @@ import ColorPanel from './color'; import BackgroundColorPanel from './backgroundColor'; import FillPanel from './fill'; import FontPanel from './font'; +import MaskPanel from './mask'; import RotationPanel from './rotationAngle'; import SizePanel from './size'; import PositionPanel from './position'; @@ -47,6 +48,7 @@ const FILL = 'fill'; const BACKGROUND_COLOR = 'backgroundColor'; const STYLE = 'style'; const VIDEO_POSTER = 'videoPoster'; +const MASK = 'mask'; export const PanelTypes = { ACTIONS, @@ -62,6 +64,7 @@ export const PanelTypes = { ROTATION_ANGLE, FILL, VIDEO_POSTER, + MASK, }; const ALL = Object.values(PanelTypes); @@ -126,6 +129,8 @@ export function getPanels(elements) { return { type, Panel: TextPanel }; case VIDEO_POSTER: return { type, Panel: VideoPosterPanel }; + case MASK: + return { type, Panel: MaskPanel }; default: throw new Error(`Unknown panel: ${type}`); } diff --git a/assets/src/edit-story/components/panels/mask.js b/assets/src/edit-story/components/panels/mask.js new file mode 100644 index 000000000000..671de1686928 --- /dev/null +++ b/assets/src/edit-story/components/panels/mask.js @@ -0,0 +1,91 @@ +/* + * 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'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { MASKS } from '../../masks'; +import { SimplePanel } from './panel'; +import getCommonValue from './utils/getCommonValue'; + +/* eslint-disable jsx-a11y/no-onchange */ +function MaskPanel({ selectedElements, onSetProperties }) { + const masks = selectedElements.map(({ mask }) => mask); + const type = masks.some((mask) => !mask) ? '' : getCommonValue(masks, 'type'); + const mask = MASKS.find((aMask) => aMask.type === type); + + const onTypeChanged = (evt) => { + const newType = evt.target.value; + if (newType === '') { + onSetProperties({ mask: null }); + } else { + const newMask = MASKS.find((aMask) => aMask.type === newType); + onSetProperties({ + mask: { + type: newType, + ...newMask.defaultProps, + }, + }); + } + }; + + return ( + + +
+ {mask && ( + + + + + + )} +
+
+ ); +} +/* eslint-enable jsx-a11y/no-onchange */ + +MaskPanel.propTypes = { + selectedElements: PropTypes.array.isRequired, + onSetProperties: PropTypes.func.isRequired, +}; + +export default MaskPanel; diff --git a/assets/src/edit-story/elements/image/edit.js b/assets/src/edit-story/elements/image/edit.js index e45c3ff0615d..a30eb3a4cefd 100644 --- a/assets/src/edit-story/elements/image/edit.js +++ b/assets/src/edit-story/elements/image/edit.js @@ -36,6 +36,7 @@ import { } from '../shared'; import { useStory } from '../../app'; import StoryPropTypes from '../../types'; +import { WithElementMask } from '../../masks'; import { imageWithScale } from './util'; import EditCropMovable from './editCropMovable'; @@ -55,10 +56,19 @@ const CropImg = styled.img` ${imageWithScale} `; -function ImageEdit({ - element: { id, src, origRatio, scale, focalX, focalY, isFill, isBackground }, - box: { x, y, width, height, rotationAngle }, -}) { +function ImageEdit({ element, box }) { + const { + id, + src, + origRatio, + scale, + focalX, + focalY, + isFill, + isBackground, + } = element; + const { x, y, width, height, rotationAngle } = box; + const [fullImage, setFullImage] = useState(null); const [croppedImage, setCroppedImage] = useState(null); const [cropBox, setCropBox] = useState(null); @@ -84,12 +94,14 @@ function ImageEdit({ - + + + {!isFill && !isBackground && cropBox && croppedImage && ( diff --git a/assets/src/edit-story/elements/image/index.js b/assets/src/edit-story/elements/image/index.js index 518442dff6f4..449738c9bab8 100644 --- a/assets/src/edit-story/elements/image/index.js +++ b/assets/src/edit-story/elements/image/index.js @@ -43,4 +43,5 @@ export const panels = [ PanelTypes.SCALE, PanelTypes.ROTATION_ANGLE, PanelTypes.FILL, + PanelTypes.MASK, ]; diff --git a/assets/src/edit-story/elements/square/index.js b/assets/src/edit-story/elements/square/index.js index d33c52247699..10f50fd81902 100644 --- a/assets/src/edit-story/elements/square/index.js +++ b/assets/src/edit-story/elements/square/index.js @@ -35,4 +35,5 @@ export const panels = [ PanelTypes.BACKGROUND_COLOR, PanelTypes.ROTATION_ANGLE, PanelTypes.FILL, + PanelTypes.MASK, ]; diff --git a/assets/src/edit-story/elements/video/index.js b/assets/src/edit-story/elements/video/index.js index 46feaa16f3a4..da49e915c5d9 100644 --- a/assets/src/edit-story/elements/video/index.js +++ b/assets/src/edit-story/elements/video/index.js @@ -44,4 +44,5 @@ export const panels = [ PanelTypes.ROTATION_ANGLE, PanelTypes.VIDEO_POSTER, PanelTypes.FILL, + PanelTypes.MASK, ]; diff --git a/assets/src/edit-story/masks/.eslintrc b/assets/src/edit-story/masks/.eslintrc new file mode 100644 index 000000000000..4cd302042b61 --- /dev/null +++ b/assets/src/edit-story/masks/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "no-restricted-imports": ["error", { "paths": ["styled-components", "@wordpress/element"] }] + } +} diff --git a/assets/src/edit-story/masks/index.js b/assets/src/edit-story/masks/index.js new file mode 100644 index 000000000000..3f45955c6c31 --- /dev/null +++ b/assets/src/edit-story/masks/index.js @@ -0,0 +1,148 @@ +/* + * 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'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import StoryPropTypes from '../types'; + +// Important! This file cannot use `styled-components` or any stateful/context +// React features to stay compatible with the "output" templates. + +const MaskIds = { + HEART: 'heart', + STAR: 'star', +}; + +const CLIP_PATHS = { + // @todo: This is a very bad heart. + [MaskIds.HEART]: + 'M 0.5,1 C 0.5,1,0,0.7,0,0.3 A 0.25,0.25,1,1,1,0.5,0.3 A 0.25,0.25,1,1,1,1,0.3 C 1,0.7,0.5,1,0.5,1 Z', + // @todo: This is a horrible star. + [MaskIds.STAR]: 'M .5,0 L .8,1 L 0,.4 L 1,.4 L .2,1 Z', + // viewbox = [0 0 163 155] + // M 81.5 0 L 100.696 59.079 H 162.815 L 112.56 95.5919 L 131.756 154.671 L 81.5 118.158 L 31.2444 154.671 L 50.4403 95.5919 L 0.184669 59.079H62.3041L81.5 0 Z +}; + +export const MASKS = [ + { + type: MaskIds.HEART, + name: __('Heart', 'web-stories'), + path: CLIP_PATHS[MaskIds.HEART], + }, + { + type: MaskIds.STAR, + name: __('Star', 'web-stories'), + path: CLIP_PATHS[MaskIds.STAR], + }, +]; + +const FILL_STYLE = { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, +}; + +export function WithElementMask({ element, fill, style, children, ...rest }) { + const mask = getElementMaskProperties(element); + return ( + + {children} + + ); +} + +WithElementMask.propTypes = { + element: StoryPropTypes.element.isRequired, + style: PropTypes.object, + fill: PropTypes.bool, + children: StoryPropTypes.children.isRequired, +}; + +function WithtMask({ elementId, mask, fill, style, children, ...rest }) { + const maskType = (mask && mask.type) || null; + + const fillStyle = fill ? FILL_STYLE : null; + + const allStyles = { + ...fillStyle, + ...style, + }; + + if (maskType) { + // @todo: Chrome cannot do inline clip-path using data: URLs. + // See https://bugs.chromium.org/p/chromium/issues/detail?id=1041024. + + const maskId = `mask-${maskType}-${elementId}`; + allStyles.clipPath = `url(#${maskId})`; + + return ( +
+ + + + + + + + {children} +
+ ); + } + return ( +
+ {children} +
+ ); +} + +WithtMask.propTypes = { + elementId: PropTypes.string.isRequired, + mask: StoryPropTypes.mask, + style: PropTypes.object, + fill: PropTypes.bool, + children: StoryPropTypes.children.isRequired, +}; + +function getElementMaskProperties({ type, mask, ...rest }) { + if (mask) { + return mask; + } + return getDefaultElementMaskProperties({ type, ...rest }); +} + +function getDefaultElementMaskProperties({}) { + // @todo: mask-based shapes (square, circle, etc) automatically assume masks. + return null; +} diff --git a/assets/src/edit-story/output/element.js b/assets/src/edit-story/output/element.js index 20a61af5c693..c267264b7553 100644 --- a/assets/src/edit-story/output/element.js +++ b/assets/src/edit-story/output/element.js @@ -18,6 +18,7 @@ * Internal dependencies */ import StoryPropTypes from '../types'; +import { WithElementMask } from '../masks'; import { getDefinitionForType } from '../elements'; import { getBox } from '../units/dimensions'; @@ -32,7 +33,8 @@ function OutputElement({ element }) { const { x, y, width, height, rotationAngle } = box; return ( -
-
+ ); } diff --git a/assets/src/edit-story/types.js b/assets/src/edit-story/types.js index f459985deef8..c7aa511592c5 100644 --- a/assets/src/edit-story/types.js +++ b/assets/src/edit-story/types.js @@ -21,6 +21,10 @@ import PropTypes from 'prop-types'; const StoryPropTypes = {}; +StoryPropTypes.mask = PropTypes.shape({ + type: PropTypes.string.isRequired, +}); + export const StoryElementPropsTypes = { id: PropTypes.string.isRequired, type: PropTypes.string.isRequired, @@ -30,6 +34,7 @@ export const StoryElementPropsTypes = { height: PropTypes.number.isRequired, rotationAngle: PropTypes.number.isRequired, isFill: PropTypes.bool, + mask: StoryPropTypes.mask, }; StoryPropTypes.size = PropTypes.exact({