Skip to content

Commit 971ff13

Browse files
committed
Lexical Plain Text Editor with Lexical Rich Text preview; custom extensible MDAST transformer for lossless conversions
1 parent e2c775e commit 971ff13

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+6764
-56
lines changed

components/comment.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import itemStyles from './item.module.css'
22
import styles from './comment.module.css'
3-
import Text, { SearchText } from './text'
3+
import { LegacyText, SearchText } from './text'
44
import Link from 'next/link'
55
import Reply from './reply'
66
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -287,11 +287,11 @@ export default function Comment ({
287287
{item.searchText
288288
? <SearchText text={item.searchText} />
289289
: (
290-
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
290+
<LegacyText itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
291291
{item.outlawed && !me?.privates?.wildWestMode
292292
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
293293
: truncate ? truncateString(item.text) : item.text}
294-
</Text>)}
294+
</LegacyText>)}
295295
</div>
296296
)}
297297
</div>

components/editor/contexts/item.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createContext, useContext, useMemo } from 'react'
2+
import { UNKNOWN_LINK_REL } from '@/lib/constants'
3+
4+
const LexicalItemContext = createContext({
5+
imgproxyUrls: null,
6+
topLevel: false,
7+
outlawed: false,
8+
rel: UNKNOWN_LINK_REL
9+
})
10+
11+
export function LexicalItemContextProvider ({ imgproxyUrls, topLevel, outlawed, rel, children }) {
12+
const value = useMemo(() => ({
13+
imgproxyUrls,
14+
topLevel,
15+
outlawed,
16+
rel
17+
}), [imgproxyUrls, topLevel, outlawed, rel])
18+
19+
return (
20+
<LexicalItemContext.Provider value={value}>
21+
{children}
22+
</LexicalItemContext.Provider>
23+
)
24+
}
25+
26+
export function useLexicalItemContext () {
27+
return useContext(LexicalItemContext)
28+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createContext, useContext, useMemo, useState, useCallback } from 'react'
2+
3+
const INITIAL_STATE = {
4+
previewMode: false
5+
}
6+
7+
const ToolbarContext = createContext()
8+
9+
export const ToolbarContextProvider = ({ children }) => {
10+
const [toolbarState, setToolbarState] = useState(INITIAL_STATE)
11+
12+
const batchUpdateToolbarState = useCallback((updates) => {
13+
setToolbarState((prev) => ({ ...prev, ...updates }))
14+
}, [])
15+
16+
const updateToolbarState = useCallback((key, value) => {
17+
setToolbarState((prev) => ({
18+
...prev,
19+
[key]: value
20+
}))
21+
}, [])
22+
23+
const contextValue = useMemo(() => {
24+
return { toolbarState, updateToolbarState, batchUpdateToolbarState }
25+
}, [toolbarState, updateToolbarState])
26+
27+
return (
28+
<ToolbarContext.Provider value={contextValue}>
29+
{children}
30+
</ToolbarContext.Provider>
31+
)
32+
}
33+
34+
export const useToolbarState = () => {
35+
return useContext(ToolbarContext)
36+
}

components/editor/editor.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import FormikBridgePlugin from '@/components/editor/plugins/formik'
2+
import LocalDraftPlugin from '@/components/editor/plugins/local-draft'
3+
import { MaxLengthPlugin } from '@/components/editor/plugins/max-length'
4+
import MentionsPlugin from '@/components/editor/plugins/mention'
5+
import FileUploadPlugin from '@/components/editor/plugins/upload'
6+
import PreviewPlugin from '@/components/editor/plugins/preview'
7+
import { AutoFocusExtension } from '@lexical/extension'
8+
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
9+
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
10+
import { LexicalExtensionComposer } from '@lexical/react/LexicalExtensionComposer'
11+
import classNames from 'classnames'
12+
import { useFormikContext } from 'formik'
13+
import { configExtension, defineExtension } from 'lexical'
14+
import { useMemo, useState } from 'react'
15+
import theme from './theme'
16+
import styles from './theme/theme.module.css'
17+
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'
18+
import { ToolbarPlugin } from './plugins/tinytoolbar'
19+
import { ToolbarContextProvider } from './contexts/toolbar'
20+
21+
/**
22+
* main lexical editor component with formik integration
23+
* @param {string} props.name - form field name
24+
* @param {string} [props.appendValue] - value to append to initial content
25+
* @param {boolean} [props.autoFocus] - whether to auto-focus the editor
26+
* @returns {JSX.Element} lexical editor component
27+
*/
28+
export default function SNEditor ({ name, appendValue, autoFocus, topLevel, ...props }) {
29+
const { values } = useFormikContext()
30+
31+
const editor = useMemo(() =>
32+
defineExtension({
33+
$initialEditorState: (editor) => {
34+
// existing lexical state
35+
if (values.lexicalState) {
36+
try {
37+
const state = editor.parseEditorState(values.lexicalState)
38+
if (!state.isEmpty()) {
39+
editor.setEditorState(state)
40+
}
41+
} catch (error) {
42+
console.error('failed to load initial state:', error)
43+
}
44+
}
45+
},
46+
name: 'editor',
47+
namespace: 'sn',
48+
dependencies: [
49+
configExtension(AutoFocusExtension, { disabled: !autoFocus })
50+
],
51+
theme: { ...theme, topLevel: topLevel ? 'topLevel' : '' },
52+
onError: (error) => console.error('editor has encountered an error:', error)
53+
}), [autoFocus, topLevel])
54+
55+
return (
56+
<LexicalExtensionComposer extension={editor} contentEditable={null}>
57+
<ToolbarContextProvider>
58+
<EditorContent topLevel={topLevel} name={name} {...props} />
59+
</ToolbarContextProvider>
60+
</LexicalExtensionComposer>
61+
)
62+
}
63+
64+
/**
65+
* editor content component containing all plugins and UI elements
66+
* @param {string} props.name - form field name for draft saving
67+
* @param {string} props.placeholder - placeholder text for empty editor
68+
* @param {Object} props.lengthOptions - max length configuration
69+
* @param {boolean} props.topLevel - whether this is a top-level editor
70+
* @returns {JSX.Element} editor content with all plugins
71+
*/
72+
function EditorContent ({ name, placeholder, lengthOptions, topLevel }) {
73+
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
74+
75+
const onRef = (_floatingAnchorElem) => {
76+
if (_floatingAnchorElem !== null) {
77+
setFloatingAnchorElem(_floatingAnchorElem)
78+
}
79+
}
80+
81+
return (
82+
<>
83+
<div className={styles.editorContainer}>
84+
<ToolbarPlugin topLevel={topLevel} />
85+
{/* we only need a plain text editor for markdown */}
86+
<PlainTextPlugin
87+
contentEditable={
88+
<div className={styles.editor} ref={onRef}>
89+
<ContentEditable
90+
className={classNames(styles.editorInput, styles.text, topLevel && 'topLevel')}
91+
placeholder={<div className={styles.editorPlaceholder}>{placeholder}</div>}
92+
/>
93+
</div>
94+
}
95+
ErrorBoundary={LexicalErrorBoundary}
96+
/>
97+
{floatingAnchorElem && <PreviewPlugin editorRef={floatingAnchorElem} topLevel={topLevel} />}
98+
<FileUploadPlugin anchorElem={floatingAnchorElem} />
99+
<MentionsPlugin />
100+
<MaxLengthPlugin lengthOptions={lengthOptions} />
101+
<LocalDraftPlugin name={name} />
102+
<FormikBridgePlugin />
103+
</div>
104+
</>
105+
)
106+
}

components/editor/index.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { forwardRef, useMemo } from 'react'
2+
import { useRouter } from 'next/router'
3+
import dynamic from 'next/dynamic'
4+
import { LexicalItemContextProvider } from './contexts/item'
5+
import { applySNCustomizations } from '@/lib/lexical/html/customs'
6+
7+
export const SNReader = forwardRef(function SNReader ({ html, children, outlawed, imgproxyUrls, topLevel, rel, ...props }, ref) {
8+
const router = useRouter()
9+
const snCustomizedHTML = useMemo(() => (
10+
<div
11+
className={props.className}
12+
// suppressHydrationWarning is used as a band-aid but maybe applySNCustomizations is not the proper solution.
13+
dangerouslySetInnerHTML={{ __html: applySNCustomizations(html, { outlawed, imgproxyUrls, topLevel }) }}
14+
suppressHydrationWarning
15+
/>
16+
), [html, outlawed, imgproxyUrls, topLevel, props.className])
17+
18+
// debug html with ?html
19+
if (router.query.html) return snCustomizedHTML
20+
21+
const Reader = useMemo(() => dynamic(() => import('./reader'), { ssr: false, loading: () => snCustomizedHTML }), [])
22+
23+
return (
24+
25+
<LexicalItemContextProvider imgproxyUrls={imgproxyUrls} topLevel={topLevel} outlawed={outlawed} rel={rel}>
26+
<Reader {...props} contentRef={ref} topLevel={topLevel}>
27+
{children}
28+
</Reader>
29+
</LexicalItemContextProvider>
30+
)
31+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
2+
import { useEffect } from 'react'
3+
import useDarkMode from '@/components/dark-mode'
4+
5+
/** syncs code block syntax highlighting theme with site dark mode */
6+
export function CodeThemePlugin () {
7+
const [editor] = useLexicalComposerContext()
8+
const [darkMode] = useDarkMode()
9+
10+
const theme = darkMode ? 'github-dark-default' : 'github-light-default'
11+
12+
useEffect(() => {
13+
if (!editor._updateCodeTheme) return
14+
return editor._updateCodeTheme(theme)
15+
}, [darkMode, theme])
16+
17+
return null
18+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import MediaOrLink, { LinkRaw } from '@/components/media-or-link'
2+
import { useLexicalItemContext } from '@/components/editor/contexts/item'
3+
import { IMGPROXY_URL_REGEXP, decodeProxyUrl } from '@/lib/url'
4+
5+
/**
6+
* wrapper component that handles media rendering with item-specific logic
7+
* like imgproxy, outlawed, rel (link) and top level
8+
9+
* @param {string} props.src - media source url
10+
* @param {string} props.status - media status (error, pending, etc.)
11+
* @param {string} props.kind - media kind (image, video)
12+
* @param {number} props.width - media width
13+
* @param {number} props.height - media height
14+
* @param {number} props.maxWidth - media max width
15+
* @returns {JSX.Element} media or link component
16+
*/
17+
export default function Media ({ src, status, kind, width, height, maxWidth }) {
18+
const { imgproxyUrls, rel, outlawed, topLevel } = useLexicalItemContext()
19+
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src
20+
const srcSet = imgproxyUrls?.[url]
21+
22+
if (outlawed) {
23+
return <p className='outlawed'>{url}</p>
24+
}
25+
26+
if (status === 'error') {
27+
return <LinkRaw src={url} rel={rel}>{url}</LinkRaw>
28+
}
29+
30+
return (
31+
<MediaOrLink
32+
src={src}
33+
srcSet={srcSet}
34+
rel={rel}
35+
kind={kind}
36+
linkFallback
37+
preTailor={{ width, height, maxWidth: maxWidth ?? 500 }}
38+
topLevel={topLevel}
39+
/>
40+
)
41+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Link from 'next/link'
2+
import { buildNestedTocStructure } from '@/lib/lexical/nodes/misc/toc'
3+
4+
/**
5+
* recursively renders table of contents items with nested structure
6+
7+
* @param {Object} props.item - toc item with text, slug, and optional children
8+
* @param {number} props.index - item index for key generation
9+
* @returns {JSX.Element} list item with nested children
10+
*/
11+
function TocItem ({ item, index }) {
12+
const hasChildren = item.children && item.children.length > 0
13+
return (
14+
<li key={`${item.slug}-${index}`}>
15+
<Link href={`#${item.slug}`}>
16+
{item.text}
17+
</Link>
18+
{hasChildren && (
19+
<ul>
20+
{item.children.map((child, idx) => (
21+
<TocItem key={`${child.slug}-${idx}`} item={child} index={idx} />
22+
))}
23+
</ul>
24+
)}
25+
</li>
26+
)
27+
}
28+
29+
/**
30+
* displays a collapsible table of contents from heading data
31+
32+
* @param {Array} props.headings - array of heading objects with text, depth, and slug
33+
* @returns {JSX.Element} collapsible details element with toc list
34+
*/
35+
export function TableOfContents ({ headings }) {
36+
const tocItems = buildNestedTocStructure(headings)
37+
38+
return (
39+
<details className='sn__collapsible sn__toc'>
40+
<summary className='sn__collapsible__header'>table of contents</summary>
41+
{tocItems.length > 0
42+
? (
43+
<ul>
44+
{tocItems.map((item, index) => (
45+
<TocItem key={`${item.slug}-${index}`} item={item} index={index} />
46+
))}
47+
</ul>
48+
)
49+
: <div className='text-muted fst-italic'>no headings</div>}
50+
</details>
51+
)
52+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
2+
import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
3+
import ErrorBoundary from '@/components/error-boundary'
4+
import KatexRenderer from '@/components/katex-renderer'
5+
import { useToast } from '@/components/toast'
6+
7+
export default function MathComponent ({ math, inline }) {
8+
const [editor] = useLexicalComposerContext()
9+
const isEditable = useLexicalEditable()
10+
const toaster = useToast()
11+
12+
return (
13+
<ErrorBoundary onError={(e) => editor._onError(e)} fallback={null}>
14+
<KatexRenderer
15+
equation={math}
16+
inline={inline}
17+
onClick={() => {
18+
if (!isEditable) {
19+
try {
20+
navigator.clipboard.writeText(math)
21+
toaster.success('math copied to clipboard')
22+
} catch {}
23+
}
24+
}}
25+
/>
26+
</ErrorBoundary>
27+
)
28+
}

0 commit comments

Comments
 (0)