refactor(blog): replace editor

This commit is contained in:
enes 2024-12-18 12:47:09 +01:00
parent fd9cd80bc1
commit b5ba87443c
10 changed files with 3930 additions and 83 deletions

3546
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@mdxeditor/editor": "^3.20.0",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@reduxjs/toolkit": "2.2.6",

View File

@ -0,0 +1,64 @@
import {
CodeBlockEditorDescriptor,
useCodeBlockEditorContext
} from '@mdxeditor/editor'
import { useCallback, useEffect, useRef } from 'react'
export const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
match: (_language, _meta) => true,
priority: 0,
Editor: ({ code, focusEmitter }) => {
const { parentEditor, lexicalNode, setCode } = useCodeBlockEditorContext()
const defaultValue = useRef(code)
const codeRef = useRef<HTMLElement>(null)
const handleInput = useCallback(
(e: React.FormEvent<HTMLElement>) => {
setCode(e.currentTarget.innerHTML)
},
[setCode]
)
useEffect(() => {
const handleFocus = () => {
if (codeRef.current) {
codeRef.current.focus()
}
}
focusEmitter.subscribe(handleFocus)
}, [focusEmitter])
useEffect(() => {
const currentRef = codeRef.current
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Backspace' || event.key === 'Delete') {
if (codeRef.current?.textContent === '') {
parentEditor.update(() => {
lexicalNode.remove(false)
})
}
}
}
if (currentRef) {
currentRef.addEventListener('keydown', handleKeyDown)
}
return () => {
if (currentRef) {
currentRef.removeEventListener('keydown', handleKeyDown)
}
}
}, [lexicalNode, parentEditor])
return (
<pre>
<code
ref={codeRef}
contentEditable={true}
onInput={handleInput}
dangerouslySetInnerHTML={{ __html: defaultValue.current }}
/>
</pre>
)
}
}

View File

@ -0,0 +1,36 @@
import { LeafDirective } from 'mdast-util-directive'
import { usePublisher, insertDirective$, DialogButton } from '@mdxeditor/editor'
function getId(url: string) {
const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/
const match = url.match(regExp)
return match && match[7].length == 11 ? match[7] : false
}
export const YouTubeButton = () => {
const insertDirective = usePublisher(insertDirective$)
return (
<DialogButton
tooltipTitle='Insert Youtube video'
submitButtonTitle='Insert video'
dialogInputPlaceholder='Paste the youtube video URL'
buttonContent='YT'
onSubmit={(url) => {
const videoId = getId(url)
if (videoId) {
insertDirective({
name: 'youtube',
type: 'leafDirective',
attributes: { id: videoId },
children: []
} as LeafDirective)
} else {
alert('Invalid YouTube URL')
}
}}
/>
)
}

View File

@ -0,0 +1,49 @@
import { LeafDirective } from 'mdast-util-directive'
import { DirectiveDescriptor } from '@mdxeditor/editor'
interface YoutubeDirectiveNode extends LeafDirective {
name: 'youtube'
attributes: { id: string }
}
export const YoutubeDirectiveDescriptor: DirectiveDescriptor<YoutubeDirectiveNode> =
{
name: 'youtube',
type: 'leafDirective',
testNode(node) {
return node.name === 'youtube'
},
attributes: ['id'],
hasChildren: false,
Editor: ({ mdastNode, lexicalNode, parentEditor }) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<button
type='button'
onClick={() => {
parentEditor.update(() => {
lexicalNode.selectNext()
lexicalNode.remove()
})
}}
>
delete
</button>
<iframe
width='560'
height='315'
src={`https://www.youtube.com/embed/${mdastNode.attributes.id}`}
title='YouTube video player'
frameBorder='0'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
></iframe>
</div>
)
}
}

View File

@ -0,0 +1,105 @@
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
codeBlockPlugin,
CodeToggle,
CreateLink,
directivesPlugin,
headingsPlugin,
imagePlugin,
InsertCodeBlock,
InsertImage,
InsertTable,
InsertThematicBreak,
linkDialogPlugin,
linkPlugin,
listsPlugin,
ListsToggle,
markdownShortcutPlugin,
MDXEditor,
MDXEditorProps,
quotePlugin,
Separator,
StrikeThroughSupSubToggles,
tablePlugin,
thematicBreakPlugin,
toolbarPlugin,
UndoRedo
} from '@mdxeditor/editor'
import { PlainTextCodeEditorDescriptor } from './PlainTextCodeEditorDescriptor'
import { YoutubeDirectiveDescriptor } from './YoutubeDirectiveDescriptor'
import { YouTubeButton } from './YoutubeButton'
import '@mdxeditor/editor/style.css'
import '../../styles/mdxEditor.scss'
interface EditorProps extends MDXEditorProps {}
export const Editor = ({
readOnly,
markdown,
onChange,
...rest
}: EditorProps) => {
const plugins = [
directivesPlugin({ directiveDescriptors: [YoutubeDirectiveDescriptor] }),
codeBlockPlugin({
defaultCodeBlockLanguage: '',
codeBlockEditorDescriptors: [PlainTextCodeEditorDescriptor]
}),
headingsPlugin(),
quotePlugin(),
imagePlugin(),
tablePlugin(),
linkPlugin(),
linkDialogPlugin(),
listsPlugin(),
thematicBreakPlugin(),
markdownShortcutPlugin()
]
if (!readOnly) {
plugins.push(
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<Separator />
<BoldItalicUnderlineToggles />
<CodeToggle />
<Separator />
<StrikeThroughSupSubToggles />
<Separator />
<ListsToggle />
<Separator />
<BlockTypeSelect />
<Separator />
<CreateLink />
<InsertImage />
<YouTubeButton />
<Separator />
<InsertTable />
<InsertThematicBreak />
<Separator />
<InsertCodeBlock />
</>
)
})
)
}
return (
<MDXEditor
contentEditableClassName='editor'
className='dark-theme dark-editor'
readOnly={readOnly}
markdown={markdown}
plugins={plugins}
onChange={onChange}
{...rest}
/>
)
}

View File

@ -5,12 +5,6 @@ import {
useNavigation,
useSubmit
} from 'react-router-dom'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { EditorContent, useEditor } from '@tiptap/react'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ProfileSection } from 'components/ProfileSection'
import { Comments } from 'components/comment'
@ -23,6 +17,7 @@ import { copyTextToClipboard } from 'utils'
import { toast } from 'react-toastify'
import { useAppSelector, useBodyScrollDisable } from 'hooks'
import { ReportPopup } from 'components/ReportPopup'
import { Editor } from 'components/editor'
const BLOG_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
@ -42,25 +37,6 @@ export const BlogPage = () => {
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
const navigation = useNavigation()
const [commentCount, setCommentCount] = useState(0)
const html = marked.parse(blog?.content || '', { async: false })
const sanitized = DOMPurify.sanitize(html)
const editor = useEditor(
{
content: sanitized,
extensions: [
StarterKit,
Link,
Image.configure({
inline: true,
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
editable: false
},
[sanitized]
)
const [showReportPopUp, setShowReportPopUp] = useState<number>()
useBodyScrollDisable(!!showReportPopUp)
@ -266,7 +242,12 @@ export const BlogPage = () => {
</h1>
</div>
<div className='IBMSMSMBSSPostBody'>
<EditorContent editor={editor} />
<Editor
key={blog.id}
markdown={blog?.content || ''}
readOnly={true}
spellCheck={false}
/>
</div>
<div className='IBMSMSMBSSTags'>
{blog.nsfw && (

View File

@ -10,7 +10,6 @@ import {
now,
parseFormData
} from 'utils'
import TurndownService from 'turndown'
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
import { toast } from 'react-toastify'
import { NDKEvent } from '@nostr-dev-kit/ndk'
@ -57,9 +56,8 @@ export const writeRouteAction =
// Return earily if there are any errors
if (Object.keys(formErrors).length) return formErrors
// Get the markdown from the html
const turndownService = new TurndownService()
const content = turndownService.turndown(formSubmit.content!)
// Get the markdown from formData
const content = decodeURIComponent(formSubmit.content!)
// Check if we are editing or this is a new blog
const { naddr } = params
@ -154,11 +152,7 @@ const validateFormData = async (
errors.title = 'Title field can not be empty'
}
if (
!formData.content ||
formData.content.trim() === '' ||
formData.content.trim() === '<p></p>'
) {
if (!formData.content || formData.content.trim() === '') {
errors.content = 'Content field can not be empty'
}

View File

@ -8,8 +8,7 @@ import {
import {
CheckboxFieldUncontrolled,
InputError,
InputFieldUncontrolled,
MenuBar
InputFieldUncontrolled
} from '../../components/Inputs'
import { ProfileSection } from '../../components/ProfileSection'
import { useAppSelector } from '../../hooks'
@ -18,14 +17,10 @@ import '../../styles/innerPage.css'
import '../../styles/styles.css'
import '../../styles/write.css'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { AlertPopup } from 'components/AlertPopup'
import { Editor } from 'components/editor'
export const WritePage = () => {
const userState = useAppSelector((state) => state.user)
const data = useLoaderData() as BlogPageLoaderResult
@ -34,26 +29,7 @@ export const WritePage = () => {
const blog = data?.blog
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
const html = marked.parse(blog?.content || '', { async: false })
const sanitized = DOMPurify.sanitize(html)
const [content, setContent] = useState<string>(sanitized)
const editor = useEditor({
content: content,
extensions: [
StarterKit,
Link,
Image.configure({
inline: true,
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
onUpdate: ({ editor }) => {
setContent(editor.getHTML())
}
})
const [content, setContent] = useState(blog?.content || '')
const formRef = useRef<HTMLFormElement>(null)
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = () => {
@ -94,19 +70,27 @@ export const WritePage = () => {
defaultValue={blog?.title}
error={formErrors?.title}
/>
{editor && (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Content</label>
<div className='inputMain'>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
<input name='content' hidden value={content} readOnly />
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Content</label>
<div className='inputMain'>
<Editor
markdown={content}
onChange={(md) => {
setContent(md)
}}
/>
</div>
)}
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
{/* encode to keep the markdown formatting */}
<input
name='content'
hidden
value={encodeURIComponent(content)}
readOnly
/>
</div>
<InputFieldUncontrolled
label='Featured Image URL'
name='image'

103
src/styles/mdxEditor.scss Normal file
View File

@ -0,0 +1,103 @@
.editor {
padding: 0;
--basePageBg: var(--slate-3);
> {
p {
margin: 5px 0 10px 0;
}
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 15px 0 15px 0;
border-bottom: solid 1px rgb(255 255 255 / 10%);
padding: 0px 0 10px 0;
line-height: 1.5;
text-wrap: pretty;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
blockquote {
border-radius: 0 10px 10px 0;
border-left: solid 6px rgba(255, 255, 255, 0.1);
padding: 25px;
background: #232323;
color: rgba(255, 255, 255, 0.75);
margin: 10px 0;
}
}
code {
background-color: var(--purple-light); // todo: fix the color
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
outline: none;
&:empty:before {
content: ' ';
}
}
pre {
background: var(--black); // todo: fix the color
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
background: #00000030;
border-radius: 5px;
border: solid 2px rebeccapurple;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
img {
width: 100%;
margin: 15px 0;
background: #232323;
border-radius: 10px;
}
}
.mdxeditor,
.mdxeditor-popup-container {
--basePageBg: var(--slate-3);
}