Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add rich text editor to paragraph field #7070

Open
wants to merge 19 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions frontend/src/assets/icons/BxsBold.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const BxsBold = (props: React.SVGProps<SVGSVGElement>): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
width="1em"
height="1em"
{...props}
>
<path d="M17.061 11.22A4.46 4.46 0 0 0 18 8.5C18 6.019 15.981 4 13.5 4H6v15h8c2.481 0 4.5-2.019 4.5-4.5a4.48 4.48 0 0 0-1.439-3.28zM13.5 7c.827 0 1.5.673 1.5 1.5s-.673 1.5-1.5 1.5H9V7h4.5zm.5 9H9v-3h5c.827 0 1.5.673 1.5 1.5S14.827 16 14 16z"></path>
</svg>
)
14 changes: 14 additions & 0 deletions frontend/src/assets/icons/BxsItalic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const BxsItalic = (
props: React.SVGProps<SVGSVGElement>,
): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
width="1em"
height="1em"
{...props}
>
<path d="M19 7V4H9v3h2.868L9.012 17H5v3h10v-3h-2.868l2.856-10z"></path>
</svg>
)
13 changes: 13 additions & 0 deletions frontend/src/assets/icons/BxsLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const BxsLink = (props: React.SVGProps<SVGSVGElement>): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<path d="M8.465 11.293c1.133-1.133 3.109-1.133 4.242 0l.707.707 1.414-1.414-.707-.707c-.943-.944-2.199-1.465-3.535-1.465s-2.592.521-3.535 1.465L4.929 12a5.008 5.008 0 0 0 0 7.071 4.983 4.983 0 0 0 3.535 1.462A4.982 4.982 0 0 0 12 19.071l.707-.707-1.414-1.414-.707.707a3.007 3.007 0 0 1-4.243 0 3.005 3.005 0 0 1 0-4.243l2.122-2.121z"></path>
<path d="m12 4.929-.707.707 1.414 1.414.707-.707a3.007 3.007 0 0 1 4.243 0 3.005 3.005 0 0 1 0 4.243l-2.122 2.121c-1.133 1.133-3.109 1.133-4.242 0L10.586 12l-1.414 1.414.707.707c.943.944 2.199 1.465 3.535 1.465s2.592-.521 3.535-1.465L19.071 12a5.008 5.008 0 0 0 0-7.071 5.006 5.006 0 0 0-7.071 0z"></path>
</svg>
)
14 changes: 14 additions & 0 deletions frontend/src/assets/icons/BxsOrderedList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const BxsOrderedList = (
props: React.SVGProps<SVGSVGElement>,
): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
width="1.2em"
height="1.2em"
{...props}
>
<path d="M5.282 12.064c-.428.328-.72.609-.875.851-.155.24-.249.498-.279.768h2.679v-.748H5.413c.081-.081.152-.151.212-.201.062-.05.182-.142.361-.27.303-.218.511-.42.626-.604.116-.186.173-.375.173-.578a.898.898 0 0 0-.151-.512.892.892 0 0 0-.412-.341c-.174-.076-.419-.111-.733-.111-.3 0-.537.038-.706.114a.889.889 0 0 0-.396.338c-.094.143-.159.346-.194.604l.894.076c.025-.188.074-.317.147-.394a.375.375 0 0 1 .279-.108c.11 0 .2.035.272.108a.344.344 0 0 1 .108.258.55.55 0 0 1-.108.297c-.074.102-.241.254-.503.453zm.055 6.386a.398.398 0 0 1-.282-.105c-.074-.07-.128-.195-.162-.378L4 18.085c.059.204.142.372.251.506.109.133.248.235.417.306.168.069.399.103.692.103.3 0 .541-.047.725-.14a1 1 0 0 0 .424-.403c.098-.175.146-.354.146-.544a.823.823 0 0 0-.088-.393.708.708 0 0 0-.249-.261 1.015 1.015 0 0 0-.286-.11.943.943 0 0 0 .345-.299.673.673 0 0 0 .113-.383.747.747 0 0 0-.281-.596c-.187-.159-.49-.238-.909-.238-.365 0-.648.072-.847.219-.2.143-.334.353-.404.626l.844.151c.023-.162.067-.274.133-.338s.151-.098.257-.098a.33.33 0 0 1 .241.089c.059.06.087.139.087.238 0 .104-.038.193-.117.27s-.177.112-.293.112a.907.907 0 0 1-.116-.011l-.045.649a1.13 1.13 0 0 1 .289-.056c.132 0 .237.041.313.126.077.082.115.199.115.352 0 .146-.04.266-.119.354a.394.394 0 0 1-.301.134zm.948-10.083V5h-.739a1.47 1.47 0 0 1-.394.523c-.168.142-.404.262-.708.365v.754a2.595 2.595 0 0 0 .937-.48v2.206h.904zM9 6h11v2H9zm0 5h11v2H9zm0 5h11v2H9z"></path>
</svg>
)
14 changes: 14 additions & 0 deletions frontend/src/assets/icons/BxsStrikethrough.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const BxsStrikethrough = (
props: React.SVGProps<SVGSVGElement>,
): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
width="1em"
height="1em"
{...props}
>
<path d="M20 11h-8c-4 0-4-1.816-4-2.5C8 7.882 8 6 12 6c2.8 0 2.99 1.678 3 2.014L16 8h1c0-1.384-1.045-4-5-4-5.416 0-6 3.147-6 4.5 0 .728.148 1.667.736 2.5H4v2h16v-2zm-8 7c-3.793 0-3.99-1.815-4-2H6c0 .04.069 4 6 4 5.221 0 6-2.819 6-4.5 0-.146-.009-.317-.028-.5h-2.006c.032.2.034.376.034.5 0 .684 0 2.5-4 2.5z"></path>
</svg>
)
12 changes: 12 additions & 0 deletions frontend/src/assets/icons/BxsTrash.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const BxsTrash = (props: React.SVGProps<SVGSVGElement>): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
style={{ fill: 'rgba(0, 0, 0, 1)', transform: ';msFilter:' }}
>
<path d="M5 20a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8h2V6h-4V4a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v2H3v2h2zM9 4h6v2H9zM8 8h9v12H7V8z"></path>
<path d="M9 10h2v8H9zm4 0h2v8h-2z"></path>
</svg>
)
14 changes: 14 additions & 0 deletions frontend/src/assets/icons/BxsUnorderedList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const BxsUnorderedList = (
props: React.SVGProps<SVGSVGElement>,
): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
width="1.2em"
height="1.2em"
{...props}
>
<path d="M4 6h2v2H4zm0 5h2v2H4zm0 5h2v2H4zm16-8V6H8.023v2H18.8zM8 11h12v2H8zm0 5h12v2H8z"></path>
</svg>
)
186 changes: 186 additions & 0 deletions frontend/src/components/RichTextEditor/LinkBubbleMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import React, { RefObject, useEffect, useState } from 'react'
import { Divider, Flex } from '@chakra-ui/react'
import { BubbleMenu, Editor, getMarkRange, getMarkType } from '@tiptap/react'
import { Props } from 'tippy.js'

import { BxCheck, BxsEditAlt } from '~assets/icons'
import { BxsTrash } from '~assets/icons/BxsTrash'
import ButtonGroup from '~components/ButtonGroup'
import IconButton from '~components/IconButton'
import Input from '~components/Input'
import Link from '~components/Link'

type LinkBubbleMenuProps = {
editor: Editor | null
containerRef: RefObject<HTMLDivElement>
}

export const LinkBubbleMenu = ({
editor,
containerRef,
}: LinkBubbleMenuProps): JSX.Element | null => {
const [link, setLink] = useState<string>(editor?.getAttributes('link').href)
const [isEditable, setisEditable] = useState<boolean>(true)

useEffect(() => {
setLink(editor?.getAttributes('link').href)
}, [editor, editor?.state.selection])

if (!editor) return null

// Update link href then set editor selection to end of the link text.
const handleUpdateLinkClick = () => {
let updateLink = link
if (updateLink === '') {
editor.chain().unsetLink().run()
return
}

if (!link.startsWith('http')) {
updateLink = 'https://' + updateLink
}

editor
.chain()
.extendMarkRange('link')
.updateAttributes('link', { href: updateLink })
.run()

// Run the commands separately so that editor selection is updated
editor
.chain()
.setTextSelection(editor.state.selection.$to.pos)
.focus()
.run()

setisEditable(false)
}

const handleEditLinkKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleUpdateLinkClick()
}
}

const popoverPadding = 4
const borderPadding = 4

const tippyOptions: Partial<Props> = {
placement: 'bottom-start',
maxWidth: 'none',
// Create a fixed position for the bubble menu, else it follows the cursor
getReferenceClientRect: (): DOMRect => {
const startPos = getMarkRange(
editor.state.selection.$anchor,
getMarkType('link', editor.schema),
)?.from
if (startPos == null) return new DOMRect()
const { left, bottom } = editor.view.coordsAtPos(startPos)
return new DOMRect(left, bottom)
},
popperOptions: {
modifiers: [
{
// Specify the container for the bubble menu to fix clipping issues.
name: 'preventOverflow',
options: {
boundary: containerRef?.current,
padding: popoverPadding + borderPadding,
flip: false,
},
},
{
// Prevent bubble menu from flipping position when link is too far right
name: 'flip',
options: {
fallbackPlacements: [],
},
},
],
},
}

return (
<BubbleMenu
editor={editor}
className="tiptap-link-bubble-menu"
shouldShow={({ editor }) => editor.isActive('link')}
tippyOptions={tippyOptions}
>
<Flex
align="center"
backgroundColor="white"
border="1px solid"
borderColor="neutral.400"
borderRadius="base"
boxShadow="0px 0px 10px 0px #D8DEEB80"
// Gives 16px gap from button to end of input
gap="4px"
height="auto"
justify="space-between"
minHeight="50px"
padding={4 + 'px'}
>
{isEditable ? (
<Input
flexBasis="200px"
flexGrow={1}
flexShrink={1}
onChange={(e) => setLink(e.target.value)}
onKeyPress={handleEditLinkKeyPress}
padding={0}
placeholder="https://form.gov.sg"
type="text"
value={link}
// Override default border styles
sx={{
border: 'none',
}}
_focus={{
border: 'none',
}}
/>
) : (
<Link
href={link}
target="_blank"
rel="noreferrer"
colorScheme="blue"
flexBasis="200px"
flexShrink={1}
flexGrow={1}
minWidth={0}
>
<p style={{ minWidth: 0 }}>{link}</p>
</Link>
)}
<ButtonGroup
alignItems="center"
colorScheme="secondary"
isFullWidth={false}
variant="clear"
>
{isEditable ? (
<IconButton
aria-label="Update Link"
icon={<BxCheck />}
onClick={handleUpdateLinkClick}
/>
) : (
<IconButton
aria-label="Edit Link"
icon={<BxsEditAlt />}
onClick={() => setisEditable(!isEditable)}
/>
)}
<Divider orientation="vertical" height="1.5rem" />
<IconButton
aria-label="Delete Link"
icon={<BxsTrash />}
onClick={() => editor.chain().focus().unsetLink().run()}
/>
</ButtonGroup>
</Flex>
</BubbleMenu>
)
}
83 changes: 83 additions & 0 deletions frontend/src/components/RichTextEditor/MenuBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Editor } from '@tiptap/react'

import { BxsBold } from '~assets/icons/BxsBold'
import { BxsItalic } from '~assets/icons/BxsItalic'
import { BxsLink } from '~assets/icons/BxsLink'
import { BxsOrderedList } from '~assets/icons/BxsOrderedList'
import { BxsStrikethrough } from '~assets/icons/BxsStrikethrough'
import { BxsUnorderedList } from '~assets/icons/BxsUnorderedList'
import ButtonGroup from '~components/ButtonGroup'
import IconButton from '~components/IconButton'

type MenuBarProps = {
editor: Editor | null
}

export const MenuBar = ({ editor }: MenuBarProps): JSX.Element | null => {
if (!editor) {
return null
}

const handleLinkClick = () => {
if (!editor.isActive('link')) {
editor.chain().focus().setLink({ href: '', target: '_blank' }).run()
} else {
editor.chain().focus().unsetLink().run()
}
}

return (
<ButtonGroup
isFullWidth={false}
width="full"
backgroundColor="neutral.100"
borderBottom="1px solid"
borderColor="neutral.400"
variant="clear"
colorScheme="secondary"
>
<IconButton
aria-label="Bold"
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
icon={<BxsBold />}
/>
<IconButton
aria-label="Italic"
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
icon={<BxsItalic />}
/>
<IconButton
aria-label="Strikethrough"
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
icon={<BxsStrikethrough />}
/>
<IconButton
aria-label="Link"
onClick={handleLinkClick}
disabled={editor.state.selection.empty && !editor.isActive('link')}
isActive={editor.isActive('link')}
icon={<BxsLink />}
/>
<IconButton
aria-label="Unordered List"
onClick={() => editor.chain().focus().toggleBulletList().run()}
disabled={!editor.can().chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
icon={<BxsUnorderedList />}
/>
<IconButton
aria-label="Ordered List"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
disabled={!editor.can().chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
icon={<BxsOrderedList />}
/>
</ButtonGroup>
)
}
Loading
Loading