refactor(blog): replace editor
This commit is contained in:
parent
fd9cd80bc1
commit
b5ba87443c
3546
package-lock.json
generated
3546
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
64
src/components/editor/PlainTextCodeEditorDescriptor.tsx
Normal file
64
src/components/editor/PlainTextCodeEditorDescriptor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
36
src/components/editor/YoutubeButton.tsx
Normal file
36
src/components/editor/YoutubeButton.tsx
Normal 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')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
49
src/components/editor/YoutubeDirectiveDescriptor.tsx
Normal file
49
src/components/editor/YoutubeDirectiveDescriptor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
105
src/components/editor/index.tsx
Normal file
105
src/components/editor/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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 && (
|
||||
|
@ -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'
|
||||
}
|
||||
|
||||
|
@ -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
103
src/styles/mdxEditor.scss
Normal 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);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user