From a8274e03f4b9dc24ab369f089aef43110c5af09f Mon Sep 17 00:00:00 2001 From: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Date: Tue, 21 Nov 2023 13:53:11 +0800 Subject: [PATCH 1/8] fix(media): bring the user back to the select media modal screen after upload (#1684) --- .../MediaCreationModal/MediaCreationModal.tsx | 8 ++++++-- src/components/media/MediaModal.jsx | 18 ++---------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/components/MediaCreationModal/MediaCreationModal.tsx b/src/components/MediaCreationModal/MediaCreationModal.tsx index cd7ef8a1a..a5e0c7fba 100644 --- a/src/components/MediaCreationModal/MediaCreationModal.tsx +++ b/src/components/MediaCreationModal/MediaCreationModal.tsx @@ -159,13 +159,14 @@ interface MediaUploadSuccessDropzoneProps { numMedia: number errorMessages: string[] mediaType: MediaFolderTypes + onProceed: () => void } const MediaUploadSuccessDropzone = ({ numMedia, errorMessages, mediaType, + onProceed, }: MediaUploadSuccessDropzoneProps) => { - const { onClose } = useModalContext() const { singularMediaLabel, pluralMediaLabel, @@ -220,7 +221,7 @@ const MediaUploadSuccessDropzone = ({ )} - Return to {singularDirectoryLabel} + Return to {singularDirectoryLabel} > ) @@ -281,6 +282,7 @@ const MediaUploadFailedDropzone = ({ interface MediaCreationModalProps { onClose: () => void + onProceed: () => void variant: MediaFolderTypes } @@ -295,6 +297,7 @@ interface MediaCreationRouteParams export const MediaCreationModal = ({ onClose, + onProceed, variant, }: MediaCreationModalProps) => { const { onClose: onModalClose } = useDisclosure() @@ -368,6 +371,7 @@ export const MediaCreationModal = ({ numMedia={numCreated} errorMessages={errorMessages} mediaType={variant} + onProceed={onProceed} /> )} {curStep === "failed" && ( diff --git a/src/components/media/MediaModal.jsx b/src/components/media/MediaModal.jsx index 71efeccf6..a922837dd 100644 --- a/src/components/media/MediaModal.jsx +++ b/src/components/media/MediaModal.jsx @@ -9,8 +9,6 @@ import { MediaAltText } from "components/media/MediaAltText" import MediasSelectModal from "components/media/MediasSelectModal" import { MediaCreationModal } from "components/MediaCreationModal/MediaCreationModal" -import { useCreateMediaHook } from "hooks/mediaHooks/useCreateMediaHook" - import { getMediaDirectoryName } from "utils" const MediaModal = ({ onClose, onProceed, type, showAltTextModal = false }) => { @@ -36,8 +34,6 @@ const MediaModal = ({ onClose, onProceed, type, showAltTextModal = false }) => { mediaDirectoryName: type, }) - const { mutateAsync: createHandler } = useCreateMediaHook(queryParams) - const retrieveMediaDirectoryParams = () => `/${getMediaDirectoryName(queryParams.mediaDirectoryName, { joinOn: "/", @@ -64,18 +60,8 @@ const MediaModal = ({ onClose, onProceed, type, showAltTextModal = false }) => { { - await createHandler({ data }) - onMediaSelect({ ...data, mediaUrl: data.content }) - if (showAltTextModal) setMediaMode("details") - else - onProceed({ - selectedMediaPath: `${retrieveMediaDirectoryParams()}/${ - data.name - }`, - }) - }} - onClose={onClose} + onProceed={() => setMediaMode("select")} + onClose={() => setMediaMode("select")} /> ) } From 860c32a9a50bb1f8a315a82fae0028ce7b472a00 Mon Sep 17 00:00:00 2001 From: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Date: Tue, 21 Nov 2023 13:56:36 +0800 Subject: [PATCH 2/8] feat(tiptap): update icon options and use boxicons (#1683) * feat(assets): add icon assets for table actions * feat(tiptap): update icon options and use boxicons * style(editor): adjust text styling and spacing based on design feedback * style(tiptap): update tooltip copies for some options --- package-lock.json | 13 + package.json | 1 + src/assets/icons/BxAddColLeft.tsx | 28 ++ src/assets/icons/BxAddColRight.tsx | 28 ++ src/assets/icons/BxAddRowAbove.tsx | 28 ++ src/assets/icons/BxAddRowBelow.tsx | 28 ++ src/assets/icons/BxDelCol.tsx | 18 + src/assets/icons/BxDelRow.tsx | 18 + src/assets/icons/index.ts | 6 + src/layouts/EditPage/EditPage.tsx | 2 + .../components/Editor/components/MenuBar.tsx | 358 +++++++++++++++--- .../components/Editor/components/MenuItem.tsx | 10 +- .../Editor/components/TableBubbleMenu.tsx | 21 +- 13 files changed, 486 insertions(+), 73 deletions(-) create mode 100644 src/assets/icons/BxAddColLeft.tsx create mode 100644 src/assets/icons/BxAddColRight.tsx create mode 100644 src/assets/icons/BxAddRowAbove.tsx create mode 100644 src/assets/icons/BxAddRowBelow.tsx create mode 100644 src/assets/icons/BxDelCol.tsx create mode 100644 src/assets/icons/BxDelRow.tsx diff --git a/package-lock.json b/package-lock.json index a28ded033..00c0845ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@tiptap/extension-table-row": "^2.1.12", "@tiptap/extension-task-item": "^2.1.12", "@tiptap/extension-task-list": "^2.1.12", + "@tiptap/extension-underline": "^2.1.12", "@tiptap/pm": "^2.1.12", "@tiptap/react": "^2.1.12", "@tiptap/starter-kit": "^2.1.12", @@ -11079,6 +11080,18 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-underline": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.1.12.tgz", + "integrity": "sha512-NwwdhFT8gDD0VUNLQx85yFBhP9a8qg8GPuxlGzAP/lPTV8Ubh3vSeQ5N9k2ZF/vHlEvnugzeVCbmYn7wf8vn1g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, "node_modules/@tiptap/pm": { "version": "2.1.12", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.1.12.tgz", diff --git a/package.json b/package.json index e37f58c10..7a0a9542e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@tiptap/extension-table-row": "^2.1.12", "@tiptap/extension-task-item": "^2.1.12", "@tiptap/extension-task-list": "^2.1.12", + "@tiptap/extension-underline": "^2.1.12", "@tiptap/pm": "^2.1.12", "@tiptap/react": "^2.1.12", "@tiptap/starter-kit": "^2.1.12", diff --git a/src/assets/icons/BxAddColLeft.tsx b/src/assets/icons/BxAddColLeft.tsx new file mode 100644 index 000000000..c6b776838 --- /dev/null +++ b/src/assets/icons/BxAddColLeft.tsx @@ -0,0 +1,28 @@ +export const BxAddColLeft = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + ) +} diff --git a/src/assets/icons/BxAddColRight.tsx b/src/assets/icons/BxAddColRight.tsx new file mode 100644 index 000000000..dd7ae4e36 --- /dev/null +++ b/src/assets/icons/BxAddColRight.tsx @@ -0,0 +1,28 @@ +export const BxAddColRight = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + ) +} diff --git a/src/assets/icons/BxAddRowAbove.tsx b/src/assets/icons/BxAddRowAbove.tsx new file mode 100644 index 000000000..60b094294 --- /dev/null +++ b/src/assets/icons/BxAddRowAbove.tsx @@ -0,0 +1,28 @@ +export const BxAddRowAbove = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + ) +} diff --git a/src/assets/icons/BxAddRowBelow.tsx b/src/assets/icons/BxAddRowBelow.tsx new file mode 100644 index 000000000..49bf6c608 --- /dev/null +++ b/src/assets/icons/BxAddRowBelow.tsx @@ -0,0 +1,28 @@ +export const BxAddRowBelow = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + ) +} diff --git a/src/assets/icons/BxDelCol.tsx b/src/assets/icons/BxDelCol.tsx new file mode 100644 index 000000000..65a22edfc --- /dev/null +++ b/src/assets/icons/BxDelCol.tsx @@ -0,0 +1,18 @@ +export const BxDelCol = (props: React.SVGProps): JSX.Element => { + return ( + + + + + ) +} diff --git a/src/assets/icons/BxDelRow.tsx b/src/assets/icons/BxDelRow.tsx new file mode 100644 index 000000000..237df10ba --- /dev/null +++ b/src/assets/icons/BxDelRow.tsx @@ -0,0 +1,18 @@ +export const BxDelRow = (props: React.SVGProps): JSX.Element => { + return ( + + + + + ) +} diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 3d2822fbb..f52fd3631 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -19,3 +19,9 @@ export * from "./BxDraggable" export * from "./BxDraggableVertical" export * from "./BxGrayTranslucent" export * from "./BxErrorSolid" +export * from "./BxAddColLeft" +export * from "./BxAddColRight" +export * from "./BxAddRowAbove" +export * from "./BxAddRowBelow" +export * from "./BxDelCol" +export * from "./BxDelRow" diff --git a/src/layouts/EditPage/EditPage.tsx b/src/layouts/EditPage/EditPage.tsx index 9873c30a8..436481956 100644 --- a/src/layouts/EditPage/EditPage.tsx +++ b/src/layouts/EditPage/EditPage.tsx @@ -11,6 +11,7 @@ import TableHeader from "@tiptap/extension-table-header" import TableRow from "@tiptap/extension-table-row" import TaskItem from "@tiptap/extension-task-item" import TaskList from "@tiptap/extension-task-list" +import Underline from "@tiptap/extension-underline" import { getHTMLFromFragment, useEditor } from "@tiptap/react" import StarterKit from "@tiptap/starter-kit" import { Context, useContext, useEffect, useState } from "react" @@ -81,6 +82,7 @@ export const EditPage = () => { TableRow, TableHeader, TableCell, + Underline, Placeholder, ], autofocus: "start", diff --git a/src/layouts/components/Editor/components/MenuBar.tsx b/src/layouts/components/Editor/components/MenuBar.tsx index e67fa34e7..966c16707 100644 --- a/src/layouts/components/Editor/components/MenuBar.tsx +++ b/src/layouts/components/Editor/components/MenuBar.tsx @@ -1,102 +1,231 @@ -import { Divider, HStack } from "@chakra-ui/react" +import { + Divider, + HStack, + Icon, + MenuButtonProps, + MenuListProps, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Text, +} from "@chakra-ui/react" +import { Button, Menu } from "@opengovsg/design-system-react" import { Editor } from "@tiptap/react" +import { + BiBold, + BiChevronDown, + BiChevronUp, + BiCodeAlt, + BiFile, + BiImageAdd, + BiItalic, + BiLink, + BiListOl, + BiListUl, + BiMinus, + BiRedo, + BiStrikethrough, + BiTable, + BiUnderline, + BiUndo, +} from "react-icons/bi" +import { IconType } from "react-icons/lib" import { useEditorModal } from "contexts/EditorModalContext" import { MenuItem } from "./MenuItem" +interface MenuBarItem { + type: "item" + title: string + icon?: IconType + textStyle?: string + useSecondaryColor?: boolean + leftItem?: JSX.Element + action: () => void + isActive?: () => boolean +} + +interface MenuBarDivider { + type: "divider" +} + +interface MenuBarVeritcalList { + type: "vertical-list" + buttonWidth: MenuButtonProps["width"] + menuWidth: MenuListProps["width"] + defaultTitle: string + items: MenuBarItem[] +} + +interface MenuBarHorizontalList { + type: "horizontal-list" + label: string + defaultIcon: IconType + items: MenuBarItem[] +} + +type MenuBarEntry = + | MenuBarDivider + | MenuBarVeritcalList + | MenuBarHorizontalList + | MenuBarItem + export const MenuBar = ({ editor }: { editor: Editor }) => { const { showModal } = useEditorModal() - const items = [ - { - icon: "h-1", - title: "Heading 1", - action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), - isActive: () => editor.isActive("heading", { level: 1 }), - }, - { - icon: "h-2", - title: "Heading 2", - action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), - isActive: () => editor.isActive("heading", { level: 2 }), - }, + const items: MenuBarEntry[] = [ { - icon: "h-3", - title: "Heading 3", - action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), - isActive: () => editor.isActive("heading", { level: 3 }), + type: "vertical-list", + buttonWidth: "9rem", + menuWidth: "19rem", + defaultTitle: "Heading 1", + items: [ + { + type: "item", + title: "Title", + textStyle: "h1", + useSecondaryColor: true, + action: () => + editor.chain().focus().toggleHeading({ level: 1 }).run(), + isActive: () => editor.isActive("heading", { level: 1 }), + }, + { + type: "item", + title: "Heading 1", + textStyle: "h2", + useSecondaryColor: true, + action: () => + editor.chain().focus().toggleHeading({ level: 2 }).run(), + isActive: () => editor.isActive("heading", { level: 2 }), + }, + { + type: "item", + title: "Heading 2", + textStyle: "h3", + useSecondaryColor: true, + action: () => + editor.chain().focus().toggleHeading({ level: 3 }).run(), + isActive: () => editor.isActive("heading", { level: 3 }), + }, + { + type: "item", + title: "Heading 3", + textStyle: "h4", + useSecondaryColor: true, + action: () => + editor.chain().focus().toggleHeading({ level: 4 }).run(), + isActive: () => editor.isActive("heading", { level: 4 }), + }, + { + type: "item", + title: "Quote block", + textStyle: "body-1", + useSecondaryColor: true, + leftItem: ( + + ), + action: () => editor.chain().focus().toggleBlockquote().run(), + isActive: () => editor.isActive("blockquote"), + }, + { + type: "item", + title: "Paragraph", + textStyle: "body-1", + action: () => + editor.chain().focus().clearNodes().unsetAllMarks().run(), + isActive: () => editor.isActive("paragraph"), + }, + ], }, { type: "divider", }, { - icon: "bold", + type: "item", + icon: BiBold, title: "Bold", action: () => editor.chain().focus().toggleBold().run(), isActive: () => editor.isActive("bold"), }, { - icon: "italic", - title: "Italic", + type: "item", + icon: BiItalic, + title: "Italicise", action: () => editor.chain().focus().toggleItalic().run(), isActive: () => editor.isActive("italic"), }, { - icon: "strikethrough", - title: "Strike", - action: () => editor.chain().focus().toggleStrike().run(), - isActive: () => editor.isActive("strike"), + type: "item", + icon: BiUnderline, + title: "Underline", + action: () => editor.chain().focus().toggleUnderline().run(), + isActive: () => editor.isActive("underline"), }, { - icon: "double-quotes-l", - title: "Blockquote", - action: () => editor.chain().focus().toggleBlockquote().run(), - isActive: () => editor.isActive("blockquote"), + type: "item", + icon: BiStrikethrough, + title: "Strikethrough", + action: () => editor.chain().focus().toggleStrike().run(), + isActive: () => editor.isActive("strike"), }, { type: "divider", }, { - icon: "list-ordered", - title: "Ordered List", - action: () => editor.chain().focus().toggleOrderedList().run(), - isActive: () => editor.isActive("orderedList"), - }, + type: "horizontal-list", + label: "Lists", + defaultIcon: BiListOl, + items: [ + { + type: "item", + icon: BiListOl, + title: "Ordered list", + action: () => editor.chain().focus().toggleOrderedList().run(), + isActive: () => editor.isActive("orderedList"), + }, - { - icon: "list-unordered", - title: "Bullet List", - action: () => editor.chain().focus().toggleBulletList().run(), - isActive: () => editor.isActive("bulletList"), + { + type: "item", + icon: BiListUl, + title: "Bullet list", + action: () => editor.chain().focus().toggleBulletList().run(), + isActive: () => editor.isActive("bulletList"), + }, + ], }, - { type: "divider", }, { - icon: "file-image-line", + type: "item", + icon: BiLink, + title: "Add link", + action: () => showModal("hyperlink"), + }, + { + type: "item", + icon: BiImageAdd, title: "Add image", action: () => showModal("images"), }, { - icon: "file-pdf-line", + type: "item", + icon: BiFile, title: "Add file", action: () => showModal("files"), }, { - icon: "links-line", - title: "Add link", - action: () => showModal("hyperlink"), - }, - { - icon: "link-unlink", - title: "Remove link", - action: () => editor.chain().focus().unsetLink().run(), - }, - { - icon: "code-s-slash-line", + type: "item", + icon: BiCodeAlt, title: "Insert embed", action: () => showModal("embed"), }, @@ -104,7 +233,8 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { type: "divider", }, { - icon: "table-line", + type: "item", + icon: BiTable, title: "Add table", action: () => editor @@ -115,20 +245,23 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { .run(), }, { - icon: "separator", - title: "Horizontal Rule", + type: "item", + icon: BiMinus, + title: "Divider", action: () => editor.chain().focus().setHorizontalRule().run(), }, { type: "divider", }, { - icon: "arrow-go-back-line", + type: "item", + icon: BiUndo, title: "Undo", action: () => editor.chain().focus().undo().run(), }, { - icon: "arrow-go-forward-line", + type: "item", + icon: BiRedo, title: "Redo", action: () => editor.chain().focus().redo().run(), }, @@ -143,19 +276,122 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { borderBottom="1px solid" borderColor="base.divider.strong" borderTopRadius="0.25rem" + spacing="0.125rem" > {items.map((item) => ( <> - {item.type === "divider" ? ( + {item.type === "divider" && ( - ) : ( - )} + + {item.type === "vertical-list" && ( + + {({ isOpen }) => { + const activeItem = item.items.find( + (subItem) => subItem.isActive && subItem.isActive() + ) + + return ( + <> + + {activeItem?.title || item.defaultTitle} + + + + {item.items.map((subItem) => ( + + {subItem.leftItem} + {subItem.title && !subItem.icon && ( + + {subItem.title} + + )} + {subItem.icon && ( + + )} + + ))} + + > + ) + }} + + )} + + {item.type === "horizontal-list" && ( + + {({ isOpen }) => ( + <> + + + + + + + + + + + + + + {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 ed44f377b..47adf7bb0 100644 --- a/src/layouts/components/Editor/components/MenuItem.tsx +++ b/src/layouts/components/Editor/components/MenuItem.tsx @@ -1,10 +1,10 @@ -import { Tooltip } from "@chakra-ui/react" +import { Icon, Tooltip } from "@chakra-ui/react" import { IconButton } from "@opengovsg/design-system-react" import { MouseEventHandler } from "react" -import remixiconUrl from "remixicon/fonts/remixicon.symbol.svg" +import { IconType } from "react-icons/lib" interface MenuItemProps { - icon?: string + icon?: IconType title?: string action?: MouseEventHandler isActive?: null | (() => boolean) @@ -32,9 +32,7 @@ export const MenuItem = ({ aria-label={title || "divider"} isRound={isRound} > - - - + ) diff --git a/src/layouts/components/Editor/components/TableBubbleMenu.tsx b/src/layouts/components/Editor/components/TableBubbleMenu.tsx index 66452e69b..ea2d33fc1 100644 --- a/src/layouts/components/Editor/components/TableBubbleMenu.tsx +++ b/src/layouts/components/Editor/components/TableBubbleMenu.tsx @@ -3,6 +3,15 @@ import { BubbleMenu } from "@tiptap/react" import { useEditorContext } from "contexts/EditorContext" +import { + BxAddColLeft, + BxAddColRight, + BxAddRowAbove, + BxAddRowBelow, + BxDelCol, + BxDelRow, +} from "assets" + import { MenuItem } from "./MenuItem" export const TableBubbleMenu = () => { @@ -24,37 +33,37 @@ export const TableBubbleMenu = () => { }} > editor.chain().focus().addColumnBefore().run()} title="Add column before" isRound /> editor.chain().focus().addColumnAfter().run()} title="Add column after" isRound /> editor.chain().focus().deleteColumn().run()} title="Delete column" isRound /> editor.chain().focus().addRowBefore().run()} title="Add row before" isRound /> editor.chain().focus().addRowAfter().run()} title="Add row after" isRound /> editor.chain().focus().deleteRow().run()} title="Delete row" isRound From 5943fea2445fc4e58df7f5276ed7c30efdf7224f Mon Sep 17 00:00:00 2001 From: Hsu Zhong Jun <27919917+dcshzj@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:09:18 +0800 Subject: [PATCH 3/8] feat(rte): use proper node for FormSG embed (#1682) * feat(rte): use proper node for FormSG embed * fix(rte): adjust imports * chore(rte): remove unused import * chore(tiptap): extract priority to constant --- .../EditorEmbedModal/EditorEmbedModal.tsx | 4 +- src/constants/tiptap.ts | 1 + src/layouts/EditPage/EditPage.tsx | 10 +- .../Editor/extensions/FormSG/FormSG.ts | 123 ++++++++++++++++++ .../Editor/extensions/FormSG/FormSGView.tsx | 36 +++++ .../Editor/extensions/FormSG/index.ts | 2 + .../Editor/extensions/Iframe/Iframe.ts | 2 +- .../components/Editor/extensions/index.ts | 1 + 8 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 src/constants/tiptap.ts create mode 100644 src/layouts/components/Editor/extensions/FormSG/FormSG.ts create mode 100644 src/layouts/components/Editor/extensions/FormSG/FormSGView.tsx create mode 100644 src/layouts/components/Editor/extensions/FormSG/index.ts diff --git a/src/components/EditorEmbedModal/EditorEmbedModal.tsx b/src/components/EditorEmbedModal/EditorEmbedModal.tsx index fcfe809ea..9ea0b78ce 100644 --- a/src/components/EditorEmbedModal/EditorEmbedModal.tsx +++ b/src/components/EditorEmbedModal/EditorEmbedModal.tsx @@ -54,9 +54,6 @@ export const EditorEmbedModal = ({ url.href = postUrl || "" const code = `` onProceed({ value: code }) - } else if ($('iframe[src^="https://form.gov.sg"]').length > 0) { - // We only want to keep the part, the rest is filled in by Tiptap - onProceed({ value: $('iframe[src^="https://form.gov.sg"]').toString() }) } else { onProceed(embedCode) } @@ -94,6 +91,7 @@ export const EditorEmbedModal = ({ "value", cursorValue .replace('', "") + .replace('', "") // Remove the closing div tag .slice(0, -6) ) diff --git a/src/constants/tiptap.ts b/src/constants/tiptap.ts new file mode 100644 index 000000000..a4ced59e5 --- /dev/null +++ b/src/constants/tiptap.ts @@ -0,0 +1 @@ +export const TIPTAP_FORMSG_NODE_PRIORITY = 300 diff --git a/src/layouts/EditPage/EditPage.tsx b/src/layouts/EditPage/EditPage.tsx index 436481956..13b54bdd8 100644 --- a/src/layouts/EditPage/EditPage.tsx +++ b/src/layouts/EditPage/EditPage.tsx @@ -29,7 +29,12 @@ import { ServicesContext } from "contexts/ServicesContext" import { useGetPageHook } from "hooks/pageHooks" import { useCspHook } from "hooks/settingsHooks" -import { Iframe } from "layouts/components/Editor/extensions" +import { + FormSG, + FormSGDiv, + FormSGIframe, + Iframe, +} from "layouts/components/Editor/extensions" import { isEmbedCodeValid } from "utils/allowedHTML" @@ -69,6 +74,9 @@ export const EditPage = () => { Image.configure({ allowBase64: true }), Link.configure({ openOnClick: false, protocols: ["mailto"] }), Iframe, + FormSG, + FormSGDiv, + FormSGIframe, Markdown, BubbleMenu.configure({ pluginKey: "linkBubble", diff --git a/src/layouts/components/Editor/extensions/FormSG/FormSG.ts b/src/layouts/components/Editor/extensions/FormSG/FormSG.ts new file mode 100644 index 000000000..fa0c0d2d5 --- /dev/null +++ b/src/layouts/components/Editor/extensions/FormSG/FormSG.ts @@ -0,0 +1,123 @@ +import { Node } from "@tiptap/core" +import { ReactNodeViewRenderer } from "@tiptap/react" +import _ from "lodash" + +import { TIPTAP_FORMSG_NODE_PRIORITY } from "constants/tiptap" + +import { FormSGView } from "./FormSGView" + +export interface FormSGOptions { + HTMLAttributes: { + [key: string]: string + } +} + +declare module "@tiptap/core" { + interface Commands { + formsg: { + setFormsg: (options: { src: string }) => ReturnType + } + } +} + +export const FormSGDiv = Node.create({ + name: "formsgdiv", + group: "formsg", + content: "inline*", + draggable: false, + defining: true, + + addAttributes() { + return { + style: { + default: null, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'div[style]:has(> a[href^="https://form.gov.sg"])', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + { + style: HTMLAttributes.style, + }, + 0, + ] + }, +}) + +export const FormSGIframe = Node.create({ + name: "formsgiframe", + group: "formsg", + atom: true, + draggable: true, + defining: true, + + addAttributes() { + return { + id: { + default: "iframe", + }, + src: { + default: null, + }, + style: { + default: "width: 100%; height: 500px", + }, + } + }, + + parseHTML() { + return [ + { + tag: 'iframe[src^="https://form.gov.sg/"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ["iframe", HTMLAttributes] + }, +}) + +export const FormSG = Node.create({ + name: "formsg", + priority: TIPTAP_FORMSG_NODE_PRIORITY, + group: "block", + content: "formsgdiv formsgiframe formsgdiv", + atom: true, + draggable: true, + defining: true, + + addOptions() { + return { + HTMLAttributes: { + class: "formsg-wrapper", + }, + } + }, + + parseHTML() { + return [ + { + tag: 'div[class="formsg-wrapper"]', + }, + ] + }, + + renderHTML() { + return ["div", { class: "formsg-wrapper" }, 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(FormSGView) + }, +}) diff --git a/src/layouts/components/Editor/extensions/FormSG/FormSGView.tsx b/src/layouts/components/Editor/extensions/FormSG/FormSGView.tsx new file mode 100644 index 000000000..cea50d5e4 --- /dev/null +++ b/src/layouts/components/Editor/extensions/FormSG/FormSGView.tsx @@ -0,0 +1,36 @@ +import { Box, Text } from "@chakra-ui/react" +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react" + +export const FormSGView = ({ node }: NodeViewProps) => { + const formSrc = node.content.child(1).attrs.src + + return ( + + + FormSG Embed + + + {formSrc} + + + ) +} diff --git a/src/layouts/components/Editor/extensions/FormSG/index.ts b/src/layouts/components/Editor/extensions/FormSG/index.ts new file mode 100644 index 000000000..f2d63cfdc --- /dev/null +++ b/src/layouts/components/Editor/extensions/FormSG/index.ts @@ -0,0 +1,2 @@ +export * from "./FormSG" +export * from "./FormSGView" diff --git a/src/layouts/components/Editor/extensions/Iframe/Iframe.ts b/src/layouts/components/Editor/extensions/Iframe/Iframe.ts index 5752c6ab2..c742d25bb 100644 --- a/src/layouts/components/Editor/extensions/Iframe/Iframe.ts +++ b/src/layouts/components/Editor/extensions/Iframe/Iframe.ts @@ -68,7 +68,7 @@ export const Iframe = Node.create({ parseHTML() { return [ { - tag: "iframe", + tag: 'iframe:not([src^="https://form.gov.sg/"])', }, ] }, diff --git a/src/layouts/components/Editor/extensions/index.ts b/src/layouts/components/Editor/extensions/index.ts index f3b4fe4eb..48e595563 100644 --- a/src/layouts/components/Editor/extensions/index.ts +++ b/src/layouts/components/Editor/extensions/index.ts @@ -1 +1,2 @@ export * from "./Iframe" +export * from "./FormSG" From 001116fa3b1e7bb08f84a86c863223a928a0b35d Mon Sep 17 00:00:00 2001 From: Kishore <42832651+kishore03109@users.noreply.github.com> Date: Tue, 21 Nov 2023 16:00:16 +0800 Subject: [PATCH 4/8] IS-782-Editor-File-text-always-comes-out-as-file-regardless-of-text (#1685) * fix(bubbleMenu): only allow change link for files * feat(image): use image src, not base64 * feat(getMediaSrc): load media src * fix(wrapAroundDiv): wrap the img ard a div... to prevent the p to be rendered * fix(image): change to wrapper * chore(image): rm redundant overload --- src/layouts/EditPage/EditPage.tsx | 21 +++++--- src/layouts/EditPage/TiptapEditPage.tsx | 48 ++++++++++++++++++- .../Editor/components/LinkBubbleMenu.tsx | 19 ++------ .../Editor/extensions/Image/Image.tsx | 20 ++++++++ .../Editor/extensions/Image/ImageView.tsx | 26 ++++++++++ .../Editor/extensions/Image/index.ts | 2 + .../components/Editor/extensions/index.ts | 1 + 7 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 src/layouts/components/Editor/extensions/Image/Image.tsx create mode 100644 src/layouts/components/Editor/extensions/Image/ImageView.tsx create mode 100644 src/layouts/components/Editor/extensions/Image/index.ts diff --git a/src/layouts/EditPage/EditPage.tsx b/src/layouts/EditPage/EditPage.tsx index 13b54bdd8..b47a442d8 100644 --- a/src/layouts/EditPage/EditPage.tsx +++ b/src/layouts/EditPage/EditPage.tsx @@ -34,6 +34,7 @@ import { FormSGDiv, FormSGIframe, Iframe, + IsomerImage, } from "layouts/components/Editor/extensions" import { isEmbedCodeValid } from "utils/allowedHTML" @@ -71,7 +72,7 @@ export const EditPage = () => { TaskList, TaskItem, CharacterCount, - Image.configure({ allowBase64: true }), + IsomerImage, Link.configure({ openOnClick: false, protocols: ["mailto"] }), Iframe, FormSG, @@ -124,11 +125,15 @@ export const EditPage = () => { const getImageSrc = async (src: string) => { const { fileName, imageDirectory } = getImageDetails(src) - return mediaService.get({ + const { mediaPath, mediaUrl } = await mediaService.get({ siteName, mediaDirectoryName: imageDirectory || "images", fileName, }) + const nomalisedMediaPath = mediaPath.startsWith("images/") + ? `/${mediaPath}` + : mediaPath + return { mediaPath: nomalisedMediaPath, mediaUrl } } const handleEmbedInsert = ({ value }: EditorEmbedContents) => { @@ -176,25 +181,27 @@ export const EditPage = () => { type={mediaType} onProceed={async ({ selectedMediaPath, altText }) => { if (mediaType === "images") { - const { mediaUrl } = await getImageSrc(selectedMediaPath) + const { mediaPath } = await getImageSrc(selectedMediaPath) editor .chain() .focus() .setImage({ - src: mediaUrl, + 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) + } else if (editor.state.selection.empty) { editor .chain() .focus() .insertContent( - `file` + `${ + altText ?? "file" + }` ) .run() - else { + } else { editor .chain() .focus() diff --git a/src/layouts/EditPage/TiptapEditPage.tsx b/src/layouts/EditPage/TiptapEditPage.tsx index fd3387307..685c95852 100644 --- a/src/layouts/EditPage/TiptapEditPage.tsx +++ b/src/layouts/EditPage/TiptapEditPage.tsx @@ -1,17 +1,26 @@ import axios from "axios" -import { useEffect } from "react" +import DOMPurify from "dompurify" +import _ from "lodash" +import { marked } from "marked" +import { useCallback, useEffect, useState } from "react" import { useParams } from "react-router-dom" import PagePreview from "components/pages/PagePreview" import { useEditorContext } from "contexts/EditorContext" +import { useGetMultipleMediaHook } from "hooks/mediaHooks" import { useGetPageHook } from "hooks/pageHooks" +import { useCspHook } from "hooks/settingsHooks" + +import checkCSP from "utils/cspUtils" +import { getMediaSrcsFromHtml } from "utils/images" import { Editor } from "../components/Editor/Editor" import { DEFAULT_BODY } from "./constants" import { EditPageLayout } from "./EditPageLayout" +import { sanitiseRawHtml, updateHtmlWithMediaData } from "./utils" // axios settings axios.defaults.withCredentials = true @@ -49,6 +58,41 @@ export const TiptapEditPage = ({ shouldUseFetchedData, ]) + const [htmlChunk, setHtmlChunk] = useState("") + const [mediaSrcs, setMediaSrcs] = useState>(new Set("")) + const editorHtmlValue = editor.getHTML() + const { data: csp } = useCspHook() + const { siteName } = useParams<{ siteName: string }>() + const { data: mediaData } = useGetMultipleMediaHook({ + siteName, + mediaSrcs, + }) + + const updateMediaSrcs = useCallback(() => { + if (!csp || _.isEmpty(csp) || !editorHtmlValue) return + const html = marked.parse(editorHtmlValue) + const { sanitisedHtml: CSPSanitisedHtml } = checkCSP(csp, html) + const DOMCSPSanitisedHtml = DOMPurify.sanitize(CSPSanitisedHtml) + setMediaSrcs(getMediaSrcsFromHtml(DOMCSPSanitisedHtml)) + }, [csp, editorHtmlValue]) + + useEffect(() => { + updateMediaSrcs() + }, [updateMediaSrcs]) + + useEffect(() => { + if (!csp || _.isEmpty(csp) || !editorHtmlValue) return + const html = marked.parse(editorHtmlValue) + const { sanitisedHtml } = sanitiseRawHtml(csp, html) + + const { html: processedChunk } = updateHtmlWithMediaData( + mediaSrcs, + sanitisedHtml, + mediaData + ) + setHtmlChunk(processedChunk) + }, [mediaData, editorHtmlValue, csp, mediaSrcs]) + return ( { @@ -64,7 +108,7 @@ export const TiptapEditPage = ({ // NOTE: Reserve 45vw for editor w="calc(100% - 45vw)" h="calc(100vh - 160px - 1rem)" - chunk={editor.getHTML()} + chunk={htmlChunk} title={initialPageData?.content?.frontMatter?.title || ""} /> diff --git a/src/layouts/components/Editor/components/LinkBubbleMenu.tsx b/src/layouts/components/Editor/components/LinkBubbleMenu.tsx index 625059070..c929099bf 100644 --- a/src/layouts/components/Editor/components/LinkBubbleMenu.tsx +++ b/src/layouts/components/Editor/components/LinkBubbleMenu.tsx @@ -39,21 +39,12 @@ const LinkButton = () => { // using the link modal. const { href: _linkHref } = editor.getAttributes("link") const linkHref = _linkHref as string - // NOTE: We only allow `mailto` and `http` protocols for our absolute links - // otherwise, they are relative links. - if ( - linkHref.startsWith("http") || - linkHref.startsWith("mailto") || - // NOTE: Files are always guaranteed to be inside `/files`. - // If users use a relative link (from inside a page), it will be situated outside - // of the `files` folder, so this invariant still holds. - (linkHref.startsWith("/") && linkHref.split("/").at(1) !== "files") - ) { - onOpen() - } else { - // Otherwise, show the file modal - // and let the user select a file to link to. + const isLinkRelativeFilePath = + linkHref.startsWith("/") && linkHref.split("/").at(1) === "files" + if (isLinkRelativeFilePath) { showModal("files") + } else { + onOpen() } }} > diff --git a/src/layouts/components/Editor/extensions/Image/Image.tsx b/src/layouts/components/Editor/extensions/Image/Image.tsx new file mode 100644 index 000000000..d43b75831 --- /dev/null +++ b/src/layouts/components/Editor/extensions/Image/Image.tsx @@ -0,0 +1,20 @@ +import Image from "@tiptap/extension-image" +import { ReactNodeViewRenderer } from "@tiptap/react" + +import { ImageView } from "./ImageView" + +export const IsomerImage = Image.extend({ + renderHTML({ HTMLAttributes }) { + return [ + "div", + { + class: "isomer-image-wrapper", + }, + ["img", HTMLAttributes], + ] + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageView) + }, +}) diff --git a/src/layouts/components/Editor/extensions/Image/ImageView.tsx b/src/layouts/components/Editor/extensions/Image/ImageView.tsx new file mode 100644 index 000000000..8e95cdf23 --- /dev/null +++ b/src/layouts/components/Editor/extensions/Image/ImageView.tsx @@ -0,0 +1,26 @@ +import { Box, Image } from "@chakra-ui/react" +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react" +import { useParams } from "react-router-dom" + +import { useGetMultipleMediaHook } from "hooks/mediaHooks" + +export const ImageView = ({ node }: NodeViewProps) => { + const { siteName } = useParams<{ siteName: string }>() + const mediaSrcs = new Set([node.attrs.src]) + + const { data: mediaData } = useGetMultipleMediaHook({ + siteName, + mediaSrcs, + }) + + let imgSrc = "/placeholder_no_image.png" + if (mediaData && mediaData.length > 0) { + // Guarenteed to be the first element since we only passed in one mediaSrc + imgSrc = mediaData[0].mediaUrl + } + return ( + + + + ) +} diff --git a/src/layouts/components/Editor/extensions/Image/index.ts b/src/layouts/components/Editor/extensions/Image/index.ts new file mode 100644 index 000000000..fcdb3ba4c --- /dev/null +++ b/src/layouts/components/Editor/extensions/Image/index.ts @@ -0,0 +1,2 @@ +export * from "./Image" +export * from "./ImageView" diff --git a/src/layouts/components/Editor/extensions/index.ts b/src/layouts/components/Editor/extensions/index.ts index 48e595563..c48dbf107 100644 --- a/src/layouts/components/Editor/extensions/index.ts +++ b/src/layouts/components/Editor/extensions/index.ts @@ -1,2 +1,3 @@ export * from "./Iframe" export * from "./FormSG" +export * from "./Image" From 95dc4da70b27f4f2b6fb34e9af92e6f5165735bd Mon Sep 17 00:00:00 2001 From: Kishore <42832651+kishore03109@users.noreply.github.com> Date: Tue, 21 Nov 2023 16:04:47 +0800 Subject: [PATCH 5/8] IS-780 fix(height): fix editor + preview heights (#1686) * IS-780 fix(height): fix editor + preview heights * fix(height): use screen height instead --- src/layouts/EditPage/MarkdownEditPage.tsx | 2 +- src/layouts/LegacyEditPage.jsx | 2 +- src/styles/isomer-cms/pages/Editor.module.scss | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/layouts/EditPage/MarkdownEditPage.tsx b/src/layouts/EditPage/MarkdownEditPage.tsx index 8218f0ffb..83c9e1d7f 100644 --- a/src/layouts/EditPage/MarkdownEditPage.tsx +++ b/src/layouts/EditPage/MarkdownEditPage.tsx @@ -157,7 +157,7 @@ export const MarkdownEditPage = ({ togglePreview }: MarkdownPageProps) => { {/* Preview */} { {/* Preview */} Date: Tue, 21 Nov 2023 16:05:04 +0800 Subject: [PATCH 6/8] is-714/chore: add copy and layout changes (#1687) * chore: add copy and layout changes * chore: bold text and set close indentation --- src/layouts/EditPage/MarkdownEditPage.tsx | 33 ++++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/layouts/EditPage/MarkdownEditPage.tsx b/src/layouts/EditPage/MarkdownEditPage.tsx index 83c9e1d7f..7a66a5462 100644 --- a/src/layouts/EditPage/MarkdownEditPage.tsx +++ b/src/layouts/EditPage/MarkdownEditPage.tsx @@ -187,26 +187,39 @@ const PreviewModal = ({ togglePreview, }: PreviewModalProps) => { return ( - + - New editing preview + Preview Isomer’s new editor - + - - - Changes you make here will reflect on the new editor if you use the - new one! + + We’re introducing a new editor on IsomerCMS. Using this editor, you + can edit pages without using any Markdown or HTML. Explore what your + content looks like on the new editor. + + + You can toggle to use the new editor anytime on Page Settings. + + + + The current editor will be phased out by{" "} + + Q2 2024 + + . + + - - Close + + I'll explore later - I'm in! + Use new editor on this page From 391ae7fb8cb08c68e829ccdaebe7d013e3a3494c Mon Sep 17 00:00:00 2001 From: Kishore <42832651+kishore03109@users.noreply.github.com> Date: Tue, 21 Nov 2023 19:02:36 +0800 Subject: [PATCH 7/8] fix(input): empty input validation (#1691) * fix(input): empty input validation * fix(touch): on touch --- src/layouts/EditPage/EditPage.tsx | 2 +- .../Editor/components/LinkBubbleMenu.tsx | 92 +++++++++++-------- 2 files changed, 53 insertions(+), 41 deletions(-) diff --git a/src/layouts/EditPage/EditPage.tsx b/src/layouts/EditPage/EditPage.tsx index b47a442d8..2034cf659 100644 --- a/src/layouts/EditPage/EditPage.tsx +++ b/src/layouts/EditPage/EditPage.tsx @@ -197,7 +197,7 @@ export const EditPage = () => { .focus() .insertContent( `${ - altText ?? "file" + altText || "file" }` ) .run() diff --git a/src/layouts/components/Editor/components/LinkBubbleMenu.tsx b/src/layouts/components/Editor/components/LinkBubbleMenu.tsx index c929099bf..ff52117de 100644 --- a/src/layouts/components/Editor/components/LinkBubbleMenu.tsx +++ b/src/layouts/components/Editor/components/LinkBubbleMenu.tsx @@ -7,11 +7,11 @@ import { ModalFooter, ModalBody, ModalCloseButton, - InputGroup, + FormControl, } from "@chakra-ui/react" -import { Button, Input } from "@opengovsg/design-system-react" +import { Button, FormErrorMessage, Input } from "@opengovsg/design-system-react" import { BubbleMenu } from "@tiptap/react" -import { useState } from "react" +import { useForm } from "react-hook-form" import { useEditorContext } from "contexts/EditorContext" import { useEditorModal } from "contexts/EditorModalContext" @@ -20,8 +20,31 @@ const LinkButton = () => { const { editor } = useEditorContext() const { onClose, onOpen, isOpen } = useDisclosure() const { showModal } = useEditorModal() - const [href, setHref] = useState("") + const { + register, + watch, + formState: { errors, isValid }, + } = useForm({ + mode: "onTouched", + defaultValues: { + href: (editor.getAttributes("link").href as string) || "", + }, + }) + const href = watch("href") + const onSubmit = () => { + if (href) { + editor + .chain() + .focus() + // NOTE: Force `https` by default + .setLink({ href }) + .run() + } else { + editor.chain().focus().unsetLink().run() + } + onClose() + } return ( <> { > Change link - - - - Update link - - - + + + + + Update link + + setHref(event.target.value)} + id="href" + {...register("href", { required: "Link is required" })} /> - - + {errors.href?.message || ""} + - - { - if (href) { - editor - .chain() - .focus() - // NOTE: Force `https` by default - .setLink({ href }) - .run() - } else { - editor.chain().focus().unsetLink().run() - } - onClose() - }} - > - Save - - - - + + + Save + + + + + > ) } From e208f1096d137b683f22237bd25330240e994aae Mon Sep 17 00:00:00 2001 From: Kishore <42832651+kishore03109@users.noreply.github.com> Date: Tue, 21 Nov 2023 19:07:55 +0800 Subject: [PATCH 8/8] 0.60.0 --- CHANGELOG.md | 13 +++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 708a31715..67eba04f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,21 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v0.60.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.59.0...v0.60.0) + +- fix(input): empty input validation [`#1691`](https://github.com/isomerpages/isomercms-frontend/pull/1691) +- is-714/chore: add copy and layout changes [`#1687`](https://github.com/isomerpages/isomercms-frontend/pull/1687) +- IS-780 fix(height): fix editor + preview heights [`#1686`](https://github.com/isomerpages/isomercms-frontend/pull/1686) +- IS-782-Editor-File-text-always-comes-out-as-file-regardless-of-text [`#1685`](https://github.com/isomerpages/isomercms-frontend/pull/1685) +- feat(rte): use proper node for FormSG embed [`#1682`](https://github.com/isomerpages/isomercms-frontend/pull/1682) +- feat(tiptap): update icon options and use boxicons [`#1683`](https://github.com/isomerpages/isomercms-frontend/pull/1683) +- fix(media): bring the user back to the select media modal screen after upload [`#1684`](https://github.com/isomerpages/isomercms-frontend/pull/1684) +- 0.59.0 [`#1680`](https://github.com/isomerpages/isomercms-frontend/pull/1680) + #### [v0.59.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.58.0...v0.59.0) +> 16 November 2023 + - feat(tiptap): allow inserting of embed code using a modal [`#1658`](https://github.com/isomerpages/isomercms-frontend/pull/1658) - feat(tiptap): rm redundant logos [`#1679`](https://github.com/isomerpages/isomercms-frontend/pull/1679) - Fix/is 689 reuse image/album tiles [`#1678`](https://github.com/isomerpages/isomercms-frontend/pull/1678) diff --git a/package-lock.json b/package-lock.json index 00c0845ad..a7a0f171d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "isomercms-frontend", - "version": "0.59.0", + "version": "0.60.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "isomercms-frontend", - "version": "0.59.0", + "version": "0.60.0", "hasInstallScript": true, "dependencies": { "@braintree/sanitize-url": "^6.0.1", diff --git a/package.json b/package.json index 7a0a9542e..472f6b10f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "isomercms-frontend", - "version": "0.59.0", + "version": "0.60.0", "private": true, "engines": { "node": ">=16.0.0"