diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index a4a4b531e5..9f9977f92c 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -29,6 +29,8 @@ import { verifyHmac } from './wallet'
import { parse } from 'tldts'
import { shuffleArray } from '@/lib/rand'
import pay from '../payIn'
+import { lexicalHTMLGenerator } from '@/lib/lexical/server/html'
+import { prepareLexicalState } from '@/lib/lexical/server/interpolator'
function commentsOrderByClause (me, models, sort) {
const sharedSortsArray = []
@@ -1150,6 +1152,62 @@ export default {
})
return result.lastViewedAt
+ },
+ executeConversion: async (parent, { itemId, fullRefresh }, { models, me }) => {
+ if (process.env.NODE_ENV !== 'development') {
+ throw new GqlAuthenticationError()
+ }
+
+ console.log(`[executeConversion] scheduling conversion for item ${itemId}`)
+
+ // check if job is already scheduled or running
+ const alreadyScheduled = await models.$queryRaw`
+ SELECT state
+ FROM pgboss.job
+ WHERE name = 'migrateLegacyContent'
+ AND data->>'itemId' = ${itemId}::TEXT
+ AND state IN ('created', 'active', 'retry')
+ LIMIT 1
+ `
+
+ if (alreadyScheduled.length > 0) {
+ console.log(`[executeConversion] item ${itemId} already has active job`)
+ return {
+ success: false,
+ message: `migration already ${alreadyScheduled[0].state} for this item`
+ }
+ }
+
+ // schedule the migration job
+ await models.$executeRaw`
+ INSERT INTO pgboss.job (
+ name,
+ data,
+ retrylimit,
+ retrybackoff,
+ startafter,
+ keepuntil,
+ singletonKey
+ )
+ VALUES (
+ 'migrateLegacyContent',
+ jsonb_build_object(
+ 'itemId', ${itemId}::INTEGER,
+ 'fullRefresh', ${fullRefresh}::BOOLEAN,
+ 'checkMedia', true
+ ),
+ 3, -- reduced retry limit for manual conversions
+ true,
+ now(),
+ now() + interval '1 hour',
+ 'migrateLegacyContent:' || ${itemId}::TEXT
+ )
+ `
+
+ return {
+ success: true,
+ message: 'migration scheduled successfully'
+ }
}
},
Item: {
@@ -1584,21 +1642,33 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
item.url = removeTracking(item.url)
}
+ // create markdown from a lexical state
+ item.lexicalState = await prepareLexicalState({ text: item.text })
+ if (!item.lexicalState) {
+ throw new GqlInputError('failed to process content')
+ }
+
if (old.bio) {
// prevent editing a bio like a regular item
- item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio` }
+ item = { id: Number(item.id), text: item.text, lexicalState: item.lexicalState, title: `@${user.name}'s bio` }
} else if (old.parentId) {
// prevent editing a comment like a post
- item = { id: Number(item.id), text: item.text, boost: item.boost }
+ item = { id: Number(item.id), text: item.text, lexicalState: item.lexicalState, boost: item.boost }
} else {
item = { subName, ...item }
item.forwardUsers = await getForwardUsers(models, forward)
}
+ // todo: refactor to use uploadIdsFromLexicalState
+ // it should be way faster and more reliable
+ // by checking MediaNodes directly.
item.uploadIds = uploadIdsFromText(item.text)
// never change author of item
item.userId = old.userId
+ // generate sanitized html from lexical state
+ item.html = lexicalHTMLGenerator(item.lexicalState)
+
return await pay('ITEM_UPDATE', item, { models, me, lnd })
}
@@ -1610,6 +1680,16 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
item.userId = me ? Number(me.id) : USER_ID.anon
item.forwardUsers = await getForwardUsers(models, forward)
+
+ // create markdown from a lexical state
+ item.lexicalState = await prepareLexicalState({ text: item.text })
+ if (!item.lexicalState) {
+ throw new GqlInputError('failed to process content')
+ }
+
+ // todo: refactor to use uploadIdsFromLexicalState
+ // it should be way faster and more reliable
+ // by checking MediaNodes directly.
item.uploadIds = uploadIdsFromText(item.text)
if (item.url && !isJob(item)) {
@@ -1627,6 +1707,9 @@ export const createItem = async (parent, { forward, ...item }, { me, models, lnd
// mark item as created with API key
item.apiKey = me?.apiKey
+ // generate sanitized html from lexical state
+ item.html = lexicalHTMLGenerator(item.lexicalState)
+
return await pay('ITEM_CREATE', item, { models, me, lnd })
}
diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js
index 09c99e559c..f324601ea6 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -6,6 +6,7 @@ import pay from '../payIn'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { uploadIdsFromText } from './upload'
import { Prisma } from '@prisma/client'
+import { prepareLexicalState } from '@/lib/lexical/server/interpolator'
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
@@ -211,6 +212,13 @@ export default {
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
+ // QUIRK
+ // if we have a lexicalState, we'll convert it to markdown to fit the schema
+ data.lexicalState = await prepareLexicalState({ text: data.desc })
+ if (!data.lexicalState) {
+ throw new GqlInputError('failed to process content')
+ }
+
data.uploadIds = uploadIdsFromText(data.desc)
if (data.oldName) {
diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js
index df9c6edcad..63d18e3fdc 100644
--- a/api/typeDefs/item.js
+++ b/api/typeDefs/item.js
@@ -53,6 +53,12 @@ export default gql`
pollVote(id: ID!): PayIn!
toggleOutlaw(id: ID!): Item!
updateCommentsViewAt(id: ID!, meCommentsViewedAt: Date!): Date
+ executeConversion(itemId: ID!, fullRefresh: Boolean): ConversionResult!
+ }
+
+ type ConversionResult {
+ success: Boolean!
+ message: String!
}
type PollOption {
@@ -105,6 +111,8 @@ export default gql`
url: String
searchText: String
text: String
+ lexicalState: JSONObject
+ html: String
parentId: Int
parent: Item
root: Item
diff --git a/components/comment-edit.js b/components/comment-edit.js
index 197150d48d..51aba0ca99 100644
--- a/components/comment-edit.js
+++ b/components/comment-edit.js
@@ -1,4 +1,4 @@
-import { Form, MarkdownInput } from '@/components/form'
+import { Form, SNInput } from '@/components/form'
import styles from './reply.module.css'
import { commentSchema } from '@/lib/validate'
import { FeeButtonProvider } from './fee-button'
@@ -39,9 +39,9 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
schema={commentSchema}
onSubmit={onSubmit}
>
-
diff --git a/components/comment.js b/components/comment.js
index 99af3b8ebb..0a3f14e595 100644
--- a/components/comment.js
+++ b/components/comment.js
@@ -1,6 +1,6 @@
import itemStyles from './item.module.css'
import styles from './comment.module.css'
-import Text, { SearchText } from './text'
+import Text, { LegacyText, SearchText } from './text'
import Link from 'next/link'
import Reply from './reply'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -286,12 +286,14 @@ export default function Comment ({
{item.searchText
?
- : (
-
- {item.outlawed && !me?.privates?.wildWestMode
- ? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
- : truncate ? truncateString(item.text) : item.text}
- )}
+ : item.lexicalState
+ ?
+ : (
+
+ {item.outlawed && !me?.privates?.wildWestMode
+ ? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
+ : truncate ? truncateString(item.text) : item.text}
+ )}
)}
diff --git a/components/editor/contexts/item.js b/components/editor/contexts/item.js
new file mode 100644
index 0000000000..11fef4b0bd
--- /dev/null
+++ b/components/editor/contexts/item.js
@@ -0,0 +1,28 @@
+import { createContext, useContext, useMemo } from 'react'
+import { UNKNOWN_LINK_REL } from '@/lib/constants'
+
+const LexicalItemContext = createContext({
+ imgproxyUrls: null,
+ topLevel: false,
+ outlawed: false,
+ rel: UNKNOWN_LINK_REL
+})
+
+export function LexicalItemContextProvider ({ imgproxyUrls, topLevel, outlawed, rel, children }) {
+ const value = useMemo(() => ({
+ imgproxyUrls,
+ topLevel,
+ outlawed,
+ rel
+ }), [imgproxyUrls, topLevel, outlawed, rel])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useLexicalItemContext () {
+ return useContext(LexicalItemContext)
+}
diff --git a/components/editor/contexts/toolbar.js b/components/editor/contexts/toolbar.js
new file mode 100644
index 0000000000..0a5fce464c
--- /dev/null
+++ b/components/editor/contexts/toolbar.js
@@ -0,0 +1,36 @@
+import { createContext, useContext, useMemo, useState, useCallback } from 'react'
+
+const INITIAL_STATE = {
+ previewMode: false
+}
+
+const ToolbarContext = createContext()
+
+export const ToolbarContextProvider = ({ children }) => {
+ const [toolbarState, setToolbarState] = useState(INITIAL_STATE)
+
+ const batchUpdateToolbarState = useCallback((updates) => {
+ setToolbarState((prev) => ({ ...prev, ...updates }))
+ }, [])
+
+ const updateToolbarState = useCallback((key, value) => {
+ setToolbarState((prev) => ({
+ ...prev,
+ [key]: value
+ }))
+ }, [])
+
+ const contextValue = useMemo(() => {
+ return { toolbarState, updateToolbarState, batchUpdateToolbarState }
+ }, [toolbarState, updateToolbarState])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useToolbarState = () => {
+ return useContext(ToolbarContext)
+}
diff --git a/components/editor/editor.js b/components/editor/editor.js
new file mode 100644
index 0000000000..d3cbe4e004
--- /dev/null
+++ b/components/editor/editor.js
@@ -0,0 +1,100 @@
+import FormikBridgePlugin from '@/components/editor/plugins/formik'
+import LocalDraftPlugin from '@/components/editor/plugins/local-draft'
+import { MaxLengthPlugin } from '@/components/editor/plugins/max-length'
+import MentionsPlugin from '@/components/editor/plugins/mention'
+import FileUploadPlugin from '@/components/editor/plugins/upload'
+import PreviewPlugin from '@/components/editor/plugins/preview'
+import { AutoFocusExtension } from '@lexical/extension'
+import { ContentEditable } from '@lexical/react/LexicalContentEditable'
+import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
+import { LexicalExtensionComposer } from '@lexical/react/LexicalExtensionComposer'
+import classNames from 'classnames'
+import { useFormikContext } from 'formik'
+import { configExtension, defineExtension } from 'lexical'
+import { useMemo, useState } from 'react'
+import theme from './theme'
+import styles from './theme/editor.module.css'
+import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'
+import { ToolbarPlugin } from './plugins/tinytoolbar'
+import { ToolbarContextProvider } from './contexts/toolbar'
+import { $initializeEditorState } from '@/lib/lexical/utils'
+
+/**
+ * main lexical editor component with formik integration
+ * @param {string} props.name - form field name
+ * @param {string} [props.appendValue] - value to append to initial content
+ * @param {boolean} [props.autoFocus] - whether to auto-focus the editor
+ * @returns {JSX.Element} lexical editor component
+ */
+export default function SNEditor ({ name, appendValue, autoFocus, topLevel, ...props }) {
+ const { values } = useFormikContext()
+
+ const editor = useMemo(() =>
+ defineExtension({
+ $initialEditorState: (editor) => {
+ // initialize editor state with appendValue or existing formik text
+ if (appendValue || values.text) {
+ $initializeEditorState(editor, appendValue ?? values.text)
+ }
+ },
+ name: 'editor',
+ namespace: 'sn',
+ dependencies: [
+ configExtension(AutoFocusExtension, { disabled: !autoFocus })
+ ],
+ theme: { ...theme, topLevel: topLevel ? 'sn__topLevel' : '' },
+ onError: (error) => console.error('editor has encountered an error:', error)
+ }), [autoFocus, topLevel])
+
+ return (
+
+
+
+
+
+ )
+}
+
+/**
+ * editor content component containing all plugins and UI elements
+ * @param {string} props.name - form field name for draft saving
+ * @param {string} props.placeholder - placeholder text for empty editor
+ * @param {Object} props.lengthOptions - max length configuration
+ * @param {boolean} props.topLevel - whether this is a top-level editor
+ * @returns {JSX.Element} editor content with all plugins
+ */
+function EditorContent ({ name, placeholder, lengthOptions, topLevel }) {
+ const [editorRef, setEditorRef] = useState(null)
+
+ const onRef = (_editorRef) => {
+ if (_editorRef !== null) {
+ setEditorRef(_editorRef)
+ }
+ }
+
+ return (
+ <>
+
+
+ {/* we only need a plain text editor for markdown */}
+
+ {placeholder} }
+ />
+
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ {editorRef && }
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/components/editor/index.js b/components/editor/index.js
new file mode 100644
index 0000000000..623554c628
--- /dev/null
+++ b/components/editor/index.js
@@ -0,0 +1,31 @@
+import { forwardRef, useMemo } from 'react'
+import { useRouter } from 'next/router'
+import dynamic from 'next/dynamic'
+import { LexicalItemContextProvider } from './contexts/item'
+import { applySNCustomizations } from '@/lib/lexical/html/customs'
+
+export const SNReader = forwardRef(function SNReader ({ html, children, outlawed, imgproxyUrls, topLevel, rel, ...props }, ref) {
+ const router = useRouter()
+ const snCustomizedHTML = useMemo(() => (
+
+ ), [html, outlawed, imgproxyUrls, topLevel, props.className])
+
+ // debug html with ?html
+ if (router.query.html) return snCustomizedHTML
+
+ const Reader = useMemo(() => dynamic(() => import('./reader'), { ssr: false, loading: () => snCustomizedHTML }), [])
+
+ return (
+
+
+
+ {children}
+
+
+ )
+})
diff --git a/components/editor/plugins/code-theme.js b/components/editor/plugins/code-theme.js
new file mode 100644
index 0000000000..cd4d8ebd2f
--- /dev/null
+++ b/components/editor/plugins/code-theme.js
@@ -0,0 +1,18 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { useEffect } from 'react'
+import useDarkMode from '@/components/dark-mode'
+
+/** syncs code block syntax highlighting theme with site dark mode */
+export function CodeThemePlugin () {
+ const [editor] = useLexicalComposerContext()
+ const [darkMode] = useDarkMode()
+
+ const theme = darkMode ? 'github-dark-default' : 'github-light-default'
+
+ useEffect(() => {
+ if (!editor._updateCodeTheme) return
+ return editor._updateCodeTheme(theme)
+ }, [darkMode, theme])
+
+ return null
+}
diff --git a/components/editor/plugins/content/media/index.js b/components/editor/plugins/content/media/index.js
new file mode 100644
index 0000000000..bdd55224bf
--- /dev/null
+++ b/components/editor/plugins/content/media/index.js
@@ -0,0 +1,41 @@
+import MediaOrLink, { LinkRaw } from '@/components/media-or-link'
+import { useLexicalItemContext } from '@/components/editor/contexts/item'
+import { IMGPROXY_URL_REGEXP, decodeProxyUrl } from '@/lib/url'
+
+/**
+ * wrapper component that handles media rendering with item-specific logic
+ * like imgproxy, outlawed, rel (link) and top level
+
+ * @param {string} props.src - media source url
+ * @param {string} props.status - media status (error, pending, etc.)
+ * @param {string} props.kind - media kind (image, video)
+ * @param {number} props.width - media width
+ * @param {number} props.height - media height
+ * @param {number} props.maxWidth - media max width
+ * @returns {JSX.Element} media or link component
+ */
+export default function Media ({ src, status, kind, width, height, maxWidth }) {
+ const { imgproxyUrls, rel, outlawed, topLevel } = useLexicalItemContext()
+ const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src
+ const srcSet = imgproxyUrls?.[url]
+
+ if (outlawed) {
+ return {url}
+ }
+
+ if (status === 'error') {
+ return {url}
+ }
+
+ return (
+
+ )
+}
diff --git a/components/editor/plugins/decorative/toc.js b/components/editor/plugins/decorative/toc.js
new file mode 100644
index 0000000000..0cdd8b537e
--- /dev/null
+++ b/components/editor/plugins/decorative/toc.js
@@ -0,0 +1,52 @@
+import Link from 'next/link'
+import { buildNestedTocStructure } from '@/lib/lexical/nodes/misc/toc'
+
+/**
+ * recursively renders table of contents items with nested structure
+
+ * @param {Object} props.item - toc item with text, slug, and optional children
+ * @param {number} props.index - item index for key generation
+ * @returns {JSX.Element} list item with nested children
+ */
+function TocItem ({ item, index }) {
+ const hasChildren = item.children && item.children.length > 0
+ return (
+
+
+ {item.text}
+
+ {hasChildren && (
+
+ {item.children.map((child, idx) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+/**
+ * displays a collapsible table of contents from heading data
+
+ * @param {Array} props.headings - array of heading objects with text, depth, and slug
+ * @returns {JSX.Element} collapsible details element with toc list
+ */
+export function TableOfContents ({ headings }) {
+ const tocItems = buildNestedTocStructure(headings)
+
+ return (
+
+ table of contents
+ {tocItems.length > 0
+ ? (
+
+ {tocItems.map((item, index) => (
+
+ ))}
+
+ )
+ : no headings
}
+
+ )
+}
diff --git a/components/editor/plugins/formatting/math/index.js b/components/editor/plugins/formatting/math/index.js
new file mode 100644
index 0000000000..1a0308fc7c
--- /dev/null
+++ b/components/editor/plugins/formatting/math/index.js
@@ -0,0 +1,28 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
+import ErrorBoundary from '@/components/error-boundary'
+import KatexRenderer from '@/components/katex-renderer'
+import { useToast } from '@/components/toast'
+
+export default function MathComponent ({ math, inline }) {
+ const [editor] = useLexicalComposerContext()
+ const isEditable = useLexicalEditable()
+ const toaster = useToast()
+
+ return (
+ editor._onError(e)} fallback={null}>
+ {
+ if (!isEditable) {
+ try {
+ navigator.clipboard.writeText(math)
+ toaster.success('math copied to clipboard')
+ } catch {}
+ }
+ }}
+ />
+
+ )
+}
diff --git a/components/editor/plugins/formik.js b/components/editor/plugins/formik.js
new file mode 100644
index 0000000000..34f2fe14c3
--- /dev/null
+++ b/components/editor/plugins/formik.js
@@ -0,0 +1,45 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { useEffect, useRef } from 'react'
+import { useField } from 'formik'
+import { $initializeEditorState, $isMarkdownEmpty } from '@/lib/lexical/utils'
+import { $getRoot } from 'lexical'
+
+// TODO: check if we're doing too much by preparing markdown on each keystroke
+// we may also already have prepareMarkdown in the server-side interpolator
+/** syncs lexical editor state with formik form field values */
+export default function FormikBridgePlugin () {
+ const [editor] = useLexicalComposerContext()
+ const [textField,, textHelpers] = useField({ name: 'text' })
+ const hadContent = useRef(false)
+
+ // keep formik in sync
+ useEffect(() => {
+ return editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ // if editor is empty, set empty string for formik validation
+ if ($isMarkdownEmpty()) {
+ textHelpers.setValue('')
+ return
+ }
+
+ const text = $getRoot().getTextContent()
+
+ textHelpers.setValue(text)
+ })
+ })
+ }, [editor, textHelpers])
+
+ // reset the editor state if the field is/goes empty
+ useEffect(() => {
+ if (textField.value !== '') {
+ hadContent.current = true
+ }
+
+ if (textField.value === '' && hadContent.current) {
+ hadContent.current = false
+ editor.update(() => $initializeEditorState(editor))
+ }
+ }, [editor, textField.value])
+
+ return null
+}
diff --git a/components/editor/plugins/local-draft.js b/components/editor/plugins/local-draft.js
new file mode 100644
index 0000000000..b5a09e4e91
--- /dev/null
+++ b/components/editor/plugins/local-draft.js
@@ -0,0 +1,56 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { useContext, useCallback, useEffect } from 'react'
+import { StorageKeyPrefixContext } from '@/components/form'
+import { $isMarkdownEmpty, $initializeEditorState, $getMarkdown } from '@/lib/lexical/utils'
+
+/**
+ * plugin that auto-saves and restores editor drafts to/from local storage
+
+ * @param {string} props.name - storage key suffix for the draft
+ */
+export default function LocalDraftPlugin ({ name }) {
+ const [editor] = useLexicalComposerContext()
+
+ // local storage keys, e.g. 'reply-123456-text'
+ const storageKeyPrefix = useContext(StorageKeyPrefixContext)
+ const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + name : undefined
+
+ /**
+ * saves or removes draft from local storage based on editor emptiness
+ * @param {string} text - markdown text content
+ */
+ const upsertDraft = useCallback((text) => {
+ if (!storageKey) return
+
+ // if the editor is empty, remove the draft
+ if ($isMarkdownEmpty()) {
+ window.localStorage.removeItem(storageKey)
+ } else {
+ window.localStorage.setItem(storageKey, text)
+ }
+ }, [storageKey])
+
+ // load the draft from local storage
+ useEffect(() => {
+ if (storageKey) {
+ const value = window.localStorage.getItem(storageKey)
+ if (value) {
+ editor.update(() => {
+ $initializeEditorState(editor, value)
+ })
+ }
+ }
+ }, [editor, storageKey])
+
+ // save the draft to local storage
+ useEffect(() => {
+ // whenever the editor state changes, save the markdown draft
+ return editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ upsertDraft($getMarkdown())
+ })
+ })
+ }, [editor, upsertDraft])
+
+ return null
+}
diff --git a/components/editor/plugins/max-length.js b/components/editor/plugins/max-length.js
new file mode 100644
index 0000000000..e505908051
--- /dev/null
+++ b/components/editor/plugins/max-length.js
@@ -0,0 +1,95 @@
+import { useEffect, useState } from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { $getSelection, $isRangeSelection, RootNode, $getRoot } from 'lexical'
+import { $trimTextContentFromAnchor } from '@lexical/selection'
+import { $restoreEditorState } from '@lexical/utils'
+import { MAX_POST_TEXT_LENGTH } from '@/lib/constants'
+
+function getRemaining (editor, maxLength) {
+ console.log('getRemaining', maxLength)
+ return editor.getEditorState().read(() => {
+ const root = $getRoot()
+ const textContentSize = root ? root.getTextContentSize() : 0
+ return Math.max(0, maxLength - textContentSize)
+ })
+}
+
+/**
+ * plugin that enforces maximum text length and displays character count
+ * @param {number} props.lengthOptions.maxLength - maximum character limit
+ * @param {boolean} props.lengthOptions.show - whether to always show character count
+ * @returns {JSX.Element|null} character count display or null
+ */
+export function MaxLengthPlugin ({ lengthOptions = {} }) {
+ const [editor] = useLexicalComposerContext()
+
+ // if no limit is set, MAX_POST_TEXT_LENGTH is used
+ // rendering is disabled if not requested
+ const { maxLength = MAX_POST_TEXT_LENGTH, show = false } = lengthOptions
+
+ console.log('MaxLengthPlugin', maxLength, show)
+
+ // track remaining characters with state so it updates on editor changes
+ const [remaining, setRemaining] = useState(() => {
+ return getRemaining(editor, maxLength)
+ })
+
+ useEffect(() => {
+ // prevent infinite restoration loops by tracking the last restored editor state
+ let lastRestoredEditorState = null
+
+ // run whenever the RootNode (editor content) changes
+ return editor.registerNodeTransform(RootNode, (node) => {
+ // get the current selection
+ const sel = $getSelection()
+ // only proceed if we have a range selection that is collapsed (cursor position)
+ if (!$isRangeSelection(sel) || !sel.isCollapsed()) return
+
+ // get the previous editor state to compare text content size
+ const prevEditorState = editor.getEditorState()
+ const prevTextContentSize = prevEditorState.read(() => {
+ node.getTextContentSize()
+ })
+
+ // get the current text content size
+ const textContentSize = node.getTextContentSize()
+
+ // only act if the text content size has changed
+ if (prevTextContentSize !== textContentSize) {
+ // calculate how many characters need to be deleted if over the limit
+ const delCount = textContentSize - maxLength
+ const anchor = sel.anchor
+
+ // if we're over the character limit, handle the overflow
+ if (delCount > 0) {
+ // if the previous state was exactly at the limit and we haven't already restored this state,
+ // restore to the previous valid state to prevent going over the limit (infinite loop)
+ if (prevTextContentSize === maxLength && lastRestoredEditorState !== prevEditorState) {
+ lastRestoredEditorState = prevEditorState
+ $restoreEditorState(editor, prevEditorState)
+ } else {
+ // otherwise, trim the excess characters from the current cursor position
+ $trimTextContentFromAnchor(editor, anchor, delCount)
+ }
+ }
+ }
+ })
+ }, [editor, maxLength])
+
+ // update remaining characters whenever editor content changes
+ useEffect(() => {
+ return editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ setRemaining(getRemaining(editor, maxLength))
+ })
+ })
+ }, [editor, maxLength])
+
+ if (show || remaining < 10) {
+ return (
+ {remaining} characters remaining
+ )
+ }
+
+ return null
+}
diff --git a/components/editor/plugins/mention/autocompleter.js b/components/editor/plugins/mention/autocompleter.js
new file mode 100644
index 0000000000..554451efe6
--- /dev/null
+++ b/components/editor/plugins/mention/autocompleter.js
@@ -0,0 +1,157 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode, $isLineBreakNode, $isParagraphNode } from 'lexical'
+import { useEffect, useState, useCallback, useRef } from 'react'
+
+function extractTextUpToCursor (selection) {
+ const anchor = selection.anchor
+ const anchorNode = anchor.getNode()
+
+ if ($isTextNode(anchorNode)) {
+ const fullText = anchorNode.getTextContent()
+ const cursorOffset = anchor.offset
+
+ // don't trigger autocomplete if cursor is in the middle of a word
+ if (cursorOffset < fullText.length) {
+ const charAfterCursor = fullText[cursorOffset]
+ if (/[a-zA-Z0-9]/.test(charAfterCursor)) {
+ return null
+ }
+ }
+
+ let text = fullText.slice(0, cursorOffset)
+
+ // walk backwards to handle spaces/punctuation
+ let prev = anchorNode.getPreviousSibling()
+ while (prev && !$isLineBreakNode(prev) && !$isParagraphNode(prev)) {
+ if ($isTextNode(prev)) {
+ text = prev.getTextContent() + text
+ }
+ prev = prev.getPreviousSibling()
+ }
+
+ return text
+ }
+}
+
+function checkForMentionPattern (text) {
+ if (!text) return null
+
+ const mentionRegex = /(^|\s|\()([@~]\w{0,75})$/
+ const match = mentionRegex.exec(text)
+
+ if (match && match[2].length >= 2) {
+ return {
+ matchingString: match[2],
+ query: match[2].slice(1), // remove @ or ~
+ isUser: match[2].startsWith('@')
+ }
+ }
+
+ return null
+}
+
+export default function useUniversalAutocomplete () {
+ const [editor] = useLexicalComposerContext()
+ const [entityData, setEntityData] = useState(null)
+ const matchLengthRef = useRef(0)
+
+ const handleSelect = useCallback((item, isUser) => {
+ editor.update(() => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection)) return
+
+ // remove trigger (@nym or ~territory)
+ const anchor = selection.anchor
+ const anchorNode = anchor.getNode()
+
+ if ($isTextNode(anchorNode)) {
+ const textContent = anchorNode.getTextContent()
+ const cursorOffset = anchor.offset
+ const matchLength = matchLengthRef.current
+
+ // split text node
+ const beforeMatch = textContent.slice(0, cursorOffset - matchLength)
+ const afterMatch = textContent.slice(cursorOffset)
+
+ // composing the mention node
+ // users: item has { id, name } structure
+ // territories: same as users, without id
+ const mentionNode = $createTextNode(`${isUser ? '@' : '~'}${item.name || item}`)
+
+ // rebuilding the structure
+ if (beforeMatch) {
+ anchorNode.setTextContent(beforeMatch)
+ anchorNode.insertAfter(mentionNode)
+ if (afterMatch) {
+ mentionNode.insertAfter($createTextNode(afterMatch))
+ }
+ } else if (afterMatch) {
+ anchorNode.setTextContent(afterMatch)
+ anchorNode.insertBefore(mentionNode)
+ } else {
+ anchorNode.replace(mentionNode)
+ }
+
+ // moving cursor after mention
+ mentionNode.selectNext()
+ }
+ })
+
+ setEntityData(null)
+ }, [editor])
+
+ useEffect(() => {
+ return editor.registerUpdateListener(({ editorState }) => {
+ let match = null
+
+ editorState.read(() => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
+ return
+ }
+
+ const textUpToCursor = extractTextUpToCursor(selection)
+ match = checkForMentionPattern(textUpToCursor)
+ })
+
+ if (!match) {
+ setEntityData(null)
+ return
+ }
+
+ // calculate dropdown position from DOM
+ const domSelection = window.getSelection()
+ if (!domSelection || domSelection.rangeCount === 0) {
+ setEntityData(null)
+ return
+ }
+
+ const range = domSelection.getRangeAt(0)
+ const rect = range.getBoundingClientRect()
+
+ // rect validation
+ if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
+ setEntityData(null)
+ return
+ }
+
+ matchLengthRef.current = match.matchingString.length
+
+ setEntityData({
+ query: match.query,
+ isUser: match.isUser,
+ matchLength: match.matchingString.length,
+ style: {
+ position: 'absolute',
+ top: `${rect.bottom + window.scrollY}px`,
+ left: `${rect.left + window.scrollX}px`
+ }
+ })
+ })
+ }, [editor])
+
+ return {
+ entityData,
+ handleSelect
+ }
+}
diff --git a/components/editor/plugins/mention/index.js b/components/editor/plugins/mention/index.js
new file mode 100644
index 0000000000..80f3d713f1
--- /dev/null
+++ b/components/editor/plugins/mention/index.js
@@ -0,0 +1,108 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import useUniversalAutocomplete from './autocompleter'
+import { BaseSuggest } from '@/components/form'
+import { useLazyQuery } from '@apollo/client'
+import { USER_SUGGESTIONS } from '@/fragments/users'
+import { SUB_SUGGESTIONS } from '@/fragments/subs'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { KEY_DOWN_COMMAND, COMMAND_PRIORITY_HIGH } from 'lexical'
+
+// bridges BageSuggest to the Universal Autocomplete hook
+function SuggestWrapper ({
+ q, onSelect, dropdownStyle, selectWithTab = false, onSuggestionsChange, children,
+ getSuggestionsQuery, itemsField
+}) {
+ // fetch suggestions on-demand
+ // getSuggestionsQuery is the query to be used to fetch suggestions
+ const [getSuggestions] = useLazyQuery(getSuggestionsQuery, {
+ onCompleted: data => {
+ if (onSuggestionsChange) {
+ // itemsField is the field in the data that contains the suggestions
+ onSuggestionsChange(data[itemsField])
+ }
+ }
+ })
+
+ // watch query changes and fetch suggestions
+ // strip prefixes (@ or ~) and trailing spaces
+ useEffect(() => {
+ if (q !== undefined) {
+ getSuggestions({ variables: { q, limit: 5 } })
+ }
+ }, [q, getSuggestions])
+
+ // will display the dropdown, calling onSelect when a mention is selected
+ return (
+
+ {children}
+
+ )
+}
+
+export default function MentionsPlugin () {
+ const [editor] = useLexicalComposerContext()
+ const { entityData, handleSelect } = useUniversalAutocomplete({ editor })
+ const keyDownHandlerRef = useRef()
+ const resetSuggestionsRef = useRef()
+ const [currentSuggestions, setCurrentSuggestions] = useState([])
+
+ // we receive the name from BaseSuggest
+ // then we find the full item from our stored suggestions
+ const handleItemSelect = useCallback((name) => {
+ const fullItem = currentSuggestions.find(item => item.name === name)
+ if (fullItem) {
+ handleSelect(fullItem, entityData?.isUser)
+ }
+ }, [handleSelect, entityData, currentSuggestions])
+
+ // clear suggestions when entity data is null
+ useEffect(() => {
+ if (!entityData) {
+ if (resetSuggestionsRef.current) {
+ resetSuggestionsRef.current()
+ }
+ setCurrentSuggestions([])
+ }
+ }, [entityData])
+
+ useEffect(() => {
+ return editor.registerCommand(
+ KEY_DOWN_COMMAND,
+ (event) => {
+ if (keyDownHandlerRef.current && entityData) {
+ keyDownHandlerRef.current(event)
+ return true
+ }
+ return false
+ },
+ COMMAND_PRIORITY_HIGH
+ )
+ }, [editor, entityData, keyDownHandlerRef])
+
+ if (!entityData) return null
+
+ return createPortal(
+
+ {({ onKeyDown, resetSuggestions }) => {
+ keyDownHandlerRef.current = onKeyDown
+ resetSuggestionsRef.current = resetSuggestions
+ return null
+ }}
+ , document.body)
+}
diff --git a/components/editor/plugins/preview/index.js b/components/editor/plugins/preview/index.js
new file mode 100644
index 0000000000..3b5f2126f6
--- /dev/null
+++ b/components/editor/plugins/preview/index.js
@@ -0,0 +1,46 @@
+import { useEffect } from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { createCommand, COMMAND_PRIORITY_CRITICAL } from 'lexical'
+import { useFormikContext } from 'formik'
+import Reader from '../../reader'
+import styles from '@/components/editor/theme/editor.module.css'
+import { useToolbarState } from '../../contexts/toolbar'
+import classNames from 'classnames'
+
+export const TOGGLE_PREVIEW_COMMAND = createCommand('TOGGLE_PREVIEW_COMMAND')
+
+export default function PreviewPlugin ({ editorRef, topLevel }) {
+ const [editor] = useLexicalComposerContext()
+ const { toolbarState, updateToolbarState } = useToolbarState()
+ const { values } = useFormikContext()
+
+ // register toggle command
+ useEffect(() => {
+ return editor.registerCommand(
+ TOGGLE_PREVIEW_COMMAND,
+ () => {
+ updateToolbarState('previewMode', !toolbarState.previewMode)
+ return true
+ },
+ COMMAND_PRIORITY_CRITICAL
+ )
+ }, [editor, updateToolbarState])
+
+ // toggle editor visibility
+ useEffect(() => {
+ if (!editorRef) return
+ editorRef.style.display = toolbarState.previewMode ? 'none' : ''
+ }, [toolbarState, editorRef])
+
+ if (!toolbarState.previewMode) return null
+
+ return (
+
+
+
+ )
+}
diff --git a/components/editor/plugins/switch.js b/components/editor/plugins/switch.js
new file mode 100644
index 0000000000..ac16497df4
--- /dev/null
+++ b/components/editor/plugins/switch.js
@@ -0,0 +1,37 @@
+import { useCallback } from 'react'
+import { useFormikContext } from 'formik'
+import styles from '@/components/editor/theme/editor.module.css'
+import Nav from 'react-bootstrap/Nav'
+import { useToolbarState } from '../contexts/toolbar'
+
+/** displays and toggles between write and preview modes */
+export default function ModeSwitcherPlugin () {
+ const { values } = useFormikContext()
+ const { toolbarState, updateToolbarState } = useToolbarState()
+
+ const handleTabSelect = useCallback((eventKey) => {
+ updateToolbarState('previewMode', (eventKey === 'preview'))
+ }, [updateToolbarState])
+
+ return (
+
+
+
+ )
+}
diff --git a/components/editor/plugins/tinytoolbar/index.js b/components/editor/plugins/tinytoolbar/index.js
new file mode 100644
index 0000000000..b882c42277
--- /dev/null
+++ b/components/editor/plugins/tinytoolbar/index.js
@@ -0,0 +1,52 @@
+import ActionTooltip from '@/components/action-tooltip'
+import classNames from 'classnames'
+import styles from '@/components/editor/theme/editor.module.css'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { SN_UPLOAD_FILES_COMMAND } from '../upload'
+import ModeSwitcherPlugin from '../switch'
+import UploadIcon from '@/svgs/editor/toolbar/inserts/upload-paperclip.svg'
+import { useToolbarState } from '../../contexts/toolbar'
+
+export function ToolbarPlugin () {
+ const [editor] = useLexicalComposerContext()
+ const { toolbarState } = useToolbarState()
+
+ return (
+
+
+ {!toolbarState.previewMode && (
+
+ editor.dispatchCommand(SN_UPLOAD_FILES_COMMAND)} tooltip='upload files'>
+
+
+
+ )}
+
+ )
+}
+
+/**
+ * single button for toolbar with tooltip and active state
+
+ * @param {string} props.id - button identifier
+ * @param {boolean} props.isActive - whether button is in active state
+ * @param {Function} props.onClick - click handler
+ * @param {string} props.tooltip - tooltip text
+ * @param {boolean} props.disabled - whether button is disabled
+ * @param {number} props.showDelay - tooltip show delay in ms (default 500ms)
+ * @returns {JSX.Element} toolbar button component
+ */
+export function ToolbarButton ({ id, isActive, onClick, tooltip, disabled = false, children, showDelay = 500 }) {
+ return (
+
+ e.preventDefault()}
+ onClick={onClick}
+ >
+ {children}
+
+
+ )
+}
diff --git a/components/editor/plugins/upload.js b/components/editor/plugins/upload.js
new file mode 100644
index 0000000000..dc94ea37fe
--- /dev/null
+++ b/components/editor/plugins/upload.js
@@ -0,0 +1,250 @@
+import { useEffect, useRef, useCallback } from 'react'
+import {
+ COMMAND_PRIORITY_EDITOR,
+ $getRoot,
+ $getSelection, $isRangeSelection, $selectAll,
+ DRAGOVER_COMMAND,
+ DROP_COMMAND,
+ COMMAND_PRIORITY_LOW,
+ PASTE_COMMAND,
+ createCommand
+} from 'lexical'
+import { mergeRegister } from '@lexical/utils'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { useFeeButton } from '@/components/fee-button'
+import { FileUpload } from '@/components/file-upload'
+import { gql, useLazyQuery } from '@apollo/client'
+import { numWithUnits } from '@/lib/format'
+import { AWS_S3_URL_REGEXP } from '@/lib/constants'
+import useDebounceCallback from '@/components/use-debounce-callback'
+import styles from '@/components/editor/theme/editor.module.css'
+
+export const SN_UPLOAD_FILES_COMMAND = createCommand('SN_UPLOAD_FILES_COMMAND')
+
+/**
+ * plugin that handles file uploads with progress tracking and fee calcs
+ * @returns {JSX.Element} hidden file upload input component
+ */
+export default function FileUploadPlugin ({ editorRef }) {
+ const [editor] = useLexicalComposerContext()
+ const placeholdersRef = useRef(new Map())
+ const fileInputRef = useRef(null)
+ const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
+ // this receives the count of uploaded files and updates the upload fees
+ // in rich mode we can count MediaNodes with getUploaded()
+ // in markdown mode we can directly pass the text content on updateListener and regex it
+ const [updateUploadFees] = useLazyQuery(gql`
+ query uploadFees($s3Keys: [Int]!) {
+ uploadFees(s3Keys: $s3Keys) {
+ nUnpaid
+ uploadFees
+ }
+ }`, {
+ fetchPolicy: 'no-cache',
+ nextFetchPolicy: 'no-cache',
+ onError: (err) => {
+ console.error(err)
+ },
+ onCompleted: ({ uploadFees }) => {
+ const { uploadFees: feePerUpload, nUnpaid } = uploadFees
+ const totalFees = feePerUpload * nUnpaid
+ merge({
+ uploadFees: {
+ term: `+ ${numWithUnits(feePerUpload, { abbreviate: false })} x ${nUnpaid}`,
+ label: 'upload fee',
+ op: '+',
+ modifier: cost => cost + totalFees,
+ omit: !totalFees
+ }
+ })
+ }
+ })
+
+ // todo: this is too messy
+ // instant version for onSuccess
+ const $refreshUploadFees = useCallback(() => {
+ let s3Keys = []
+ const text = $getRoot().getTextContent() || ''
+ s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
+ updateUploadFees({ variables: { s3Keys } })
+ }, [updateUploadFees])
+
+ // debounced version for update listener
+ const refreshUploadFeesDebounced = useDebounceCallback(() => {
+ editor.getEditorState().read(() => {
+ $refreshUploadFees()
+ })
+ }, 1000, [$refreshUploadFees])
+
+ const onUpload = useCallback((file) => {
+ editor.update(() => {
+ const selection = $getSelection()
+ const identifier = Math.random().toString(36).substring(2, 8)
+ selection.insertText(`\n\n`)
+ placeholdersRef.current.set(file, identifier)
+ }, { tag: 'history-merge' })
+ setSubmitDisabled?.(true)
+ }, [editor, setSubmitDisabled])
+
+ const onSuccess = useCallback(({ url, name, id, file }) => {
+ const identifier = placeholdersRef.current.get(file)
+ if (!identifier) return
+ editor.update(() => {
+ placeholdersRef.current.delete(file)
+ let text = $getRoot().getTextContent() || ''
+ text = text.replace(``, ``)
+ $selectAll()
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ selection.insertText(text)
+ }
+ console.log('onSuccess markdown', text)
+ }, { tag: 'history-merge' })
+ // refresh upload fees after the update is complete
+ editor.read(() => $refreshUploadFees())
+ setSubmitDisabled?.(false)
+ }, [editor, setSubmitDisabled])
+
+ const onError = useCallback(({ file }) => {
+ const identifier = placeholdersRef.current.get(file)
+ if (!identifier) return
+ editor.update(() => {
+ placeholdersRef.current.delete(file)
+ let text = $getRoot().getTextContent() || ''
+ text = text.replace(``, '')
+ $selectAll()
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ selection.insertText(text)
+ }
+ }, { tag: 'history-merge' })
+ setSubmitDisabled?.(false)
+ }, [editor, setSubmitDisabled])
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerCommand(
+ SN_UPLOAD_FILES_COMMAND,
+ () => {
+ if (!editor.isEditable()) return false
+
+ fileInputRef.current?.click()
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR
+ )
+ )
+ }, [editor])
+
+ // update upload fees when the editor state changes in any way, debounced
+ useEffect(() => {
+ return editor.registerUpdateListener(() =>
+ refreshUploadFeesDebounced()
+ )
+ }, [editor, refreshUploadFeesDebounced])
+
+ // drag'n'drop + paste file handling
+ useEffect(() => {
+ const unregisters = mergeRegister(
+ editor.registerCommand(
+ PASTE_COMMAND,
+ (e) => {
+ const items = e.clipboardData?.items || []
+ if (items.length === 0) return false
+ console.log('paste command', items)
+
+ const fileList = new window.DataTransfer()
+ let hasImages = false
+
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i]
+ console.log('item', item)
+ if (item.type.startsWith('image')) {
+ const blob = item.getAsFile()
+ const file = new File([blob], 'image', { type: blob.type })
+ fileList.items.add(file)
+ hasImages = true
+ }
+ }
+
+ if (hasImages) {
+ console.log('has images', hasImages)
+ e.preventDefault()
+ const changeEvent = new Event('change', { bubbles: true })
+ fileInputRef.current.files = fileList.files
+ fileInputRef.current.dispatchEvent(changeEvent)
+ }
+
+ console.log('return', hasImages)
+ return hasImages
+ },
+ COMMAND_PRIORITY_LOW
+ ),
+ editor.registerCommand(
+ DRAGOVER_COMMAND,
+ (e) => {
+ const rootElement = editor.getRootElement()
+ if (rootElement) {
+ rootElement.classList.add(styles.dragOver)
+ }
+ return true
+ },
+ COMMAND_PRIORITY_LOW
+ ),
+ // TODO: this doesn't handle selection, won't create a selection
+ // use MediaNode DnD to figure this out
+ editor.registerCommand(
+ DROP_COMMAND,
+ (e) => {
+ e.preventDefault()
+ const rootElement = editor.getRootElement()
+ if (rootElement) {
+ rootElement.classList.remove(styles.dragOver)
+ }
+ console.log('drop command', e.dataTransfer.files)
+ const changeEvent = new Event('change', { bubbles: true })
+ fileInputRef.current.files = e.dataTransfer.files
+ fileInputRef.current.dispatchEvent(changeEvent)
+ console.log('return', e.dataTransfer.files)
+ return true
+ },
+ COMMAND_PRIORITY_LOW
+ )
+ )
+
+ const onDragLeave = () => {
+ const rootElement = editor.getRootElement()
+ if (rootElement) {
+ rootElement.classList.remove(styles.dragOver)
+ }
+ }
+
+ if (editorRef) {
+ editorRef.addEventListener('dragleave', onDragLeave)
+ }
+ return () => {
+ unregisters()
+ if (editorRef) {
+ editorRef.removeEventListener('dragleave', onDragLeave)
+ }
+ }
+ }, [editor, editorRef])
+
+ useEffect(() => {
+ return () => {
+ placeholdersRef.current.clear()
+ }
+ }, [placeholdersRef])
+
+ return (
+
+
+
+ )
+}
diff --git a/components/editor/reader.js b/components/editor/reader.js
new file mode 100644
index 0000000000..2f65302356
--- /dev/null
+++ b/components/editor/reader.js
@@ -0,0 +1,63 @@
+import { forwardRef, useMemo } from 'react'
+import { defineExtension, configExtension } from 'lexical'
+import { RichTextExtension } from '@lexical/rich-text'
+import { ContentEditable } from '@lexical/react/LexicalContentEditable'
+import { LexicalExtensionComposer } from '@lexical/react/LexicalExtensionComposer'
+import { ReactExtension } from '@lexical/react/ReactExtension'
+import { TableExtension } from '@lexical/table'
+import theme from './theme'
+import { CodeShikiSNExtension } from '@/lib/lexical/exts/shiki'
+import { CodeThemePlugin } from './plugins/code-theme'
+import DefaultNodes from '@/lib/lexical/nodes'
+import { setMarkdown } from '@/lib/lexical/utils/mdast'
+
+const initiateLexical = (editor, lexicalState, markdown) => {
+ if (markdown) {
+ setMarkdown(editor, markdown)
+ return
+ }
+
+ if (lexicalState) {
+ try {
+ const state = editor.parseEditorState(lexicalState)
+
+ if (!state.isEmpty()) {
+ editor.setEditorState(state)
+ }
+ } catch (error) {
+ console.error(error)
+ }
+ }
+}
+
+export default forwardRef(function Reader ({ className, contentRef, topLevel, lexicalState, markdown, children }, ref) {
+ const reader = useMemo(() =>
+ defineExtension({
+ name: 'reader',
+ namespace: 'sn',
+ editable: false,
+ nodes: DefaultNodes,
+ dependencies: [
+ RichTextExtension,
+ TableExtension,
+ CodeShikiSNExtension,
+ configExtension(ReactExtension, { contentEditable: null })
+ ],
+ theme: {
+ ...theme,
+ topLevel: topLevel && 'sn__topLevel'
+ },
+ $initialEditorState: (editor) => initiateLexical(editor, lexicalState, markdown),
+ onError: (error) => console.error(error)
+ }), [topLevel, markdown])
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+})
diff --git a/components/editor/theme/editor.module.css b/components/editor/theme/editor.module.css
new file mode 100644
index 0000000000..65fbe84ad3
--- /dev/null
+++ b/components/editor/theme/editor.module.css
@@ -0,0 +1,579 @@
+/* editor container */
+
+.editor {
+ position: relative;
+}
+
+.editorContainer {
+ width: 100%;
+ color: var(--theme-color);
+ position: relative;
+ line-height: 20px;
+ font-weight: 400;
+ text-align: left;
+ border-top-left-radius: 0.4rem;
+ border-top-right-radius: 0.4rem;
+}
+
+.editorInner {
+ position: relative;
+}
+
+/* editor input */
+
+.editorInput {
+ caret-color: var(--theme-color);
+ position: relative;
+ tab-size: 1;
+ outline: 0;
+ padding: 10px;
+ border: 1px solid var(--theme-borderColor);
+ border-bottom-left-radius: 0.4rem;
+ border-bottom-right-radius: 0.4rem;
+ border-top-right-radius: 0.4rem;
+ min-height: 120px;
+ resize: vertical;
+ overflow: auto;
+}
+
+.editorInput:focus-within {
+ outline: 0;
+ border: 1px solid var(--bs-primary) !important;
+ box-shadow: 0 0 0 0.2rem rgb(250 218 94 / 25%);
+}
+
+:global([class*="reply"]) .editorInput {
+ border: 1px solid color-mix(in srgb, var(--theme-borderColor) 50%, transparent) !important;
+ background-color: var(--theme-commentBg) !important;
+}
+
+:global([class*="reply"]) .editorInput:focus-within {
+ border: 1px solid var(--bs-primary) !important;
+}
+
+.editorPlaceholder {
+ top: 9px;
+ left: 11px;
+ font-size: 94%;
+ opacity: 0.5;
+ position: absolute;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ user-select: none;
+ white-space: nowrap;
+ display: inline-block;
+ pointer-events: none;
+}
+
+/* markdown mode code block styling */
+
+.editorInput code[data-language="markdown"] {
+ background-color: var(--theme-body) !important;
+ color: var(--theme-color);
+ font-family: inherit;
+ font-size: 100% !important;
+ display: block;
+ padding: 0 !important;
+ line-height: 1.53;
+ margin: 0 0 8px;
+ tab-size: 2;
+ overflow-x: auto;
+ position: relative;
+}
+
+/* toolbar */
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ -webkit-touch-callout: none;
+ touch-action: manipulation;
+}
+
+.toolbar :global(.dropdown-toggle::after),
+.bottomBar :global(.dropdown-toggle::after),
+.tableCellActionButtonContainer :global(.dropdown-toggle::after) {
+ display: none;
+}
+
+.divider {
+ min-width: 1px;
+ height: 20px;
+ background-color: var(--theme-borderColor);
+ border-radius: 0.4rem;
+ margin: 0 4px;
+ opacity: 0.3;
+}
+
+:global(.dropdown) + .divider {
+ margin-left: 1.25rem !important;
+}
+
+.toolbarFormatting {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ opacity: 1;
+ overflow: auto;
+ scrollbar-width: none;
+ transition: opacity 0.1s ease-in-out, visibility 0.1s ease-in-out;
+}
+
+.toolbarFormatting.hasOverflow {
+ mask: linear-gradient(to right, black 0%, black calc(100% - 0.2rem), transparent 100%);
+}
+
+.toolbarFormatting.hidden {
+ visibility: hidden;
+ opacity: 0;
+}
+
+/* toolbar items */
+
+.toolbarItem {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0.2rem;
+ border-radius: 0.4rem;
+ opacity: 0.6;
+ -webkit-touch-callout: none;
+ touch-action: manipulation;
+}
+
+.toolbarItem + .toolbarItem {
+ margin-left: 0.25rem !important;
+}
+
+:global(.dropdown):has(.toolbarItem) + :global(.dropdown):has(.toolbarItem) {
+ margin-left: 0.25rem !important;
+}
+
+.toolbarItem:hover {
+ background-color: var(--theme-toolbarActive);
+ opacity: 1;
+}
+
+.toolbarItem:active,
+.toolbarItem.active:active {
+ opacity: 0.8;
+}
+
+.toolbarItem.active {
+ background-color: var(--theme-toolbarActive);
+ opacity: 1;
+}
+
+.toolbarItem svg {
+ color: var(--bs-body-color);
+ width: 20px;
+ height: 20px;
+}
+
+.toolbarItemText {
+ font-size: 12px;
+ font-weight: 500;
+ margin-left: 0.25rem;
+ color: var(--bs-body-color) !important;
+}
+
+.toolbarInsert {
+ display: flex;
+ align-items: center;
+ border-radius: 0.4rem;
+ opacity: 0.6;
+ padding: 0.25rem;
+}
+
+.toolbarInsert:hover,
+.toolbarInsert.active {
+ background-color: var(--theme-toolbarActive);
+ opacity: 1;
+}
+
+.toolbarInsert svg {
+ fill: var(--bs-body-color) !important;
+ width: 20px;
+ height: 20px;
+}
+
+/* bottom bar */
+
+.bottomBar {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ -webkit-touch-callout: none;
+ touch-action: manipulation;
+}
+
+.bottomBarItem {
+ cursor: pointer;
+ font-size: 10px;
+ color: var(--theme-grey);
+ margin-top: 2px;
+}
+
+.bottomBarDivider {
+ color: var(--theme-grey);
+ font-size: 10px;
+ margin-top: 2px;
+}
+
+.bottomBarItem:hover,
+.bottomBarItem:active {
+ color: var(--theme-brandColor);
+ filter: drop-shadow(0 0 6px var(--bs-primary));
+}
+
+.bottomBarItem:active {
+ opacity: 0.8;
+}
+
+/* dropdown menus */
+
+.dropdownExtra, .tableActionMenuDropdown {
+ padding: 0.45rem 0.35rem;
+ width: 240px;
+ border-radius: 0.4rem;
+ z-index: 2000;
+}
+
+.dropdownSearchContainer {
+ padding: 0.15rem;
+ margin-bottom: 0.1rem;
+ border-radius: 0.3rem;
+ opacity: 0.5;
+}
+
+.dropdownSearchInput {
+ width: 100%;
+}
+
+.dropdownExtraItem, .tableActionMenuItem {
+ display: flex;
+ gap: 1.25rem;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.1rem;
+ padding: 0.35rem;
+ font-size: 14px;
+ font-weight: normal;
+ text-wrap: wrap;
+}
+
+.dropdownExtraItem:hover, .tableActionMenuItem:hover {
+ background-color: var(--theme-toolbarActive);
+ opacity: 1;
+ border-radius: 0.3rem;
+}
+
+.dropdownExtraItem.active {
+ opacity: 1;
+ font-weight: 500;
+ font-size: 15px;
+}
+
+.dropdownExtraItem.active .dropdownExtraItemText {
+ color: var(--theme-brandColor) !important;
+ filter: drop-shadow(0 0 6px var(--bs-primary));
+ font-weight: 500;
+}
+
+.dropdownExtraItemLabel, .tableActionMenuItemLabel {
+ display: flex;
+ align-items: center;
+ gap: 0.15rem;
+}
+
+.dropdownExtraItemLabel svg, .tableActionMenuItemLabel svg {
+ width: 16px;
+ height: 16px;
+ margin-top: 3px;
+ margin-right: 0.25rem;
+}
+
+.dropdownExtraItemShortcut, .tableActionMenuItemShortcut {
+ color: var(--theme-grey);
+ font-size: 12px !important;
+ font-weight: 400;
+ opacity: 0.5;
+}
+
+/* skeleton animation */
+
+.skeleton .otherItem {
+ display: inline-flex;
+ width: 100%;
+ height: 100%;
+ border-radius: 0.27rem;
+ background-size: 200% 100%;
+ animation: shimmer 4s infinite linear;
+ margin-bottom: 0.5rem;
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+/* preview warning icon */
+
+.snPreview {
+ font-size: 16px;
+ font-family: 'lightning';
+ font-weight: 500;
+ margin-right: 8px;
+ margin-top: 4px;
+ gap: 4px;
+ color: var(--bs-danger);
+ filter: drop-shadow(0 0 6px var(--bs-danger));
+}
+
+/* floating toolbar */
+
+.floatingToolbarContainer {
+ position: absolute;
+ background-color: var(--theme-inputBg);
+ border-radius: 0.4rem;
+ top: 0;
+ left: 0;
+ z-index: 1000;
+ opacity: 0;
+ transform: translate(-10000px, -10000px);
+ box-shadow: 0 0 8px 0 rgba(0, 0, 0, var(--theme-floating-toolbar-shadow-opacity));
+ transition: opacity 0.2s ease-in-out;
+ will-change: transform;
+}
+
+.floatingToolbar {
+ border-radius: 0.4rem;
+ background-color: var(--theme-commentBg);
+ padding: 4px;
+ padding-bottom: 0;
+}
+
+/* code action menu */
+
+.codeActionMenuContainer {
+ height: 36px;
+ font-size: 10px;
+ color: var(--theme-navLink);
+ position: absolute;
+ display: flex;
+ align-items: center;
+ gap: 0.15rem;
+ user-select: none;
+ -webkit-touch-callout: none;
+ touch-action: manipulation;
+}
+
+.codeActionCopyButton svg,
+.codeActionLanguage svg {
+ fill: var(--theme-navLink);
+ width: 16px;
+ height: 16px;
+}
+
+.codeActionCopyButton svg {
+ margin-bottom: 3px;
+}
+
+.codeActionCopyButton svg:hover,
+.codeActionLanguage svg:hover {
+ fill: var(--theme-navLinkFocus);
+}
+
+.codeActionCopyButton svg:active,
+.codeActionLanguage svg:active {
+ fill: var(--theme-navLinkActive);
+}
+
+.draggableBlockMenu {
+ border-radius: 4px;
+ padding: 2px 1px;
+ cursor: grab;
+ opacity: 0;
+ position: absolute;
+ left: 0;
+ top: 0;
+ will-change: transform;
+ display: flex;
+ gap: 2px;
+ z-index: 1000;
+}
+
+.draggableBlockMenuAdd {
+ cursor: pointer;
+}
+
+.draggableBlockMenu:active {
+ cursor: grabbing;
+}
+
+.draggableBlockMenu svg {
+ width: 16px;
+ height: 16px;
+ opacity: 0.3;
+}
+
+.draggableBlockTargetLine {
+ pointer-events: none;
+ background: deepskyblue;
+ height: 4px;
+ position: absolute;
+ left: 0;
+ top: 0;
+ opacity: 0;
+ will-change: transform;
+}
+
+/* to be implemented */
+.tableAddColumns {
+ position: absolute;
+ background-color: var(--theme-toolbarActive);
+ height: 100%;
+ animation: table-controls 0.2s ease;
+ border: 0;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tableAddColumns svg, .tableAddRows svg {
+ fill: var(--bs-body-color);
+ width: 12px;
+ height: 12px;
+}
+
+.tableAddColumns:hover,
+.tableAddRows:hover {
+ background-color: var(--theme-toolbarActive);
+ opacity: 0.8;
+}
+.tableAddRows {
+ position: absolute;
+ width: calc(100% - 25px);
+ background-color: var(--theme-toolbarActive);
+ animation: table-controls 0.2s ease;
+ border: 0;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@keyframes table-controls {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+/* table action menu */
+.tableCellActionButtonContainer {
+ position: absolute;
+ z-index: 3;
+ top: 0;
+ left: 0;
+ will-change: transform;
+}
+
+.tableCellActionButtonContainerActive {
+ pointer-events: auto;
+ opacity: 1;
+}
+
+.tableCellActionButtonContainerInactive {
+ pointer-events: none;
+ opacity: 0;
+}
+.tableCellActionButton {
+ background-color: var(--theme-commentBg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--theme-borderColor);
+ border-radius: 4px;
+ position: absolute;
+ top: 17px;
+ right: 2px;
+ color: var(--theme-color);
+ cursor: pointer;
+ font-size: 12px;
+ line-height: 1;
+ padding: 0;
+ width: 12px;
+ height: 12px;
+}
+
+.tableCellActionButton:hover {
+ background-color: var(--theme-toolbarActive);
+}
+
+.tableCellActionButton svg {
+ fill: var(--bs-body-color);
+ width: 12px;
+ height: 12px;
+}
+
+.tableActionMenuWrapper {
+ position: fixed;
+ z-index: 10000;
+ opacity: 0;
+ transition: opacity 0.2s;
+}
+
+.tableActionMenuDropdown {
+ width: 100% !important;
+}
+
+.tableActionMenuLabel {
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: flex-start !important;
+}
+
+.tableActionMenuItemShortcut {
+ font-size: 10px !important;
+}
+
+.tableActionMenuGroup {
+ padding: 0.25rem 0;
+}
+
+.dragOver {
+ box-shadow: 0 0 10px var(--bs-info);
+}
+
+.modeSwitcherContainer {
+ margin-bottom: -0.05rem !important;
+}
+
+.modeSwitcherContainer :global(.nav-tabs .nav-link) {
+ color: var(--theme-dropdownItemColor) !important;
+ font-size: 12px !important;
+ padding-bottom: 0.25rem;
+ padding-top: 0.25rem;
+}
+
+.modeSwitcherContainer :global(.nav-tabs .nav-link.active) {
+ border: 1px solid color-mix(in srgb, var(--theme-borderColor) 50%, transparent) !important;
+ border-bottom: 0 !important;
+ background-color: var(--theme-commentBg);
+}
+
+.toolbar:has(~ .editor .editorInput:focus-within) .modeSwitcherContainer :global(.nav-tabs .nav-link.active) {
+ border: 1px solid var(--bs-primary) !important;
+ border-bottom: 0 !important;
+ box-shadow: 0 0 0 0.2rem rgb(240 218 94 / 25%);
+ clip-path: inset(-0.2rem -0.2rem -0.005rem -0.2rem);
+}
\ No newline at end of file
diff --git a/components/editor/theme/index.js b/components/editor/theme/index.js
new file mode 100644
index 0000000000..6cff809353
--- /dev/null
+++ b/components/editor/theme/index.js
@@ -0,0 +1,96 @@
+const theme = {
+ blockCursor: 'sn__blockCursor',
+ paragraph: 'sn__paragraph',
+ heading: {
+ h1: 'sn__headings',
+ h2: 'sn__headings',
+ h3: 'sn__headings',
+ h4: 'sn__headings',
+ h5: 'sn__headings',
+ h6: 'sn__headings'
+ },
+ quote: 'sn__quote',
+ image: 'sn__image',
+ mediaContainer: 'sn__mediaContainer',
+ link: 'sn__link',
+ code: 'sn__codeBlock',
+ userMention: 'sn__userMention',
+ territoryMention: 'sn__territoryMention',
+ itemMention: 'sn__itemMention',
+ embeds: {
+ base: 'sn__embedWrapper',
+ focus: 'sn__embedWrapperFocus',
+ twitter: {
+ container: 'sn__twitterContainer',
+ embed: 'sn__twitter'
+ },
+ nostr: {
+ container: 'sn__nostrContainer',
+ embed: 'sn__nostrContainer'
+ },
+ wavlake: {
+ container: 'sn__wavlakeWrapper',
+ embed: 'sn__wavlakeWrapper'
+ },
+ spotify: {
+ container: 'sn__spotifyWrapper',
+ embed: 'sn__spotifyWrapper'
+ },
+ youtube: {
+ container: 'sn__videoWrapper',
+ embed: 'sn__videoWrapper'
+ },
+ rumble: {
+ container: 'sn__videoWrapper',
+ embed: 'sn__videoWrapper'
+ },
+ peertube: {
+ container: 'sn__videoWrapper',
+ embed: 'sn__videoWrapper'
+ }
+ },
+ list: {
+ nested: {
+ listitem: 'sn__nestedListItem'
+ },
+ ol: 'sn__listOl',
+ ul: 'sn__listUl',
+ listitem: 'sn__listItem',
+ listitemChecked: 'sn__listItemChecked',
+ listitemUnchecked: 'sn__listItemUnchecked'
+ },
+ table: 'sn__table',
+ tableCell: 'sn__tableCell',
+ tableCellActionButton: 'sn__tableCellActionButton',
+ tableCellActionButtonContainer:
+ 'sn__tableCellActionButtonContainer',
+ tableCellHeader: 'sn__tableCellHeader',
+ tableCellResizer: 'sn__tableCellResizer',
+ tableCellSelected: 'sn__tableCellSelected',
+ tableScrollableWrapper: 'sn__tableScrollableWrapper',
+ tableSelected: 'sn__tableSelected',
+ tableSelection: 'sn__tableSelection',
+ tableAlignment: {
+ center: 'sn__tableAlignmentCenter',
+ right: 'sn__tableAlignmentRight'
+ },
+ text: {
+ bold: 'sn__textBold',
+ italic: 'sn__textItalic',
+ superscript: 'sn__textSuperscript',
+ subscript: 'sn__textSubscript',
+ highlight: 'sn__textHighlight',
+ underline: 'sn__textUnderline',
+ strikethrough: 'sn__textStrikethrough',
+ underlineStrikethrough: 'sn__textUnderlineStrikethrough',
+ code: 'sn__code'
+ },
+ math: 'sn__math',
+ hr: 'sn__hr',
+ toc: 'sn__toc',
+ spoilerContainer: 'sn__spoilerContainer',
+ spoilerSummary: 'sn__spoilerSummary',
+ spoilerContent: 'sn__spoilerContent'
+}
+
+export default theme
diff --git a/components/editor/theme/media.module.css b/components/editor/theme/media.module.css
new file mode 100644
index 0000000000..a091215776
--- /dev/null
+++ b/components/editor/theme/media.module.css
@@ -0,0 +1,150 @@
+.imageCaptionContentEditable {
+ min-height: 20px;
+ border: 0px;
+ resize: none;
+ cursor: text;
+ background-color: transparent !important;
+ display: block;
+ position: relative;
+ outline: 0px;
+ padding: 3px;
+ user-select: text;
+ font-size: 12px;
+ width: 100%;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #fff !important;
+}
+
+.imageCaptionContainer {
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin-top: 10px;
+ padding: 0;
+ background-color: rgba(0, 0, 0, .5) !important;
+ min-width: 100px;
+ color: #fff !important;
+ overflow: hidden;
+}
+
+/* if before this element there is a div with a child video */
+.imageCaptionContainer:has(> div > video) {
+ position: relative;
+ margin-top: 0;
+}
+
+.imageCaptionButton {
+ display: block;
+ position: absolute;
+ bottom: 20px;
+ left: 0;
+ right: 0;
+ width: 30%;
+ padding: 10px;
+ margin: 0 auto;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 5px;
+ background-color: rgba(0, 0, 0, 0.5);
+ min-width: 100px;
+ color: #fff;
+ cursor: pointer;
+ user-select: none;
+}
+
+.imageCaptionButton:hover {
+ background-color: rgba(60, 132, 244, 0.5);
+}
+
+.loadError {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ background-color: rgba(0, 0, 0, 0.5);
+ color: #fff !important;
+ border-radius: .4rem;
+ text-align: center;
+ padding: 10px;
+}
+
+.loadErrorLink {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.loadErrorLink:hover {
+ text-decoration: none;
+}
+
+.loadError span {
+ color: inherit !important;
+}
+
+.loadError svg {
+ fill: #fff !important;
+}
+
+.imageControlWrapperResizing {
+ touch-action: none;
+}
+
+.imageResizer {
+ display: block;
+ width: 7px;
+ height: 7px;
+ position: absolute;
+ background-color: rgb(60, 132, 244);
+ border: 1px solid #fff;
+}
+
+.imageResizer.imageResizerN {
+ top: -6px;
+ left: 48%;
+ cursor: n-resize;
+}
+
+.imageResizer.imageResizerNe {
+ top: -6px;
+ right: -6px;
+ cursor: ne-resize;
+}
+
+.imageResizer.imageResizerE {
+ bottom: 48%;
+ right: -6px;
+ cursor: e-resize;
+}
+
+.imageResizer.imageResizerSe {
+ bottom: -2px;
+ right: -6px;
+ cursor: nwse-resize;
+}
+
+.imageResizer.imageResizerS {
+ bottom: -2px;
+ left: 48%;
+ cursor: s-resize;
+}
+
+.imageResizer.imageResizerSw {
+ bottom: -2px;
+ left: -6px;
+ cursor: sw-resize;
+}
+
+.imageResizer.imageResizerW {
+ bottom: 48%;
+ left: -6px;
+ cursor: w-resize;
+}
+
+.imageResizer.imageResizerNw {
+ top: -6px;
+ left: -6px;
+ cursor: nw-resize;
+}
\ No newline at end of file
diff --git a/components/form.js b/components/form.js
index 8d81324357..8088877706 100644
--- a/components/form.js
+++ b/components/form.js
@@ -11,7 +11,7 @@ import Row from 'react-bootstrap/Row'
import Markdown from '@/svgs/markdown-line.svg'
import AddFileIcon from '@/svgs/file-upload-line.svg'
import styles from './form.module.css'
-import Text from '@/components/text'
+import { LegacyText } from '@/components/text'
import AddIcon from '@/svgs/add-fill.svg'
import CloseIcon from '@/svgs/close-line.svg'
import { gql, useLazyQuery } from '@apollo/client'
@@ -40,6 +40,7 @@ import dynamic from 'next/dynamic'
import { useIsClient } from './use-client'
import PageLoading from './page-loading'
import { WalletPromptClosed } from '@/wallets/client/hooks'
+import SNEditor from './editor/editor'
export class SessionRequiredError extends Error {
constructor () {
@@ -306,6 +307,14 @@ export function DualAutocompleteWrapper ({
)
}
+export function SNInput ({ label, topLevel, groupClassName, onChange, ...props }) {
+ return (
+
+
+
+ )
+}
+
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props)
@@ -553,7 +562,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
{tab !== 'write' &&
- {meta.value}
+ {meta.value}
}
@@ -1045,7 +1054,7 @@ export function CheckboxGroup ({ label, groupClassName, children, ...props }) {
)
}
-const StorageKeyPrefixContext = createContext()
+export const StorageKeyPrefixContext = createContext()
export function Form ({
initial, validate, schema, onSubmit, children, initialError, validateImmediately,
diff --git a/components/item-full.js b/components/item-full.js
index 8831a6d72e..3ebd38af22 100644
--- a/components/item-full.js
+++ b/components/item-full.js
@@ -2,7 +2,7 @@ import Item from './item'
import ItemJob from './item-job'
import Reply from './reply'
import Comment from './comment'
-import Text, { SearchText } from './text'
+import Text, { LegacyText, SearchText } from './text'
import MediaOrLink from './media-or-link'
import Comments from './comments'
import styles from '@/styles/item.module.css'
@@ -157,7 +157,9 @@ function TopLevelItem ({ item, noReply, ...props }) {
function ItemText ({ item }) {
return item.searchText
?
- : {item.text}
+ : item.lexicalState
+ ?
+ : {item.text}
}
export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) {
diff --git a/components/item-info.js b/components/item-info.js
index c45a8f9cfc..2d0d0df7bc 100644
--- a/components/item-info.js
+++ b/components/item-info.js
@@ -30,6 +30,9 @@ import SubPopover from './sub-popover'
import useCanEdit from './use-can-edit'
import { useRetryPayIn } from './payIn/hooks/use-retry-pay-in'
import { willAutoRetryPayIn } from './payIn/hooks/use-auto-retry-pay-ins'
+// TODO: clean up from dev debugging tools
+import { useMutation } from '@apollo/client'
+import gql from 'graphql-tag'
function itemTitle (item) {
let title = ''
@@ -230,6 +233,15 @@ export default function ItemInfo ({
>}
+ {/* TODO: remove this once we're done debugging */}
+ {/* this is a debug tool for lexical state migration */}
+ {process.env.NODE_ENV === 'development' &&
+ <>
+
+
+
+
+ >}
>
}
@@ -384,3 +396,86 @@ function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, edit
return null
}
+
+function DevCopyMarkdownDropdownItem ({ item }) {
+ const toaster = useToast()
+ return (
+ {
+ try {
+ toaster.success('markdown copied to clipboard')
+ navigator.clipboard.writeText(item.text)
+ } catch (error) {
+ toaster.danger('failed to copy markdown to clipboard')
+ }
+ }}
+ >
+ copy markdown
+
+ )
+}
+
+// TODO: remove this once we're done debugging
+// temporary debugging tool for lexical state migration
+function DevLexicalConversionDropdownItem ({ item }) {
+ const toaster = useToast()
+ const router = useRouter()
+ const isPost = !item.parentId
+ const [shiftHeld, setShiftHeld] = useState(false)
+
+ const [executeConversion] = useMutation(gql`
+ mutation executeConversion($itemId: ID!, $fullRefresh: Boolean!) {
+ executeConversion(itemId: $itemId, fullRefresh: $fullRefresh) {
+ success
+ message
+ }
+ }
+ `, {
+ onCompleted: (data) => {
+ if (data.executeConversion.success) {
+ toaster.success('conversion scheduled, refreshing in 15 seconds...')
+ setTimeout(() => {
+ isPost ? router.push(`/items/${item.id}`) : router.push(`/items/${item.parentId}?commentId=${item.id}`)
+ toaster.success('refreshing now...')
+ }, 15000)
+ } else {
+ toaster.danger(data.executeConversion.message)
+ }
+ }
+ })
+
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.shiftKey) {
+ setShiftHeld(true)
+ }
+ }
+
+ const handleKeyUp = (e) => {
+ if (!e.shiftKey) {
+ setShiftHeld(false)
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ window.addEventListener('keyup', handleKeyUp)
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ window.removeEventListener('keyup', handleKeyUp)
+ }
+ }, [])
+
+ // press shift to force a full refresh
+ const getDropdownText = () => {
+ if (shiftHeld) {
+ return 'FULL REFRESH!'
+ }
+ return !item.lexicalState ? 'convert to lexical' : 'refresh html'
+ }
+
+ return (
+ { executeConversion({ variables: { itemId: item.id, fullRefresh: shiftHeld } }) }}>
+ {getDropdownText()}
+
+ )
+}
diff --git a/components/katex-renderer.js b/components/katex-renderer.js
new file mode 100644
index 0000000000..5805207884
--- /dev/null
+++ b/components/katex-renderer.js
@@ -0,0 +1,44 @@
+import { useEffect, useRef } from 'react'
+import katex from 'katex'
+
+export default function KatexRenderer ({ equation, inline, onClick, onDoubleClick }) {
+ const katexElementRef = useRef(null)
+
+ useEffect(() => {
+ const katexElement = katexElementRef.current
+ if (!katexElement) return
+
+ katex.render(equation, katexElement, {
+ displayMode: !inline,
+ errorColor: '#cc0000',
+ output: 'html',
+ strict: 'warn',
+ throwOnError: false,
+ trust: false
+ })
+ }, [equation, inline])
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/components/notifications.js b/components/notifications.js
index da04139246..ebdde5b310 100644
--- a/components/notifications.js
+++ b/components/notifications.js
@@ -24,7 +24,7 @@ import { Checkbox, Form } from './form'
import { useRouter } from 'next/router'
import { useData } from './use-data'
import { nostrZapDetails } from '@/lib/nostr'
-import Text from './text'
+import { LegacyText } from './text'
import NostrIcon from '@/svgs/nostr.svg'
import { msatsToSats, numWithUnits } from '@/lib/format'
import BountyIcon from '@/svgs/bounty-bag.svg'
@@ -355,7 +355,7 @@ function NostrZap ({ n }) {
)
: 'nostr'}
{timeSince(new Date(n.sortTime))}
- {content && {content}}
+ {content && {content}}
)
}
@@ -391,7 +391,7 @@ function PayInProxyPayment ({ n }) {
{timeSince(new Date(n.sortTime))}
{n.payIn.payerPrivates.payInBolt11.comment &&
- {n.payIn.payerPrivates.payInBolt11.comment.comment}
+ {n.payIn.payerPrivates.payInBolt11.comment.comment}
{payerSig}
}
diff --git a/components/reply.js b/components/reply.js
index 0a551bfc19..ec37a0c3d5 100644
--- a/components/reply.js
+++ b/components/reply.js
@@ -1,4 +1,4 @@
-import { Form, MarkdownInput } from '@/components/form'
+import { Form, SNInput } from '@/components/form'
import styles from './reply.module.css'
import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
@@ -13,6 +13,7 @@ import { injectComment } from '@/lib/comments'
import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag'
import useCommentsView from './use-comments-view'
+import { MAX_COMMENT_TEXT_LENGTH } from '@/lib/constants'
export default forwardRef(function Reply ({
item,
@@ -141,14 +142,13 @@ export default forwardRef(function Reply ({
onSubmit={onSubmit}
storageKeyPrefix={`reply-${parentId}`}
>
-
diff --git a/components/territory-header.js b/components/territory-header.js
index 3b188ac200..1d9e120a87 100644
--- a/components/territory-header.js
+++ b/components/territory-header.js
@@ -3,7 +3,7 @@ import { Badge, Button, CardFooter, Dropdown } from 'react-bootstrap'
import { AccordianCard } from './accordian-item'
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
import Link from 'next/link'
-import Text from './text'
+import { LegacyText } from './text'
import { numWithUnits } from '@/lib/format'
import styles from './item.module.css'
import Badges from './badge'
@@ -60,7 +60,7 @@ export function TerritoryInfo ({ sub, includeLink }) {
<>
{includeLink && {sub.name}}
- {sub.desc}
+ {sub.desc}
diff --git a/components/text.js b/components/text.js
index f4e7397b2c..73922171f3 100644
--- a/components/text.js
+++ b/components/text.js
@@ -21,6 +21,7 @@ import remarkUnicode from '@/lib/remark-unicode'
import Embed from './embed'
import remarkMath from 'remark-math'
import remarkToc from '@/lib/remark-toc'
+import { SNReader } from './editor'
const rehypeSNStyled = () => rehypeSN({
stylers: [{
@@ -52,8 +53,99 @@ export function SearchText ({ text }) {
)
}
+export function useOverflow ({ element, truncated = false }) {
+ // would the text overflow on the current screen size?
+ const [overflowing, setOverflowing] = useState(false)
+ // should we show the full text?
+ const [show, setShow] = useState(false)
+ const showOverflow = useCallback(() => setShow(true), [setShow])
+
+ // clip item and give it a`show full text` button if we are overflowing
+ useEffect(() => {
+ if (!element) return
+
+ const node = 'current' in element ? element.current : element
+ if (!node || !(node instanceof window.Element)) return
+
+ function checkOverflow () {
+ setOverflowing(
+ truncated
+ ? node.scrollHeight > window.innerHeight * 0.5
+ : node.scrollHeight > window.innerHeight * 2
+ )
+ }
+
+ let resizeObserver
+ if ('ResizeObserver' in window) {
+ resizeObserver = new window.ResizeObserver(checkOverflow)
+ resizeObserver.observe(node)
+ }
+
+ window.addEventListener('resize', checkOverflow)
+ checkOverflow()
+ return () => {
+ window.removeEventListener('resize', checkOverflow)
+ resizeObserver?.disconnect()
+ }
+ }, [element, setOverflowing])
+
+ const Overflow = useMemo(() => {
+ if (overflowing && !show) {
+ return (
+
+ )
+ }
+ return null
+ }, [showOverflow, overflowing, show, setShow])
+
+ return { overflowing, show, setShow, Overflow }
+}
+
+// TODO: revisit
+export default function Text ({ markdown, topLevel, rel = UNKNOWN_LINK_REL, children, ...props }) {
+ const [element, setElement] = useState(null)
+ const { overflowing, show, Overflow } = useOverflow({ element, truncated: !!children })
+
+ const textClassNames = useMemo(() => {
+ return classNames(
+ 'sn__text',
+ topLevel && 'sn__topLevel',
+ show ? 'sn__textUncontained' : overflowing && 'sn__textContained'
+ )
+ }, [topLevel, show, overflowing])
+
+ if (children) {
+ return (
+
+ {children}
+ {Overflow}
+
+ )
+ }
+
+ return (
+
+ {Overflow}
+
+ )
+}
+
// this is one of the slowest components to render
-export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
+export const LegacyText = memo(function LegacyText ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
// include remarkToc if topLevel
const remarkPlugins = topLevel ? [...baseRemarkPlugins, remarkToc] : baseRemarkPlugins
diff --git a/fragments/comments.js b/fragments/comments.js
index 72f19a9ce4..135e222192 100644
--- a/fragments/comments.js
+++ b/fragments/comments.js
@@ -20,6 +20,8 @@ export const COMMENT_FIELDS = gql`
createdAt
deletedAt
text
+ lexicalState
+ html
user {
id
name
@@ -73,6 +75,8 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
createdAt
deletedAt
text
+ lexicalState
+ html
user {
id
name
@@ -119,6 +123,8 @@ export const COMMENTS_ITEM_EXT_FIELDS = gql`
${STREAK_FIELDS}
fragment CommentItemExtFields on Item {
text
+ lexicalState
+ html
root {
id
title
diff --git a/fragments/items.js b/fragments/items.js
index a687ee119a..7eebec64ff 100644
--- a/fragments/items.js
+++ b/fragments/items.js
@@ -94,6 +94,8 @@ export const ITEM_FULL_FIELDS = gql`
fragment ItemFullFields on Item {
...ItemFields
text
+ lexicalState
+ html
root {
id
createdAt
diff --git a/lib/dompurify.js b/lib/dompurify.js
new file mode 100644
index 0000000000..6fa7242c75
--- /dev/null
+++ b/lib/dompurify.js
@@ -0,0 +1,69 @@
+/**
+ * creates a fake DOM using LinkeDOM for server-side rendering
+ * @param {string} html - HTML content to parse
+ * @returns {Object} parsed HTML object with window and document
+ */
+export function createLinkeDOM (html) {
+ const { parseHTML } = require('linkedom')
+ return parseHTML(html || '')
+}
+
+/**
+ * returns DOMPurify instance for either browser or server environment
+ * @param {Object} [domWindow] - optional DOM window object (for server-side)
+ * @returns {Object} DOMPurify instance
+ */
+export function getDOMPurify (domWindow) {
+ const DOMPurify = require('dompurify')
+
+ if (typeof window === 'undefined') {
+ if (domWindow) {
+ return DOMPurify(domWindow)
+ }
+ const { window } = createLinkeDOM()
+ return DOMPurify(window)
+ }
+
+ return DOMPurify
+}
+
+/**
+ * sanitizes HTML using DOMPurify with optional custom DOM window
+ * @param {string} html - HTML content to sanitize
+ * @param {Object} [domWindow] - optional DOM window object
+ * @returns {string} sanitized HTML string
+ */
+export function sanitizeHTML (html, domWindow) {
+ return getDOMPurify(domWindow).sanitize(html)
+}
+
+/**
+ * parses and sanitizes HTML, returning a document object
+ * @param {string} html - HTML content to parse and sanitize
+ * @returns {Document} parsed and sanitized document object
+ */
+export function getParsedHTML (html) {
+ if (typeof window === 'undefined') {
+ const normalizedHTML = !html.toLowerCase().startsWith('')
+ ? `
+
+
+ ${html}
+
+ `
+ : html
+ const parsed = createLinkeDOM(normalizedHTML)
+ const domWindow = parsed.window
+
+ // sanitize
+ const sanitizedHTML = getDOMPurify(domWindow).sanitize(html)
+
+ // update the body with sanitized content
+ parsed.document.body.innerHTML = sanitizedHTML
+ return parsed.document
+ }
+
+ // client-side
+ const sanitizedHTML = sanitizeHTML(html)
+ return new window.DOMParser().parseFromString(sanitizedHTML, 'text/html')
+}
diff --git a/lib/lexical/exts/media-check.js b/lib/lexical/exts/media-check.js
new file mode 100644
index 0000000000..c758074296
--- /dev/null
+++ b/lib/lexical/exts/media-check.js
@@ -0,0 +1,163 @@
+import { createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getNodeByKey, $createTextNode } from 'lexical'
+import { $createLinkNode } from '@lexical/link'
+import { mergeRegister } from '@lexical/utils'
+import { MediaNode } from '@/lib/lexical/nodes/content/media'
+import { PUBLIC_MEDIA_CHECK_URL, UNKNOWN_LINK_REL } from '@/lib/constants'
+import { fetchWithTimeout } from '@/lib/fetch'
+
+export const MEDIA_CHECK_COMMAND = createCommand('MEDIA_CHECK_COMMAND')
+
+export const MediaCheckExtension = defineExtension({
+ name: 'MediaCheckExtension',
+ register: (editor) => {
+ const aborters = new Map()
+ const tokens = new Map()
+ const promises = new Map()
+
+ // replaces a media node with a link node
+ const replaceMediaWithLink = (node) => {
+ const url = node.getSrc()
+ const link = $createLinkNode(url, { target: '_blank', rel: UNKNOWN_LINK_REL })
+ link.append($createTextNode(url))
+ node.replace(link)
+ }
+
+ // checks media type and updates node accordingly
+ const checkMediaNode = (nodeKey, url) => {
+ if (promises.has(nodeKey)) {
+ return promises.get(nodeKey)
+ }
+
+ const prev = aborters.get(nodeKey)
+ if (prev) prev.abort()
+
+ const token = (tokens.get(nodeKey) ?? 0) + 1
+ tokens.set(nodeKey, token)
+
+ // set node status to pending while checking
+ editor.update(() => {
+ const node = $getNodeByKey(nodeKey)
+ if (node instanceof MediaNode) node.setStatus('pending')
+ }, { tag: 'history-merge' })
+
+ // create new abort controller for this request
+ const controller = new AbortController()
+ aborters.set(nodeKey, controller)
+
+ const promise = checkMedia(PUBLIC_MEDIA_CHECK_URL, url, { signal: controller.signal })
+ .then((result) => {
+ if (tokens.get(nodeKey) !== token) return
+
+ editor.update(() => {
+ const node = $getNodeByKey(nodeKey)
+ if (!(node instanceof MediaNode)) return
+
+ if (result.type === 'unknown') {
+ replaceMediaWithLink(node)
+ } else {
+ node.applyCheckResult(result.type)
+ }
+ }, { tag: 'history-merge' })
+ return result
+ })
+ .catch((error) => {
+ console.error('media check failed:', error)
+ if (tokens.get(nodeKey) !== token) throw error
+
+ editor.update(() => {
+ const node = $getNodeByKey(nodeKey)
+ if (node instanceof MediaNode) {
+ node.setStatus('error')
+ replaceMediaWithLink(node)
+ }
+ }, { tag: 'history-merge' })
+ })
+ .finally(() => {
+ if (aborters.get(nodeKey) === controller) aborters.delete(nodeKey)
+ promises.delete(nodeKey)
+ })
+
+ promises.set(nodeKey, promise)
+ return promise
+ }
+
+ const unregisterTransforms = mergeRegister(
+ // register command to check media type for a given node
+ editor.registerCommand(MEDIA_CHECK_COMMAND, ({ nodeKey, url }) => {
+ checkMediaNode(nodeKey, url)
+ return true
+ }, COMMAND_PRIORITY_EDITOR),
+ // register transform to automatically check unknown media nodes
+ editor.registerNodeTransform(MediaNode, (node) => {
+ // trigger media check for unknown media nodes that are idle and have a source
+ if (node.getKind() === 'unknown' && node.getStatus() === 'idle' && node.getSrc()) {
+ editor.dispatchCommand(MEDIA_CHECK_COMMAND, { nodeKey: node.getKey(), url: node.getSrc() })
+ }
+ })
+ )
+
+ return () => {
+ unregisterTransforms()
+ aborters.forEach((controller) => controller.abort())
+ aborters.clear()
+ tokens.clear()
+ promises.clear()
+ }
+ }
+})
+
+/**
+ * checks if a URL points to video or image by calling media check endpoint
+ * @param {string} endpoint - media check endpoint URL
+ * @param {string} url - URL to check
+ * @param {Object} [options] - options object
+ * @param {AbortSignal} [options.signal] - abort signal for request cancellation
+ * @returns {Promise
diff --git a/worker/index.js b/worker/index.js
index 4de4a01390..77a1c7da4e 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -41,6 +41,7 @@ import { expireBoost } from './expireBoost'
import { autoDropBolt11s } from './autoDropBolt11'
import { postToSocial } from './socialPoster'
import { untrackOldItems } from './untrackOldItems'
+import { distributedMigration, migratePartition, migrateLegacyContent } from './lexical/migrate'
// WebSocket polyfill
import ws from 'isomorphic-ws'
@@ -129,6 +130,10 @@ async function work () {
await boss.work('imgproxy', jobWrapper(imgproxy))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
}
+ // lexical migration jobs
+ await boss.work('migrateLegacyContent', jobWrapper(migrateLegacyContent))
+ await boss.work('distributedMigration', jobWrapper(distributedMigration))
+ await boss.work('migratePartition', jobWrapper(migratePartition))
await boss.work('expireBoost', jobWrapper(expireBoost))
await boss.work('weeklyPost-*', jobWrapper(weeklyPost))
await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty))
diff --git a/worker/lexical/migrate.js b/worker/lexical/migrate.js
new file mode 100644
index 0000000000..db24668289
--- /dev/null
+++ b/worker/lexical/migrate.js
@@ -0,0 +1,389 @@
+import { prepareLexicalState } from '@/lib/lexical/server/interpolator'
+import { lexicalHTMLGenerator } from '@/lib/lexical/server/html'
+import { Prisma } from '@prisma/client'
+
+const PARTITION_COUNT = 10 // items distributed across n partitions (e.g. 100 items / 10 partitions = 10 items per partition)
+const PARTITION_FETCH_SIZE = 1000 // items fetched per partition batch (e.g. 1000 items per batch)
+const PARTITION_CONCURRENCY = 5 // items processed concurrently per batch (e.g. 5 items per batch)
+const PARTITION_DELAY_MS = 100 // delay between partition batches (e.g. 100ms between batches)
+
+async function addMigrationError ({ itemId, type, message, models }) {
+ await models.lexicalMigrationLog.upsert({
+ where: { itemId },
+ create: { itemId, type, message, retryCount: 1, createdAt: new Date() },
+ update: {
+ message,
+ retryCount: { increment: 1 },
+ updatedAt: new Date()
+ }
+ })
+}
+
+export async function migrateItem ({ itemId, fullRefresh = false, checkMedia = true, models }) {
+ const startTime = Date.now()
+
+ try {
+ const item = await models.item.findUnique({
+ where: { id: itemId },
+ select: {
+ id: true,
+ text: true,
+ lexicalState: true,
+ html: true
+ }
+ })
+
+ if (!item) throw new Error(`item not found: ${itemId}`)
+ if (item.lexicalState && !fullRefresh) {
+ return {
+ success: true,
+ skipped: true,
+ itemId,
+ message: 'already migrated'
+ }
+ }
+
+ if (!item.text || item.text.trim() === '') {
+ return {
+ success: true,
+ skipped: true,
+ itemId,
+ message: 'no text content'
+ }
+ }
+
+ let lexicalState = {}
+ let migrationError = null
+
+ try {
+ lexicalState = await prepareLexicalState({ text: item.text }, { checkMedia })
+
+ if (!lexicalState) throw new Error('prepareLexicalState did not return a valid lexical state')
+ } catch (error) {
+ console.error(`failed to convert markdown for item ${itemId}:`, error)
+ migrationError = error
+ }
+
+ if (!lexicalState || migrationError) {
+ await addMigrationError({
+ itemId,
+ type: 'LEXICAL_CONVERSION',
+ message: migrationError?.message || 'unknown error',
+ models
+ })
+
+ return {
+ success: false,
+ itemId,
+ error: migrationError?.message || 'unknown error'
+ }
+ }
+
+ let html = null
+ let htmlError = null
+
+ try {
+ html = lexicalHTMLGenerator(lexicalState)
+
+ if (!html) {
+ throw new Error('html generation did not produce a valid HTML string')
+ }
+ } catch (error) {
+ console.error(`failed to generate html for item ${itemId}:`, error)
+ htmlError = error
+ }
+
+ if (!html || htmlError) {
+ await addMigrationError({
+ itemId,
+ type: 'HTML_GENERATION',
+ message: htmlError?.message || 'unknown error',
+ models
+ })
+
+ return {
+ success: false,
+ itemId,
+ error: htmlError?.message || 'unknown error'
+ }
+ }
+
+ await models.$transaction(async (tx) => {
+ // using updateMany to prevent errors in race conditions
+ // this way we can use count to check if the item was already migrated
+ const updated = await tx.item.updateMany({
+ where: {
+ id: itemId,
+ // multiple workers may be running this
+ // for idempotency, only update if not already migrated
+ ...(fullRefresh ? {} : { lexicalState: { equals: Prisma.AnyNull } })
+ },
+ data: {
+ lexicalState,
+ html
+ }
+ })
+
+ if (updated.count === 0) {
+ return {
+ success: true,
+ skipped: true,
+ itemId,
+ message: 'already migrated'
+ }
+ }
+
+ await tx.lexicalMigrationLog.deleteMany({ where: { itemId } })
+ })
+
+ const durationMs = Date.now() - startTime
+
+ return {
+ success: true,
+ itemId,
+ message: 'migration successful',
+ durationMs
+ }
+ } catch (error) {
+ console.error(`unexpected error migrating item ${itemId}:`, error)
+ await addMigrationError({
+ itemId,
+ type: 'UNEXPECTED',
+ message: error.message,
+ models
+ })
+
+ return {
+ success: false,
+ itemId,
+ error: error.message
+ }
+ }
+}
+
+export async function migrateLegacyContent ({ data, models }) {
+ const { itemId, fullRefresh = false, checkMedia = true } = data
+
+ console.log(`[migrateLegacyContent] starting migration for item ${itemId}`)
+
+ const result = await migrateItem({
+ itemId,
+ fullRefresh,
+ checkMedia,
+ models
+ })
+
+ if (!result.success) {
+ // throw error to trigger pgboss retry mechanism
+ throw new Error(result.error || 'migration failed')
+ }
+
+ return result
+}
+
+/** get summary from migration result */
+function getSummary (result) {
+ return {
+ totalProcessed: result.successCount + result.failureCount,
+ successCount: result.successCount,
+ failureCount: result.failureCount,
+ failures: result.failures,
+ durationMs: Date.now() - result.startTime
+ }
+}
+
+function logSuccess (result) {
+ return { ...result, successCount: result.successCount + 1 }
+}
+
+function logFailure (result, itemId, error) {
+ return {
+ ...result,
+ failureCount: result.failureCount + 1,
+ failures: [...result.failures, {
+ itemId,
+ error: error.message || error,
+ timestamp: new Date().toISOString()
+ }]
+ }
+}
+
+export async function distributedMigration ({ boss, models, data }) {
+ const { totalPartitions = PARTITION_COUNT, checkMedia = false } = data || {}
+ console.log('[distributedMigration] analyzing items for migration...')
+
+ const stats = await models.$queryRaw`
+ SELECT
+ MIN(id) as min_id,
+ MAX(id) as max_id,
+ COUNT(*) as total
+ FROM "Item"
+ WHERE "lexicalState" IS NULL
+ AND text IS NOT NULL
+ AND TRIM(text) != ''
+ `
+
+ const minId = Number(stats[0].min_id)
+ const maxId = Number(stats[0].max_id)
+ const total = Number(stats[0].total)
+
+ if (!total || total === 0) {
+ console.log('[distributedMigration] no items to migrate')
+ return { success: true, message: 'no items to migrate', totalPartitions: 0 }
+ }
+
+ const idRange = Math.ceil((maxId - minId) / totalPartitions)
+ const itemsPerPartition = Math.ceil(total / totalPartitions)
+
+ console.log(`[distributedMigration] found ${total} items to migrate in ${totalPartitions} partitions`)
+ console.log(`[distributedMigration] range per partition: ${idRange}, each partition will process ${itemsPerPartition} items`)
+
+ // now we create many small partition jobs, we may have 3 workers that pgboss can distribute to
+ const jobs = []
+ for (let i = 0; i < totalPartitions; i++) {
+ const fromId = minId + (i * idRange)
+ const toId = minId + ((i + 1) * idRange)
+ jobs.push(
+ boss.send(
+ 'migratePartition',
+ { fromId, toId, partition: i + 1, totalPartitions, checkMedia },
+ {
+ singletonKey: `migrate-partition-${i}`,
+ retryLimit: 3,
+ retryDelay: 60,
+ retryBackoff: true
+ }
+ )
+ )
+ }
+
+ await Promise.all(jobs)
+ console.log(`[distributedMigration] ${totalPartitions} partitions scheduled`)
+
+ return {
+ success: true,
+ totalPartitions,
+ totalItems: Number(total),
+ itemsPerPartition,
+ idRange: { min: Number(minId), max: Number(maxId) }
+ }
+}
+
+export async function migratePartition ({ models, data }) {
+ const { fromId, toId, partition, totalPartitions, checkMedia = false } = data
+
+ console.log(`[partition ${partition}/${totalPartitions}] processing range ${fromId}-${toId}...`)
+
+ let result = {
+ partition,
+ totalPartitions,
+ fromId,
+ toId,
+ successCount: 0,
+ failureCount: 0,
+ failures: [],
+ startTime: Date.now()
+ }
+
+ let lastId = fromId - 1
+ let batchNumber = 0
+
+ try {
+ while (true) {
+ batchNumber++
+
+ const items = await models.$queryRaw`
+ SELECT id, text
+ FROM "Item"
+ WHERE id > ${lastId}
+ AND id <= ${toId}
+ AND text IS NOT NULL AND TRIM(text) != ''
+ AND "lexicalState" IS NULL
+ ORDER BY id ASC
+ LIMIT ${PARTITION_FETCH_SIZE}
+ `
+
+ if (items.length === 0) {
+ console.log(`[partition ${partition}/${totalPartitions}] completed (${batchNumber} batches)`)
+ break
+ }
+
+ console.log(`[partition ${partition}/${totalPartitions}] processing batch ${batchNumber} containing ${items.length} items`)
+
+ result = await processPartitionBatch(items, models, result, checkMedia)
+
+ lastId = items[items.length - 1].id
+
+ await new Promise(resolve => setTimeout(resolve, PARTITION_DELAY_MS))
+ }
+
+ const summary = getSummary(result)
+ await models.lexicalBatchMigrationLog.create({
+ data: {
+ successCount: summary.successCount,
+ failureCount: summary.failureCount,
+ durationMs: summary.durationMs,
+ summary: JSON.stringify({
+ ...summary,
+ partition,
+ totalPartitions,
+ fromId,
+ toId,
+ batches: batchNumber
+ })
+ }
+ })
+
+ console.log(
+ `[partition ${partition}/${totalPartitions}] completed:`,
+ `${summary.successCount} successes, ${summary.failureCount} failures`,
+ `(${summary.durationMs}ms total)`
+ )
+
+ return summary
+ } catch (error) {
+ console.error(`[partition ${partition}/${totalPartitions}] unexpected error:`, error)
+ throw error
+ }
+}
+
+async function processPartitionBatch (items, models, result, checkMedia) {
+ let currentResult = result
+
+ for (let i = 0; i < items.length; i += PARTITION_CONCURRENCY) {
+ const chunk = items.slice(i, i + PARTITION_CONCURRENCY)
+
+ const chunkResults = await Promise.all(
+ chunk.map(async (item) => {
+ try {
+ const migrationResult = await migrateItem({
+ itemId: item.id,
+ fullRefresh: false,
+ checkMedia,
+ models
+ })
+
+ if (migrationResult.success && !migrationResult.skipped) {
+ return { success: true, itemId: item.id }
+ } else if (!migrationResult.success) {
+ return { success: false, itemId: item.id, error: migrationResult.error }
+ }
+ return { skipped: true }
+ } catch (error) {
+ console.error(`batch migration error for item ${item.id}:`, error)
+ return { success: false, itemId: item.id, error }
+ }
+ })
+ )
+
+ for (const chunkResult of chunkResults) {
+ if (chunkResult.success) {
+ currentResult = logSuccess(currentResult)
+ } else if (chunkResult.success === false) {
+ currentResult = logFailure(currentResult, chunkResult.itemId, chunkResult.error)
+ }
+ }
+ }
+
+ return currentResult
+}