From b36644cbbcf91ce6f9e0e4e98f5b9167f20b65c6 Mon Sep 17 00:00:00 2001 From: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Date: Thu, 30 Nov 2023 10:59:15 +0800 Subject: [PATCH 01/21] feat(tiptap): allow inserting of complex blocks (#1697) --- src/assets/images/EditorAccordionImage.tsx | 31 ++++ src/assets/images/EditorCardsImage.tsx | 22 +++ src/assets/images/EditorDividerImage.tsx | 17 ++ src/assets/images/index.ts | 3 + src/layouts/EditPage/EditPage.tsx | 1 - .../components/Editor/components/MenuBar.tsx | 155 +++++++++++++++--- .../components/Editor/components/MenuItem.tsx | 2 + 7 files changed, 210 insertions(+), 21 deletions(-) create mode 100644 src/assets/images/EditorAccordionImage.tsx create mode 100644 src/assets/images/EditorCardsImage.tsx create mode 100644 src/assets/images/EditorDividerImage.tsx diff --git a/src/assets/images/EditorAccordionImage.tsx b/src/assets/images/EditorAccordionImage.tsx new file mode 100644 index 000000000..f692f552c --- /dev/null +++ b/src/assets/images/EditorAccordionImage.tsx @@ -0,0 +1,31 @@ +export const EditorAccordionImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + ) +} diff --git a/src/assets/images/EditorCardsImage.tsx b/src/assets/images/EditorCardsImage.tsx new file mode 100644 index 000000000..a0dfc4311 --- /dev/null +++ b/src/assets/images/EditorCardsImage.tsx @@ -0,0 +1,22 @@ +export const EditorCardsImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + ) +} diff --git a/src/assets/images/EditorDividerImage.tsx b/src/assets/images/EditorDividerImage.tsx new file mode 100644 index 000000000..2c3bb35e1 --- /dev/null +++ b/src/assets/images/EditorDividerImage.tsx @@ -0,0 +1,17 @@ +export const EditorDividerImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + ) +} diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index 30ca9f72a..b70a5ac81 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -26,3 +26,6 @@ export * from "./SingpassLogo" export * from "./EmptyAlbumImage" export * from "./EmptyDirectoryImage" export * from "./BulkUploadAnnouncementImage" +export * from "./EditorAccordionImage" +export * from "./EditorCardsImage" +export * from "./EditorDividerImage" diff --git a/src/layouts/EditPage/EditPage.tsx b/src/layouts/EditPage/EditPage.tsx index c89785a09..589d7456b 100644 --- a/src/layouts/EditPage/EditPage.tsx +++ b/src/layouts/EditPage/EditPage.tsx @@ -2,7 +2,6 @@ import { useDisclosure } from "@chakra-ui/react" import BubbleMenu from "@tiptap/extension-bubble-menu" import CharacterCount from "@tiptap/extension-character-count" import Highlight from "@tiptap/extension-highlight" -import Image from "@tiptap/extension-image" import Link from "@tiptap/extension-link" import Placeholder from "@tiptap/extension-placeholder" import Table from "@tiptap/extension-table" diff --git a/src/layouts/components/Editor/components/MenuBar.tsx b/src/layouts/components/Editor/components/MenuBar.tsx index 966c16707..c3d1596ff 100644 --- a/src/layouts/components/Editor/components/MenuBar.tsx +++ b/src/layouts/components/Editor/components/MenuBar.tsx @@ -1,4 +1,5 @@ import { + Box, Divider, HStack, Icon, @@ -9,6 +10,7 @@ import { PopoverContent, PopoverTrigger, Text, + VStack, } from "@chakra-ui/react" import { Button, Menu } from "@opengovsg/design-system-react" import { Editor } from "@tiptap/react" @@ -24,6 +26,7 @@ import { BiListOl, BiListUl, BiMinus, + BiPlus, BiRedo, BiStrikethrough, BiTable, @@ -34,6 +37,12 @@ import { IconType } from "react-icons/lib" import { useEditorModal } from "contexts/EditorModalContext" +import { + EditorAccordionImage, + EditorCardsImage, + EditorDividerImage, +} from "assets" + import { MenuItem } from "./MenuItem" interface MenuBarItem { @@ -66,10 +75,25 @@ interface MenuBarHorizontalList { items: MenuBarItem[] } +interface MenuBarDetailedItem { + name: string + description: string + icon: IconType + action: () => void +} + +interface MenuBarDetailedList { + type: "detailed-list" + label: string + icon: IconType + items: MenuBarDetailedItem[] +} + type MenuBarEntry = | MenuBarDivider | MenuBarVeritcalList | MenuBarHorizontalList + | MenuBarDetailedList | MenuBarItem export const MenuBar = ({ editor }: { editor: Editor }) => { @@ -217,6 +241,18 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { title: "Add image", action: () => showModal("images"), }, + { + type: "item", + icon: BiTable, + title: "Add table", + action: () => + editor + .chain() + .focus() + // NOTE: Default to smallest multi table + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(), + }, { type: "item", icon: BiFile, @@ -233,22 +269,30 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { type: "divider", }, { - type: "item", - icon: BiTable, - title: "Add table", - action: () => - editor - .chain() - .focus() - // NOTE: Default to smallest multi table - .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) - .run(), - }, - { - type: "item", - icon: BiMinus, - title: "Divider", - action: () => editor.chain().focus().setHorizontalRule().run(), + type: "detailed-list", + label: "Add complex blocks", + icon: BiPlus, + items: [ + { + name: "Accordion", + description: "Let users hide or show content.", + icon: EditorAccordionImage, + action: () => editor.chain().focus().setHorizontalRule().run(), + }, + { + name: "Card grid", + description: + "Lay out content in a card grid. You can add images, links, and/or text.", + icon: EditorCardsImage, + action: () => editor.chain().focus().setHorizontalRule().run(), + }, + { + name: "Divider", + description: "Use a divider to create sections on your page.", + icon: EditorDividerImage, + action: () => editor.chain().focus().setHorizontalRule().run(), + }, + ], }, { type: "divider", @@ -276,7 +320,7 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { borderBottom="1px solid" borderColor="base.divider.strong" borderTopRadius="0.25rem" - spacing="0.125rem" + spacing="0.25rem" > {items.map((item) => ( <> @@ -286,6 +330,7 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { border="px solid" borderColor="base.divider.strong" h="1.25rem" + mx="0.25rem" /> )} @@ -353,11 +398,11 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { bgColor="transparent" border="none" h="1.75rem" - px="0.5rem" + px={0} py="0.25rem" aria-label={item.label} > - + { - + {item.items.map((subItem) => ( @@ -391,6 +436,76 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { )} + {item.type === "detailed-list" && ( + + + + + + + + {item.items.map((subItem) => ( + + ))} + + + + + )} + {item.type === "item" && } ))} diff --git a/src/layouts/components/Editor/components/MenuItem.tsx b/src/layouts/components/Editor/components/MenuItem.tsx index 47adf7bb0..f12ccbaba 100644 --- a/src/layouts/components/Editor/components/MenuItem.tsx +++ b/src/layouts/components/Editor/components/MenuItem.tsx @@ -28,6 +28,8 @@ export const MenuItem = ({ border="none" h="1.75rem" w="1.75rem" + minH="1.75rem" + minW="1.75rem" p="0.25rem" aria-label={title || "divider"} isRound={isRound} From 6ac3c70e13682eeb99a3366a72f518e3598c37e4 Mon Sep 17 00:00:00 2001 From: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Date: Thu, 30 Nov 2023 11:03:22 +0800 Subject: [PATCH 02/21] feat(tiptap): add card grid block (#1701) * feat(tiptap): add card grid block * feat(cards): add basic bubble menu * feat(cards): adjust editor commands to support creation --- .../images/EditorCardsPlaceholderImage.tsx | 130 ++++++ src/assets/images/index.ts | 1 + src/layouts/EditPage/EditPage.tsx | 31 +- src/layouts/components/Editor/Editor.tsx | 5 +- .../Editor/components/CardsBubbleMenu.tsx | 78 ++++ .../components/Editor/components/MenuBar.tsx | 3 +- .../Editor/extensions/Cards/Cards.ts | 420 ++++++++++++++++++ .../Editor/extensions/Cards/CardsView.tsx | 32 ++ .../Editor/extensions/Cards/index.ts | 2 + .../components/Editor/extensions/index.ts | 1 + src/types/editPage.ts | 14 + 11 files changed, 712 insertions(+), 5 deletions(-) create mode 100644 src/assets/images/EditorCardsPlaceholderImage.tsx create mode 100644 src/layouts/components/Editor/components/CardsBubbleMenu.tsx create mode 100644 src/layouts/components/Editor/extensions/Cards/Cards.ts create mode 100644 src/layouts/components/Editor/extensions/Cards/CardsView.tsx create mode 100644 src/layouts/components/Editor/extensions/Cards/index.ts diff --git a/src/assets/images/EditorCardsPlaceholderImage.tsx b/src/assets/images/EditorCardsPlaceholderImage.tsx new file mode 100644 index 000000000..bf8bb7828 --- /dev/null +++ b/src/assets/images/EditorCardsPlaceholderImage.tsx @@ -0,0 +1,130 @@ +export const EditorCardsPlaceholderImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index b70a5ac81..53f98f119 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -29,3 +29,4 @@ export * from "./BulkUploadAnnouncementImage" export * from "./EditorAccordionImage" export * from "./EditorCardsImage" export * from "./EditorDividerImage" +export * from "./EditorCardsPlaceholderImage" diff --git a/src/layouts/EditPage/EditPage.tsx b/src/layouts/EditPage/EditPage.tsx index 589d7456b..7672d8f69 100644 --- a/src/layouts/EditPage/EditPage.tsx +++ b/src/layouts/EditPage/EditPage.tsx @@ -33,8 +33,16 @@ import { FormSGDiv, FormSGIframe, Iframe, - IsomerImage, Instagram, + IsomerCard, + IsomerCardBody, + IsomerCardDescription, + IsomerCardImage, + IsomerCardLink, + IsomerCards, + IsomerCardTitle, + IsomerClickableCard, + IsomerImage, } from "layouts/components/Editor/extensions" import { isEmbedCodeValid } from "utils/allowedHTML" @@ -73,12 +81,28 @@ export const EditPage = () => { TaskItem, CharacterCount, IsomerImage, - Link.configure({ openOnClick: false, protocols: ["mailto"] }), + Link.extend({ + priority: 100, + parseHTML() { + return [{ tag: "a:not(.isomer-card)" }] + }, + }).configure({ + openOnClick: false, + protocols: ["mailto"], + }), Iframe, FormSG, FormSGDiv, FormSGIframe, Instagram, + IsomerCards, + IsomerCard, + IsomerClickableCard, + IsomerCardImage, + IsomerCardBody, + IsomerCardTitle, + IsomerCardDescription, + IsomerCardLink, Markdown, BubbleMenu.configure({ pluginKey: "linkBubble", @@ -89,6 +113,9 @@ export const EditPage = () => { BubbleMenu.configure({ pluginKey: "imageBubble", }), + BubbleMenu.configure({ + pluginKey: "cardsBubble", + }), Table.configure({ resizable: false, }), diff --git a/src/layouts/components/Editor/Editor.tsx b/src/layouts/components/Editor/Editor.tsx index e13f6f81b..ed16a35df 100644 --- a/src/layouts/components/Editor/Editor.tsx +++ b/src/layouts/components/Editor/Editor.tsx @@ -6,6 +6,7 @@ import { EditorContent } from "@tiptap/react" import { useEditorContext } from "contexts/EditorContext" import { LinkBubbleMenu, MenuBar, TableBubbleMenu } from "./components" +import { CardsBubbleMenu } from "./components/CardsBubbleMenu" import { ImageBubbleMenu } from "./components/ImageBubbleMenu" export const Editor = (props: BoxProps) => { @@ -25,13 +26,15 @@ export const Editor = (props: BoxProps) => { + diff --git a/src/layouts/components/Editor/components/CardsBubbleMenu.tsx b/src/layouts/components/Editor/components/CardsBubbleMenu.tsx new file mode 100644 index 000000000..8ca893d1c --- /dev/null +++ b/src/layouts/components/Editor/components/CardsBubbleMenu.tsx @@ -0,0 +1,78 @@ +import { HStack, Icon } from "@chakra-ui/react" +import { IconButton } from "@opengovsg/design-system-react" +import { BubbleMenu } from "@tiptap/react" +import { BiPencil, BiTrash } from "react-icons/bi" + +import { useEditorContext } from "contexts/EditorContext" + +const CardsButton = () => { + const { isOpen, onOpen, onClose } = useDisclosure() + const { editor } = useEditorContext() + + return ( + <> + + + + + editor.chain().focus().deleteCards().run()} + bgColor="transparent" + border="none" + h="1.75rem" + w="1.75rem" + minH="1.75rem" + minW="1.75rem" + p="0.25rem" + aria-label="delete cards block" + > + + + + + ) +} + +export const CardsBubbleMenu = () => { + const { editor } = useEditorContext() + + return ( + editor.isActive("isomercards")} + editor={editor} + tippyOptions={{ + duration: 100, + placement: "top-start", + offset: [511, -16], + zIndex: 0, + }} + > + + + ) +} diff --git a/src/layouts/components/Editor/components/MenuBar.tsx b/src/layouts/components/Editor/components/MenuBar.tsx index c3d1596ff..dde401ea0 100644 --- a/src/layouts/components/Editor/components/MenuBar.tsx +++ b/src/layouts/components/Editor/components/MenuBar.tsx @@ -25,7 +25,6 @@ import { BiLink, BiListOl, BiListUl, - BiMinus, BiPlus, BiRedo, BiStrikethrough, @@ -284,7 +283,7 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { description: "Lay out content in a card grid. You can add images, links, and/or text.", icon: EditorCardsImage, - action: () => editor.chain().focus().setHorizontalRule().run(), + action: () => editor.chain().focus().addCards().run(), }, { name: "Divider", diff --git a/src/layouts/components/Editor/extensions/Cards/Cards.ts b/src/layouts/components/Editor/extensions/Cards/Cards.ts new file mode 100644 index 000000000..b301ef88d --- /dev/null +++ b/src/layouts/components/Editor/extensions/Cards/Cards.ts @@ -0,0 +1,420 @@ +import { Node } from "@tiptap/core" +import { Node as ProseMirrorNode, Schema } from "@tiptap/pm/model" +import { Selection } from "@tiptap/pm/state" +import { ReactNodeViewRenderer } from "@tiptap/react" + +import { EditorCard } from "types/editPage" + +import { CardsView } from "./CardsView" + +export interface CardOptions { + HTMLAttributes: { + class: string + } +} + +declare module "@tiptap/core" { + interface Commands { + cards: { + // Add a new card grid block + addCards: () => ReturnType + // Delete the current card grid block + deleteCards: () => ReturnType + } + } +} + +interface CreateCardProps { + schema: Schema + title?: string + description?: string + footer?: string + link?: string + image?: string + altText?: string +} + +const createCard = ({ + schema, + title, + description, + footer, + link, + image, + altText, +}: CreateCardProps): ProseMirrorNode => { + const { nodes } = schema + const templateCardBody = [] + const templateCard = [] + + if (title && title !== "") { + templateCardBody.push(nodes.isomercardtitle.create({}, schema.text(title))) + } + + if (description && description !== "") { + templateCardBody.push( + nodes.isomercarddescription.create({}, schema.text(description)) + ) + } + + if (link && link !== "" && footer && footer !== "") { + templateCardBody.push(nodes.isomercardlink.create({}, schema.text(footer))) + } + + if (image && image !== "") { + templateCard.push( + nodes.isomercardimage.create( + {}, + nodes.image.create({ + src: image, + alt: altText ?? "Placeholder image", + }) + ) + ) + } + + templateCard.push(nodes.isomercardbody.create({}, templateCardBody)) + + if (link) { + return nodes.isomerclickablecard.create({ href: link }, templateCard) + } + + return nodes.isomercard.create({}, templateCard) +} + +const createCardGrid = ( + schema: Schema, + numberOfCards: number +): ProseMirrorNode => { + const { nodes } = schema + + const cards = Array(numberOfCards).fill( + createCard({ + schema, + title: "This is a title for your card", + description: "This is body text for your card. Describe your card.", + footer: "This is a link for your card", + link: "https://www.isomer.gov.sg", + image: "https://placehold.co/600x400", + altText: "Placeholder image", + }) + ) + + return nodes.isomercards.create({}, cards) +} + +const createCardGridWithContent = ( + schema: Schema, + content: EditorCard[] +): ProseMirrorNode => { + const { nodes } = schema + + const cards = content.map((card) => + createCard({ + schema, + title: card.title, + description: card.description, + footer: card.linkText, + link: card.linkUrl, + image: card.image, + altText: card.altText, + }) + ) + + return nodes.isomercards.create({}, cards) +} + +export const IsomerCards = Node.create({ + name: "isomercards", + group: "block", + atom: true, + draggable: true, + defining: true, + selectable: true, + + content: "(isomercard | isomerclickablecard)+", + + addAttributes() { + return { + class: { + default: "isomer-card-grid", + }, + } + }, + + addCommands() { + return { + addCards: () => ({ tr, dispatch, editor }) => { + const node = createCardGrid(editor.schema, 3) + + if (dispatch) { + const offset = tr.selection.anchor + 1 + + tr.replaceSelectionWith(node) + .scrollIntoView() + .setSelection(Selection.near(tr.doc.resolve(offset))) + } + + return true + }, + deleteCards: () => ({ tr, editor }) => { + editor.state.selection.replace(tr) + return true + }, + } + }, + + parseHTML() { + return [ + { + tag: "div.isomer-card-grid:has(> div.isomer-card)", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["div", HTMLAttributes, 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(CardsView) + }, +}) + +export const IsomerCard = Node.create({ + name: "isomercard", + group: "isomercardblock", + draggable: false, + selectable: false, + defining: false, + + content: + "(isomercardimage isomercardbody?) | (isomercardimage? isomercardbody)", + + addAttributes() { + return { + class: { + default: "isomer-card", + }, + } + }, + + parseHTML() { + return [ + { + tag: "div.isomer-card-grid > div.isomer-card", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["div", HTMLAttributes, 0] + }, +}) + +export const IsomerClickableCard = Node.create({ + name: "isomerclickablecard", + group: "isomercardblock", + draggable: false, + selectable: false, + + content: + "(isomercardimage isomercardbody?) | (isomercardimage? isomercardbody)", + + addAttributes() { + return { + class: { + default: "isomer-card", + }, + href: { + default: null, + }, + rel: { + default: "noopener noreferrer nofollow", + }, + } + }, + + parseHTML() { + return [ + { + tag: "div.isomer-card-grid > a.isomer-card", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["a", HTMLAttributes, 0] + }, +}) + +export const IsomerCardImage = Node.create({ + name: "isomercardimage", + group: "isomercardblock", + draggable: false, + selectable: false, + + content: "image", + + addAttributes() { + return { + class: { + default: "isomer-card-image", + }, + } + }, + + parseHTML() { + return [ + { + tag: "div.isomer-card-grid > div.isomer-card > div.isomer-card-image", + }, + { + tag: "div.isomer-card-grid > a.isomer-card > div.isomer-card-image", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["div", HTMLAttributes, 0] + }, +}) + +export const IsomerCardBody = Node.create({ + name: "isomercardbody", + group: "isomercardblock", + draggable: false, + selectable: false, + + content: `(isomercardtitle isomercarddescription? isomercardlink?) | + (isomercardtitle? isomercarddescription isomercardlink?) | + (isomercardtitle? isomercarddescription? isomercardlink)`, + + addAttributes() { + return { + class: { + default: "isomer-card-body", + }, + } + }, + + parseHTML() { + return [ + { + tag: "div.isomer-card-grid > div.isomer-card > div.isomer-card-body", + }, + { + tag: "div.isomer-card-grid > a.isomer-card > div.isomer-card-body", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["div", HTMLAttributes, 0] + }, +}) + +export const IsomerCardTitle = Node.create({ + name: "isomercardtitle", + group: "isomercardblock", + draggable: false, + selectable: false, + marks: "", + + content: "text?", + + addAttributes() { + return { + class: { + default: "isomer-card-title", + }, + } + }, + + parseHTML() { + return [ + { + tag: + "div.isomer-card-grid > div.isomer-card > div.isomer-card-body > div.isomer-card-title", + }, + { + tag: + "div.isomer-card-grid > a.isomer-card > div.isomer-card-body > div.isomer-card-title", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["div", HTMLAttributes, 0] + }, +}) + +export const IsomerCardDescription = Node.create({ + name: "isomercarddescription", + group: "isomercardblock", + draggable: false, + selectable: false, + marks: "", + + content: "text?", + + addAttributes() { + return { + class: { + default: "isomer-card-description", + }, + } + }, + + parseHTML() { + return [ + { + tag: + "div.isomer-card-grid > div.isomer-card > div.isomer-card-body > div.isomer-card-description", + }, + { + tag: + "div.isomer-card-grid > a.isomer-card > div.isomer-card-body > div.isomer-card-description", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["div", HTMLAttributes, 0] + }, +}) + +export const IsomerCardLink = Node.create({ + name: "isomercardlink", + group: "isomercardblock", + draggable: false, + selectable: false, + marks: "", + + content: "text?", + + addAttributes() { + return { + class: { + default: "isomer-card-link", + }, + } + }, + + parseHTML() { + return [ + { + tag: + "div.isomer-card-grid > div.isomer-card > div.isomer-card-body > div.isomer-card-link", + }, + { + tag: + "div.isomer-card-grid > a.isomer-card > div.isomer-card-body > div.isomer-card-link", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["div", HTMLAttributes, 0] + }, +}) diff --git a/src/layouts/components/Editor/extensions/Cards/CardsView.tsx b/src/layouts/components/Editor/extensions/Cards/CardsView.tsx new file mode 100644 index 000000000..9515653b7 --- /dev/null +++ b/src/layouts/components/Editor/extensions/Cards/CardsView.tsx @@ -0,0 +1,32 @@ +import { Box, Text } from "@chakra-ui/react" +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react" + +import { EditorCardsPlaceholderImage } from "assets" + +export const CardsView = ({ node, selected }: NodeViewProps) => { + return ( + + + {selected && ( + + Cards grid + + )} + + + + + + ) +} diff --git a/src/layouts/components/Editor/extensions/Cards/index.ts b/src/layouts/components/Editor/extensions/Cards/index.ts new file mode 100644 index 000000000..f56846c5a --- /dev/null +++ b/src/layouts/components/Editor/extensions/Cards/index.ts @@ -0,0 +1,2 @@ +export * from "./Cards" +export * from "./CardsView" diff --git a/src/layouts/components/Editor/extensions/index.ts b/src/layouts/components/Editor/extensions/index.ts index 7abec2abb..6bb0b11b0 100644 --- a/src/layouts/components/Editor/extensions/index.ts +++ b/src/layouts/components/Editor/extensions/index.ts @@ -1,3 +1,4 @@ +export * from "./Cards" export * from "./Iframe" export * from "./FormSG" export * from "./Image" diff --git a/src/types/editPage.ts b/src/types/editPage.ts index 43b6526c6..139f76d43 100644 --- a/src/types/editPage.ts +++ b/src/types/editPage.ts @@ -1,3 +1,17 @@ export interface EditorEmbedContents { value: string } + +export interface EditorCard { + image: string + altText: string + title: string + description?: string + linkUrl: string + linkText: string +} + +export interface EditorCardsInfo { + isDisplayImage: boolean + cards: EditorCard[] +} From fda9bb4a1558f44564c887ef9d381df91fec034b Mon Sep 17 00:00:00 2001 From: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Date: Thu, 30 Nov 2023 11:15:09 +0800 Subject: [PATCH 03/21] feat(cards): introduce drawer to support sub-editing (#1704) * feat(tiptap): add card grid block * feat(cards): adjust editor commands to support creation * feat(cards): introduce drawer to support sub-editing --- .../EditorCardsDrawer/EditorCardsDrawer.tsx | 37 +++ src/components/EditorCardsDrawer/index.ts | 2 + src/components/EditorDrawer/EditorDrawer.tsx | 96 ++++++++ src/components/EditorDrawer/index.ts | 1 + src/components/MiniDrawer/MiniDrawer.tsx | 30 +++ src/components/MiniDrawer/index.ts | 1 + src/contexts/EditorDrawerContext.tsx | 47 ++++ src/layouts/EditPage/EditPage.tsx | 216 +++++++++++------- src/layouts/EditPage/EditPageLayout.tsx | 13 +- src/layouts/EditPage/TiptapEditPage.tsx | 16 ++ .../Editor/components/CardsBubbleMenu.tsx | 8 +- .../Editor/extensions/TrailingNode.ts | 72 ++++++ src/types/editPage.ts | 2 + 13 files changed, 451 insertions(+), 90 deletions(-) create mode 100644 src/components/EditorCardsDrawer/EditorCardsDrawer.tsx create mode 100644 src/components/EditorCardsDrawer/index.ts create mode 100644 src/components/EditorDrawer/EditorDrawer.tsx create mode 100644 src/components/EditorDrawer/index.ts create mode 100644 src/components/MiniDrawer/MiniDrawer.tsx create mode 100644 src/components/MiniDrawer/index.ts create mode 100644 src/contexts/EditorDrawerContext.tsx create mode 100644 src/layouts/components/Editor/extensions/TrailingNode.ts diff --git a/src/components/EditorCardsDrawer/EditorCardsDrawer.tsx b/src/components/EditorCardsDrawer/EditorCardsDrawer.tsx new file mode 100644 index 000000000..78eba8199 --- /dev/null +++ b/src/components/EditorCardsDrawer/EditorCardsDrawer.tsx @@ -0,0 +1,37 @@ +import { Text } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" +import { Editor } from "@tiptap/core" + +import { EditorDrawer } from "components/EditorDrawer" + +interface EditorCardsDrawerProps { + editor: Editor + isOpen: boolean + onClose: () => void + onProceed: () => void +} + +export const EditorCardsDrawer = ({ + editor, + isOpen, + onClose, + onProceed, +}: EditorCardsDrawerProps): JSX.Element => { + return ( + + + + Editing card grid + + + + + Placeholder content + + + + + + + ) +} diff --git a/src/components/EditorCardsDrawer/index.ts b/src/components/EditorCardsDrawer/index.ts new file mode 100644 index 000000000..67daafd10 --- /dev/null +++ b/src/components/EditorCardsDrawer/index.ts @@ -0,0 +1,2 @@ +export * from "./EditorCardsDrawer" +export * from "./EditorCardItem" diff --git a/src/components/EditorDrawer/EditorDrawer.tsx b/src/components/EditorDrawer/EditorDrawer.tsx new file mode 100644 index 000000000..cf2c9ba65 --- /dev/null +++ b/src/components/EditorDrawer/EditorDrawer.tsx @@ -0,0 +1,96 @@ +import { Grid, GridItem, HStack, Icon, Spacer } from "@chakra-ui/react" +import { IconButton } from "@opengovsg/design-system-react" +import React, { PropsWithChildren } from "react" +import { BiX } from "react-icons/bi" + +import { MiniDrawer } from "components/MiniDrawer" + +interface EditorDrawerProps { + isOpen: boolean +} + +interface EditorDrawerHeaderProps { + onClose: () => void + children: React.ReactNode +} + +interface EditorDrawerContentProps { + children: React.ReactNode +} + +interface EditorDrawerFooterProps { + children: React.ReactNode +} + +export const EditorDrawer = ({ + isOpen, + children, +}: PropsWithChildren): JSX.Element => { + return ( + + + {children} + + + ) +} + +export const EditorDrawerHeader = ({ + onClose, + children, +}: EditorDrawerHeaderProps): JSX.Element => { + return ( + + + {children} + + + + + + + ) +} + +export const EditorDrawerContent = ({ + children, +}: EditorDrawerContentProps): JSX.Element => { + return ( + + {children} + + ) +} + +export const EditorDrawerFooter = ({ + children, +}: EditorDrawerFooterProps): JSX.Element => { + return ( + + + + {children} + + + ) +} + +EditorDrawer.Header = EditorDrawerHeader +EditorDrawer.Content = EditorDrawerContent +EditorDrawer.Footer = EditorDrawerFooter diff --git a/src/components/EditorDrawer/index.ts b/src/components/EditorDrawer/index.ts new file mode 100644 index 000000000..a92223ca6 --- /dev/null +++ b/src/components/EditorDrawer/index.ts @@ -0,0 +1 @@ +export * from "./EditorDrawer" diff --git a/src/components/MiniDrawer/MiniDrawer.tsx b/src/components/MiniDrawer/MiniDrawer.tsx new file mode 100644 index 000000000..8ce508dfd --- /dev/null +++ b/src/components/MiniDrawer/MiniDrawer.tsx @@ -0,0 +1,30 @@ +import { BoxProps, Slide, SlideProps } from "@chakra-ui/react" + +type MiniDrawerProps = SlideProps & { + isOpen: boolean + width?: BoxProps["width"] +} + +export const MiniDrawer = ({ + isOpen, + width, + children, + ...rest +}: MiniDrawerProps): JSX.Element => { + return ( + + {children} + + ) +} diff --git a/src/components/MiniDrawer/index.ts b/src/components/MiniDrawer/index.ts new file mode 100644 index 000000000..2fdd321d2 --- /dev/null +++ b/src/components/MiniDrawer/index.ts @@ -0,0 +1 @@ +export * from "./MiniDrawer" diff --git a/src/contexts/EditorDrawerContext.tsx b/src/contexts/EditorDrawerContext.tsx new file mode 100644 index 000000000..7a63a94dd --- /dev/null +++ b/src/contexts/EditorDrawerContext.tsx @@ -0,0 +1,47 @@ +import { PropsWithChildren, createContext, useContext } from "react" + +import { DrawerVariant } from "types/editPage" + +interface EditorDrawerContextProps { + isAnyDrawerOpen: boolean + isDrawerOpen: (drawerVariant: DrawerVariant) => boolean + onDrawerOpen: (drawerVariant: DrawerVariant) => () => void + onDrawerClose: (drawerVariant: DrawerVariant) => () => void + onDrawerProceed: (drawerVariant: DrawerVariant) => () => void +} + +const EditorDrawerContext = createContext(null) + +export const useEditorDrawerContext = (): EditorDrawerContextProps => { + const editorDrawerContext = useContext(EditorDrawerContext) + + if (!editorDrawerContext) { + throw new Error( + "useEditorDrawer must be used within an EditorDrawerContextProvider" + ) + } + + return editorDrawerContext +} + +export const EditorDrawerContextProvider = ({ + isAnyDrawerOpen, + isDrawerOpen, + onDrawerOpen, + onDrawerClose, + onDrawerProceed, + ...rest +}: PropsWithChildren): JSX.Element => { + return ( + + ) +} diff --git a/src/layouts/EditPage/EditPage.tsx b/src/layouts/EditPage/EditPage.tsx index 7672d8f69..a4b517425 100644 --- a/src/layouts/EditPage/EditPage.tsx +++ b/src/layouts/EditPage/EditPage.tsx @@ -22,6 +22,7 @@ import HyperlinkModal from "components/HyperlinkModal" import MediaModal from "components/media/MediaModal" import { EditorContextProvider } from "contexts/EditorContext" +import { EditorDrawerContextProvider } from "contexts/EditorDrawerContext" import { EditorModalContextProvider } from "contexts/EditorModalContext" import { ServicesContext } from "contexts/ServicesContext" @@ -48,7 +49,7 @@ import { import { isEmbedCodeValid } from "utils/allowedHTML" import { MediaService } from "services" -import { EditorEmbedContents } from "types/editPage" +import { DrawerVariant, EditorEmbedContents } from "types/editPage" import { getDecodedParams, getImageDetails } from "utils" import { MarkdownEditPage } from "./MarkdownEditPage" @@ -146,6 +147,12 @@ export const EditPage = () => { onClose: onEmbedModalClose, } = useDisclosure() + const { + isOpen: isCardsDrawerOpen, + onOpen: onCardsDrawerOpen, + onClose: onCardsDrawerClose, + } = useDisclosure() + const { siteName } = decodedParams const { mediaService } = useContext<{ mediaService: MediaService }>( @@ -175,6 +182,37 @@ export const EditPage = () => { onEmbedModalClose() } + const isDrawerOpen = (drawerType: DrawerVariant) => { + if (drawerType === "cards") { + return isCardsDrawerOpen + } + return false + } + + const onDrawerOpen = (drawerType: DrawerVariant) => { + if (drawerType === "cards") { + return onCardsDrawerOpen + } + + return () => undefined + } + + const onDrawerClose = (drawerType: DrawerVariant) => { + if (drawerType === "cards") { + return onCardsDrawerClose + } + + return () => undefined + } + + const onDrawerProceed = (drawerType: DrawerVariant) => { + if (drawerType === "cards") { + return onCardsDrawerClose + } + + return () => undefined + } + return ( { } }} > - {isHyperlinkModalOpen && ( - { - editor - .chain() - .focus() - .insertContent( - `${text}` - ) - .run() - onHyperlinkModalClose() - }} - onClose={onHyperlinkModalClose} - /> - )} - {isMediaModalOpen && ( - { - if (mediaType === "images") { - const { mediaPath } = await getImageSrc(selectedMediaPath) - editor - .chain() - .focus() - .setImage({ - src: mediaPath, - alt: altText, - }) - .run() - // NOTE: If it's a file and there's no selection made, just add a link with default text - } else if (editor.state.selection.empty) { + + {isHyperlinkModalOpen && ( + { editor .chain() .focus() .insertContent( - `${ - altText || "file" - }` + `${text}` ) .run() - } else { - editor - .chain() - .focus() - .setLink({ href: selectedMediaPath }) - .run() + onHyperlinkModalClose() + }} + onClose={onHyperlinkModalClose} + /> + )} + {isMediaModalOpen && ( + { + if (mediaType === "images") { + const { mediaPath } = await getImageSrc(selectedMediaPath) + editor + .chain() + .focus() + .setImage({ + src: mediaPath, + alt: altText, + }) + .run() + // NOTE: If it's a file and there's no selection made, just add a link with default text + } else if (editor.state.selection.empty) { + editor + .chain() + .focus() + .insertContent( + `${ + altText || "file" + }` + ) + .run() + } else { + editor + .chain() + .focus() + .setLink({ href: selectedMediaPath }) + .run() + } + onMediaModalClose() + }} + /> + )} + {isEmbedModalOpen && ( + - )} - {isEmbedModalOpen && ( - - )} + /> + )} - {variant === "markdown" ? ( - { - variant === "markdown" - ? setVariant("tiptap") - : setVariant("markdown") - }} - /> - ) : ( - `tiptap`. - // This is because this implies that the user has - // changed the variant from markdown to tiptap. - // Hence, there might be changes in either markdown editor content - // or in the preview editor that should be persisted. - shouldUseFetchedData={ - variant === "tiptap" && - initialPageData?.content?.frontMatter?.variant === "tiptap" - } - /> - )} + {variant === "markdown" ? ( + { + variant === "markdown" + ? setVariant("tiptap") + : setVariant("markdown") + }} + /> + ) : ( + `tiptap`. + // This is because this implies that the user has + // changed the variant from markdown to tiptap. + // Hence, there might be changes in either markdown editor content + // or in the preview editor that should be persisted. + shouldUseFetchedData={ + variant === "tiptap" && + initialPageData?.content?.frontMatter?.variant === "tiptap" + } + /> + )} + ) diff --git a/src/layouts/EditPage/EditPageLayout.tsx b/src/layouts/EditPage/EditPageLayout.tsx index f6f739c92..045a12061 100644 --- a/src/layouts/EditPage/EditPageLayout.tsx +++ b/src/layouts/EditPage/EditPageLayout.tsx @@ -20,6 +20,8 @@ import Header from "components/Header" import { OverwriteChangesModal } from "components/OverwriteChangesModal" import { WarningModal } from "components/WarningModal" +import { useEditorDrawerContext } from "contexts/EditorDrawerContext" + import { useGetMultipleMediaHook } from "hooks/mediaHooks" import { useGetPageHook, useUpdatePageHook } from "hooks/pageHooks" import { useCspHook, useGetSiteColorsHook } from "hooks/settingsHooks" @@ -44,6 +46,7 @@ export const EditPageLayout = ({ variant = "markdown", children, }: PropsWithChildren) => { + const { isAnyDrawerOpen } = useEditorDrawerContext() const params = useParams<{ siteName: string }>() const decodedParams = getDecodedParams(params) const [mediaSrcs, setMediaSrcs] = useState(new Set("")) @@ -200,7 +203,13 @@ export const EditPageLayout = ({ isEditPage params={decodedParams} /> - + {/* Editor */} {children} @@ -214,7 +223,7 @@ export const EditPageLayout = ({ // TODO: Add an alert/modal // to warn the user when they violate our csp // so they know why + can take action to remedy - isDisabled={isContentViolation} + isDisabled={isContentViolation || isAnyDrawerOpen} isLoading={isSavingPage} > Save diff --git a/src/layouts/EditPage/TiptapEditPage.tsx b/src/layouts/EditPage/TiptapEditPage.tsx index 685c95852..51a34b93f 100644 --- a/src/layouts/EditPage/TiptapEditPage.tsx +++ b/src/layouts/EditPage/TiptapEditPage.tsx @@ -5,9 +5,11 @@ import { marked } from "marked" import { useCallback, useEffect, useState } from "react" import { useParams } from "react-router-dom" +import { EditorCardsDrawer } from "components/EditorCardsDrawer" import PagePreview from "components/pages/PagePreview" import { useEditorContext } from "contexts/EditorContext" +import { useEditorDrawerContext } from "contexts/EditorDrawerContext" import { useGetMultipleMediaHook } from "hooks/mediaHooks" import { useGetPageHook } from "hooks/pageHooks" @@ -31,6 +33,11 @@ interface TiptapEditPageProps { export const TiptapEditPage = ({ shouldUseFetchedData, }: TiptapEditPageProps) => { + const { + isDrawerOpen, + onDrawerClose, + onDrawerProceed, + } = useEditorDrawerContext() const params = useParams<{ siteName: string }>() const { data: initialPageData, isLoading: isLoadingPage } = useGetPageHook( params @@ -101,8 +108,17 @@ export const TiptapEditPage = ({ getEditorContent={() => editor.getHTML()} variant="tiptap" > + {/* Editor drawers */} + + {/* Editor */} + {/* Preview */} { - const { isOpen, onOpen, onClose } = useDisclosure() + const { onDrawerOpen } = useEditorDrawerContext() const { editor } = useEditorContext() return ( @@ -16,13 +17,14 @@ const CardsButton = () => { borderRadius="0.25rem" border="1px solid" borderColor="base.divider.medium" + boxShadow="0px 8px 12px 0px rgba(187, 187, 187, 0.50)" px="0.5rem" py="0.25rem" > ({ + name: "trailingNode", + + addOptions() { + return { + node: "paragraph", + notAfter: ["paragraph"], + } + }, + + addProseMirrorPlugins() { + const plugin = new PluginKey(this.name) + const disabledNodes = Object.entries(this.editor.schema.nodes) + .map(([, value]) => value) + .filter((node) => this.options.notAfter.includes(node.name)) + + return [ + new Plugin({ + key: plugin, + appendTransaction: (_, __, state) => { + const { doc, tr, schema } = state + const shouldInsertNodeAtEnd = plugin.getState(state) + const endPosition = doc.content.size + const type = schema.nodes[this.options.node] + + if (shouldInsertNodeAtEnd) { + return tr.insert(endPosition, type.create()) + } + + return null + }, + state: { + init: (_, state) => { + const lastNode = state.tr.doc.lastChild + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }) + }, + apply: (tr, value) => { + if (!tr.docChanged) { + return value + } + + const lastNode = tr.doc.lastChild + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }) + }, + }, + }), + ] + }, +}) diff --git a/src/types/editPage.ts b/src/types/editPage.ts index 139f76d43..f3a67604c 100644 --- a/src/types/editPage.ts +++ b/src/types/editPage.ts @@ -2,6 +2,8 @@ export interface EditorEmbedContents { value: string } +export type DrawerVariant = "cards" + export interface EditorCard { image: string altText: string From 1202329a6e2b1326bec185d4e8d732bb5090f00f Mon Sep 17 00:00:00 2001 From: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Date: Thu, 30 Nov 2023 11:20:32 +0800 Subject: [PATCH 04/21] feat(cards): allow editing of cards within drawer (#1705) --- src/components/Editable/Editable.tsx | 3 + .../EditorCardsDrawer/EditorCardItem.tsx | 173 +++++++++ .../EditorCardsDrawer/EditorCardsDrawer.tsx | 333 +++++++++++++++++- src/constants/tiptap.ts | 3 + .../Editor/extensions/Cards/Cards.ts | 18 + 5 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 src/components/EditorCardsDrawer/EditorCardItem.tsx diff --git a/src/components/Editable/Editable.tsx b/src/components/Editable/Editable.tsx index cdf39486b..f39a59e87 100644 --- a/src/components/Editable/Editable.tsx +++ b/src/components/Editable/Editable.tsx @@ -177,10 +177,13 @@ type ContactUsDroppableZone = type NavDroppableZone = "link" | `sublink-${number}` +type NormalPageDroppableZone = "cards" + type DroppableZone = | HomepageDroppableZone | ContactUsDroppableZone | NavDroppableZone + | NormalPageDroppableZone type DropInfo = { droppableId: DroppableZone diff --git a/src/components/EditorCardsDrawer/EditorCardItem.tsx b/src/components/EditorCardsDrawer/EditorCardItem.tsx new file mode 100644 index 000000000..6a9c62680 --- /dev/null +++ b/src/components/EditorCardsDrawer/EditorCardItem.tsx @@ -0,0 +1,173 @@ +import { Box, FormControl, VStack } from "@chakra-ui/react" +import { + Button, + FormErrorMessage, + FormHelperText, + FormLabel, + Input, + Textarea, +} from "@opengovsg/design-system-react" +import _ from "lodash" +import { UseFormReturn } from "react-hook-form" + +import { Editable } from "components/Editable" +import { FormContext } from "components/Form" +import FormFieldMedia from "components/FormFieldMedia" + +import { TIPTAP_CARDS_DESCRIPTION_CHAR_LIMIT } from "constants/tiptap" + +import { useEditableContext } from "contexts/EditableContext" + +import { EditorCardsInfo } from "types/editPage" + +interface EditorCardItemProps { + index: number + methods: UseFormReturn +} + +export const EditorCardItem = ({ + index, + methods, +}: EditorCardItemProps): JSX.Element => { + const { onDelete } = useEditableContext() + const { errors } = methods.formState + + return ( + + + {/* Card image */} + {methods.watch("isDisplayImage") && ( + <> + + + methods.setValue(`cards.${index}.image`, e.target.value) + } + > + + Image + + + + {errors.cards?.[index]?.image?.message} + + + + + {/* Card image alt text */} + + Alt text + + A short description about the image for accessibility and SEO + + + + {errors.cards?.[index]?.altText?.message} + + + + )} + + {/* Card title */} + + Title + + + {errors.cards?.[index]?.title?.message} + + + + {/* Card description */} + + Description +