Skip to content

Commit 940d0e9

Browse files
Soxasorahuumn
andauthored
Markdown editor with Rich Text preview via MDAST - with cleanup (#2676)
* Lexical Plain Text Editor with Lexical Rich Text preview; custom extensible MDAST transformer for lossless conversions * wip: Lexical MDAST docs * enable Lexical reader on items and comments; re-implement distributed migration, debug 1-click migration; adapt interpolator to text -> lexicalState only * revert Embed node to a classic Lexical node klass; styling bits * support MD hard breaks, support multiple children in list item nodes and divide with line breaks; efficient transformer search by mdastType rather than blind-search; fix: set the code theme for every CodeNode instead of just the direct descendants; fix: insert default values from formik; add support for new editor item edits; tweaks to styling * separate content CSS from editor CSS * migrate to visitor-based MDAST transformations, unist for post-processing; changes to styling * light cleanup * fix mentions autocomplete dropdown positioning * re-implement appendValue * move lexical-markdown to lib/lexical/mdast * replace hand-written unist visits with findAndReplace helper, update README * execute media checks as a background job instead of polluting API response time; cleanup * change codeblock theme via command instead of mutating the editor * standardize node creation configs * cleanup: better code separation and naming; remove upload dead code * mediaCheck worker db update, mdast math nodes * styling, add support for every form that used MarkdownInput; don't throw on unrecognized markdown, let it pass * add BioForm, fix lint * fix: add lexical state to reader memo deps * barebones shortcuts extension 1:1 with MarkdownInput, restore ActionTooltip fade, improve structure and apply BEM convention to CSS * add submit keyboard shortcut * cleanup SCSS and CSS; fix: preserve list ordering by looking at the start value * naming changes, seamless upgrade to Lexical from MarkdownInput, implement truncated text for SNInput; fix: trim empty nodes in a separate editor update * remove spoilers support for stage 1; restructure plugins * QA: fix HeadingNode replacement not respecting heading depth, fix codeblocks language interpretation, fix block elements not being spread inside listItem, fix spacing inside nodes that can't contain paragraphs; MDAST: enforce actions as sole transformations interface, remove redundant footnote exts, add exts to the Lexical->Markdown pipeline * add footnotes support to MDAST and Lexical, update MDAST README; bit: full border radius on preview mode * table alignments; improve list item visitor readability; cleanup * fix globals.scss contenteditable conflicts; add support for table of contents; re-implement show-full-text if url has an hash * rewrite Mentions plugin * remove old Mentions plugin * cleanup new Mentions plugin * footnote backref as its own node, add toMarkdown extensions for custom nodes, document custom MDAST nodes, bugfixes * poc: use DataLoader for lexical state generation on-request; fix territory description rendering * fix territory form layout and rendering, update reader memo deps * fix: ensure lexical is always under CarouselProvider * address console logs, max length plugin and debug plugins * add comment to memo deps of editor and reader; address invalid imgproxy html customs attributes * add support for title and alt attributes for images; use Text component for PreviewPlugin; fix: footnote backref json export * refactor node components; migrate to PlainTextExtension * improve link fallbacks display via MediaNode: use display:inline for link fallbacks * move Editor entrypoint to index SNEditor * improve SNReader structure: Overflow handled in Text preventing re-renders on click; export links instead of media when exporting MediaNode to HTML; always open ToC * cleanup: remove MediaCheckExtension (unused), complete some comments, multi-exports where necessary * correct selector for padding rules in text.scss * fix HTML<->Lexical style and spacings mismatch * remove mediaCheck and lexicalState/html persistence in DB * remove manual debug executeConversion md->lexical mutation * fix imgproxyUrls typo * FileUploadPlugin code clarity, add support for drag'n'drop uploads * don't spawn a new preview reader unless the text has changed, conditionally hide rather than conditionally render the preview reader; use HistoryExtension * derive Table of contents via Lexical with EditorRefPlugin; useCallbackRef for quick ref callbacks * render bare links as media/embed only if they're standalone in a paragraph * revert Text component re-export * update Lexical to 0.39.0 * use Editor instance ref to reset formik form, fix hr html/lexical style mismatch, fix ToC button vertical spacing * cleanup initializeEditorState function * cleanup: code consistency * revert ToC markdown fallback, readerRef is unavailable on first load because of CLS * simplify EmbedNode and Embed Placeholder (just output a link during html), protect mention toString funcs * optimize: ToC headings extraction via cached nodesOfType; cleanup: remove checkMedia option from SSR lexicalState generator, add comments * metaOrCtrl for shortcuts, clean SNReader HTML fallback memo deps * move DataLoader to Lexical lib group * fix: enforce github-dark-default default code theme, compute inside useEffect * fix: bi-directional preview toggle keyboard shortcut, scoped to the active editor; enforce a boolean pattern on commands called by shortcuts * prevent scroll when focusing preview mode * fix: override editorInput overflow rules when rendering Preview mode * GalleryNode for adjacent image/video grid tiling; GalleryExtension for grouping MediaNodes in a GalleryNode; GalleryNode MDAST visitor; restore legacy responsive media sizing * handle Autolinks in Lexical rather than MDAST, introduce GalleryExtension and AutolinkExtension to SSR lexical generator; tweaks to media styling for 1:1 prod behavior; basic media checks off MediaOrLink * clickable headings with CSS; cleanup SNHeadingNode * fix wrong lexical node comparison on LexicalLinkVisitor * clearer helpers naming on SNHeadingNode * replace clicks on <a> links with next/router allowing nextjs events (e.g. show full text on hash links); no attributes on internal links * move MediaComponent useEffect at the top * add support for lexical->mdast table alignments * blocks: replace parent instead of link in the case of decorator block nodes (embeds), replace parent instead of TextNode in the case of {:toc} with unist's visit, treat TableOfContentsNode as DecoratorBlockNode * clarify use-callback-ref utility * refactor: ItemContextExtension to handle outlawed and imgproxy urls at API level, compute imgproxy-derived styles and urls and pass it to the MediaNode; MediaComponent->MediaOrLink architecture shake-up, imgproxy calculations are now done via Extension and MediaNode, simpler DOM * remove LexicalItemContext * remove html customizations * exhaustive deps on sizes useMemo for useMediaHelper, remove LinkRaw from MediaComponent as we are not using states in stage 1 * fix weird checkbox positioning on checklist item with children checklist items; fix ItemContextExtension transform loop by applying rel to links; add general 66vw size to media with srcset; cleanup: hide unrecognized markdown errors in production * remove: LegacyText, Markdown Text Components, MarkdownInput * rehype parity: add support for misleading link transforms; add support for Nostr ID parsing into njump link * remove: react-markdown, react-syntax-highlighter, react-textarea-autosize, rehype-mathjax, remark-gfm, remark-math; remove: legacy md toc utility * cleanup and share imgproxy url srcset parsing to MediaOrLink for legacy support, rollback-friendly * consistent markdown shortcuts command return * fix lint, remove html from Reader useMemo deps as we don't need re-renders on html change (unlikely) * fix lint, remove html from Reader useMemo deps as we don't need re-renders on html change (unlikely) * media: exportDOM with imgproxy media type support * README: Stacker News Lexical Extensions and /lib walkthrough; prepare for server and editor readmes * fix: correct selector for code blocks css margins; cleanup: avoid nested editor.update calls, conventional editor.update handling * fix exhaustive deps on Table of contents component * performance: remove useMemo from dynamic Reader as dynamic already memoizes * performance fix: avoid SNReader re-renders on text changes by making PreviewSync read from formik instead of passing text via props * conditionally serve the style prop for legacy srcset * check blob existence during paste/dragndrop file upload * clear editor when canceling a reply * fix: restore paragraph on enter, normalize imported markdown * exclude format from srcSet imgproxy object * performance: avoid recreating the Reader component on each render via dynamic * cleanup: remove console.log * ssr fallback with context * revert Reader onReady * territories: render description with lexical state * fix: clear the preview and return to editor on submit * revert SNFormattingExtension workaround that restored paragraphs on newlines * remove topLevel from territory header * fix: synchronize formik and preview to the given field-name; fix: hide heading slug link if heading is itself a link * support truncated territory descriptions on popovers * add truncated territory description support to TerritoryDetails --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
1 parent 046a48a commit 940d0e9

File tree

128 files changed

+9217
-2202
lines changed

Some content is hidden

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

128 files changed

+9217
-2202
lines changed

api/resolvers/item.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { verifyHmac } from './wallet'
2929
import { parse } from 'tldts'
3030
import { shuffleArray } from '@/lib/rand'
3131
import pay from '../payIn'
32+
import { lexicalHTMLGenerator } from '@/lib/lexical/server/html'
3233

3334
function commentsOrderByClause (me, models, sort) {
3435
const sharedSortsArray = []
@@ -1507,6 +1508,21 @@ export default {
15071508
AND data->>'userId' = ${meId}::TEXT
15081509
AND state = 'created'`
15091510
return reminderJobs[0]?.startafter ?? null
1511+
},
1512+
lexicalState: async (item, args, { lexicalStateLoader }) => {
1513+
if (!item.text) return null
1514+
return lexicalStateLoader.load({ text: item.text, context: { outlawed: item.outlawed, imgproxyUrls: item.imgproxyUrls, rel: item.rel } })
1515+
},
1516+
html: async (item, args, { lexicalStateLoader }) => {
1517+
if (!item.text) return null
1518+
try {
1519+
const lexicalState = await lexicalStateLoader.load({ text: item.text, context: { outlawed: item.outlawed, imgproxyUrls: item.imgproxyUrls, rel: item.rel } })
1520+
if (!lexicalState) return null
1521+
return lexicalHTMLGenerator(lexicalState)
1522+
} catch (error) {
1523+
console.error('error generating HTML from Lexical State:', error)
1524+
return null
1525+
}
15101526
}
15111527
}
15121528
}
@@ -1594,6 +1610,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
15941610
item = { subName, ...item }
15951611
item.forwardUsers = await getForwardUsers(models, forward)
15961612
}
1613+
// note for the future: could also check MediaNodes directly via Lexical
15971614
item.uploadIds = uploadIdsFromText(item.text)
15981615

15991616
// never change author of item

api/resolvers/sub.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import pay from '../payIn'
66
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
77
import { uploadIdsFromText } from './upload'
88
import { Prisma } from '@prisma/client'
9+
import { lexicalHTMLGenerator } from '@/lib/lexical/server/html'
910

1011
export async function getSub (parent, { name }, { models, me }) {
1112
if (!name) return null
@@ -361,7 +362,22 @@ export default {
361362

362363
return sub.SubSubscription?.length > 0
363364
},
364-
createdAt: sub => sub.createdAt || sub.created_at
365+
createdAt: sub => sub.createdAt || sub.created_at,
366+
lexicalState: async (sub, args, { lexicalStateLoader }) => {
367+
if (!sub.desc) return null
368+
return lexicalStateLoader.load({ text: sub.desc })
369+
},
370+
html: async (sub, args, { lexicalStateLoader }) => {
371+
if (!sub.desc) return null
372+
try {
373+
const lexicalState = await lexicalStateLoader.load({ text: sub.desc })
374+
if (!lexicalState) return null
375+
return lexicalHTMLGenerator(lexicalState)
376+
} catch (error) {
377+
console.error('error generating HTML from Lexical State:', error)
378+
return null
379+
}
380+
}
365381
}
366382
}
367383

api/ssrApollo.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
1616
import { NOFOLLOW_LIMIT } from '@/lib/constants'
1717
import { satsToMsats } from '@/lib/format'
1818
import { MULTI_AUTH_ANON, MULTI_AUTH_POINTER, multiAuthMiddleware } from '@/lib/auth'
19+
import { lexicalStateLoader } from '@/lib/lexical/server/loader'
1920

2021
export default async function getSSRApolloClient ({ req, res, me = null }) {
2122
// switch session cookie before getting session on SSR
@@ -36,7 +37,8 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
3637
? session.user
3738
: me,
3839
lnd,
39-
search
40+
search,
41+
lexicalStateLoader: lexicalStateLoader()
4042
}
4143
}),
4244
cache: new InMemoryCache({

api/typeDefs/item.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export default gql`
105105
url: String
106106
searchText: String
107107
text: String
108+
lexicalState: JSONObject
109+
html: String
108110
parentId: Int
109111
parent: Item
110112
root: Item

api/typeDefs/sub.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export default gql`
3939
userId: Int!
4040
user: User!
4141
desc: String
42+
lexicalState: JSONObject
43+
html: String
4244
updatedAt: Date!
4345
postTypes: [String!]!
4446
allowFreebies: Boolean!

components/action-tooltip.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useFormikContext } from 'formik'
22
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
33
import Tooltip from 'react-bootstrap/Tooltip'
44

5-
export default function ActionTooltip ({ children, notForm, disable, overlayText, placement }) {
5+
export default function ActionTooltip ({ children, notForm, disable, overlayText, placement, noWrapper, showDelay, hideDelay, transition }) {
66
// if we're in a form, we want to hide tooltip on submit
77
let formik
88
if (!notForm) {
@@ -21,6 +21,8 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText
2121
}
2222
trigger={['hover', 'focus']}
2323
show={formik?.isSubmitting ? false : undefined}
24+
delay={{ show: showDelay || 0, hide: hideDelay || 0 }}
25+
transition={transition || false}
2426
popperConfig={{
2527
modifiers: {
2628
preventOverflow: {
@@ -29,9 +31,7 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText
2931
}
3032
}}
3133
>
32-
<span>
33-
{children}
34-
</span>
34+
{noWrapper ? children : <span>{children}</span>}
3535
</OverlayTrigger>
3636
)
3737
}

components/bounty-form.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Form, Input, MarkdownInput } from '@/components/form'
1+
import { Form, Input, SNInput } from '@/components/form'
22
import { useApolloClient } from '@apollo/client'
33
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
44
import InputGroup from 'react-bootstrap/InputGroup'
@@ -60,7 +60,7 @@ export function BountyForm ({
6060
label={bountyLabel} name='bounty' required
6161
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
6262
/>
63-
<MarkdownInput
63+
<SNInput
6464
topLevel
6565
label={
6666
<>

components/comment-edit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Form, MarkdownInput } from '@/components/form'
1+
import { Form, SNInput } from '@/components/form'
22
import styles from './reply.module.css'
33
import { commentSchema } from '@/lib/validate'
44
import { FeeButtonProvider } from './fee-button'
@@ -39,7 +39,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
3939
schema={commentSchema}
4040
onSubmit={onSubmit}
4141
>
42-
<MarkdownInput
42+
<SNInput
4343
name='text'
4444
minRows={6}
4545
autoFocus

components/comment.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import LinkToContext from './link-to-context'
2828
import Boost from './boost-button'
2929
import { gql, useApolloClient } from '@apollo/client'
3030
import classNames from 'classnames'
31+
import useCallbackRef from './use-callback-ref'
3132

3233
function Parent ({ item, rootText }) {
3334
const root = useRoot()
@@ -110,6 +111,7 @@ export default function Comment ({
110111
? 'yep'
111112
: 'nope')
112113
const ref = useRef(null)
114+
const { onRef: onReaderRef } = useCallbackRef()
113115
const router = useRouter()
114116
const root = useRoot()
115117
const { ref: textRef, quote, quoteReply, cancelQuote } = useQuoteReply({ text: item.text })
@@ -287,10 +289,10 @@ export default function Comment ({
287289
{item.searchText
288290
? <SearchText text={item.searchText} />
289291
: (
290-
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
292+
<Text itemId={item.id} state={item.lexicalState} html={item.html} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls} readerRef={onReaderRef}>
291293
{item.outlawed && !me?.privates?.wildWestMode
292294
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
293-
: truncate ? truncateString(item.text) : item.text}
295+
: truncate ? truncateString(item.text) : undefined}
294296
</Text>)}
295297
</div>
296298
)}

components/discussion-form.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Form, Input, MarkdownInput } from '@/components/form'
1+
import { Form, Input, SNInput } from '@/components/form'
22
import { useRouter } from 'next/router'
33
import { gql, useApolloClient, useLazyQuery } from '@apollo/client'
44
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
@@ -69,7 +69,7 @@ export function DiscussionForm ({
6969
}}
7070
maxLength={MAX_TITLE_LENGTH}
7171
/>
72-
<MarkdownInput
72+
<SNInput
7373
topLevel
7474
label={<>{textLabel} <small className='text-muted ms-2'>optional</small></>}
7575
name='text'

0 commit comments

Comments
 (0)