diff --git a/packages/design-system/src/lib/components/rich-text-editor/EditorActions.module.scss b/packages/design-system/src/lib/components/rich-text-editor/EditorActions.module.scss new file mode 100644 index 000000000..4e34505c8 --- /dev/null +++ b/packages/design-system/src/lib/components/rich-text-editor/EditorActions.module.scss @@ -0,0 +1,19 @@ +@use 'sass:color'; +@import 'src/lib/assets/styles/design-tokens/colors.module'; + +.editor-actions-wrapper { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + padding-inline: 0.5rem; +} + +.editor-actions-button { + display: grid; + place-items: center; + + &.selected { + background-color: color.adjust($dv-secondary-color, $blackness: 30%); + } +} diff --git a/packages/design-system/src/lib/components/rich-text-editor/EditorActions.tsx b/packages/design-system/src/lib/components/rich-text-editor/EditorActions.tsx new file mode 100644 index 000000000..f321f00ee --- /dev/null +++ b/packages/design-system/src/lib/components/rich-text-editor/EditorActions.tsx @@ -0,0 +1,267 @@ +import { useRef, useState } from 'react' +import { Editor } from '@tiptap/react' +import { ButtonGroup } from '../button-group/ButtonGroup' +import { Button } from '../button/Button' +import { + BlockquoteRight, + Code, + Link, + ListOl, + ListUl, + TypeBold, + TypeH1, + TypeH2, + TypeH3, + TypeItalic, + TypeStrikethrough, + TypeUnderline +} from 'react-bootstrap-icons' +import styles from './EditorActions.module.scss' +import { Modal } from '../modal/Modal' +import { Form } from '../form/Form' +import { Col } from '../grid/Col' + +const EDITOR_FORMATS = { + heading: 'heading', + bold: 'bold', + italic: 'italic', + underline: 'underline', + strike: 'strike', + code: 'code', + link: 'link', + blockquote: 'blockquote', + bulletList: 'bulletList', + orderedList: 'orderedList' +} as const + +interface EditorActionsProps { + editor: Editor + disabled?: boolean +} + +export const EditorActions = ({ editor, disabled }: EditorActionsProps) => { + const [linkDialogOpen, setLinkDialogOpen] = useState(false) + const linkTextfieldRef = useRef(null) + + const handleOpenLinkDialog = () => setLinkDialogOpen(true) + const handleCloseLinkDialog = () => setLinkDialogOpen(false) + + const handleOKLinkDialog = () => { + const url = linkTextfieldRef.current?.value + + if (url) { + editor.chain().focus().extendMarkRange(EDITOR_FORMATS.link).setLink({ href: url }).run() + } else { + editor.chain().focus().extendMarkRange(EDITOR_FORMATS.link).unsetLink().run() + } + setLinkDialogOpen(false) + } + + const handleToggleH1 = () => editor.chain().focus().toggleHeading({ level: 1 }).run() + const isActiveH1 = !disabled && editor.isActive(EDITOR_FORMATS.heading, { level: 1 }) + + const handleToggleH2 = () => editor.chain().focus().toggleHeading({ level: 2 }).run() + const isActiveH2 = !disabled && editor.isActive(EDITOR_FORMATS.heading, { level: 2 }) + + const handleToggleH3 = () => editor.chain().focus().toggleHeading({ level: 3 }).run() + const isActiveH3 = !disabled && editor.isActive(EDITOR_FORMATS.heading, { level: 3 }) + + const handleToggleBold = () => editor.chain().focus().toggleBold().run() + const isActiveBold = !disabled && editor.isActive(EDITOR_FORMATS.bold) + + const handleToggleItalic = () => editor.chain().focus().toggleItalic().run() + const isActiveItalic = !disabled && editor.isActive(EDITOR_FORMATS.italic) + + const handleToggleUnderline = () => editor.chain().focus().toggleUnderline().run() + const isActiveUnderline = !disabled && editor.isActive(EDITOR_FORMATS.underline) + + const handleToggleStrike = () => editor.chain().focus().toggleStrike().run() + const isActiveStrike = !disabled && editor.isActive(EDITOR_FORMATS.strike) + + const handleToggleCode = () => editor.chain().focus().toggleCode().run() + const isActiveCode = !disabled && editor.isActive(EDITOR_FORMATS.code) + + const handleToggleBlockquote = () => editor.chain().focus().toggleBlockquote().run() + const isActiveBlockquote = !disabled && editor.isActive(EDITOR_FORMATS.blockquote) + + const handleToggleBulletList = () => editor.chain().focus().toggleBulletList().run() + const isActiveBulletList = !disabled && editor.isActive(EDITOR_FORMATS.bulletList) + + const handleToggleOrderedList = () => editor.chain().focus().toggleOrderedList().run() + const isActiveOrderedList = !disabled && editor.isActive(EDITOR_FORMATS.orderedList) + + return ( + <> +
+ {/* Headings */} + +
+ + + Add Link + + + + URL + + + + + + + + + + + + ) +} diff --git a/packages/design-system/src/lib/components/rich-text-editor/RichTextEditor.scss b/packages/design-system/src/lib/components/rich-text-editor/RichTextEditor.scss new file mode 100644 index 000000000..e64344a86 --- /dev/null +++ b/packages/design-system/src/lib/components/rich-text-editor/RichTextEditor.scss @@ -0,0 +1,52 @@ +@import 'src/lib/assets/styles/design-tokens/colors.module'; + +.rich-text-editor-wrapper { + display: flex; + flex-direction: column; + padding-top: 0.5rem; + border: solid 1px $dv-border-color; + border-radius: 6px; +} + +.editor-content-wrapper { + display: flex; + flex: 1; + flex-direction: column; + + & > div { + display: flex; + flex: 1; + flex-direction: column; + } + + .rich-text-editor-content { + flex: 1; + min-height: 200px; + max-height: 350px; + margin: 0.5rem; + padding: 6px 12px; + overflow-y: auto; + border: solid 1px $dv-border-color; + border-radius: 4px; + + &.ProseMirror-focused { + border-color: #99bddb; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(51 122 183 / 25%); + } + + blockquote { + padding: 1px 10px; + background-color: rgba(0 0 0 / 15%); + border-left: solid 5px gray; + border-radius: 4px; + } + + code { + padding: 0.1em 0.35em; + font-size: 85%; + background-color: rgba(0 0 0 / 10%); + border-radius: 0.375rem; + } + } +} diff --git a/packages/design-system/src/lib/components/rich-text-editor/RichTextEditor.tsx b/packages/design-system/src/lib/components/rich-text-editor/RichTextEditor.tsx new file mode 100644 index 000000000..f39dac2d0 --- /dev/null +++ b/packages/design-system/src/lib/components/rich-text-editor/RichTextEditor.tsx @@ -0,0 +1,54 @@ +import { useEffect } from 'react' +import { useEditor, EditorContent } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Underline from '@tiptap/extension-underline' +import Link from '@tiptap/extension-link' +import { EditorActions } from './EditorActions' +import './RichTextEditor.scss' + +export interface RichTextFieldProps { + initialValue?: string | undefined + onChange: (value: string) => void + error?: string + disabled?: boolean +} + +export const RichTextEditor = ({ initialValue, onChange, disabled }: RichTextFieldProps) => { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: { + levels: [1, 2, 3] + } + }), + Underline, + Link.configure({ + openOnClick: false, + autolink: true, + linkOnPaste: true + }) + ], + content: initialValue, + editorProps: { + attributes: { + class: 'rich-text-editor-content' + } + }, + onUpdate: ({ editor }) => onChange(editor.getHTML()) + }) + + useEffect(() => { + if (editor) editor.setEditable(disabled ? false : true, false) + }, [disabled, editor]) + + if (!editor) return null + + return ( +
+ +
+ +
+
+ ) +} diff --git a/packages/design-system/src/lib/stories/rich-text-editor/RichTextEditor.stories.tsx b/packages/design-system/src/lib/stories/rich-text-editor/RichTextEditor.stories.tsx new file mode 100644 index 000000000..3ef2e8129 --- /dev/null +++ b/packages/design-system/src/lib/stories/rich-text-editor/RichTextEditor.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { RichTextEditor } from '../../components/rich-text-editor/RichTextEditor' + +const meta: Meta = { + title: 'RichTextEditor', + component: RichTextEditor +} + +export default meta +type Story = StoryObj + +const handleChange = (value: string) => { + console.log({ value }) +} + +export const Default: Story = { + render: () => +} + +export const WithInitialValue: Story = { + render: () => ( + + ) +} + +export const Disabled: Story = { + render: () => +}