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({