From 4d0d23de291617aa078f8dc595f96e036ae7d2ca Mon Sep 17 00:00:00 2001 From: Rebecca Thompson <33927854+rebecca-thompson@users.noreply.github.com> Date: Thu, 23 Mar 2023 09:39:35 +0000 Subject: [PATCH] feat: Cartoon element (#286) Co-authored-by: Parisa Tork --- demo/helpers.ts | 6 +- demo/index.ts | 45 ++- ...erticalFieldLayout.tsx => FieldLayout.tsx} | 7 + .../SvgCrossRound.tsx | 17 + src/elements/cartoon/CartoonForm.tsx | 308 ++++++++++++++++++ src/elements/cartoon/CartoonSpec.tsx | 72 ++++ .../cartoon/cartoonDataTransformer.ts | 99 ++++++ src/elements/code/CodeElementForm.tsx | 2 +- src/elements/comment/CommentForm.tsx | 2 +- src/elements/content-atom/ContentAtomForm.tsx | 2 +- .../demo-image/DemoImageElementForm.tsx | 2 +- src/elements/deprecated/DeprecatedForm.tsx | 2 +- src/elements/embed/Callout.tsx | 2 +- src/elements/embed/EmbedForm.tsx | 2 +- src/elements/helpers/getImageSrc.ts | 20 ++ src/elements/helpers/transform.ts | 2 + src/elements/helpers/types/Media.ts | 32 ++ src/elements/image/ImageElement.tsx | 36 +- src/elements/image/ImageElementForm.tsx | 33 +- .../image/imageElementDataTransformer.ts | 3 +- src/elements/interactive/InteractiveForm.tsx | 2 +- src/elements/membership/MembershipForm.tsx | 2 +- src/elements/pullquote/PullquoteForm.tsx | 2 +- src/elements/rich-link/RichlinkForm.tsx | 2 +- src/elements/standard/StandardForm.tsx | 2 +- src/elements/table/TableForm.tsx | 2 +- src/elements/tweet/TweetForm.tsx | 2 +- src/index.ts | 1 + src/plugin/helpers/validation.ts | 36 +- 29 files changed, 663 insertions(+), 82 deletions(-) rename src/editorial-source-components/{VerticalFieldLayout.tsx => FieldLayout.tsx} (60%) create mode 100644 src/editorial-source-components/SvgCrossRound.tsx create mode 100644 src/elements/cartoon/CartoonForm.tsx create mode 100644 src/elements/cartoon/CartoonSpec.tsx create mode 100644 src/elements/cartoon/cartoonDataTransformer.ts create mode 100644 src/elements/helpers/getImageSrc.ts create mode 100644 src/elements/helpers/types/Media.ts diff --git a/demo/helpers.ts b/demo/helpers.ts index e5effe25..627f761b 100644 --- a/demo/helpers.ts +++ b/demo/helpers.ts @@ -2,7 +2,7 @@ import type { EditorState, Transaction } from "prosemirror-state"; import { Plugin } from "prosemirror-state"; import type { DemoSetMedia } from "../src/elements/demo-image/DemoImageElement"; import type { Asset } from "../src/elements/helpers/defaultTransform"; -import type { SetMedia } from "../src/elements/image/ImageElement"; +import type { SetMedia } from "../src/elements/helpers/types/Media"; type GridAsset = { mimeType: string; @@ -17,7 +17,7 @@ type GridResponse = { metadata: { description: string; suppliersReference: string; - source: string; + credit: string; byline: string; }; id: string; @@ -80,7 +80,7 @@ const handleGridResponse = (setMedia: SetMedia) => ({ data }: GridResponse) => { suppliersReference: data.image.data.metadata.suppliersReference, caption: data.image.data.metadata.description, photographer: data.image.data.metadata.byline, - source: data.image.data.metadata.source, + source: data.image.data.metadata.credit, }); }; diff --git a/demo/index.ts b/demo/index.ts index 1bea1587..aee8f22b 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -10,6 +10,8 @@ import { schema as basicSchema, marks } from "prosemirror-schema-basic"; import { EditorState } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { + buildElementPlugin, + cartoonElement, codeElement, commentElement, createCalloutElement, @@ -25,11 +27,10 @@ import { pullquoteElement, richlinkElement, tableElement, + transformElementOut, undefinedDropdownValue, } from "../src"; -import { transformElementOut } from "../src/elements/helpers/transform"; -import type { MediaPayload } from "../src/elements/image/ImageElement"; -import { buildElementPlugin } from "../src/plugin/element"; +import type { MediaPayload } from "../src/elements/helpers/types/Media"; import { createParsers, docToHtml, @@ -93,6 +94,7 @@ const tweetElementName = "tweet"; const contentAtomName = "content-atom"; const commentElementName = "comment"; const campaignCalloutListElementName = "callout"; +const cartoonElementName = "cartoon"; type Name = | typeof embedElementName @@ -115,7 +117,8 @@ type Name = | typeof tweetElementName | typeof contentAtomName | typeof commentElementName - | typeof campaignCalloutListElementName; + | typeof campaignCalloutListElementName + | typeof cartoonElementName; const createCaptionPlugins = (schema: Schema) => exampleSetup({ schema }); const mockThirdPartyTracking = (html: string) => @@ -208,6 +211,7 @@ const { vine: deprecatedElement, instagram: deprecatedElement, comment: commentElement, + cartoon: cartoonElement(onCropImage, createCaptionPlugins), tweet: createTweetElement({ checkThirdPartyTracking: mockThirdPartyTracking, createCaptionPlugins, @@ -418,7 +422,7 @@ const createEditor = (server: CollabServer) => { ); const imageElementButton = document.createElement("button"); - imageElementButton.innerHTML = "Image element"; + imageElementButton.innerHTML = "Add Image"; imageElementButton.id = imageElementName; imageElementButton.addEventListener("click", () => { const setMedia = (mediaPayload: MediaPayload) => { @@ -443,12 +447,39 @@ const createEditor = (server: CollabServer) => { }; onCropImage(setMedia); }); + btnContainer.appendChild(imageElementButton); + + const cartoonElementButton = document.createElement("button"); + cartoonElementButton.innerHTML = "Add Cartoon"; + cartoonElementButton.id = cartoonElementName; + cartoonElementButton.addEventListener("click", () => { + const setMedia = (mediaPayload: MediaPayload) => { + const { + photographer, + mediaId, + mediaApiUri, + assets, + suppliersReference, + caption, + source, + } = mediaPayload; + insertElement({ + elementName: cartoonElementName, + values: { + desktopImages: [{ assets, suppliersReference, mediaId, mediaApiUri }], + credit: photographer, + source, + caption, + }, + })(view.state, view.dispatch); + }; + onCropImage(setMedia); + }); + btnContainer.appendChild(cartoonElementButton); // Add a button allowing you to toggle the image role fields - btnContainer.appendChild(imageElementButton); const toggleImageFields = document.createElement("button"); toggleImageFields.innerHTML = "Randomise image role options"; - toggleImageFields.addEventListener("click", () => { updateAdditionalRoleOptions( [...additionalRoleOptions].splice(Math.floor(Math.random() * 3), 2) diff --git a/src/editorial-source-components/VerticalFieldLayout.tsx b/src/editorial-source-components/FieldLayout.tsx similarity index 60% rename from src/editorial-source-components/VerticalFieldLayout.tsx rename to src/editorial-source-components/FieldLayout.tsx index bd0f093b..9537f99c 100644 --- a/src/editorial-source-components/VerticalFieldLayout.tsx +++ b/src/editorial-source-components/FieldLayout.tsx @@ -6,3 +6,10 @@ export const FieldLayoutVertical = styled.div` flex-direction: column; gap: ${space[3]}px; `; + +export const FieldLayoutHorizontal = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: ${space[3]}px; +`; diff --git a/src/editorial-source-components/SvgCrossRound.tsx b/src/editorial-source-components/SvgCrossRound.tsx new file mode 100644 index 00000000..cf7d203a --- /dev/null +++ b/src/editorial-source-components/SvgCrossRound.tsx @@ -0,0 +1,17 @@ +import { iconSize } from "@guardian/src-foundations/size"; +import type { FunctionComponent } from "react"; +import React from "react"; + +export const SvgCrossRound: FunctionComponent = () => ( + + + +); diff --git a/src/elements/cartoon/CartoonForm.tsx b/src/elements/cartoon/CartoonForm.tsx new file mode 100644 index 00000000..469e5792 --- /dev/null +++ b/src/elements/cartoon/CartoonForm.tsx @@ -0,0 +1,308 @@ +import { css } from "@emotion/react"; +import { neutral, space } from "@guardian/src-foundations"; +import { Column, Columns } from "@guardian/src-layout"; +import type { Schema } from "prosemirror-model"; +import type { Plugin } from "prosemirror-state"; +import type { FunctionComponent } from "react"; +import React, { useMemo } from "react"; +import { Button } from "../../editorial-source-components/Button"; +import { + FieldLayoutHorizontal, + FieldLayoutVertical, +} from "../../editorial-source-components/FieldLayout"; +import { FieldWrapper } from "../../editorial-source-components/FieldWrapper"; +import { InputHeading } from "../../editorial-source-components/InputHeading"; +import { SvgCrop } from "../../editorial-source-components/SvgCrop"; +import { SvgCrossRound } from "../../editorial-source-components/SvgCrossRound"; +import { Tooltip } from "../../editorial-source-components/Tooltip"; +import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; +import { CustomCheckboxView } from "../../renderers/react/customFieldViewComponents/CustomCheckboxView"; +import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; +import { getImageSrc } from "../helpers/getImageSrc"; +import type { + ImageSelector, + MainImageData, + MediaPayload, +} from "../helpers/types/Media"; +import { cartoonFields } from "./CartoonSpec"; + +export const cartoonElement = ( + imageSelector: ImageSelector, + createCaptionPlugins: (schema: Schema) => Plugin[] +) => { + return createReactElementSpec( + cartoonFields(imageSelector, createCaptionPlugins), + ({ fields }) => { + const addImageAtIndex = ( + mediaPayload: MediaPayload, + images: MainImageData[], + index?: number + ) => { + if (index !== undefined && index > -1 && index < images.length) { + return images.map((image, i) => { + if (i === index) { + return mediaPayload; + } else { + return image; + } + }); + } else { + return [...images, mediaPayload]; + } + }; + + return ( + + { + fields.desktopImages.description.props.imageSelector( + (mediaPayload: MediaPayload) => + fields.desktopImages.update( + addImageAtIndex( + mediaPayload, + fields.desktopImages.value, + index + ) + ), + mediaId + ); + }} + removeImage={(index) => { + fields.desktopImages.update( + fields.desktopImages.value.filter((_, i) => i !== index) + ); + }} + required={true} + mainMediaId={fields.desktopImages.value[0]?.mediaId} + /> + { + fields.mobileImages.description.props.imageSelector( + (mediaPayload: MediaPayload) => + fields.mobileImages.update( + addImageAtIndex( + mediaPayload, + fields.mobileImages.value, + index + ) + ), + mediaId + ); + }} + removeImage={(index) => { + fields.mobileImages.update( + fields.mobileImages.value.filter((_, i) => i !== index) + ); + }} + mainMediaId={fields.desktopImages.value[0]?.mediaId} + /> + + + Alt text + + } + headingContent={ + <> + +

+ ‘Alt text’ describes what’s in an image. It helps users of + screen readers understand our images, and improves our SEO. +

+

+ + Find out more + +

+
+ + + } + /> + + + + + + + + + + + + + + + + + + + + + + +
+ ); + } + ); +}; + +const ImageSet: FunctionComponent<{ + label: string; + images: MainImageData[]; + alt: string; + addImage: (mediaId?: string, index?: number) => void; + removeImage: (index: number) => void; + required?: boolean; + mainMediaId?: string; +}> = ({ + label, + images, + alt, + addImage, + removeImage, + required = false, + mainMediaId, +}) => { + return ( +
+ + + {images.map((image, index) => ( + + ))} + + +
+ ); +}; + +const imageThumbnailStyle = css` + position: relative; + width: 150px; + height: 150px; + background-color: #ccc; + img { + position: absolute; + max-width: 100%; + max-height: 100%; + inset: 0; + margin: auto; + } +`; + +const removeImageButton = css` + position: absolute; + top: -10px; + right: -10px; + padding: 0; + border: 0; + background: none; + cursor: pointer; + svg { + fill: ${neutral[20]}; + } + :hover svg { + fill: ${neutral[60]}; + } +`; + +const ImageThumbnail: FunctionComponent<{ + index: number; + image: MainImageData; + alt: string; + recropImage: (mediaId?: string, index?: number) => void; + removeImage: (index: number) => void; + required: boolean; +}> = ({ index, image, alt, recropImage, removeImage, required }) => { + return ( +
+
+ {!required && ( + + )} + getImageSrc(image.assets, 1200), [image.assets])} + alt={alt} + > +
+ {required && ( + + )} +
+ ); +}; diff --git a/src/elements/cartoon/CartoonSpec.tsx b/src/elements/cartoon/CartoonSpec.tsx new file mode 100644 index 00000000..beb6525a --- /dev/null +++ b/src/elements/cartoon/CartoonSpec.tsx @@ -0,0 +1,72 @@ +import type { Schema } from "prosemirror-model"; +import type { Plugin } from "prosemirror-state"; +import { + createCustomDropdownField, + createCustomField, +} from "../../plugin/fieldViews/CustomFieldView"; +import { createFlatRichTextField } from "../../plugin/fieldViews/RichTextFieldView"; +import { createTextField } from "../../plugin/fieldViews/TextFieldView"; +import { undefinedDropdownValue } from "../../plugin/helpers/constants"; +import { + htmlMaxLength, + htmlRequired, + numbersOnly, + validHexidecimalValue, +} from "../../plugin/helpers/validation"; +import { useTyperighterAttrs } from "../helpers/typerighter"; +import type { ImageSelector, MainImageData } from "../helpers/types/Media"; +import { minAssetValidation } from "../image/ImageElement"; + +export const cartoonFields = ( + imageSelector: ImageSelector, + createCaptionPlugins: (schema: Schema) => Plugin[] +) => { + return { + desktopImages: createCustomField< + MainImageData[], + { imageSelector: ImageSelector } + >([], { imageSelector }, [minAssetValidation]), + mobileImages: createCustomField< + MainImageData[], + { imageSelector: ImageSelector } + >([], { imageSelector }, [minAssetValidation]), + caption: createFlatRichTextField({ + createPlugins: createCaptionPlugins, + marks: "em strong link strike", + validators: [htmlMaxLength(1000, undefined, "WARN")], + placeholder: "Enter a caption for this cartoon…", + attrs: useTyperighterAttrs, + }), + alt: createTextField({ + rows: 2, + validators: [htmlMaxLength(1000), htmlRequired()], + placeholder: "Describe the cartoon for visually impaired readers", + isResizeable: true, + attrs: useTyperighterAttrs, + }), + credit: createTextField({ + validators: [htmlMaxLength(100)], + placeholder: "Enter the artist...", + }), + source: createTextField({ + validators: [htmlMaxLength(100)], + placeholder: "Enter the source...", + }), + displayCredit: createCustomField(true, true), + role: createCustomDropdownField(undefinedDropdownValue, [ + { text: "inline (default)", value: undefinedDropdownValue }, + { text: "supporting", value: "supporting" }, + { text: "showcase", value: "showcase" }, + { text: "thumbnail", value: "thumbnail" }, + { text: "immersive", value: "immersive" }, + ]), + verticalPadding: createTextField({ + validators: [htmlMaxLength(2), numbersOnly()], + placeholder: "20", + }), + backgroundColour: createTextField({ + validators: [htmlMaxLength(6), validHexidecimalValue()], + placeholder: "FFFFFF", + }), + }; +}; diff --git a/src/elements/cartoon/cartoonDataTransformer.ts b/src/elements/cartoon/cartoonDataTransformer.ts new file mode 100644 index 00000000..76e7df86 --- /dev/null +++ b/src/elements/cartoon/cartoonDataTransformer.ts @@ -0,0 +1,99 @@ +import type { Breakpoint } from "@guardian/src-foundations"; +import { undefinedDropdownValue } from "../../plugin/helpers/constants"; +import type { FieldNameToValueMap } from "../../plugin/helpers/fieldView"; +import type { Asset } from "../helpers/defaultTransform"; +import type { MainImageData } from "../helpers/types/Media"; +import type { TransformIn, TransformOut } from "../helpers/types/Transform"; +import type { cartoonFields } from "./CartoonSpec"; + +export type Element = { + elementType: string; + fields: Record; + assets: Asset[]; + elements?: Element[]; +}; + +export const transformElementIn: TransformIn< + Element, + ReturnType +> = ({ fields, elements }) => { + const { role, photographer, caption, source } = fields; + + const getImages = (breakpoint: Breakpoint): MainImageData[] => { + if (Array.isArray(elements)) { + return elements + .filter( + (element) => + element.elementType === "image" && + element.fields.breakpoint === breakpoint + ) + .map((element) => { + return { + mediaId: element.fields.mediaId, + mediaApiUri: element.fields.mediaApiUri, + assets: element.assets, + caption: element.fields.caption, + }; + }); + } else { + return []; + } + }; + + return { + role: role ?? undefinedDropdownValue, + credit: photographer, + source, + alt: caption, + desktopImages: getImages("desktop"), + mobileImages: getImages("mobile"), + }; +}; + +export const transformElementOut: TransformOut< + Element, + ReturnType +> = ({ + desktopImages, + mobileImages, + displayCredit, + role, + ...rest +}: FieldNameToValueMap>): Element => { + const getElementFromImage = ( + image: MainImageData, + breakpoint: Breakpoint + ) => { + const { assets, ...rest } = image; + return { + elementType: "image", + fields: { + breakpoint, + ...rest, + }, + assets, + }; + }; + + const elements = mobileImages + .map((image) => getElementFromImage(image, "mobile")) + .concat( + desktopImages.map((image) => getElementFromImage(image, "desktop")) + ); + + return { + elementType: "cartoon", + fields: { + displayCredit: displayCredit.toString(), + role: role === undefinedDropdownValue ? undefined : role, + ...rest, + }, + elements, + assets: [], + }; +}; + +export const transformElement = { + in: transformElementIn, + out: transformElementOut, +}; diff --git a/src/elements/code/CodeElementForm.tsx b/src/elements/code/CodeElementForm.tsx index ec20d582..757d5485 100644 --- a/src/elements/code/CodeElementForm.tsx +++ b/src/elements/code/CodeElementForm.tsx @@ -1,6 +1,6 @@ import React from "react"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { FieldWrapper } from "../../editorial-source-components/FieldWrapper"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; import { codeFields } from "./CodeElementSpec"; diff --git a/src/elements/comment/CommentForm.tsx b/src/elements/comment/CommentForm.tsx index 920b1d45..dd1f9105 100644 --- a/src/elements/comment/CommentForm.tsx +++ b/src/elements/comment/CommentForm.tsx @@ -1,4 +1,4 @@ -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; import { Preview } from "../helpers/Preview"; diff --git a/src/elements/content-atom/ContentAtomForm.tsx b/src/elements/content-atom/ContentAtomForm.tsx index ad7eefbf..b97373c4 100644 --- a/src/elements/content-atom/ContentAtomForm.tsx +++ b/src/elements/content-atom/ContentAtomForm.tsx @@ -1,8 +1,8 @@ import { upperFirst } from "lodash"; import { useEffect, useState } from "react"; import { Error } from "../../editorial-source-components/Error"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { Label, NonBoldLabel } from "../../editorial-source-components/Label"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import { undefinedDropdownValue } from "../../plugin/helpers/constants"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomCheckboxView } from "../../renderers/react/customFieldViewComponents/CustomCheckboxView"; diff --git a/src/elements/demo-image/DemoImageElementForm.tsx b/src/elements/demo-image/DemoImageElementForm.tsx index 5f476dc3..e90366ee 100644 --- a/src/elements/demo-image/DemoImageElementForm.tsx +++ b/src/elements/demo-image/DemoImageElementForm.tsx @@ -1,6 +1,6 @@ import React from "react"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { FieldWrapper } from "../../editorial-source-components/FieldWrapper"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import type { CustomField } from "../../plugin/types/Element"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; diff --git a/src/elements/deprecated/DeprecatedForm.tsx b/src/elements/deprecated/DeprecatedForm.tsx index 820cdaad..edc07c29 100644 --- a/src/elements/deprecated/DeprecatedForm.tsx +++ b/src/elements/deprecated/DeprecatedForm.tsx @@ -1,8 +1,8 @@ import { upperFirst } from "lodash"; import React from "react"; import { Description } from "../../editorial-source-components/Description"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { InputHeading } from "../../editorial-source-components/InputHeading"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { fields } from "./DeprecatedSpec"; diff --git a/src/elements/embed/Callout.tsx b/src/elements/embed/Callout.tsx index 34ac3ce4..2a8bff5d 100644 --- a/src/elements/embed/Callout.tsx +++ b/src/elements/embed/Callout.tsx @@ -3,8 +3,8 @@ import { neutral, space, text } from "@guardian/src-foundations"; import { textSans } from "@guardian/src-foundations/typography"; import React, { useEffect, useState } from "react"; import { Error } from "../../editorial-source-components/Error"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { Label } from "../../editorial-source-components/Label"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import type { Campaign } from "../callout/CalloutTypes"; import { EmbedTestId } from "./EmbedForm"; diff --git a/src/elements/embed/EmbedForm.tsx b/src/elements/embed/EmbedForm.tsx index a03397bd..6896121d 100644 --- a/src/elements/embed/EmbedForm.tsx +++ b/src/elements/embed/EmbedForm.tsx @@ -1,8 +1,8 @@ import type { Schema } from "prosemirror-model"; import type { Plugin } from "prosemirror-state"; import React from "react"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { FieldWrapper } from "../../editorial-source-components/FieldWrapper"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomCheckboxView } from "../../renderers/react/customFieldViewComponents/CustomCheckboxView"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; diff --git a/src/elements/helpers/getImageSrc.ts b/src/elements/helpers/getImageSrc.ts new file mode 100644 index 00000000..995af76b --- /dev/null +++ b/src/elements/helpers/getImageSrc.ts @@ -0,0 +1,20 @@ +import type { Asset } from "./defaultTransform"; + +export const getImageSrc = (assets: Asset[], desiredWidth: number) => { + const widthDifference = (width: number) => Math.abs(desiredWidth - width); + + const stringOrNumberToNumber = (value: string | number) => { + const parsedValue = parseInt(value.toString()); + return !isNaN(parsedValue) ? parsedValue : 0; + }; + + const sortByWidthDifference = (assetA: Asset, assetB: Asset) => + widthDifference(stringOrNumberToNumber(assetA.fields.width)) - + widthDifference(stringOrNumberToNumber(assetB.fields.width)); + + const sortedAssets = assets + .filter((asset) => !asset.fields.isMaster) + .sort(sortByWidthDifference); + + return sortedAssets.length > 0 ? sortedAssets[0].url : undefined; +}; diff --git a/src/elements/helpers/transform.ts b/src/elements/helpers/transform.ts index 40240e79..e6e19574 100644 --- a/src/elements/helpers/transform.ts +++ b/src/elements/helpers/transform.ts @@ -1,4 +1,5 @@ import { transformElement as calloutElementTransform } from "../callout/calloutDataTransformer"; +import { transformElement as cartoonElementTransform } from "../cartoon/cartoonDataTransformer"; import type { codeFields } from "../code/CodeElementSpec"; import type { commentFields } from "../comment/CommentSpec"; import type { contentAtomFields } from "../content-atom/ContentAtomSpec"; @@ -23,6 +24,7 @@ const transformMap = { embed: embedElementTransform, callout: calloutElementTransform, image: imageElementTransform, + cartoon: cartoonElementTransform, interactive: interactiveElementTransform, pullquote: defaultElementTransform(), "rich-link": defaultElementTransform({ diff --git a/src/elements/helpers/types/Media.ts b/src/elements/helpers/types/Media.ts new file mode 100644 index 00000000..41f7711c --- /dev/null +++ b/src/elements/helpers/types/Media.ts @@ -0,0 +1,32 @@ +import type { Schema } from "prosemirror-model"; +import type { Plugin } from "prosemirror-state"; +import type { Options } from "../../../plugin/fieldViews/DropdownFieldView"; +import type { Asset } from "../defaultTransform"; + +export type MediaPayload = { + mediaId: string; + mediaApiUri: string; + assets: Asset[]; + suppliersReference: string; + caption: string; + photographer: string; + source: string; +}; + +export type SetMedia = (mediaPayload: MediaPayload) => void; + +export type MainImageData = { + mediaId?: string | undefined; + mediaApiUri?: string | undefined; + assets: Asset[]; + suppliersReference?: string; + caption?: string; +}; + +export type ImageSelector = (setMedia: SetMedia, mediaId?: string) => void; + +export type ImageElementOptions = { + openImageSelector: ImageSelector; + createCaptionPlugins?: (schema: Schema) => Plugin[]; + additionalRoleOptions: Options; +}; diff --git a/src/elements/image/ImageElement.tsx b/src/elements/image/ImageElement.tsx index cc95b71d..dcffe16f 100644 --- a/src/elements/image/ImageElement.tsx +++ b/src/elements/image/ImageElement.tsx @@ -1,45 +1,19 @@ -import type { Schema } from "prosemirror-model"; -import type { Plugin } from "prosemirror-state"; import { createCustomDropdownField, createCustomField, } from "../../plugin/fieldViews/CustomFieldView"; -import type { Options } from "../../plugin/fieldViews/DropdownFieldView"; import { createFlatRichTextField } from "../../plugin/fieldViews/RichTextFieldView"; import { createTextField } from "../../plugin/fieldViews/TextFieldView"; import { undefinedDropdownValue } from "../../plugin/helpers/constants"; import { htmlMaxLength, htmlRequired } from "../../plugin/helpers/validation"; -import type { Asset } from "../helpers/defaultTransform"; import { useTyperighterAttrs } from "../helpers/typerighter"; +import type { + ImageElementOptions, + ImageSelector, + MainImageData, +} from "../helpers/types/Media"; import { largestAssetMinDimension } from "./imageElementValidation"; -export type MediaPayload = { - mediaId: string; - mediaApiUri: string; - assets: Asset[]; - suppliersReference: string; - caption: string; - photographer: string; - source: string; -}; - -export type SetMedia = (mediaPayload: MediaPayload) => void; - -export type MainImageData = { - mediaId?: string | undefined; - mediaApiUri?: string | undefined; - assets: Asset[]; - suppliersReference?: string; -}; - -export type ImageSelector = (setMedia: SetMedia, mediaId?: string) => void; - -export type ImageElementOptions = { - openImageSelector: ImageSelector; - createCaptionPlugins?: (schema: Schema) => Plugin[]; - additionalRoleOptions: Options; -}; - export const minAssetValidation = largestAssetMinDimension(460); export const createImageFields = ({ diff --git a/src/elements/image/ImageElementForm.tsx b/src/elements/image/ImageElementForm.tsx index 045029f5..50c943b3 100644 --- a/src/elements/image/ImageElementForm.tsx +++ b/src/elements/image/ImageElementForm.tsx @@ -5,10 +5,10 @@ import { Column, Columns } from "@guardian/src-layout"; import React, { useContext, useEffect, useMemo } from "react"; import { Button } from "../../editorial-source-components/Button"; import { Error } from "../../editorial-source-components/Error"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { FieldWrapper } from "../../editorial-source-components/FieldWrapper"; import { SvgCrop } from "../../editorial-source-components/SvgCrop"; import { Tooltip } from "../../editorial-source-components/Tooltip"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import type { Options } from "../../plugin/fieldViews/DropdownFieldView"; import type { CustomField } from "../../plugin/types/Element"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; @@ -17,15 +17,15 @@ import { CustomDropdownView } from "../../renderers/react/customFieldViewCompone import { createStore } from "../../renderers/react/store"; import { TelemetryContext } from "../../renderers/react/TelemetryContext"; import { useCustomFieldState } from "../../renderers/react/useCustomFieldViewState"; -import type { Asset } from "../helpers/defaultTransform"; -import { htmlLength } from "../helpers/validation"; +import { getImageSrc } from "../helpers/getImageSrc"; import type { ImageElementOptions, ImageSelector, MainImageData, MediaPayload, SetMedia, -} from "./ImageElement"; +} from "../helpers/types/Media"; +import { htmlLength } from "../helpers/validation"; import { createImageFields, minAssetValidation } from "./ImageElement"; import { ImageElementTelemetryType } from "./imageElementTelemetryEvents"; @@ -34,7 +34,7 @@ type ImageViewProps = { field: CustomField; }; -const AltText = styled.span` +export const AltText = styled.span` margin-right: ${space[2]}px; `; @@ -234,26 +234,9 @@ const ImageView = ({ field, updateFields }: ImageViewProps) => { } }; - const imageSrc = useMemo(() => { - const desiredWidth = 1200; - - const widthDifference = (width: number) => Math.abs(desiredWidth - width); - - const stringOrNumberToNumber = (value: string | number) => { - const parsedValue = parseInt(value.toString()); - return !isNaN(parsedValue) ? parsedValue : 0; - }; - - const sortByWidthDifference = (assetA: Asset, assetB: Asset) => - widthDifference(stringOrNumberToNumber(assetA.fields.width)) - - widthDifference(stringOrNumberToNumber(assetB.fields.width)); - - const sortedAssets = imageFields.assets - .filter((asset) => !asset.fields.isMaster) - .sort(sortByWidthDifference); - - return sortedAssets.length > 0 ? sortedAssets[0].url : undefined; - }, [imageFields.assets]); + const imageSrc = useMemo(() => getImageSrc(imageFields.assets, 1200), [ + imageFields.assets, + ]); return (
diff --git a/src/elements/image/imageElementDataTransformer.ts b/src/elements/image/imageElementDataTransformer.ts index 6aaa3850..f5ca7e34 100644 --- a/src/elements/image/imageElementDataTransformer.ts +++ b/src/elements/image/imageElementDataTransformer.ts @@ -2,8 +2,9 @@ import pickBy from "lodash/pickBy"; import { undefinedDropdownValue } from "../../plugin/helpers/constants"; import type { FieldNameToValueMap } from "../../plugin/helpers/fieldView"; import type { Asset } from "../helpers/defaultTransform"; +import type { MainImageData } from "../helpers/types/Media"; import type { TransformIn, TransformOut } from "../helpers/types/Transform"; -import type { createImageFields, MainImageData } from "./ImageElement"; +import type { createImageFields } from "./ImageElement"; export type ImageFields = { alt?: string; diff --git a/src/elements/interactive/InteractiveForm.tsx b/src/elements/interactive/InteractiveForm.tsx index 66a28ef0..bf24ccec 100644 --- a/src/elements/interactive/InteractiveForm.tsx +++ b/src/elements/interactive/InteractiveForm.tsx @@ -1,7 +1,7 @@ import React from "react"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { FieldWrapper } from "../../editorial-source-components/FieldWrapper"; import { Link } from "../../editorial-source-components/Link"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomCheckboxView } from "../../renderers/react/customFieldViewComponents/CustomCheckboxView"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; diff --git a/src/elements/membership/MembershipForm.tsx b/src/elements/membership/MembershipForm.tsx index 59703d69..83977f1f 100644 --- a/src/elements/membership/MembershipForm.tsx +++ b/src/elements/membership/MembershipForm.tsx @@ -1,9 +1,9 @@ import { css } from "@emotion/react"; import { space } from "@guardian/src-foundations"; import React from "react"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { InputHeading } from "../../editorial-source-components/InputHeading"; import { Link } from "../../editorial-source-components/Link"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; import { membershipFields } from "./MembershipSpec"; diff --git a/src/elements/pullquote/PullquoteForm.tsx b/src/elements/pullquote/PullquoteForm.tsx index 43886052..b98add65 100644 --- a/src/elements/pullquote/PullquoteForm.tsx +++ b/src/elements/pullquote/PullquoteForm.tsx @@ -1,7 +1,7 @@ import { Column, Columns } from "@guardian/src-layout"; import React from "react"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { FieldWrapper } from "../../editorial-source-components/FieldWrapper"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; import { pullquoteFields } from "./PullquoteSpec"; diff --git a/src/elements/rich-link/RichlinkForm.tsx b/src/elements/rich-link/RichlinkForm.tsx index 3bcea8e5..4437426c 100644 --- a/src/elements/rich-link/RichlinkForm.tsx +++ b/src/elements/rich-link/RichlinkForm.tsx @@ -1,7 +1,7 @@ import { css } from "@emotion/react"; import { SvgAlertTriangle } from "@guardian/src-icons"; import React from "react"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; import { richlinkFields } from "./RichlinkSpec"; diff --git a/src/elements/standard/StandardForm.tsx b/src/elements/standard/StandardForm.tsx index 98fb1cfa..8cfaa08d 100644 --- a/src/elements/standard/StandardForm.tsx +++ b/src/elements/standard/StandardForm.tsx @@ -1,10 +1,10 @@ import styled from "@emotion/styled"; import { Column, Columns } from "@guardian/src-layout"; import React from "react"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { FieldWrapper } from "../../editorial-source-components/FieldWrapper"; import { InputHeading } from "../../editorial-source-components/InputHeading"; import { Link } from "../../editorial-source-components/Link"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; import type { FieldNameToField } from "../../plugin/types/Element"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomCheckboxView } from "../../renderers/react/customFieldViewComponents/CustomCheckboxView"; diff --git a/src/elements/table/TableForm.tsx b/src/elements/table/TableForm.tsx index 08444c5e..a66aaab0 100644 --- a/src/elements/table/TableForm.tsx +++ b/src/elements/table/TableForm.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import React from "react"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; import { tableFields } from "./TableSpec"; diff --git a/src/elements/tweet/TweetForm.tsx b/src/elements/tweet/TweetForm.tsx index 286b72e3..e0eec6a6 100644 --- a/src/elements/tweet/TweetForm.tsx +++ b/src/elements/tweet/TweetForm.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { FieldLayoutVertical } from "../../editorial-source-components/VerticalFieldLayout"; +import { FieldLayoutVertical } from "../../editorial-source-components/FieldLayout"; import { createReactElementSpec } from "../../renderers/react/createReactElementSpec"; import { CustomCheckboxView } from "../../renderers/react/customFieldViewComponents/CustomCheckboxView"; import { CustomDropdownView } from "../../renderers/react/customFieldViewComponents/CustomDropdownView"; diff --git a/src/index.ts b/src/index.ts index 7dcf32f8..4eb1c90c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,4 +25,5 @@ export { export { useTyperighterAttr } from "./elements/helpers/typerighter"; export { fieldGroupName, isProseMirrorElement } from "./plugin/nodeSpec"; export type { Options } from "./plugin/fieldViews/DropdownFieldView"; +export { cartoonElement } from "./elements/cartoon/CartoonForm"; export { undefinedDropdownValue } from "./plugin/helpers/constants"; diff --git a/src/plugin/helpers/validation.ts b/src/plugin/helpers/validation.ts index aafcd88c..02e5d0ef 100644 --- a/src/plugin/helpers/validation.ts +++ b/src/plugin/helpers/validation.ts @@ -5,9 +5,9 @@ import type { ValidationError, Validator, } from "../elementSpec"; -import type { FieldNameToValueMap } from "../helpers/fieldView"; import type { FieldDescriptions } from "../types/Element"; import { undefinedDropdownValue } from "./constants"; +import type { FieldNameToValueMap } from "./fieldView"; export const createValidator = ( fieldValidationMap: Record @@ -201,3 +201,37 @@ export const dropDownRequired = ( return []; } }; + +export const numbersOnly = ( + customMessage: string | undefined = undefined, + level: ErrorLevel = "ERROR" +): FieldValidator => (value) => { + const reg = new RegExp("^[0-9]*$"); + if (typeof value === "string" && !reg.test(value)) { + return [ + { + error: "Numbers only", + message: customMessage ?? `Only numbers are permitted`, + level, + }, + ]; + } + return []; +}; + +export const validHexidecimalValue = ( + customMessage: string | undefined = undefined, + level: ErrorLevel = "ERROR" +): FieldValidator => (value) => { + const reg = new RegExp("^(?:[0-9a-fA-F]{3}){1,2}$"); + if (typeof value === "string" && !reg.test(value) && value !== "") { + return [ + { + error: "Not a valid colour value", + message: customMessage ?? `Must be a valid colour value`, + level, + }, + ]; + } + return []; +};