From b1c8fb1a7617153f7894b24854a1a3b6e1892956 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 5 Jul 2023 18:53:15 -0700 Subject: [PATCH] Add Editor component This commit adds the bulk of the code for our new rich-text editor. The Editor component will be used to edit and display rich text via Tiptap. --- components/common/Editor/index.module.scss | 233 +++++++++++++++++++++ components/common/Editor/index.tsx | 151 +++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 components/common/Editor/index.module.scss create mode 100644 components/common/Editor/index.tsx diff --git a/components/common/Editor/index.module.scss b/components/common/Editor/index.module.scss new file mode 100644 index 00000000..add75a6c --- /dev/null +++ b/components/common/Editor/index.module.scss @@ -0,0 +1,233 @@ +.wrapper { + border-radius: $input-corner; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; + + &.bound { + background-color: var(--input-bg); + height: 350px; // Temporary + } + + & > div { + display: flex; + flex-grow: 1; + overflow: hidden; + } + + .editor { + -webkit-font-smoothing: antialiased; + box-sizing: border-box; + color: var(--text-primary); + display: block; + flex-grow: 1; + font-family: system-ui, -apple-system, 'Helvetica Neue', Helvetica, Arial, + sans-serif; + font-size: $font-regular; + line-height: 1.4; + overflow: scroll; + padding: $unit * 1.5 $unit-2x; + white-space: pre-wrap; + width: 100%; + + &:focus { + // border: 2px solid $blue; + outline: none; + } + + &.bound { + background-color: var(--input-bound-bg); + + &:hover { + background-color: var(--input-bound-bg-hover); + } + } + + &.editParty { + border-bottom-left-radius: $input-corner; + border-bottom-right-radius: $input-corner; + } + + a:hover { + cursor: pointer; + } + + strong { + font-weight: $bold; + } + + em { + font-style: italic; + } + + iframe { + background: var(--input-bound-bg); + border-radius: $card-corner; + min-width: 200px; + min-height: 200px; + display: block; + outline: 0px solid transparent; + margin: $unit 0; + + &:hover { + background: var(--input-bound-bg-hover); + } + } + + .mention { + border-radius: $item-corner-small; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px rgba(0, 0, 0, 0.25); + background: var(--card-bg); + font-weight: $medium; + font-size: 15px; + padding: 1px $unit-half; + + &:hover { + background: var(--card-bg-hover); + text-decoration: none; + cursor: pointer; + } + + &[data-element='fire'] { + background: var(--fire-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--fire-shadow); + color: var(--fire-text); + + &:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--fire-shadow-hover); + color: var(--fire-text-hover); + } + } + + &[data-element='water'] { + background: var(--water-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--water-shadow); + color: var(--water-text); + + &:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--water-shadow-hover); + color: var(--water-text-hover); + } + } + + &[data-element='earth'] { + background: var(--earth-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--earth-shadow); + color: var(--earth-text); + + &:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--earth-shadow-hover); + color: var(--earth-text-hover); + } + } + + &[data-element='wind'] { + background: var(--wind-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--wind-shadow); + color: var(--wind-text); + + &:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--wind-shadow-hover); + color: var(--wind-text-hover); + } + } + + &[data-element='dark'] { + background: var(--dark-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--dark-shadow); + color: var(--dark-text); + + &:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--dark-shadow-hover); + color: var(--dark-text-hover); + } + } + + &[data-element='light'] { + background: var(--light-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--light-shadow); + color: var(--light-text); + + &:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--light-shadow-hover); + color: var(--light-text-hover); + } + } + } + } + + .toolbar { + background: var(--toolbar-bg); + position: sticky; + align-items: center; + display: flex; + gap: $unit-half; + padding: $unit; + z-index: 10; + + button { + background: var(--toolbar-item-bg); + border-radius: $bubble-menu-item-corner; + color: var(--toolbar-item-text); + font-weight: $medium; + font-size: $font-small; + padding: $unit-half $unit; + + &:hover { + background: var(--toolbar-item-bg-hover); + color: var(--toolbar-item-text-hover); + cursor: pointer; + } + + &.active { + background: var(--toolbar-item-bg-active); + color: var(--toolbar-item-text-active); + } + } + + .divider { + background: var(--toolbar-divider-bg); + border-radius: $full-corner; + height: calc($unit-2x + $unit-half); + width: $unit-fourth; + } + } +} + +.menu { + background: var(--formatting-menu-bg); + border-radius: $bubble-menu-corner; + padding: $unit-half; + + button { + background: var(--formatting-menu-item-bg); + border-radius: $bubble-menu-item-corner; + color: var(--formatting-menu-item-text); + font-weight: $medium; + font-size: $font-small; + + &:hover { + background: var(--formatting-menu-item-bg-hover); + color: var(--formatting-menu-item-text-hover); + } + + &:active { + background: var(--formatting-menu-item-bg-active); + color: var(--formatting-menu-item-text-active); + } + } +} diff --git a/components/common/Editor/index.tsx b/components/common/Editor/index.tsx new file mode 100644 index 00000000..9a0eec12 --- /dev/null +++ b/components/common/Editor/index.tsx @@ -0,0 +1,151 @@ +import { ComponentProps, useCallback } from 'react' +import { useRouter } from 'next/router' +import { useEditor, EditorContent } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Link from '@tiptap/extension-link' +import Youtube from '@tiptap/extension-youtube' +import CustomMention from '~extensions/CustomMention' +import classNames from 'classnames' + +import type { JSONContent } from '@tiptap/core' + +import styles from './index.module.scss' +import { mentionSuggestionOptions } from '~components/Suggestion' + +interface Props extends ComponentProps<'div'> { + bound: boolean + editable?: boolean + content?: string + onUpdate?: (content: JSONContent) => void +} + +const Editor = ({ + bound, + className, + content, + editable, + onUpdate, + ...props +}: Props) => { + const router = useRouter() + const locale = router.locale || 'en' + + const editor = useEditor({ + editable: editable, + editorProps: { + attributes: { + class: classNames( + { + [styles.editor]: true, + [styles.bound]: bound, + }, + className?.split(' ').map((c) => styles[c]) + ), + }, + }, + extensions: [ + StarterKit, + Link, + CustomMention.configure({ + renderLabel({ options, node }) { + return `${node.attrs.id.name[locale] ?? node.attrs.id.granblue_en}` + }, + suggestion: mentionSuggestionOptions, + HTMLAttributes: { + class: classNames({ + [styles.mention]: true, + }), + }, + }), + Youtube.configure({ + inline: false, + modestBranding: true, + interfaceLanguage: locale, + }), + ], + content: content ? JSON.parse(content) : '', + onUpdate: ({ editor }) => { + const json = editor.getJSON() + if (onUpdate) onUpdate(json) + }, + }) + + const setLink = useCallback(() => { + const previousUrl = editor?.getAttributes('link').href + const url = window.prompt('URL', previousUrl) + + // cancelled + if (url === null) { + return + } + + // empty + if (url === '') { + editor?.chain().focus().extendMarkRange('link').unsetLink().run() + + return + } + + // update link + editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run() + }, [editor]) + + const addYoutubeVideo = () => { + const url = prompt('Enter YouTube URL') + + if (editor && url) { + editor.commands.setYoutubeVideo({ + src: url, + width: 320, + height: 180, + }) + } + } + + if (!editor) { + return null + } + + return ( +
+ {editor && editable === true && ( + + )} + +
+ ) +} + +Editor.defaultProps = { + bound: false, + editable: false, +} + +export default Editor