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![Uploading ${file.name}…](${identifier})`) + 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(`![Uploading ${name}…](${identifier})`, `![](${url})`) + $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(`![Uploading ${file.name}…](${identifier})`, '') + $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} object with type property ('video', 'image', or 'unknown') + */ +export async function checkMedia (endpoint, url, { signal } = {}) { + try { + const res = await fetchWithTimeout(`${endpoint}/${encodeURIComponent(url)}`, { signal, timeout: 10000 }) + if (!res.ok) throw new Error('failed to check media') + const json = await res.json() + console.log('media check response:', json) + if (!json || (json.isVideo === undefined || json.isImage === undefined)) throw new Error('invalid media check response') + // the fetch would return mime, isVideo, isImage + const type = json.isVideo ? 'video' : json.isImage ? 'image' : 'unknown' + return { type } + } catch (error) { + console.error('error checking media', error) + return { type: 'unknown' } + } +} + +/** + * checks multiple URLs concurrently with configurable concurrency limit + * @param {string[]} urls - array of URLs to check + * @param {Object} [options] - options object + * @param {number} [options.concurrency=8] - maximum number of concurrent requests + * @param {AbortSignal} [options.signal] - abort signal for request cancellation + * @returns {Promise} map of URL to check result objects + */ +export async function batchedCheckMedia (urls, { concurrency = 8, signal } = {}) { + console.log('batchedCheckMedia urls:', urls) + const queue = Array.from(new Set(urls)).filter(Boolean) + const results = new Map() + + async function worker () { + while (queue.length > 0) { + const url = queue.shift() + if (!url) break + + try { + const result = await checkMedia(process.env.MEDIA_CHECK_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_CHECK_URL, url, { signal }) + results.set(url, result) + } catch (error) { + console.error('error checking media', error) + results.set(url, { type: 'unknown' }) + } + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker)) + return results +} diff --git a/lib/lexical/exts/shiki.js b/lib/lexical/exts/shiki.js new file mode 100644 index 0000000000..2ab616e6ef --- /dev/null +++ b/lib/lexical/exts/shiki.js @@ -0,0 +1,29 @@ +import { $nodesOfType } from 'lexical' +import { defineExtension } from '@lexical/extension' +import { registerCodeHighlighting, ShikiTokenizer } from '@lexical/code-shiki' +import { CodeExtension, CodeNode } from '@lexical/code' + +export const CodeShikiSNExtension = defineExtension({ + name: 'CodeShikiSNExtension', + config: { tokenizer: { ...ShikiTokenizer, defaultLanguage: 'text', defaultTheme: 'github-dark-default' } }, + dependencies: [CodeExtension], + register: (editor, { tokenizer }) => { + const cleanup = registerCodeHighlighting(editor, tokenizer) + + editor._updateCodeTheme = (newTheme) => { + // remove previous registration + cleanup() + // set theme on all code nodes + editor.update(() => { + $nodesOfType(CodeNode).forEach(node => node.setTheme(newTheme)) + }) + + return registerCodeHighlighting(editor, { ...tokenizer, defaultTheme: newTheme }) + } + + return () => { + cleanup() + editor._updateCodeTheme = null + } + } +}) diff --git a/lib/lexical/html/customs/imgproxy.js b/lib/lexical/html/customs/imgproxy.js new file mode 100644 index 0000000000..842e50ba0c --- /dev/null +++ b/lib/lexical/html/customs/imgproxy.js @@ -0,0 +1,44 @@ +/** + * adds srcset attributes to images and poster to videos using imgproxy URLs + * @param {Document} doc - document object + * @param {Object} imgproxyUrls - map of original URLs to imgproxy URL sets + * @param {boolean} topLevel - whether content is top-level (affects sizes attribute) + */ +export function addSrcSetToMediaAndVideoNodes (doc, imgproxyUrls, topLevel) { + const body = doc.body + + body.querySelectorAll('img[src]').forEach(img => { + const src = img.getAttribute('src') + const srcSetInitial = imgproxyUrls?.[src] + const { dimensions, video, format, ...srcSetObj } = srcSetInitial || {} + if (srcSetObj && Object.keys(srcSetObj).length > 0) { + // srcSetObj shape: { [widthDescriptor]: , ... } + const srcSet = Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => { + // backwards compatibility: we used to replace image urls with imgproxy urls rather just storing paths + if (!url.startsWith('http')) { + url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString() + } + return acc + `${url} ${wDescriptor}` + (i < arr.length - 1 ? ', ' : '') + }, '') + img.setAttribute('srcset', srcSet) + img.setAttribute('sizes', topLevel ? '100vw' : '66vw') + } + }) + + body.querySelectorAll('video[src]').forEach(videoEl => { + const src = videoEl.getAttribute('src') + const srcSetInitial = imgproxyUrls?.[src] + const { dimensions, video, format, ...srcSetObj } = srcSetInitial || {} + if (srcSetObj && Object.keys(srcSetObj).length > 0) { + const bestResSrc = Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => { + if (!url.startsWith('http')) { + url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString() + } + const w = Number(wDescriptor.replace(/w$/, '')) + return w > acc.w ? { w, url } : acc + }, { w: 0, url: undefined }).url + videoEl.setAttribute('poster', bestResSrc !== src ? bestResSrc : undefined) + videoEl.setAttribute('preload', bestResSrc !== src ? 'metadata' : undefined) + } + }) +} diff --git a/lib/lexical/html/customs/index.js b/lib/lexical/html/customs/index.js new file mode 100644 index 0000000000..ce211529f4 --- /dev/null +++ b/lib/lexical/html/customs/index.js @@ -0,0 +1,27 @@ +import { getParsedHTML } from '@/lib/dompurify' +import { applySNOutlawedCustomizations } from '@/lib/lexical/html/customs/outlawed' +import { addSrcSetToMediaAndVideoNodes } from '@/lib/lexical/html/customs/imgproxy' + +export function applySNCustomizations (html, options = {}) { + if (!html) return null + // if no options are provided, return html as-is + if (!options || Object.keys(options).length === 0) { + return html + } + + const { outlawed = false, imgproxyUrls = {}, topLevel = false } = options + + try { + const doc = getParsedHTML(html) + if (outlawed) { + applySNOutlawedCustomizations(doc) + } + if (imgproxyUrls) { + addSrcSetToMediaAndVideoNodes(doc, imgproxyUrls, topLevel) + } + return doc.body.innerHTML + } catch (error) { + console.error('error applying SN customizations: ', error) + return html + } +} diff --git a/lib/lexical/html/customs/outlawed.js b/lib/lexical/html/customs/outlawed.js new file mode 100644 index 0000000000..cf5573a68d --- /dev/null +++ b/lib/lexical/html/customs/outlawed.js @@ -0,0 +1,64 @@ +/** + * replaces media (img, video, iframe, embed) with plain text + * @param {Document} doc - document object + */ +function replaceMedia (doc) { + const body = doc.body + body.querySelectorAll('span.sn__mediaContainer').forEach(mediaContainer => { + const media = mediaContainer.querySelector('img,video,iframe,embed') + const src = media?.getAttribute('src') || '' + const p = doc.createElement('p') + p.className = 'sn__paragraph outlawed' + p.textContent = src + + // replace the mediaContainer with the new paragraph + const parentParagraph = mediaContainer.closest('.sn__paragraph') + if (parentParagraph) { + parentParagraph.replaceWith(p) + } else { + mediaContainer.replaceWith(p) + } + }) +} + +/** + * replaces embeds with plain text + * @param {Document} doc - document object + */ +function replaceEmbeds (doc) { + const body = doc.body + body.querySelectorAll('div.sn__embedWrapper__explainer').forEach(embed => { + const src = embed.getAttribute('data-lexical-embed-src') || '' + const p = doc.createElement('p') + p.className = 'sn__paragraph outlawed' + p.textContent = src + const parentParagraph = embed.closest('.sn__paragraph') + if (parentParagraph) { + parentParagraph.replaceWith(p) + } else { + embed.replaceWith(p) + } + }) +} + +/** + * replaces links with plain text + * @param {Document} doc - document object + */ +function replaceLinks (doc) { + const body = doc.body + body.querySelectorAll('a[href]').forEach(link => { + const href = link.getAttribute('href') || '' + link.replaceWith(doc.createTextNode(href)) + }) +} + +/** + * applies all 'outlawed' transformations to document + * @param {Document} doc - document object to transform + */ +export function applySNOutlawedCustomizations (doc) { + replaceMedia(doc) + replaceEmbeds(doc) + replaceLinks(doc) +} diff --git a/lib/lexical/mdast/FormatConstants.js b/lib/lexical/mdast/FormatConstants.js new file mode 100644 index 0000000000..d1cd33e158 --- /dev/null +++ b/lib/lexical/mdast/FormatConstants.js @@ -0,0 +1,10 @@ +// text node formatting: bitwise flags matching lexical's internal format +export const DEFAULT_FORMAT = 0 +export const IS_BOLD = 0b1 +export const IS_ITALIC = 0b10 +export const IS_STRIKETHROUGH = 0b100 +export const IS_UNDERLINE = 0b1000 +export const IS_CODE = 0b10000 +export const IS_SUBSCRIPT = 0b100000 +export const IS_SUPERSCRIPT = 0b1000000 +export const IS_HIGHLIGHT = 0b10000000 diff --git a/lib/lexical/mdast/README.md b/lib/lexical/mdast/README.md new file mode 100644 index 0000000000..49691761ce --- /dev/null +++ b/lib/lexical/mdast/README.md @@ -0,0 +1,430 @@ +# mdast4lexical + +mdast-based markdown transformation for lexical + +## bi-directional architecture + +``` +markdown string + │ + ▼ (micromark + mdast-util-from-markdown) +mdast tree + │ + ▼ (mdast transforms) +mdast tree (transformed) + │ + ▼ (import visitors) +lexical nodes + │ + ▼ (export visitors) +mdast tree + │ + ▼ (mdast-util-to-markdown) +markdown string +``` + +## how to handle markdown transformations + +```javascript +const mentionTransform = createMentionTransform() + +function setMarkdown (editor, markdown) { + editor.update(() => { + const root = $getRoot() + root.clear() + + importMarkdownToLexical({ + root, + markdown, + visitors: importVisitors, + syntaxExtensions: [], // micromark extensions + mdastExtensions: [], // mdast-util extensions + mdastTransforms: [ // tree transforms (that run after parsing) + mentionTransform + ] + }) + }) +} + +function getMarkdown (editor) { + return editor.getEditorState().read(() => { + return exportMarkdownFromLexical({ + root: $getRoot(), + visitors: exportVisitors, + toMarkdownExtensions: [], + toMarkdownOptions: {} + }) + }) +} +``` + +## visitors + +visitors handle translations between mdast nodes and lexical nodes. +each feature has two visitors: + +- **import visitor**: mdast → lexical +- **export visitor**: lexical → mdast + +### import visitors (mdast → lexical) + +```javascript +import { $createHeadingNode } from '@lexical/rich-text' + +export const MdastHeadingVisitor = { + // match mdast nodes by type string + testNode: 'heading', + + // optional priority (higher = checked first, default = 0) + priority: 0, + + visitNode ({ mdastNode, lexicalParent, actions }) { + const tag = `h${mdastNode.depth}` + actions.addAndStepInto($createHeadingNode(tag)) + } +} +``` + +#### testNode options + +```javascript +// match by type string +testNode: 'heading' + +// or match by function for precise detection +testNode: (node) => node.type === 'html' && node.value === tag +``` + +#### actions available in visitNode + +| action | description | +|--------|-------------| +| `actions.addAndStepInto(lexicalNode)` | append node to parent, then recursively visit mdast children | +| `actions.visitChildren(mdastNode, lexicalParent)` | manually visit children | +| `actions.nextVisitor()` | skip this visitor, try the next matching one | +| `actions.addFormatting(format)` | add text formatting (bold, italic, etc.) | +| `actions.getParentFormatting()` | get inherited formatting from parent | + +#### element nodes vs decorator nodes + +**element nodes** (have children): use `actions.addAndStepInto()` +```javascript +// paragraph, heading, list, quote, etc. +visitNode ({ mdastNode, actions }) { + actions.addAndStepInto($createParagraphNode()) +} +``` + +**decorator nodes** (leaf nodes, no children): append directly +```javascript +// embed, media, mentions, etc. +visitNode ({ mdastNode, lexicalParent }) { + const node = $createEmbedNode(...) + lexicalParent.append(node) +} +``` + +#### priority and nextVisitor + +when multiple visitors match the same mdast type, we can use priority to control order: + +```javascript +// high priority: check if link is an embed first +export const MdastEmbedFromLinkVisitor = { + testNode: 'link', + priority: 15, + visitNode ({ mdastNode, lexicalParent, actions }) { + if (!isBareLink(mdastNode)) { + actions.nextVisitor() // not a raw link, try next visitor + return + } + + const embed = getEmbed(mdastNode.url) + if (embed.provider) { + const node = $createEmbedNode(...) + lexicalParent.append(node) + return + } + + actions.nextVisitor() // not an embed, try next visitor + } +} + +// default priority: fallback to regular link +export const MdastLinkVisitor = { + testNode: 'link', + // priority: 0 (default) + visitNode ({ mdastNode, actions }) { + actions.addAndStepInto($createLinkNode(mdastNode.url)) + } +} +``` + +### export visitors (lexical → mdast) + +```javascript +import { $isHeadingNode } from '@lexical/rich-text' + +export const LexicalHeadingVisitor = { + // match lexical nodes by predicate function + testLexicalNode: $isHeadingNode, + + visitLexicalNode ({ lexicalNode, actions }) { + const depth = parseInt(lexicalNode.getTag().slice(1)) + actions.addAndStepInto('heading', { depth }) + } +} +``` + +#### actions available in visitLexicalNode + +| action | description | +|--------|-------------| +| `actions.addAndStepInto(type, props)` | create mdast node, append to parent, visit children | +| `actions.appendToParent(mdastParent, node)` | append mdast node directly (for leaf nodes) | + +#### export examples + +**element nodes**: use `addAndStepInto` +```javascript +export const LexicalParagraphVisitor = { + testLexicalNode: $isParagraphNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('paragraph') + } +} +``` + +**leaf/decorator nodes**: use `appendToParent` +```javascript +export const LexicalEmbedVisitor = { + testLexicalNode: $isEmbedNode, + visitLexicalNode ({ lexicalNode, mdastParent, actions }) { + // output plain text url + actions.appendToParent(mdastParent, { + type: 'text', + value: lexicalNode.getSrc() + }) + } +} +``` + +## mdast transforms + +transforms modify the mdast tree after parsing but before visitor traversal. useful for: +- custom syntax that conflicts with standard markdown (like `~territory` vs `~strikethrough~`) +- post-processing parsed content +- injecting custom node types + +### creating a transform with Unist + +**WIP: this might not be the best way to present a mentionTransform** + +```javascript +import { visit, SKIP } from 'unist-util-visit' + +export function createMentionTransform () { + return function mentionTransform (tree) { + visit(tree, 'text', (node, index, parent) => { + // skip text inside code blocks + if (parent?.type === 'code' || parent?.type === 'inlineCode') { + return SKIP + } + + // find mentions in text + const parts = node.value.split(/(@\w+)/g) + if (parts.length === 1) return + + // replace text node with multiple nodes + const newNodes = parts.filter(Boolean).map(part => { + if (part.startsWith('@')) { + return { type: 'userMention', value: { name: part.slice(1) } } + } + return { type: 'text', value: part } + }) + + parent.children.splice(index, 1, ...newNodes) + return SKIP // don't revisit inserted nodes + }) + } +} +``` + +### using transforms + +**WIP: this might not be the best way to use mentionTransform** + +```javascript +import { createMentionTransform } from './transforms/mentions' + +const mentionTransform = createMentionTransform() + +importMarkdownToLexical({ + root, + markdown, + visitors: importVisitors, + mdastTransforms: [mentionTransform] +}) +``` + +### why unist transforms instead of micromark extensions + +| approach | pros | cons | +|----------|------|------| +| mdast transform | simple regex, no conflicts, easy to test | extra tree pass | +| micromark extension | single parse pass, context-aware | complex state machine, can conflict | + +as said before, we can use transforms for custom syntax that might conflict with standard markdown (like `~territory` vs `~strikethrough~`). + +## adding new features + +### example: adding table support + +1. create `visitors/table.js`: + +```javascript +import { $createTableNode, $isTableNode } from '@lexical/table' +import { $createTableRowNode, $isTableRowNode } from '@lexical/table' +import { $createTableCellNode, $isTableCellNode } from '@lexical/table' + +// mdast → lexical +export const MdastTableVisitor = { + testNode: 'table', + visitNode ({ actions }) { + actions.addAndStepInto($createTableNode()) + } +} + +export const MdastTableRowVisitor = { + testNode: 'tableRow', + visitNode ({ actions }) { + actions.addAndStepInto($createTableRowNode()) + } +} + +export const MdastTableCellVisitor = { + testNode: 'tableCell', + visitNode ({ actions }) { + actions.addAndStepInto($createTableCellNode()) + } +} + +// lexical → mdast +export const LexicalTableVisitor = { + testLexicalNode: $isTableNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('table') + } +} + +export const LexicalTableRowVisitor = { + testLexicalNode: $isTableRowNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('tableRow') + } +} + +export const LexicalTableCellVisitor = { + testLexicalNode: $isTableCellNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('tableCell') + } +} +``` + +2. add to `visitors/index.js`: + +```javascript +import { + MdastTableVisitor, + MdastTableRowVisitor, + MdastTableCellVisitor, + LexicalTableVisitor, + LexicalTableRowVisitor, + LexicalTableCellVisitor +} from './table.js' + +export const importVisitors = [ + // ... existing + MdastTableVisitor, + MdastTableRowVisitor, + MdastTableCellVisitor +] + +export const exportVisitors = [ + // ... existing + LexicalTableVisitor, + LexicalTableRowVisitor, + LexicalTableCellVisitor +] +``` + +3. add micromark/mdast extensions for gfm tables: + +```javascript +import { gfmTable } from 'micromark-extension-gfm-table' +import { gfmTableFromMarkdown } from 'mdast-util-gfm-table' + +importMarkdownToLexical({ + // ... + syntaxExtensions: [gfmTable()], + mdastExtensions: [gfmTableFromMarkdown()] +}) +``` + +## file structure + +``` +lib/lexical/mdast/ + index.js # main exports + importMarkdownToLexical.js # mdast → lexical core + exportMarkdownFromLexical.js # lexical → mdast core + FormatConstants.js # text format flags (bold, italic, etc.) + visitors/ + index.js # visitor arrays + root.js # root visitors + paragraph.js # paragraph visitors + text.js # text + formatting visitors + linebreak.js # linebreak visitors + formatting.js # bold, italic, strikethrough, etc. + heading.js # heading visitors + link.js # link, embed, media, image visitors + quote.js # blockquote visitors + list.js # list + list item visitors + code.js # code block visitors + horizontal-rule.js # horizontal rule visitors + mentions.js # user, territory, item mention visitors + transforms/ + index.js # transform exports + mentions.js # @user and ~territory transform +``` + +## some debugging + +### log the mdast tree + +```javascript +importMarkdownToLexical({ + // ... + mdastTransforms: [ + (tree) => console.log('mdast:', JSON.stringify(tree, null, 2)) + ] +}) +``` + +### handle unknown node types + +if you see `UnrecognizedMarkdownConstructError`, add a visitor for that mdast type: + +```javascript +// check what type is failing +mdastTransforms: [ + (tree) => { + visit(tree, (node) => console.log(node.type)) + } +] +``` + +## infos + +MIT - this system is based on code from MDXEditor diff --git a/lib/lexical/mdast/exportMarkdownFromLexical.js b/lib/lexical/mdast/exportMarkdownFromLexical.js new file mode 100644 index 0000000000..b493212a57 --- /dev/null +++ b/lib/lexical/mdast/exportMarkdownFromLexical.js @@ -0,0 +1,153 @@ +import { $isElementNode } from 'lexical' +import { toMarkdown } from 'mdast-util-to-markdown' + +function isParent (node) { + return node.children instanceof Array +} + +// convert a lexical tree to an mdast tree +export function exportLexicalTreeToMdast ({ + root, + visitors +}) { + let unistRoot = null + + visitors = visitors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + + visit(root, null) + + function appendToParent (parentNode, node) { + if (unistRoot === null) { + unistRoot = node + return unistRoot + } + + if (!isParent(parentNode)) { + throw new Error('Attempting to append children to a non-parent') + } + + const siblings = parentNode.children + const prevSibling = siblings.at(-1) + + if (prevSibling) { + const joinVisitor = visitors.find((visitor) => visitor.shouldJoin?.(prevSibling, node)) + if (joinVisitor) { + const joinedNode = joinVisitor.join(prevSibling, node) + siblings.splice(siblings.length - 1, 1, joinedNode) + return joinedNode + } + } + + siblings.push(node) + return node + } + + function visitChildren (lexicalNode, parentNode) { + lexicalNode.getChildren().forEach((lexicalChild) => { + visit(lexicalChild, parentNode) + }) + } + + function visit (lexicalNode, mdastParent, usedVisitors = null) { + const visitor = visitors.find((visitor, index) => { + if (usedVisitors?.has(index)) { + return false + } + return visitor.testLexicalNode?.(lexicalNode) + }) + + if (!visitor) { + throw new Error(`no lexical visitor found for ${lexicalNode.getType()}`, { + cause: lexicalNode + }) + } + + visitor.visitLexicalNode?.({ + lexicalNode, + mdastParent, + actions: { + addAndStepInto (type, props = {}, hasChildren = true) { + const newNode = { + type, + ...props, + ...(hasChildren ? { children: [] } : {}) + } + appendToParent(mdastParent, newNode) + if ($isElementNode(lexicalNode) && hasChildren) { + visitChildren(lexicalNode, newNode) + } + }, + appendToParent, + visitChildren, + visit, + nextVisitor () { + visit(lexicalNode, mdastParent, (usedVisitors ?? new Set()).add(visitors.indexOf(visitor))) + } + } + }) + } + + if (unistRoot === null) { + throw new Error('traversal ended with no root element') + } + + fixWrappingWhitespace(unistRoot, []) + + return unistRoot +} + +const TRAILING_WHITESPACE_REGEXP = /\s+$/ +const LEADING_WHITESPACE_REGEXP = /^\s+/ + +function fixWrappingWhitespace (node, parentChain) { + if (node.type === 'strong' || node.type === 'emphasis') { + const lastChild = node.children.at(-1) + if (lastChild?.type === 'text') { + const trailingWhitespace = lastChild.value.match(TRAILING_WHITESPACE_REGEXP) + if (trailingWhitespace) { + lastChild.value = lastChild.value.replace(TRAILING_WHITESPACE_REGEXP, '') + const parent = parentChain.at(-1) + if (parent) { + parent.children.splice(parent.children.indexOf(node) + 1, 0, { + type: 'text', + value: trailingWhitespace[0] + }) + fixWrappingWhitespace(parent, parentChain.slice(0, -1)) + } + } + } + const firstChild = node.children.at(0) + if (firstChild?.type === 'text') { + const leadingWhitespace = firstChild.value.match(LEADING_WHITESPACE_REGEXP) + if (leadingWhitespace) { + firstChild.value = firstChild.value.replace(LEADING_WHITESPACE_REGEXP, '') + const parent = parentChain.at(-1) + if (parent) { + parent.children.splice(parent.children.indexOf(node), 0, { + type: 'text', + value: leadingWhitespace[0] + }) + fixWrappingWhitespace(parent, parentChain.slice(0, -1)) + } + } + } + } + if ('children' in node && node.children.length > 0) { + node.children.forEach((child) => { + fixWrappingWhitespace(child, [...parentChain, node]) + }) + } +} + +// convert a lexical tree to a markdown string +export function exportMarkdownFromLexical ({ + root, + toMarkdownOptions = {}, + toMarkdownExtensions = [], + visitors +}) { + return toMarkdown(exportLexicalTreeToMdast({ root, visitors }), { + extensions: toMarkdownExtensions, + ...toMarkdownOptions + }) +} diff --git a/lib/lexical/mdast/importMarkdownToLexical.js b/lib/lexical/mdast/importMarkdownToLexical.js new file mode 100644 index 0000000000..2c1f1598f9 --- /dev/null +++ b/lib/lexical/mdast/importMarkdownToLexical.js @@ -0,0 +1,196 @@ +import { fromMarkdown } from 'mdast-util-from-markdown' +import { toMarkdown } from 'mdast-util-to-markdown' + +function isParent (node) { + return node.children instanceof Array +} + +export class MarkdownParseError extends Error { + constructor (message, cause) { + super(message) + this.name = 'MarkdownParseError' + this.cause = cause + } +} + +export class UnrecognizedMarkdownConstructError extends Error { + constructor (message) { + super(message) + this.name = 'UnrecognizedMarkdownConstructError' + } +} + +function gatherMetadata (mdastNode) { + const importsMap = new Map() + if (mdastNode.type !== 'root') { + return { importDeclarations: {} } + } + + const importStatements = mdastNode.children + .filter((n) => n.type === 'mdxjsEsm') + .filter((n) => n.value.startsWith('import ')) + + importStatements.forEach((imp) => { + ;(imp.data?.estree?.body ?? []).forEach((declaration) => { + if (declaration.type !== 'ImportDeclaration') { + return + } + declaration.specifiers.forEach((specifier) => { + importsMap.set(specifier.local.name, { + source: `${declaration.source.value}`, + defaultExport: specifier.type === 'ImportDefaultSpecifier' + }) + }) + }) + }) + + return { importDeclarations: Object.fromEntries(importsMap.entries()) } +} + +// parse markdown string and import into a lexical tree +export function importMarkdownToLexical ({ + root, + markdown, + visitors, + syntaxExtensions, + mdastExtensions, + mdastTransforms = [], + ...descriptors +}) { + let mdastRoot + + try { + mdastRoot = fromMarkdown(markdown, { + extensions: syntaxExtensions, + mdastExtensions + }) + } catch (e) { + if (e instanceof Error) { + throw new MarkdownParseError(`Error parsing markdown: ${e.message}`, e) + } else { + throw new MarkdownParseError(`Error parsing markdown: ${e}`, e) + } + } + + // apply mdast transforms (e.g., mention detection) + for (const transform of mdastTransforms) { + transform(mdastRoot) + } + + if (mdastRoot.children.length === 0) { + mdastRoot.children.push({ type: 'paragraph', children: [] }) + } + + if (mdastRoot.children.at(-1)?.type !== 'paragraph') { + mdastRoot.children.push({ type: 'paragraph', children: [] }) + } + + console.log('mdastRoot', mdastRoot) + + importMdastTreeToLexical({ root, mdastRoot, visitors, ...descriptors }) +} + +// import an existing mdast tree into a lexical tree +export function importMdastTreeToLexical ({ + root, + mdastRoot, + visitors, + ...descriptors +}) { + const formattingMap = new WeakMap() + const styleMap = new WeakMap() + const metaData = gatherMetadata(mdastRoot) + visitors = visitors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + + function visitChildren (mdastNode, lexicalParent) { + if (!isParent(mdastNode)) { + throw new Error('Attempting to visit children of a non-parent') + } + mdastNode.children.forEach((child) => { + visit(child, lexicalParent, mdastNode) + }) + } + + function visit (mdastNode, lexicalParent, mdastParent, skipVisitors = null) { + const visitor = visitors.find((visitor, index) => { + if (skipVisitors?.has(index)) { + return false + } + if (typeof visitor.testNode === 'string') { + return visitor.testNode === mdastNode.type + } + return visitor.testNode(mdastNode, descriptors) + }) + + if (!visitor) { + try { + throw new UnrecognizedMarkdownConstructError(`Unsupported markdown syntax: ${toMarkdown(mdastNode)}`) + } catch { + throw new UnrecognizedMarkdownConstructError( + `Parsing of the following markdown structure failed: ${JSON.stringify({ + type: mdastNode.type, + name: 'name' in mdastNode ? mdastNode.name : 'N/A' + })}` + ) + } + } + + visitor.visitNode({ + mdastNode, + lexicalParent, + mdastParent, + descriptors, + metaData, + actions: { + visitChildren, + nextVisitor () { + visit(mdastNode, lexicalParent, mdastParent, (skipVisitors ?? new Set()).add(visitors.indexOf(visitor))) + }, + addAndStepInto (lexicalNode) { + lexicalParent.append(lexicalNode) + if (isParent(mdastNode)) { + visitChildren(mdastNode, lexicalNode) + } + }, + addFormatting (format, node) { + if (!node) { + if (isParent(mdastNode)) { + node = mdastNode + } + } + if (node) { + formattingMap.set(node, format | (formattingMap.get(mdastParent) ?? 0)) + } + }, + removeFormatting (format, node) { + if (!node) { + if (isParent(mdastNode)) { + node = mdastNode + } + } + if (node) { + formattingMap.set(node, format ^ (formattingMap.get(mdastParent) ?? 0)) + } + }, + getParentFormatting () { + return formattingMap.get(mdastParent) ?? 0 + }, + addStyle (style, node) { + if (!node) { + if (isParent(mdastNode)) { + node = mdastNode + } + } + if (node) { + styleMap.set(node, style) + } + }, + getParentStyle () { + return styleMap.get(mdastParent) ?? '' + } + } + }) + } + + visit(mdastRoot, root, null) +} diff --git a/lib/lexical/mdast/index.js b/lib/lexical/mdast/index.js new file mode 100644 index 0000000000..fc332e5c36 --- /dev/null +++ b/lib/lexical/mdast/index.js @@ -0,0 +1,31 @@ +export { + importMarkdownToLexical, + importMdastTreeToLexical, + MarkdownParseError, + UnrecognizedMarkdownConstructError +} from './importMarkdownToLexical.js' + +export { + exportMarkdownFromLexical, + exportLexicalTreeToMdast +} from './exportMarkdownFromLexical.js' + +// format constants +export { + DEFAULT_FORMAT, + IS_BOLD, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_CODE, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_HIGHLIGHT +} from './FormatConstants.js' + +// visitors +export { importVisitors, exportVisitors } from './visitors/index.js' + +export { isMdastText } from './visitors/index.js' + +// mdast transforms +export { createMentionTransform } from './transforms/index.js' diff --git a/lib/lexical/mdast/transforms/index.js b/lib/lexical/mdast/transforms/index.js new file mode 100644 index 0000000000..34022625df --- /dev/null +++ b/lib/lexical/mdast/transforms/index.js @@ -0,0 +1 @@ +export { createMentionTransform } from './mentions.js' diff --git a/lib/lexical/mdast/transforms/mentions.js b/lib/lexical/mdast/transforms/mentions.js new file mode 100644 index 0000000000..1fb63cc9c8 --- /dev/null +++ b/lib/lexical/mdast/transforms/mentions.js @@ -0,0 +1,37 @@ +import { visit, SKIP } from 'unist-util-visit' + +const MENTION_PATTERN = /(@[a-zA-Z0-9_/]+|~[a-zA-Z0-9_]+)/g +const SKIP_TYPES = new Set(['code', 'inlineCode']) + +export function createMentionTransform () { + return function mentionTransform (tree) { + visit(tree, 'text', (node, index, parent) => { + // skip text inside code blocks + if (!parent || SKIP_TYPES.has(parent.type)) return SKIP + + const parts = node.value.split(MENTION_PATTERN) + if (parts.length === 1) return + + // user mentions and territory mentions parsing + const newNodes = parts.filter(Boolean).map(part => { + if (part.startsWith('@')) { + const [name, ...pathParts] = part.slice(1).split('/') + return { + type: 'userMention', + value: { name, path: pathParts.length ? '/' + pathParts.join('/') : '' } + } + } + if (part.startsWith('~')) { + return { + type: 'territoryMention', + value: part.slice(1) + } + } + return { type: 'text', value: part } + }) + + parent.children.splice(index, 1, ...newNodes) + return SKIP + }) + } +} diff --git a/lib/lexical/mdast/visitors/code.js b/lib/lexical/mdast/visitors/code.js new file mode 100644 index 0000000000..fcd49ef709 --- /dev/null +++ b/lib/lexical/mdast/visitors/code.js @@ -0,0 +1,25 @@ +import { $createCodeNode, $isCodeNode } from '@lexical/code' +import { $createTextNode } from 'lexical' + +// mdast -> lexical +export const MdastCodeBlockVisitor = { + testNode: 'code', + visitNode ({ mdastNode, actions }) { + const codeNode = $createCodeNode(mdastNode.lang || undefined) + // code blocks don't have children in mdast: the value is the content + codeNode.append($createTextNode(mdastNode.value)) + actions.addAndStepInto(codeNode) + } +} + +// lexical -> mdast +export const LexicalCodeBlockVisitor = { + testLexicalNode: $isCodeNode, + visitLexicalNode ({ lexicalNode, actions, mdastParent }) { + actions.appendToParent(mdastParent, { + type: 'code', + lang: lexicalNode.getLanguage() || null, + value: lexicalNode.getTextContent() + }) + } +} diff --git a/lib/lexical/mdast/visitors/formatting.js b/lib/lexical/mdast/visitors/formatting.js new file mode 100644 index 0000000000..86fbed5402 --- /dev/null +++ b/lib/lexical/mdast/visitors/formatting.js @@ -0,0 +1,83 @@ +import { $createTextNode } from 'lexical' +import { + IS_BOLD, + IS_CODE, + IS_HIGHLIGHT, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT +} from '../FormatConstants.js' + +// helper to build html tag formatting visitors (open/close tags) +function buildHtmlTagVisitors (tag, format) { + return [ + { + testNode: (node) => node.type === 'html' && node.value === `<${tag}>`, + visitNode ({ actions, mdastParent }) { + actions.addFormatting(format, mdastParent) + } + }, + { + testNode: (node) => node.type === 'html' && node.value === ``, + visitNode ({ actions, mdastParent }) { + actions.removeFormatting(format, mdastParent) + } + } + ] +} + +// mdast strikethrough -> lexical +export const MdStrikeThroughVisitor = { + testNode: 'delete', + visitNode ({ mdastNode, actions, lexicalParent }) { + actions.addFormatting(IS_STRIKETHROUGH) + actions.visitChildren(mdastNode, lexicalParent) + } +} + +// mdast highlight -> lexical +export const MdHighlightVisitor = { + testNode: 'highlight', + visitNode ({ mdastNode, actions, lexicalParent }) { + actions.addFormatting(IS_HIGHLIGHT) + actions.visitChildren(mdastNode, lexicalParent) + } +} + +// mdast inline code -> lexical +export const MdCodeVisitor = { + testNode: 'inlineCode', + visitNode ({ mdastNode, actions }) { + actions.addAndStepInto($createTextNode(mdastNode.value).setFormat(actions.getParentFormatting() | IS_CODE)) + } +} + +// mdast emphasis -> lexical +export const MdEmphasisVisitor = { + testNode: 'emphasis', + visitNode ({ mdastNode, actions, lexicalParent }) { + actions.addFormatting(IS_ITALIC) + actions.visitChildren(mdastNode, lexicalParent) + } +} + +// mdast strong -> lexical +export const MdStrongVisitor = { + testNode: 'strong', + visitNode ({ mdastNode, actions, lexicalParent }) { + actions.addFormatting(IS_BOLD) + actions.visitChildren(mdastNode, lexicalParent) + } +} + +// all formatting visitors combined +export const formattingVisitors = [ + MdEmphasisVisitor, + MdStrongVisitor, + MdCodeVisitor, + MdStrikeThroughVisitor, + MdHighlightVisitor, + ...buildHtmlTagVisitors('sup', IS_SUPERSCRIPT), + ...buildHtmlTagVisitors('sub', IS_SUBSCRIPT) +] diff --git a/lib/lexical/mdast/visitors/heading.js b/lib/lexical/mdast/visitors/heading.js new file mode 100644 index 0000000000..d4788aea5b --- /dev/null +++ b/lib/lexical/mdast/visitors/heading.js @@ -0,0 +1,19 @@ +import { $createHeadingNode, $isHeadingNode } from '@lexical/rich-text' + +// mdast -> lexical +export const MdastHeadingVisitor = { + testNode: 'heading', + visitNode ({ mdastNode, actions }) { + const tag = `h${mdastNode.depth}` + actions.addAndStepInto($createHeadingNode(tag)) + } +} + +// lexical -> mdast +export const LexicalHeadingVisitor = { + testLexicalNode: $isHeadingNode, + visitLexicalNode ({ lexicalNode, actions }) { + const depth = parseInt(lexicalNode.getTag().slice(1)) + actions.addAndStepInto('heading', { depth }) + } +} diff --git a/lib/lexical/mdast/visitors/horizontal-rule.js b/lib/lexical/mdast/visitors/horizontal-rule.js new file mode 100644 index 0000000000..f5b0a83650 --- /dev/null +++ b/lib/lexical/mdast/visitors/horizontal-rule.js @@ -0,0 +1,17 @@ +import { $createHorizontalRuleNode, $isHorizontalRuleNode } from '@lexical/extension' + +// mdast -> lexical +export const MdastHorizontalRuleVisitor = { + testNode: 'thematicBreak', + visitNode ({ actions }) { + actions.addAndStepInto($createHorizontalRuleNode()) + } +} + +// lexical -> mdast +export const LexicalHorizontalRuleVisitor = { + testLexicalNode: $isHorizontalRuleNode, + visitLexicalNode ({ actions, mdastParent }) { + actions.appendToParent(mdastParent, { type: 'thematicBreak' }) + } +} diff --git a/lib/lexical/mdast/visitors/index.js b/lib/lexical/mdast/visitors/index.js new file mode 100644 index 0000000000..6bd2b54c79 --- /dev/null +++ b/lib/lexical/mdast/visitors/index.js @@ -0,0 +1,113 @@ +// root +import { MdastRootVisitor, LexicalRootVisitor } from './root.js' + +// paragraph +import { MdastParagraphVisitor, LexicalParagraphVisitor } from './paragraph.js' + +// text +import { MdastTextVisitor, LexicalTextVisitor } from './text.js' + +// linebreak +import { MdastBreakVisitor, LexicalLinebreakVisitor } from './linebreak.js' + +// formatting +import { formattingVisitors } from './formatting.js' + +// heading +import { MdastHeadingVisitor, LexicalHeadingVisitor } from './heading.js' + +// link, embed, media, image +import { + MdastItemMentionFromLinkVisitor, + MdastEmbedFromLinkVisitor, + MdastMediaFromLinkVisitor, + MdastLinkVisitor, + MdastImageVisitor, + LexicalLinkVisitor, + LexicalEmbedVisitor, + LexicalMediaVisitor +} from './link.js' + +// quote +import { MdastQuoteVisitor, LexicalQuoteVisitor } from './quote.js' + +// list +import { + MdastListVisitor, + MdastListItemVisitor, + LexicalListVisitor, + LexicalListItemVisitor +} from './list.js' + +// code +import { MdastCodeBlockVisitor, LexicalCodeBlockVisitor } from './code.js' + +// horizontal rule +import { MdastHorizontalRuleVisitor, LexicalHorizontalRuleVisitor } from './horizontal-rule.js' + +// mentions +import { + MdastUserMentionVisitor, + MdastTerritoryMentionVisitor, + LexicalUserMentionVisitor, + LexicalTerritoryMentionVisitor, + LexicalItemMentionVisitor +} from './mentions.js' + +import { + MdastTableVisitor, + MdastTableRowVisitor, + MdastTableCellVisitor, + LexicalTableVisitor, + LexicalTableRowVisitor, + LexicalTableCellVisitor +} from './table.js' + +export { isMdastText } from './text.js' + +// pre-assembled visitor arrays for convenience +export const importVisitors = [ + MdastRootVisitor, + MdastParagraphVisitor, + MdastTextVisitor, + MdastBreakVisitor, + MdastHeadingVisitor, + MdastItemMentionFromLinkVisitor, + MdastEmbedFromLinkVisitor, + MdastMediaFromLinkVisitor, + MdastLinkVisitor, + MdastImageVisitor, + MdastQuoteVisitor, + MdastListVisitor, + MdastListItemVisitor, + MdastCodeBlockVisitor, + MdastHorizontalRuleVisitor, + MdastUserMentionVisitor, + MdastTerritoryMentionVisitor, + MdastTableVisitor, + MdastTableRowVisitor, + MdastTableCellVisitor, + ...formattingVisitors +] + +export const exportVisitors = [ + LexicalRootVisitor, + LexicalParagraphVisitor, + LexicalTextVisitor, + LexicalLinebreakVisitor, + LexicalHeadingVisitor, + LexicalLinkVisitor, + LexicalEmbedVisitor, + LexicalMediaVisitor, + LexicalQuoteVisitor, + LexicalListVisitor, + LexicalListItemVisitor, + LexicalCodeBlockVisitor, + LexicalHorizontalRuleVisitor, + LexicalUserMentionVisitor, + LexicalTerritoryMentionVisitor, + LexicalItemMentionVisitor, + LexicalTableVisitor, + LexicalTableRowVisitor, + LexicalTableCellVisitor +] diff --git a/lib/lexical/mdast/visitors/linebreak.js b/lib/lexical/mdast/visitors/linebreak.js new file mode 100644 index 0000000000..0279384e69 --- /dev/null +++ b/lib/lexical/mdast/visitors/linebreak.js @@ -0,0 +1,17 @@ +import { $createLineBreakNode, $isLineBreakNode } from 'lexical' + +// mdast -> lexical +export const MdastBreakVisitor = { + testNode: 'break', + visitNode ({ actions }) { + actions.addAndStepInto($createLineBreakNode()) + } +} + +// lexical -> mdast +export const LexicalLinebreakVisitor = { + testLexicalNode: $isLineBreakNode, + visitLexicalNode ({ mdastParent, actions }) { + actions.appendToParent(mdastParent, { type: 'text', value: '\n' }) + } +} diff --git a/lib/lexical/mdast/visitors/link.js b/lib/lexical/mdast/visitors/link.js new file mode 100644 index 0000000000..35454b3e73 --- /dev/null +++ b/lib/lexical/mdast/visitors/link.js @@ -0,0 +1,155 @@ +import { $createTextNode } from 'lexical' +import { $createLinkNode, $isLinkNode } from '@lexical/link' +import { $createItemMentionNode } from '@/lib/lexical/nodes/decorative/mentions/item' +import { $createEmbedNode, $isEmbedNode } from '@/lib/lexical/nodes/content/embeds' +import { $createMediaNode, $isMediaNode } from '@/lib/lexical/nodes/content/media' +import { parseInternalLinks, parseEmbedUrl, ensureProtocol } from '@/lib/url' + +// helper to check if link is a "bare link" (text matches url) +function isBareLink (mdastNode) { + const linkText = mdastNode.children?.[0]?.value + return linkText === mdastNode.url +} + +// helper to get embed info from url +function getEmbed (src) { + const href = ensureProtocol(src) + const embed = parseEmbedUrl(href) + return embed ? { ...embed, src: href } : { provider: null } +} + +// bare link -> item mention (highest priority) +// recognizes stacker.news item/comment links +export const MdastItemMentionFromLinkVisitor = { + testNode: 'link', + priority: 20, + visitNode ({ mdastNode, lexicalParent, actions }) { + if (!isBareLink(mdastNode)) { + actions.nextVisitor() + return + } + + try { + const { itemId, commentId, linkText } = parseInternalLinks(mdastNode.url) + if (itemId || commentId) { + const node = $createItemMentionNode({ + id: commentId || itemId, + text: linkText, + url: mdastNode.url + }) + // decorator nodes don't have children, just append directly + lexicalParent.append(node) + return + } + } catch {} + + actions.nextVisitor() + } +} + +// bare link -> embed (high priority) +// recognizes youtube, twitter, etc +export const MdastEmbedFromLinkVisitor = { + testNode: 'link', + priority: 15, + visitNode ({ mdastNode, lexicalParent, actions }) { + if (!isBareLink(mdastNode)) { + actions.nextVisitor() + return + } + + const embed = getEmbed(mdastNode.url) + if (embed.provider) { + const node = $createEmbedNode(embed.provider, embed.src, embed.id, embed.meta) + // decorator nodes don't have children, just append directly + lexicalParent.append(node) + return + } + + actions.nextVisitor() + } +} + +// bare link -> media (medium priority) +// fallback for other bare links - treated as media +export const MdastMediaFromLinkVisitor = { + testNode: 'link', + priority: 10, + visitNode ({ mdastNode, lexicalParent, actions }) { + if (!isBareLink(mdastNode)) { + actions.nextVisitor() + return + } + + const node = $createMediaNode({ src: mdastNode.url }) + // decorator nodes don't have children, just append directly + lexicalParent.append(node) + } +} + +// regular link (default priority) +export const MdastLinkVisitor = { + testNode: 'link', + visitNode ({ mdastNode, actions }) { + const link = $createLinkNode(mdastNode.url, { + title: mdastNode.title, + target: '_blank', + rel: 'noopener noreferrer' + }) + + // if no children, use url as text + if (!mdastNode.children?.length) { + link.append($createTextNode(mdastNode.url)) + } + + actions.addAndStepInto(link) + } +} + +// lexical -> mdast: regular link +export const LexicalLinkVisitor = { + testLexicalNode: $isLinkNode, + visitLexicalNode ({ lexicalNode, actions }) { + actions.addAndStepInto('link', { + url: lexicalNode.getURL(), + title: lexicalNode.getTitle() + }) + } +} + +// lexical -> mdast: embed outputs plain text url +export const LexicalEmbedVisitor = { + testLexicalNode: $isEmbedNode, + visitLexicalNode ({ lexicalNode, mdastParent, actions }) { + actions.appendToParent(mdastParent, { + type: 'text', + value: lexicalNode.getSrc() || '' + }) + } +} + +// lexical -> mdast: media outputs image syntax +export const LexicalMediaVisitor = { + testLexicalNode: $isMediaNode, + visitLexicalNode ({ lexicalNode, mdastParent, actions }) { + actions.appendToParent(mdastParent, { + type: 'image', + url: lexicalNode.getSrc(), + alt: lexicalNode.getAltText() || '', + title: null + }) + } +} + +// mdast -> lexical: image +export const MdastImageVisitor = { + testNode: 'image', + visitNode ({ mdastNode, lexicalParent }) { + const node = $createMediaNode({ + src: mdastNode.url, + altText: mdastNode.alt || '' + }) + // decorator nodes don't have children, just append directly + lexicalParent.append(node) + } +} diff --git a/lib/lexical/mdast/visitors/list.js b/lib/lexical/mdast/visitors/list.js new file mode 100644 index 0000000000..e1bf75c4fa --- /dev/null +++ b/lib/lexical/mdast/visitors/list.js @@ -0,0 +1,82 @@ +import { $createListNode, $createListItemNode, $isListNode, $isListItemNode } from '@lexical/list' + +// mdast -> lexical: list +export const MdastListVisitor = { + testNode: 'list', + visitNode ({ mdastNode, actions }) { + const hasChecks = mdastNode.children.some((item) => typeof item.checked === 'boolean') + const listType = hasChecks ? 'check' : mdastNode.ordered ? 'number' : 'bullet' + actions.addAndStepInto($createListNode(listType)) + } +} + +// mdast -> lexical: list item +export const MdastListItemVisitor = { + testNode: 'listItem', + visitNode ({ mdastNode, actions }) { + const listItemNode = $createListItemNode( + typeof mdastNode.checked === 'boolean' ? mdastNode.checked : undefined + ) + actions.addAndStepInto(listItemNode) + } +} + +// lexical -> mdast: list +export const LexicalListVisitor = { + testLexicalNode: $isListNode, + visitLexicalNode ({ lexicalNode, actions }) { + const listType = lexicalNode.getListType() + actions.addAndStepInto('list', { + ordered: listType === 'number', + spread: false + }) + } +} + +// lexical -> mdast: list item +export const LexicalListItemVisitor = { + testLexicalNode: $isListItemNode, + visitLexicalNode ({ lexicalNode, mdastParent, actions }) { + const children = lexicalNode.getChildren() + const firstChild = children[0] + + // handle nested lists + if (children.length === 1 && $isListNode(firstChild)) { + const prevListItem = mdastParent.children.at(-1) + if (prevListItem) { + actions.visitChildren(lexicalNode, prevListItem) + } else { + actions.visitChildren(firstChild, mdastParent) + } + return + } + + // get parent list type for checkbox status + const parentList = lexicalNode.getParent() + const isCheckList = $isListNode(parentList) && parentList.getListType() === 'check' + + const listItem = { + type: 'listItem', + spread: false, + checked: isCheckList ? Boolean(lexicalNode.getChecked()) : undefined, + children: [] + } + + actions.appendToParent(mdastParent, listItem) + + // wrap inline content in paragraph + let paragraph = null + children.forEach((child) => { + if ($isListNode(child)) { + paragraph = null + actions.visit(child, listItem) + } else { + if (!paragraph) { + paragraph = { type: 'paragraph', children: [] } + listItem.children.push(paragraph) + } + actions.visit(child, paragraph) + } + }) + } +} diff --git a/lib/lexical/mdast/visitors/mentions.js b/lib/lexical/mdast/visitors/mentions.js new file mode 100644 index 0000000000..021792b646 --- /dev/null +++ b/lib/lexical/mdast/visitors/mentions.js @@ -0,0 +1,68 @@ +import { $createUserMentionNode, $isUserMentionNode } from '@/lib/lexical/nodes/decorative/mentions/user' +import { $createTerritoryMentionNode, $isTerritoryMentionNode } from '@/lib/lexical/nodes/decorative/mentions/territory' +import { $isItemMentionNode } from '@/lib/lexical/nodes/decorative/mentions/item' + +// user mentions (@user, @user/path) +// uses transforms to parse mentions + +// mdast -> lexical +export const MdastUserMentionVisitor = { + testNode: 'userMention', + visitNode ({ mdastNode, actions }) { + const node = $createUserMentionNode({ + name: mdastNode.value.name, + path: mdastNode.value.path || '' + }) + actions.addAndStepInto(node) + } +} + +// lexical -> mdast +export const LexicalUserMentionVisitor = { + testLexicalNode: $isUserMentionNode, + visitLexicalNode ({ lexicalNode, mdastParent, actions }) { + actions.appendToParent(mdastParent, { + type: 'userMention', + value: { + name: lexicalNode.getUserMentionName(), + path: lexicalNode.getPath() || '' + } + }) + } +} + +// territory mentions (~territory) +// uses transforms to parse mentions + +// mdast -> lexical +export const MdastTerritoryMentionVisitor = { + testNode: 'territoryMention', + visitNode ({ mdastNode, actions }) { + const node = $createTerritoryMentionNode(mdastNode.value) + actions.addAndStepInto(node) + } +} + +// lexical -> mdast +export const LexicalTerritoryMentionVisitor = { + testLexicalNode: $isTerritoryMentionNode, + visitLexicalNode ({ lexicalNode, mdastParent, actions }) { + actions.appendToParent(mdastParent, { + type: 'territoryMention', + value: lexicalNode.getTerritoryMentionName() + }) + } +} + +// item mentions are created from bare links (see link.js) +// lexical -> mdast: outputs plain text URL +export const LexicalItemMentionVisitor = { + testLexicalNode: $isItemMentionNode, + visitLexicalNode ({ lexicalNode, mdastParent, actions }) { + // export as plain text URL, not a link + actions.appendToParent(mdastParent, { + type: 'text', + value: lexicalNode.getURL() + }) + } +} diff --git a/lib/lexical/mdast/visitors/paragraph.js b/lib/lexical/mdast/visitors/paragraph.js new file mode 100644 index 0000000000..5a967df6a2 --- /dev/null +++ b/lib/lexical/mdast/visitors/paragraph.js @@ -0,0 +1,25 @@ +import { $createParagraphNode, $isParagraphNode } from 'lexical' + +// node types that should not wrap content in paragraphs +const lexicalTypesThatShouldSkipParagraphs = ['listitem', 'quote', 'admonition'] + +// mdast -> lexical +export const MdastParagraphVisitor = { + testNode: 'paragraph', + visitNode ({ mdastNode, lexicalParent, actions }) { + // markdown inserts paragraphs in lists, but lexical does not need them + if (lexicalTypesThatShouldSkipParagraphs.includes(lexicalParent.getType())) { + actions.visitChildren(mdastNode, lexicalParent) + } else { + actions.addAndStepInto($createParagraphNode()) + } + } +} + +// lexical -> mdast +export const LexicalParagraphVisitor = { + testLexicalNode: $isParagraphNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('paragraph') + } +} diff --git a/lib/lexical/mdast/visitors/quote.js b/lib/lexical/mdast/visitors/quote.js new file mode 100644 index 0000000000..9117dc16df --- /dev/null +++ b/lib/lexical/mdast/visitors/quote.js @@ -0,0 +1,17 @@ +import { $createQuoteNode, $isQuoteNode } from '@lexical/rich-text' + +// mdast -> lexical +export const MdastQuoteVisitor = { + testNode: 'blockquote', + visitNode ({ actions }) { + actions.addAndStepInto($createQuoteNode()) + } +} + +// lexical -> mdast +export const LexicalQuoteVisitor = { + testLexicalNode: $isQuoteNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('blockquote') + } +} diff --git a/lib/lexical/mdast/visitors/root.js b/lib/lexical/mdast/visitors/root.js new file mode 100644 index 0000000000..1ad16e7808 --- /dev/null +++ b/lib/lexical/mdast/visitors/root.js @@ -0,0 +1,17 @@ +import { $isRootNode } from 'lexical' + +// mdast -> lexical +export const MdastRootVisitor = { + testNode: 'root', + visitNode ({ actions, mdastNode, lexicalParent }) { + actions.visitChildren(mdastNode, lexicalParent) + } +} + +// lexical -> mdast +export const LexicalRootVisitor = { + testLexicalNode: $isRootNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('root') + } +} diff --git a/lib/lexical/mdast/visitors/table.js b/lib/lexical/mdast/visitors/table.js new file mode 100644 index 0000000000..77e63d7a6c --- /dev/null +++ b/lib/lexical/mdast/visitors/table.js @@ -0,0 +1,49 @@ +import { + $createTableNode, $isTableNode, + $createTableRowNode, $isTableRowNode, + $createTableCellNode, $isTableCellNode +} from '@lexical/table' + +// mdast -> lexical +export const MdastTableVisitor = { + testNode: 'table', + visitNode ({ actions }) { + actions.addAndStepInto($createTableNode()) + } +} + +export const MdastTableRowVisitor = { + testNode: 'tableRow', + visitNode ({ actions }) { + actions.addAndStepInto($createTableRowNode()) + } +} + +export const MdastTableCellVisitor = { + testNode: 'tableCell', + visitNode ({ actions }) { + actions.addAndStepInto($createTableCellNode()) + } +} + +// lexical -> mdast +export const LexicalTableVisitor = { + testLexicalNode: $isTableNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('table') + } +} + +export const LexicalTableRowVisitor = { + testLexicalNode: $isTableRowNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('tableRow') + } +} + +export const LexicalTableCellVisitor = { + testLexicalNode: $isTableCellNode, + visitLexicalNode ({ actions }) { + actions.addAndStepInto('tableCell') + } +} diff --git a/lib/lexical/mdast/visitors/text.js b/lib/lexical/mdast/visitors/text.js new file mode 100644 index 0000000000..8305fa2380 --- /dev/null +++ b/lib/lexical/mdast/visitors/text.js @@ -0,0 +1,127 @@ +import { $createTextNode, $isTextNode } from 'lexical' +import { + IS_BOLD, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_CODE, + IS_HIGHLIGHT, + IS_SUPERSCRIPT, + IS_SUBSCRIPT +} from '../FormatConstants.js' + +// format definitions for data-driven export +// order matters: formats are applied in this sequence +const FORMAT_DEFINITIONS = [ + { flag: IS_SUPERSCRIPT, openTag: '', closeTag: '' }, + { flag: IS_SUBSCRIPT, openTag: '', closeTag: '' }, + { flag: IS_ITALIC, mdastType: 'emphasis' }, + { flag: IS_BOLD, mdastType: 'strong' }, + { flag: IS_STRIKETHROUGH, mdastType: 'delete' }, + { flag: IS_HIGHLIGHT, mdastType: 'highlight' } +] + +// helper to check if a node is mdast text +export function isMdastText (mdastNode) { + return mdastNode.type === 'text' +} + +// mdast -> lexical +export const MdastTextVisitor = { + testNode: 'text', + visitNode ({ mdastNode, actions }) { + const node = $createTextNode(mdastNode.value) + node.setFormat(actions.getParentFormatting()) + actions.addAndStepInto(node) + } +} + +// lexical -> mdast +export const LexicalTextVisitor = { + shouldJoin (prevNode, currentNode) { + if (['text', 'emphasis', 'strong', 'delete', 'highlight'].includes(prevNode.type)) { + return prevNode.type === currentNode.type + } + return false + }, + + join (prevNode, currentNode) { + if (isMdastText(prevNode) && isMdastText(currentNode)) { + return { + type: 'text', + value: prevNode.value + currentNode.value + } + } + return { + ...prevNode, + children: [...prevNode.children, ...currentNode.children] + } + }, + + testLexicalNode: $isTextNode, + + visitLexicalNode ({ lexicalNode, mdastParent, actions }) { + const previousSibling = lexicalNode.getPreviousSibling() + const prevFormat = $isTextNode(previousSibling) ? previousSibling.getFormat() : 0 + const textContent = lexicalNode.getTextContent() + const format = lexicalNode.getFormat() + + // collect html tags that need to wrap the text + const openTags = [] + const closeTags = [] + + for (const def of FORMAT_DEFINITIONS) { + if (def.openTag && (format & def.flag)) { + openTags.push(def.openTag) + closeTags.unshift(def.closeTag) + } + } + + // add opening html tags + for (const tag of openTags) { + actions.appendToParent(mdastParent, { type: 'html', value: tag }) + } + + let localParentNode = mdastParent + + // apply mdast format wrappers (non-html formats) + for (const { flag, mdastType } of FORMAT_DEFINITIONS) { + if (!mdastType) continue + // handle continued formatting from previous sibling + if (prevFormat & format & flag) { + localParentNode = actions.appendToParent(localParentNode, { + type: mdastType, + children: [] + }) + } + } + + for (const { flag, mdastType } of FORMAT_DEFINITIONS) { + if (!mdastType) continue + // handle new formatting introduced with this node + if (format & flag && !(prevFormat & flag)) { + localParentNode = actions.appendToParent(localParentNode, { + type: mdastType, + children: [] + }) + } + } + + // handle inline code separately (it's a leaf node, not a wrapper) + if (format & IS_CODE) { + actions.appendToParent(localParentNode, { + type: 'inlineCode', + value: textContent + }) + } else { + actions.appendToParent(localParentNode, { + type: 'text', + value: textContent + }) + } + + // add closing html tags + for (const tag of closeTags) { + actions.appendToParent(mdastParent, { type: 'html', value: tag }) + } + } +} diff --git a/lib/lexical/nodes/content/embeds/index.jsx b/lib/lexical/nodes/content/embeds/index.jsx new file mode 100644 index 0000000000..53e8d8cfe2 --- /dev/null +++ b/lib/lexical/nodes/content/embeds/index.jsx @@ -0,0 +1,162 @@ +import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode' +import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents' +import { placeholderNode } from './placeholder' + +export function $convertEmbedElement (domNode) { + const provider = domNode.getAttribute('data-lexical-embed-provider') + if (!provider) return null + + const id = domNode.getAttribute('data-lexical-embed-id') + const src = domNode.getAttribute('data-lexical-embed-src') + const metaString = domNode.getAttribute('data-lexical-embed-meta') + let meta = null + + if (metaString) { + try { + meta = JSON.parse(metaString) + } catch (e) { + console.warn(`failed to parse ${provider} embed meta:`, e) + } + } + + const node = $createEmbedNode(provider, src, id, meta) + return { node } +} + +export class EmbedNode extends DecoratorBlockNode { + __provider + __id + __src + __meta + + static getType () { + return 'embed' + } + + static clone (node) { + return new EmbedNode( + node.__provider, + node.__src, + node.__id, + node.__meta, + node.__key + ) + } + + static importJSON (serializedNode) { + const { provider, src, id, meta } = serializedNode + return $createEmbedNode(provider, src, id, meta) + } + + static importDOM () { + return { + span: (domNode) => { + const provider = domNode.getAttribute('data-lexical-embed-provider') + if (!provider) return null + + const hasEmbedId = domNode.hasAttribute('data-lexical-embed-id') + const hasEmbedSrc = domNode.hasAttribute('data-lexical-embed-src') + const hasEmbedMeta = domNode.hasAttribute('data-lexical-embed-meta') + + if (!hasEmbedId && !hasEmbedSrc && !hasEmbedMeta) { + return null + } + + return { + conversion: $convertEmbedElement, + priority: 2 + } + }, + div: (domNode) => { + return EmbedNode.importDOM().span(domNode) + } + } + } + + constructor (provider = null, src = null, id = null, meta = null, key) { + super(key) + this.__provider = provider + this.__src = src + this.__id = id + this.__meta = meta + } + + exportJSON () { + return { + ...super.exportJSON(), + provider: this.__provider, + src: this.__src, + id: this.__id, + meta: this.__meta + } + } + + exportDOM () { + return { + element: placeholderNode({ + provider: this.__provider || '', + id: this.__id || '', + src: this.__src || '', + meta: this.__meta || {} + }) + } + } + + updateDOM () { + return false + } + + getTextContent () { + return this.__src || this.__meta?.href + } + + getProvider () { + return this.__provider + } + + getSrc () { + return this.__src + } + + getId () { + return this.__id + } + + getMeta () { + return this.__meta + } + + decorate (_editor, config) { + const Embed = require('@/components/embed').default + const embedBlockTheme = config.theme.embeds || {} + const className = { + base: embedBlockTheme.base || '', + focus: embedBlockTheme.focus || '' + } + + return ( + + + + ) + } +} + +export function $createEmbedNode (provider = null, src = null, id = null, meta = null) { + return new EmbedNode(provider, src, id, meta) +} + +export function $isEmbedNode (node) { + return node instanceof EmbedNode +} diff --git a/lib/lexical/nodes/content/embeds/placeholder.jsx b/lib/lexical/nodes/content/embeds/placeholder.jsx new file mode 100644 index 0000000000..17e0fc880f --- /dev/null +++ b/lib/lexical/nodes/content/embeds/placeholder.jsx @@ -0,0 +1,57 @@ +/** + * creates a loading placeholder DOM node for embeds + * @param {string} params.provider - embed provider name + * @param {string} [params.id] - provider-specific content ID + * @param {string} [params.src] - source URL + * @param {Object} [params.meta] - additional metadata, e.g. youtube start time + * @returns {HTMLElement} placeholder container element + */ +export function placeholderNode ({ provider, id, src, meta = {} }) { + const container = document.createElement('span') + container.className = 'sn__videoWrapper' + id && container.setAttribute('data-lexical-' + provider + '-id', id) + src && container.setAttribute('data-lexical-embed-src', src) + meta && container.setAttribute('data-lexical-' + provider + '-meta', JSON.stringify(meta)) + + const loadingContainer = document.createElement('span') + loadingContainer.className = 'sn__embedWrapper__loading' + container.append(loadingContainer) + + const messageContainer = document.createElement('span') + messageContainer.className = 'sn__embedWrapper__loading__message' + loadingContainer.append(messageContainer) + + // copied from svg source + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + icon.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + icon.setAttribute('viewBox', '0 0 24 24') + icon.setAttribute('width', '24') + icon.setAttribute('height', '24') + icon.setAttribute('class', 'spin fill-grey') + + const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path1.setAttribute('fill', 'none') + path1.setAttribute('d', 'M0 0h24v24H0z') + + const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path2.setAttribute('d', 'M11.38 2.019a7.5 7.5 0 1 0 10.6 10.6C21.662 17.854 17.316 22 12.001 22 6.477 22 2 17.523 2 12c0-5.315 4.146-9.661 9.38-9.981z') + + icon.appendChild(path1) + icon.appendChild(path2) + messageContainer.append(icon) + + const message = document.createElement('span') + message.textContent = `preparing ${provider}...` + messageContainer.append(message) + + if (src) { + const link = document.createElement('a') + link.href = src + link.target = '_blank' + link.rel = 'noopener noreferrer' + link.textContent = 'view on ' + new URL(src).hostname + messageContainer.append(link) + } + + return container +} diff --git a/lib/lexical/nodes/content/media.jsx b/lib/lexical/nodes/content/media.jsx new file mode 100644 index 0000000000..056a80a80b --- /dev/null +++ b/lib/lexical/nodes/content/media.jsx @@ -0,0 +1,203 @@ +import { $applyNodeReplacement, DecoratorNode, createState, $getState, $setState } from 'lexical' + +// kind and status can change over time, so we need to store them in states +const kindState = createState('kind', { + parse: (value) => (typeof value === 'string' ? value : 'unknown') +}) + +const statusState = createState('status', { + parse: (value) => (typeof value === 'string' ? value : 'idle') +}) + +function $convertMediaElement (domNode) { + if (domNode instanceof window.HTMLImageElement || domNode instanceof window.HTMLVideoElement) { + const { alt: altText, src, width, height } = domNode + const kind = domNode instanceof window.HTMLImageElement ? 'image' : 'video' + const node = $createMediaNode({ altText, src, width, height }) + $setState(node, kindState, kind) + $setState(node, statusState, 'done') + return { node } + } + return null +} + +export class MediaNode extends DecoratorNode { + __src + __altText + __width + __height + __maxWidth + + $config () { + return this.config('media', { + extends: DecoratorNode, + stateConfigs: [ + { flat: true, stateConfig: kindState }, + { flat: true, stateConfig: statusState } + ] + }) + } + + constructor (src, altText, width, height, maxWidth, key) { + super(key) + this.__src = src + this.__altText = altText ?? '' + this.__width = width ?? 0 + this.__height = height ?? 0 + this.__maxWidth = maxWidth ?? 500 + } + + static clone (node) { + const clone = new MediaNode( + node.__src, + node.__altText, + node.__width, + node.__height, + node.__maxWidth, + node.__key + ) + return clone + } + + static importJSON (serializedNode) { + const { src, altText, width, height, maxWidth, kind, status } = serializedNode + const node = $createMediaNode({ src, altText, width, height, maxWidth }) + $setState(node, kindState, kind ?? 'unknown') + $setState(node, statusState, status ?? 'idle') + return node + } + + exportJSON () { + return { + ...super.exportJSON(), + src: this.__src, + altText: this.__altText, + width: this.__width, + height: this.__height, + maxWidth: this.__maxWidth, + kind: $getState(this, kindState), + status: $getState(this, statusState) + } + } + + static importDOM () { + return { + img: () => ({ + conversion: $convertMediaElement, + priority: 0 + }), + video: () => ({ + conversion: $convertMediaElement, + priority: 0 + }) + } + } + + exportDOM (editor) { + const element = document.createElement('span') + const className = editor._config.theme?.mediaContainer + if (className) { + element.className = className + } + + const style = { + '--width': this.__width ? `${this.__width}px` : 'inherit', + '--height': this.__height ? `${this.__height}px` : 'inherit', + '--aspect-ratio': this.__width && this.__height ? `${this.__width} / ${this.__height}` : 'auto', + '--max-width': `${this.__maxWidth}px` + } + element.setAttribute('style', Object.entries(style).map(([k, v]) => `${k}: ${v}`).join('; ')) + + const kind = $getState(this, kindState) + const media = document.createElement(kind === 'video' ? 'video' : 'img') + media.setAttribute('src', this.__src) + media.setAttribute('alt', this.__altText) + if (this.__width) media.setAttribute('width', String(this.__width)) + if (this.__height) media.setAttribute('height', String(this.__height)) + if (kind === 'video') media.setAttribute('controls', 'true') + + element.appendChild(media) + return { element } + } + + createDOM (config) { + const span = document.createElement('span') + const className = config.theme?.mediaContainer + if (className) { + span.className = className + } + span.style.setProperty('--max-width', `${this.__maxWidth}px`) + return span + } + + updateDOM () { + return false + } + + getSrc () { + return this.__src + } + + getAltText () { + return this.__altText + } + + getKind () { + return $getState(this, kindState) + } + + getStatus () { + return $getState(this, statusState) + } + + getWidthAndHeight () { + return { width: this.__width, height: this.__height } + } + + setKind (kind) { + $setState(this, kindState, kind) + } + + setStatus (status) { + $setState(this, statusState, status) + } + + // shortcut for setting kind and status via media check + applyCheckResult (kind) { + $setState(this, kindState, kind) + $setState(this, statusState, kind === 'unknown' ? 'error' : 'done') + } + + decorate () { + const Media = require('@/components/editor/plugins/content/media').default + return ( + + ) + } +} + +export function $createMediaNode ({ src, altText, width, height, maxWidth, key }) { + return $applyNodeReplacement( + new MediaNode( + src, + altText, + width, + height, + maxWidth ? Math.min(maxWidth, 500) : Math.min(width ?? 320, 500), + key + ) + ) +} + +export function $isMediaNode (node) { + return node instanceof MediaNode +} diff --git a/lib/lexical/nodes/decorative/mentions/item.jsx b/lib/lexical/nodes/decorative/mentions/item.jsx new file mode 100644 index 0000000000..41c861e6e5 --- /dev/null +++ b/lib/lexical/nodes/decorative/mentions/item.jsx @@ -0,0 +1,128 @@ +import { DecoratorNode, $applyNodeReplacement } from 'lexical' + +function $convertItemMentionElement (domNode) { + const id = domNode.getAttribute('data-lexical-item-mention-id') + const text = domNode.querySelector('a')?.textContent + const url = domNode.querySelector('a')?.getAttribute('href') + + if (id) { + const node = $createItemMentionNode({ id, text, url }) + return { node } + } + + return null +} + +export class ItemMentionNode extends DecoratorNode { + __itemMentionId + __text + __url + + static getType () { + return 'item-mention' + } + + getItemMentionId () { + return this.__itemMentionId + } + + getText () { + return this.__text + } + + getURL () { + return this.__url + } + + static clone (node) { + return new ItemMentionNode(node.__itemMentionId, node.__text, node.__url, node.__key) + } + + static importJSON (serializedNode) { + return $createItemMentionNode(serializedNode.itemMentionId, serializedNode.text, serializedNode.url) + } + + constructor (itemMentionId, text, url, key) { + super(key) + this.__itemMentionId = itemMentionId + this.__text = text + this.__url = url + } + + exportJSON () { + return { + type: 'item-mention', + version: 1, + itemMentionId: this.__itemMentionId, + text: this.__text, + url: this.__url + } + } + + createDOM (config) { + const domNode = document.createElement('span') + const theme = config.theme + const className = theme.itemMention + if (className !== undefined) { + domNode.className = className + } + domNode.setAttribute('data-lexical-item-mention', true) + domNode.setAttribute('data-lexical-item-mention-id', this.__itemMentionId) + return domNode + } + + // we need to find a way to allow display name changes + exportDOM (editor) { + const wrapper = document.createElement('span') + wrapper.setAttribute('data-lexical-item-mention', true) + const theme = editor._config.theme + const className = theme.itemMention + if (className !== undefined) { + wrapper.className = className + } + wrapper.setAttribute('data-lexical-item-mention-id', this.__itemMentionId) + const a = document.createElement('a') + a.setAttribute('href', this.__url) + a.textContent = this.__text || `#${this.__itemMentionId}` + wrapper.appendChild(a) + return { element: wrapper } + } + + static importDOM () { + return { + span: (domNode) => { + if (!domNode.hasAttribute('data-lexical-item-mention')) return null + return { conversion: $convertItemMentionElement, priority: 1 } + } + } + } + + isInline () { + return true + } + + updateDOM () { + return false + } + + decorate () { + const ItemPopover = require('@/components/item-popover').default + const Link = require('next/link').default + const id = this.__itemMentionId + const href = this.__url + const text = this.__text || `#${this.__itemMentionId}` + return ( + + {text} + + ) + } +} + +export function $createItemMentionNode ({ id, text, url }) { + return $applyNodeReplacement(new ItemMentionNode(id, text, url)) +} + +export function $isItemMentionNode (node) { + return node instanceof ItemMentionNode +} diff --git a/lib/lexical/nodes/decorative/mentions/territory.jsx b/lib/lexical/nodes/decorative/mentions/territory.jsx new file mode 100644 index 0000000000..534879a15b --- /dev/null +++ b/lib/lexical/nodes/decorative/mentions/territory.jsx @@ -0,0 +1,113 @@ +import { DecoratorNode, $applyNodeReplacement } from 'lexical' + +function $convertTerritoryMentionElement (domNode) { + const textContent = domNode.textContent + const territoryName = domNode.getAttribute('data-lexical-territory-mention-name') + + if (textContent !== null) { + const node = $createTerritoryMentionNode(territoryName || textContent, textContent) + return { node } + } + + return null +} + +// TODO: support path like item and user mentions +export class TerritoryMentionNode extends DecoratorNode { + __territoryMentionName + + static getType () { + return 'territory-mention' + } + + getTerritoryMentionName () { + return this.__territoryMentionName + } + + static clone (node) { + return new TerritoryMentionNode(node.__territoryMentionName, node.__key) + } + + static importJSON (serializedNode) { + return $createTerritoryMentionNode(serializedNode.territoryMentionName) + } + + constructor (territoryMentionName, key) { + super(key) + this.__territoryMentionName = territoryMentionName + } + + exportJSON () { + return { + type: 'territory-mention', + version: 1, + territoryMentionName: this.__territoryMentionName + } + } + + createDOM (config) { + const domNode = document.createElement('span') + const theme = config.theme + const className = theme.territoryMention + if (className !== undefined) { + domNode.className = className + } + domNode.setAttribute('data-lexical-territory-mention', true) + domNode.setAttribute('data-lexical-territory-mention-name', this.__territoryMentionName) + return domNode + } + + // we need to find a way to allow display name changes + exportDOM (editor) { + const wrapper = document.createElement('span') + wrapper.setAttribute('data-lexical-territory-mention', true) + const theme = editor._config.theme + const className = theme.territoryMention + if (className !== undefined) { + wrapper.className = className + } + wrapper.setAttribute('data-lexical-territory-mention-name', this.__territoryMentionName) + const a = document.createElement('a') + a.setAttribute('href', '/~' + encodeURIComponent(this.__territoryMentionName.toString())) + a.textContent = '~' + this.__territoryMentionName + wrapper.appendChild(a) + return { element: wrapper } + } + + static importDOM () { + return { + span: (domNode) => { + if (!domNode.hasAttribute('data-lexical-territory-mention')) return null + return { conversion: $convertTerritoryMentionElement, priority: 1 } + } + } + } + + isInline () { + return true + } + + updateDOM () { + return false + } + + decorate () { + const name = this.__territoryMentionName + const href = '/~' + name + const SubPopover = require('@/components/sub-popover').default + const Link = require('next/link').default + return ( + + ~{name} + + ) + } +} + +export function $createTerritoryMentionNode (territoryMentionName) { + return $applyNodeReplacement(new TerritoryMentionNode(territoryMentionName)) +} + +export function $isTerritoryMentionNode (node) { + return node instanceof TerritoryMentionNode +} diff --git a/lib/lexical/nodes/decorative/mentions/user.jsx b/lib/lexical/nodes/decorative/mentions/user.jsx new file mode 100644 index 0000000000..8ca32919be --- /dev/null +++ b/lib/lexical/nodes/decorative/mentions/user.jsx @@ -0,0 +1,126 @@ +import { DecoratorNode, $applyNodeReplacement } from 'lexical' + +function $convertUserMentionElement (domNode) { + const textContent = domNode.textContent + const userMentionName = domNode.getAttribute('data-lexical-user-mention-name') + const path = domNode.getAttribute('data-lexical-user-mention-path') + + if (textContent !== null) { + const node = $createUserMentionNode({ name: userMentionName || textContent, path }) + return { node } + } + + return null +} + +export class UserMentionNode extends DecoratorNode { + __userMentionName + __path + + static getType () { + return 'user-mention' + } + + getUserMentionName () { + return this.__userMentionName + } + + getPath () { + return this.__path + } + + static clone (node) { + return new UserMentionNode(node.__userMentionName, node.__path, node.__key) + } + + static importJSON (serializedNode) { + return $createUserMentionNode({ name: serializedNode.userMentionName, path: serializedNode.path }) + } + + constructor (userMentionName, path, key) { + super(key) + this.__userMentionName = userMentionName + this.__path = path + } + + exportJSON () { + return { + type: 'user-mention', + version: 1, + userMentionName: this.__userMentionName, + path: this.__path + } + } + + createDOM (config) { + const domNode = document.createElement('span') + const theme = config.theme + const className = theme.userMention + if (className !== undefined) { + domNode.className = className + } + domNode.setAttribute('data-lexical-user-mention', true) + domNode.setAttribute('data-lexical-user-mention-name', this.__userMentionName) + domNode.setAttribute('data-lexical-user-mention-path', this.__path) + return domNode + } + + // we need to find a way to allow display name changes + exportDOM (editor) { + const wrapper = document.createElement('span') + wrapper.setAttribute('data-lexical-user-mention', true) + const theme = editor._config.theme + const className = theme.userMention + if (className !== undefined) { + wrapper.className = className + } + wrapper.setAttribute('data-lexical-user-mention-name', this.__userMentionName) + wrapper.setAttribute('data-lexical-user-mention-path', this.__path) + const a = document.createElement('a') + const href = '/' + encodeURIComponent(this.__userMentionName.toString()) + this.__path.toString() + a.setAttribute('href', href) + const text = '@' + this.__userMentionName + this.__path + a.textContent = text + wrapper.appendChild(a) + return { element: wrapper } + } + + static importDOM () { + return { + span: (domNode) => { + if (!domNode.hasAttribute('data-lexical-user-mention')) return null + return { conversion: $convertUserMentionElement, priority: 1 } + } + } + } + + isInline () { + return true + } + + updateDOM () { + return false + } + + decorate () { + const UserPopover = require('@/components/user-popover').default + const Link = require('next/link').default + const name = this.__userMentionName + const path = this.__path + const href = '/' + encodeURIComponent(name.toString()) + path.toString() + const text = '@' + name + path + return ( + + {text} + + ) + } +} + +export function $createUserMentionNode ({ name, path }) { + return $applyNodeReplacement(new UserMentionNode(name, path)) +} + +export function $isUserMentionNode (node) { + return node instanceof UserMentionNode +} diff --git a/lib/lexical/nodes/formatting/math.jsx b/lib/lexical/nodes/formatting/math.jsx new file mode 100644 index 0000000000..578aae3ad9 --- /dev/null +++ b/lib/lexical/nodes/formatting/math.jsx @@ -0,0 +1,146 @@ +import { $applyNodeReplacement, DecoratorNode } from 'lexical' +import katex from 'katex' + +export const $encodeMath = (math) => Buffer.from(math).toString('base64') +export const $decodeMath = (math) => Buffer.from(math, 'base64').toString('utf-8') + +const MAX_MATH_LENGTH = 10000 + +function $validateMathContent (math) { + if (!math) return math + if (math.length > MAX_MATH_LENGTH) { + console.warn('math too big, truncating') + return math.slice(0, MAX_MATH_LENGTH) + } + return math +} + +function $convertMathElement (domNode) { + let math = domNode.getAttribute('data-lexical-math') + if (!math) return null + math = $validateMathContent(math) + try { + math = $decodeMath(math) + } catch (error) { + console.error('error decoding math', error) + return null + } + if (!math) return null + const inline = domNode.getAttribute('data-lexical-inline') === 'true' + return { node: $createMathNode(math, inline) } +} + +export class MathNode extends DecoratorNode { + __math + __inline + + static getType () { + return 'math' + } + + static clone (node) { + return new MathNode(node.__math, node.__inline, node.__key) + } + + constructor (math, inline, key) { + super(key) + this.__math = $validateMathContent(math) + this.__inline = inline ?? false + } + + static importJSON (serializedNode) { + return $createMathNode( + serializedNode.math, + serializedNode.inline + ).updateFromJSON(serializedNode) + } + + exportJSON () { + return { + ...super.exportJSON(), + math: this.getMath(), + inline: this.__inline + } + } + + createDOM (_config) { + const element = document.createElement(this.__inline ? 'span' : 'div') + element.className = _config.theme.math + return element + } + + exportDOM () { + const element = document.createElement(this.__inline ? 'span' : 'div') + let math = this.__math + try { + math = $encodeMath(math) + } catch (error) { + console.error('error encoding math', error) + return null + } + element.setAttribute('data-lexical-math', math) + element.setAttribute('data-lexical-inline', this.__inline) + katex.render(this.__math, element, { + displayMode: !this.__inline, + errorColor: '#cc0000', + output: 'html', + strict: 'warn', + throwOnError: false, + trust: false + }) + return { element } + } + + static importDOM () { + return { + div: (domNode) => { + if (!domNode.hasAttribute('data-lexical-math')) return null + return { conversion: $convertMathElement, priority: 2 } + }, + span: (domNode) => { + if (!domNode.hasAttribute('data-lexical-math')) return null + return { conversion: $convertMathElement, priority: 1 } + } + } + } + + updateDOM (prevNode) { + return this.__inline !== prevNode.__inline + } + + getTextContent () { + return this.__math + } + + getMath () { + return this.__math + } + + getInline () { + return this.__inline + } + + setMath (math) { + const writable = this.getWritable() + writable.__math = $validateMathContent(math) + } + + decorate () { + const MathComponent = require('@/components/editor/plugins/formatting/math').default + return ( + + ) + } +} + +export const $createMathNode = (math = '', inline = false) => { + const node = new MathNode(math, inline) + return $applyNodeReplacement(node) +} + +export function $isMathNode (node) { + return node instanceof MathNode +} diff --git a/lib/lexical/nodes/formatting/spoiler/container.jsx b/lib/lexical/nodes/formatting/spoiler/container.jsx new file mode 100644 index 0000000000..f13f4afb66 --- /dev/null +++ b/lib/lexical/nodes/formatting/spoiler/container.jsx @@ -0,0 +1,157 @@ +import { IS_CHROME } from '@lexical/utils' +import { + $getSiblingCaret, + $isElementNode, + $rewindSiblingCaret, + ElementNode, + isHTMLElement +} from 'lexical' + +import { setDomHiddenUntilFound } from './utils' + +export function $convertDetailsElement (domNode) { + const isOpen = domNode.open !== undefined ? domNode.open : true + const node = $createSpoilerContainerNode(isOpen) + return { + node + } +} + +// from lexical, TODO: re-examine +export class SpoilerContainerNode extends ElementNode { + __open + + constructor (open, key) { + super(key) + this.__open = open + } + + static getType () { + return 'spoiler-container' + } + + static clone (node) { + return new SpoilerContainerNode(node.__open, node.__key) + } + + isShadowRoot () { + return true + } + + collapseAtStart (selection) { + // Unwrap the SpoilerContainerNode by replacing it with the children + // of its children (SpoilerTitleNode, SpoilerContentNode) + const nodesToInsert = [] + for (const child of this.getChildren()) { + if ($isElementNode(child)) { + nodesToInsert.push(...child.getChildren()) + } + } + const caret = $rewindSiblingCaret($getSiblingCaret(this, 'previous')) + caret.splice(1, nodesToInsert) + // Merge the first child of the SpoilerTitleNode with the + // previous sibling of the SpoilerContainerNode + const [firstChild] = nodesToInsert + if (firstChild) { + firstChild.selectStart().deleteCharacter(true) + } + return true + } + + createDOM (config, editor) { + // details is not well supported in Chrome #5582 + let dom + if (IS_CHROME) { + dom = document.createElement('div') + dom.setAttribute('open', '') + } else { + const detailsDom = document.createElement('details') + detailsDom.open = this.__open + detailsDom.addEventListener('toggle', () => { + const open = editor.getEditorState().read(() => this.getOpen()) + if (open !== detailsDom.open) { + editor.update(() => this.toggleOpen()) + } + }) + dom = detailsDom + } + dom.classList.add('sn__collapsible', 'sn__spoiler__container') + + return dom + } + + updateDOM (prevNode, dom) { + const currentOpen = this.__open + if (prevNode.__open !== currentOpen) { + // details is not well supported in Chrome #5582 + if (IS_CHROME) { + const contentDom = dom.children[1] + if (!isHTMLElement(contentDom)) { + throw new Error('Expected contentDom to be an HTMLElement') + } + if (currentOpen) { + dom.setAttribute('open', '') + contentDom.hidden = false + } else { + dom.removeAttribute('open') + setDomHiddenUntilFound(contentDom) + } + } else { + dom.open = this.__open + } + } + + return false + } + + static importDOM () { + return { + details: (domNode) => { + return { + conversion: $convertDetailsElement, + priority: 1 + } + } + } + } + + static importJSON (serializedNode) { + return $createSpoilerContainerNode(serializedNode.open).updateFromJSON( + serializedNode + ) + } + + exportDOM () { + const element = document.createElement('details') + element.classList.add('sn__collapsible', 'sn__spoiler__container') + return { element } + } + + exportJSON () { + return { + ...super.exportJSON(), + open: false // always save spoilers as collapsed + } + } + + setOpen (open) { + const writable = this.getWritable() + writable.__open = open + } + + getOpen () { + return this.getLatest().__open + } + + toggleOpen () { + this.setOpen(!this.getOpen()) + } +} + +export function $createSpoilerContainerNode (isOpen) { + return new SpoilerContainerNode(isOpen) +} + +export function $isSpoilerContainerNode (node) { + return node instanceof SpoilerContainerNode +} diff --git a/lib/lexical/nodes/formatting/spoiler/content.jsx b/lib/lexical/nodes/formatting/spoiler/content.jsx new file mode 100644 index 0000000000..38e77d3f2c --- /dev/null +++ b/lib/lexical/nodes/formatting/spoiler/content.jsx @@ -0,0 +1,98 @@ +import { IS_CHROME } from '@lexical/utils' +import { + ElementNode +} from 'lexical' + +import { $isSpoilerContainerNode } from './container' +import { domOnBeforeMatch, setDomHiddenUntilFound } from './utils' + +export function $convertSpoilerContentElement (domNode) { + const node = $createSpoilerContentNode() + return { + node + } +} + +// from lexical, TODO: re-examine +export class SpoilerContentNode extends ElementNode { + static getType () { + return 'spoiler-content' + } + + static clone (node) { + return new SpoilerContentNode(node.__key) + } + + createDOM (config, editor) { + const dom = document.createElement('div') + dom.classList.add('sn__spoiler__content') + if (IS_CHROME) { + editor.getEditorState().read(() => { + const containerNode = this.getParentOrThrow() + if (!$isSpoilerContainerNode(containerNode)) { + throw new Error( + 'Expected parent node to be a SpoilerContainerNode' + ) + } + if (!containerNode.__open) { + setDomHiddenUntilFound(dom) + } + }) + domOnBeforeMatch(dom, () => { + editor.update(() => { + const containerNode = this.getParentOrThrow().getLatest() + if (!$isSpoilerContainerNode(containerNode)) { + throw new Error( + 'Expected parent node to be a SpoilerContainerNode' + ) + } + if (!containerNode.__open) { + containerNode.toggleOpen() + } + }) + }) + } + return dom + } + + updateDOM (prevNode, dom) { + return false + } + + static importDOM () { + return { + div: (domNode) => { + if (!domNode.hasAttribute('data-lexical-spoiler-content')) { + return null + } + return { + conversion: $convertSpoilerContentElement, + priority: 2 + } + } + } + } + + exportDOM () { + const element = document.createElement('div') + element.classList.add('sn__spoiler__content') + element.setAttribute('data-lexical-spoiler-content', 'true') + return { element } + } + + static importJSON (serializedNode) { + return $createSpoilerContentNode().updateFromJSON(serializedNode) + } + + isShadowRoot () { + return true + } +} + +export function $createSpoilerContentNode () { + return new SpoilerContentNode() +} + +export function $isSpoilerContentNode (node) { + return node instanceof SpoilerContentNode +} diff --git a/lib/lexical/nodes/formatting/spoiler/title.jsx b/lib/lexical/nodes/formatting/spoiler/title.jsx new file mode 100644 index 0000000000..272ccffc67 --- /dev/null +++ b/lib/lexical/nodes/formatting/spoiler/title.jsx @@ -0,0 +1,102 @@ +import { IS_CHROME } from '@lexical/utils' +import { + $createParagraphNode, + $isElementNode, + buildImportMap, + ElementNode +} from 'lexical' + +import { $isSpoilerContainerNode } from './container' +import { $isSpoilerContentNode } from './content' + +export function $convertSummaryElement (domNode) { + const node = $createSpoilerTitleNode() + return { + node + } +} + +// from lexical, TODO: re-examine +/** @noInheritDoc */ +export class SpoilerTitleNode extends ElementNode { + /** @internal */ + $config () { + return this.config('spoiler-title', { + $transform (node) { + if (node.isEmpty()) { + node.remove() + } + }, + extends: ElementNode, + importDOM: buildImportMap({ + summary: () => ({ + conversion: $convertSummaryElement, + priority: 1 + }) + }) + }) + } + + createDOM (config, editor) { + const dom = document.createElement('summary') + dom.classList.add('sn__collapsible__header', 'sn__spoiler__title') + if (IS_CHROME) { + dom.addEventListener('click', () => { + editor.update(() => { + const spoilerContainer = this.getLatest().getParentOrThrow() + if (!$isSpoilerContainerNode(spoilerContainer)) { + throw new Error( + 'Expected parent node to be a SpoilerContainerNode' + ) + } + spoilerContainer.toggleOpen() + }) + }) + } + return dom + } + + updateDOM (prevNode, dom) { + return false + } + + insertNewAfter (_, restoreSelection = true) { + const containerNode = this.getParentOrThrow() + + if (!$isSpoilerContainerNode(containerNode)) { + throw new Error( + 'sn__spoilerTitleNode expects to be child of SpoilerContainerNode' + ) + } + + if (containerNode.getOpen()) { + const contentNode = this.getNextSibling() + if (!$isSpoilerContentNode(contentNode)) { + throw new Error( + 'sn__spoilerTitleNode expects to have SpoilerContentNode sibling' + ) + } + + const firstChild = contentNode.getFirstChild() + if ($isElementNode(firstChild)) { + return firstChild + } else { + const paragraph = $createParagraphNode() + contentNode.append(paragraph) + return paragraph + } + } else { + const paragraph = $createParagraphNode() + containerNode.insertAfter(paragraph, restoreSelection) + return paragraph + } + } +} + +export function $createSpoilerTitleNode () { + return new SpoilerTitleNode() +} + +export function $isSpoilerTitleNode (node) { + return node instanceof SpoilerTitleNode +} diff --git a/lib/lexical/nodes/formatting/spoiler/utils.js b/lib/lexical/nodes/formatting/spoiler/utils.js new file mode 100644 index 0000000000..66b8f07ad9 --- /dev/null +++ b/lib/lexical/nodes/formatting/spoiler/utils.js @@ -0,0 +1,8 @@ +// from lexical, TODO: re-examine +export function setDomHiddenUntilFound (dom) { + dom.hidden = 'until-found' +} + +export function domOnBeforeMatch (dom, callback) { + dom.onbeforematch = callback +} diff --git a/lib/lexical/nodes/index.js b/lib/lexical/nodes/index.js new file mode 100644 index 0000000000..e3198cf58c --- /dev/null +++ b/lib/lexical/nodes/index.js @@ -0,0 +1,50 @@ +import { QuoteNode, HeadingNode } from '@lexical/rich-text' +import { TableCellNode, TableNode, TableRowNode } from '@lexical/table' +import { ListItemNode, ListNode } from '@lexical/list' +import { CodeHighlightNode, CodeNode } from '@lexical/code' +import { AutoLinkNode, LinkNode } from '@lexical/link' +import { HorizontalRuleNode } from '@lexical/extension' +import { MediaNode } from './content/media' +import { UserMentionNode } from './decorative/mentions/user' +import { TerritoryMentionNode } from './decorative/mentions/territory' +import { ItemMentionNode } from './decorative/mentions/item' +import { EmbedNode } from './content/embeds' +import { MathNode } from './formatting/math' +import { SpoilerContainerNode } from './formatting/spoiler/container' +import { SpoilerTitleNode } from './formatting/spoiler/title' +import { SpoilerContentNode } from './formatting/spoiler/content' +import { TableOfContentsNode } from './misc/toc' +import { SNHeadingNode, $createSNHeadingNode } from './misc/heading' + +const DefaultNodes = [ + SNHeadingNode, + { + replace: HeadingNode, + with: () => $createSNHeadingNode(), + withKlass: SNHeadingNode + }, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + CodeHighlightNode, + TableNode, + TableCellNode, + TableRowNode, + AutoLinkNode, + LinkNode, + HorizontalRuleNode, + // custom SN nodes + MediaNode, + UserMentionNode, + TerritoryMentionNode, + ItemMentionNode, + MathNode, + SpoilerContainerNode, + SpoilerTitleNode, + SpoilerContentNode, + TableOfContentsNode, + EmbedNode +] + +export default DefaultNodes diff --git a/lib/lexical/nodes/misc/heading.jsx b/lib/lexical/nodes/misc/heading.jsx new file mode 100644 index 0000000000..47f162485b --- /dev/null +++ b/lib/lexical/nodes/misc/heading.jsx @@ -0,0 +1,170 @@ +import { HeadingNode } from '@lexical/rich-text' +import { slug } from 'github-slugger' +import { setNodeIndentFromDOM, $applyNodeReplacement, $createParagraphNode } from 'lexical' + +function $convertSNHeadingElement (element) { + const nodeName = element.nodeName.toLowerCase() + let node = null + if (nodeName === 'h1' || nodeName === 'h2' || nodeName === 'h3' || nodeName === 'h4' || nodeName === 'h5' || nodeName === 'h6') { + node = $createSNHeadingNode(nodeName) + if (element.style !== null) { + setNodeIndentFromDOM(element, node) + node.setFormat(element.style.textAlign) + } + } + return { + node + } +} + +function isGoogleDocsTitle (domNode) { + if (domNode.nodeName.toLowerCase() === 'span') { + return domNode.style.fontSize === '26pt' + } + return false +} + +// re-implements HeadingNode with slug support +export class SNHeadingNode extends HeadingNode { + static getType () { + return 'sn-heading' + } + + static clone (node) { + return new SNHeadingNode(node.__tag, node.__key) + } + + getSlug () { + return slug(this.getTextContent().replace(/[^\w\-\s]+/gi, '')) + } + + // headings are not links by default, because lexical creates a span + // so if we were to append a link to the element, it would render as sibling + // instead of wrapping the text in a link. + // the workaround used here is to use CSS to make this clickable. + createDOM (config, editor) { + const element = super.createDOM(config, editor) + // anchor navigation + const headingId = this.getSlug() + if (headingId) { + element.setAttribute('id', headingId) + } + + return element + } + + updateDOM (prevNode, dom, config) { + // update ID on content changes + const prevSlug = prevNode.getSlug() + const currentSlug = this.getSlug() + if (prevSlug !== currentSlug) { + dom.setAttribute('id', currentSlug) + } + return super.updateDOM(prevNode, dom, config) + } + + exportDOM (editor) { + const { element } = super.exportDOM(editor) + const headingId = this.getSlug() + + if (headingId) { + element.setAttribute('id', headingId) + } + + return { element } + } + + // override + insertNewAfter (selection, restoreSelection = true) { + const anchorOffset = selection ? selection.anchor.offset : 0 + const lastDesc = this.getLastDescendant() + const isAtEnd = !lastDesc || (selection && selection.anchor.key === lastDesc.getKey() && anchorOffset === lastDesc.getTextContentSize()) + const newElement = isAtEnd || !selection ? $createParagraphNode() : $createSNHeadingNode(this.getTag()) + const direction = this.getDirection() + newElement.setDirection(direction) + this.insertAfter(newElement, restoreSelection) + if (anchorOffset === 0 && !this.isEmpty() && selection) { + const paragraph = $createParagraphNode() + paragraph.select() + this.replace(paragraph, true) + } + return newElement + } + + // override + collapseAtStart () { + const newElement = !this.isEmpty() ? $createSNHeadingNode(this.getTag()) : $createParagraphNode() + const children = this.getChildren() + children.forEach(child => newElement.append(child)) + this.replace(newElement) + return true + } + + // override + static importJSON (serializedNode) { + return $createSNHeadingNode(serializedNode.tag).updateFromJSON(serializedNode) + } + + static importDOM () { + return { + h1: node => ({ + conversion: $convertSNHeadingElement, + priority: 0 + }), + h2: node => ({ + conversion: $convertSNHeadingElement, + priority: 0 + }), + h3: node => ({ + conversion: $convertSNHeadingElement, + priority: 0 + }), + h4: node => ({ + conversion: $convertSNHeadingElement, + priority: 0 + }), + h5: node => ({ + conversion: $convertSNHeadingElement, + priority: 0 + }), + h6: node => ({ + conversion: $convertSNHeadingElement, + priority: 0 + }), + p: node => { + const paragraph = node + const firstChild = paragraph.firstChild + if (firstChild !== null && isGoogleDocsTitle(firstChild)) { + return { + conversion: () => ({ + node: null + }), + priority: 3 + } + } + return null + }, + span: node => { + if (isGoogleDocsTitle(node)) { + return { + conversion: domNode => { + return { + node: $createSNHeadingNode('h1') + } + }, + priority: 3 + } + } + return null + } + } + } +} + +export function $createSNHeadingNode (tag = 'h1') { + return $applyNodeReplacement(new SNHeadingNode(tag)) +} + +export function $isSNHeadingNode (node) { + return node instanceof SNHeadingNode +} diff --git a/lib/lexical/nodes/misc/toc.jsx b/lib/lexical/nodes/misc/toc.jsx new file mode 100644 index 0000000000..9a21df5440 --- /dev/null +++ b/lib/lexical/nodes/misc/toc.jsx @@ -0,0 +1,187 @@ +import { DecoratorNode, $applyNodeReplacement, $getRoot } from 'lexical' +import { $isSNHeadingNode } from '@/lib/lexical/nodes/misc/heading' + +// extract headings from root node +function $extractHeadingsFromRoot () { + const headings = [] + const root = $getRoot() + + const extractHeadings = (node) => { + const children = node.getChildren() + for (const child of children) { + if ($isSNHeadingNode(child)) { + const text = child.getTextContent() + const depth = parseInt(child.getTag().substring(1)) // h1 -> 1, h2 -> 2, etc. + const headingSlug = child.getSlug() + headings.push({ text, depth, slug: headingSlug }) + } + // recursively check children + if (child.getChildrenSize && child.getChildrenSize() > 0) { + extractHeadings(child) + } + } + } + + extractHeadings(root) + return headings +} + +// builds nested data structure from flat headings array +export function buildNestedTocStructure (headings) { + if (headings.length === 0) return [] + + const items = [] + const stack = [] + + for (const heading of headings) { + // pop stack until we find appropriate parent level + while (stack.length > 0 && heading.depth <= stack[stack.length - 1].depth) { + stack.pop() + } + + const item = { ...heading, children: [] } + + if (stack.length === 0) { + items.push(item) + } else { + stack[stack.length - 1].children.push(item) + } + + stack.push(item) + } + + return items +} + +// converts nested structure to DOM elements recursively +function buildHtmlFromStructure (items) { + const ul = document.createElement('ul') + + for (const item of items) { + const li = document.createElement('li') + const a = document.createElement('a') + a.setAttribute('href', `#${item.slug}`) + a.textContent = item.text + li.appendChild(a) + + if (item.children.length > 0) { + li.appendChild(buildHtmlFromStructure(item.children)) + } + + ul.appendChild(li) + } + + return ul +} + +function $convertTableOfContentsElement (domNode) { + if (domNode.hasAttribute('data-lexical-toc')) { + const node = $createTableOfContentsNode() + return { node } + } + return null +} + +export class TableOfContentsNode extends DecoratorNode { + static getType () { + return 'table-of-contents' + } + + static clone (node) { + return new TableOfContentsNode(node.__key) + } + + static importJSON (serializedNode) { + return $createTableOfContentsNode() + } + + exportJSON () { + return { + type: 'table-of-contents', + version: 1 + } + } + + createDOM (config) { + const domNode = document.createElement('div') + domNode.setAttribute('data-lexical-toc', 'true') + return domNode + } + + exportDOM (editor) { + return editor.getEditorState().read(() => { + const div = document.createElement('div') + div.setAttribute('data-lexical-toc', 'true') + const details = document.createElement('details') + details.setAttribute('class', 'sn__collapsible sn__toc') + + const summary = document.createElement('summary') + summary.setAttribute('class', 'sn__collapsible__header') + summary.textContent = 'table of contents' + details.appendChild(summary) + + const headings = $extractHeadingsFromRoot() + const structure = buildNestedTocStructure(headings) + + if (structure.length === 0) { + const emptyDiv = document.createElement('div') + emptyDiv.setAttribute('class', 'text-muted fst-italic') + emptyDiv.textContent = 'No headings found' + details.appendChild(emptyDiv) + return { element: details } + } + + const tocList = buildHtmlFromStructure(structure) + details.appendChild(tocList) + div.appendChild(details) + return { element: div } + }) + } + + static importDOM () { + return { + div: (domNode) => { + if (!domNode.hasAttribute('data-lexical-toc')) return null + return { conversion: $convertTableOfContentsElement, priority: 1 } + }, + details: (domNode) => { + if (!domNode.hasAttribute('data-lexical-toc')) return null + return { conversion: $convertTableOfContentsElement, priority: 1 } + }, + nav: (domNode) => { + if (!domNode.hasAttribute('data-lexical-toc')) return null + return { conversion: $convertTableOfContentsElement, priority: 1 } + } + } + } + + updateDOM () { + return false + } + + isInline () { + return false + } + + isSelectable () { + return true + } + + isIsolated () { + return true + } + + decorate (editor) { + const { TableOfContents } = require('@/components/editor/plugins/decorative/toc') + const headings = $extractHeadingsFromRoot() + return + } +} + +export function $createTableOfContentsNode () { + return $applyNodeReplacement(new TableOfContentsNode()) +} + +export function $isTableOfContentsNode (node) { + return node instanceof TableOfContentsNode +} diff --git a/lib/lexical/server/headless.js b/lib/lexical/server/headless.js new file mode 100644 index 0000000000..fb40024d8e --- /dev/null +++ b/lib/lexical/server/headless.js @@ -0,0 +1,64 @@ +import { createHeadlessEditor } from '@lexical/headless' +import DefaultTheme from '@/components/editor/theme' +import DefaultNodes from '@/lib/lexical/nodes' +import { createLinkeDOM } from '@/lib/dompurify' + +/** wraps a function with a fake DOM environment for SSR + * + * manages global DOM variables and cleans up after execution + * + * if! a DOM already exists (nested calls), reuses it to avoid creating multiple DOMs + * @param {Function} fn - function to wrap + * @returns {Object} result of the function + */ +export function withDOM (fn) { + const prevWindow = global.window + + // if window already exists, we're in a nested call and we'll reuse the existing DOM + if (prevWindow) { + return fn(prevWindow) + } + + // save previous global state + const prevDocument = global.document + + // create new DOM environment + const { window: newWindow, document: newDocument } = createLinkeDOM() + global.window = newWindow + global.document = newDocument + + try { + return fn(newWindow) + } finally { + // restore previous state and clean up + global.window = prevWindow + global.document = prevDocument + } +} + +/** creates a headless editor with SN default options + * @param {Object} options - editor options + * @param {string} options.namespace - editor namespace + * @param {Object} options.theme - editor theme + * @param {Array} options.nodes - editor nodes + * @param {Function} options.onError - error handler + * @returns {Object} headless editor instance + */ +export function $createSNHeadlessEditor (options = {}) { + // default values + const { + namespace = 'snSSR', + theme = DefaultTheme, + nodes = DefaultNodes, + onError = (error) => { + console.error(error) + } + } = options + + return createHeadlessEditor({ + namespace, + nodes, + theme, + onError + }) +} diff --git a/lib/lexical/server/html.js b/lib/lexical/server/html.js new file mode 100644 index 0000000000..e49f14cade --- /dev/null +++ b/lib/lexical/server/html.js @@ -0,0 +1,53 @@ +import { $generateHtmlFromNodes } from '@lexical/html' +import { sanitizeHTML } from '@/lib/dompurify' +import { withDOM, $createSNHeadlessEditor } from '@/lib/lexical/server/headless' + +/** + * generates HTML from Lexical state on server-side + * + * note: gets called by APIs to submit items, check performance + * @param {string} lexicalState - serialized lexical editor state + * @param {number} [itemId=null] - optional item ID for error logging + * @param {Object} [options={}] - generation options + * @param {boolean} [options.sanitize=true] - whether to sanitize HTML + * @param {Object} [options.selection=null] - optional selection for partial HTML generation + * @param {Object} [editorOptions={}] - editor configuration options + * @returns {string} generated HTML or error message + */ +export function lexicalHTMLGenerator (lexicalState, options = {}, editorOptions = {}) { + if (typeof window !== 'undefined') { + throw new Error('can\'t use $ssrLexicalHTMLGenerator in a client environment as it creates a DOM and sets global window and document') + } + + const { + sanitize = true, + selection = null + } = options + + return withDOM(window => { + const editor = $createSNHeadlessEditor(editorOptions) + + try { + editor.setEditorState(editor.parseEditorState(lexicalState)) + } catch (error) { + return 'error generating HTML, another attempt will be made. the text will be hydrated in a moment.' + } + let html = '' + + editor.update(() => { + try { + html = $generateHtmlFromNodes(editor, selection) + if (sanitize) { + html = sanitizeHTML(html, window) + } + } catch (error) { + // if the html conversion fails, we'll use the lexicalState directly + // this might be a problem for instant content + console.error('error generating HTML in SSR from Lexical State: ', error) + html = null + } + }) + + return html + }) +} diff --git a/lib/lexical/server/interpolator.js b/lib/lexical/server/interpolator.js new file mode 100644 index 0000000000..a2b3009898 --- /dev/null +++ b/lib/lexical/server/interpolator.js @@ -0,0 +1,57 @@ +import { $createSNHeadlessEditor } from '@/lib/lexical/server/headless' +import { $ssrCheckMediaNodes } from '@/lib/lexical/server/media/check' +import { $trimEmptyNodes } from '@/lib/lexical/utils' +import { setMarkdown } from '@/lib/lexical/utils/mdast' + +/** + * converts markdown to Lexical state or processes existing state + * @param {string} [params.text] - markdown text to convert + * @param {Object} [options={}] - processing options + * @param {boolean} [options.checkMedia=true] - whether to check media URLs + * @returns {Promise} object with text and lexicalState properties + */ +export async function prepareLexicalState ({ text }, { checkMedia = true } = {}) { + if (typeof window !== 'undefined') { + throw new Error('can\'t use prepareLexicalState in a client environment') + } + if (!text) { + throw new Error('text is required') + } + + const editor = $createSNHeadlessEditor() + + let hasError = false + + // transform the markdown text into a lexical state via MDAST + editor.update(() => { + setMarkdown(editor, text) + $trimEmptyNodes() + }) + + // get all the media nodes that are of unknown type, and check their type + // via capture-media-check + if (checkMedia) { + try { + await $ssrCheckMediaNodes(editor) + } catch (error) { + console.error('error checking media nodes: ', error) + // move on, media check nodes are not critical to the content + } + } + + let lexicalState = {} + editor.read(() => { + try { + lexicalState = editor.getEditorState().toJSON() + console.log('lexicalState: ', JSON.stringify(lexicalState, null, 2)) + } catch (error) { + console.error('error generating Lexical JSON State: ', error) + hasError = true + } + }) + + if (hasError) return null + + // prepared text and lexical state + return lexicalState +} diff --git a/lib/lexical/server/media/check.js b/lib/lexical/server/media/check.js new file mode 100644 index 0000000000..ec96f3f9ed --- /dev/null +++ b/lib/lexical/server/media/check.js @@ -0,0 +1,45 @@ +import { $getNodeByKey, $nodesOfType, $createTextNode } from 'lexical' +import { $createLinkNode } from '@lexical/link' +import { MediaNode } from '@/lib/lexical/nodes/content/media' +import { batchedCheckMedia } from '@/lib/lexical/exts/media-check' +import { UNKNOWN_LINK_REL } from '@/lib/constants' + +/** checks media nodes in batches + * @param {Object} editor - lexical editor instance + * @returns {Promise} void + */ +export async function $ssrCheckMediaNodes (editor) { + // get all the urls from the media nodes + const keys = [] + const urls = [] + editor.read(() => { + $nodesOfType(MediaNode).forEach(node => { + if (node.getKind() !== 'unknown' || node.getStatus() === 'done' || !node.getSrc()) return + keys.push(node.getKey()) + urls.push(node.getSrc()) + }) + }) + // check media nodes in batches + if (urls.length === 0) return + const map = await batchedCheckMedia(urls) + // apply the results to the media nodes + editor.update(() => { + for (const key of keys) { + const node = $getNodeByKey(key) + if (node instanceof MediaNode) { + const result = map.get(node.getSrc()) + if (result) { + const kind = result.type + if (kind === 'unknown') { + const url = node.getSrc() + const link = $createLinkNode(url, { target: '_blank', rel: UNKNOWN_LINK_REL }) + link.append($createTextNode(url)) + node.replace(link) + } else { + node.applyCheckResult(kind) + } + } + } + } + }) +} diff --git a/lib/lexical/utils/index.js b/lib/lexical/utils/index.js new file mode 100644 index 0000000000..5eb5caacb5 --- /dev/null +++ b/lib/lexical/utils/index.js @@ -0,0 +1,59 @@ +import { $getRoot, $createTextNode, $createParagraphNode } from 'lexical' + +export function $getMarkdown () { + return $getRoot().getTextContent() +} + +export function $setMarkdown (editor, markdown) { + $initializeEditorState(editor, markdown) +} + +export function $isMarkdownEmpty () { + return $getMarkdown().trim() === '' +} + +/** + * initializes editor state with markdown or rich text mode + * @param {boolean} markdown - whether to use markdown mode + * @param {Object} editor - lexical editor instance + * @param {string} [initialValue=''] - initial content + */ +export function $initializeEditorState (editor, initialValue = '') { + const root = $getRoot() + root + .clear() + .append($createParagraphNode() + .append($createTextNode(initialValue))).selectEnd() + // markdown transformations + // root.clear().append(...fromMdast(mdast)) +} + +/** removes empty nodes from the start and end of the root */ +export function $trimEmptyNodes () { + const root = $getRoot() + const children = root.getChildren() + + if (children.length === 0) return + + // first non-empty index + let startIdx = 0 + while (startIdx < children.length && children[startIdx]?.isEmpty?.()) { + startIdx++ + } + + // last non-empty index + let endIdx = children.length - 1 + while (endIdx >= startIdx && children[endIdx]?.isEmpty?.()) { + endIdx-- + } + + // remove empty nodes at start + for (let i = 0; i < startIdx; i++) { + children[i].remove() + } + + // remove empty nodes at end + for (let i = children.length - 1; i > endIdx; i--) { + children[i].remove() + } +} diff --git a/lib/lexical/utils/mdast.js b/lib/lexical/utils/mdast.js new file mode 100644 index 0000000000..ee713975b6 --- /dev/null +++ b/lib/lexical/utils/mdast.js @@ -0,0 +1,53 @@ +import { $getRoot } from 'lexical' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { mathFromMarkdown } from 'mdast-util-math' +import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote' +import { gfm } from 'micromark-extension-gfm' +import { gfmFootnote } from 'micromark-extension-gfm-footnote' +import { math } from 'micromark-extension-math' +import { + importMarkdownToLexical, + exportMarkdownFromLexical, + importVisitors, + exportVisitors, + createMentionTransform +} from '@/lib/lexical/mdast' + +const mentionTransform = createMentionTransform() + +export function setMarkdown (editor, markdown) { + editor.update(() => { + const root = $getRoot() + root.clear() + + importMarkdownToLexical({ + root, + markdown, + visitors: importVisitors, + syntaxExtensions: [ + gfm(), + math(), + gfmFootnote() + ], + mdastExtensions: [ + gfmFromMarkdown(), + mathFromMarkdown(), + gfmFootnoteFromMarkdown() + ], + mdastTransforms: [ + mentionTransform + ] + }) + }) +} + +export function getMarkdown (editor) { + return editor.getEditorState().read(() => { + return exportMarkdownFromLexical({ + root: $getRoot(), + visitors: exportVisitors, + toMarkdownExtensions: [], + toMarkdownOptions: {} + }) + }) +} diff --git a/next.config.js b/next.config.js index 496ec7509f..cd32e54923 100644 --- a/next.config.js +++ b/next.config.js @@ -289,6 +289,12 @@ module.exports = withPlausibleProxy()({ } ) + // linkedom references canvas but we're not using it + config.resolve.alias = { + ...(config.resolve.alias || {}), + canvas: false + } + return config } }) diff --git a/package-lock.json b/package-lock.json index f844d55d01..9b9b16af53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,15 @@ "@cashu/cashu-ts": "^2.4.1", "@graphile/depth-limit": "^0.3.1", "@graphql-tools/schema": "^10.0.6", + "@lexical/clipboard": "^0.38.2", + "@lexical/code-shiki": "^0.38.2", + "@lexical/headless": "^0.38.2", + "@lexical/history": "^0.38.2", + "@lexical/html": "^0.38.2", + "@lexical/react": "^0.38.2", + "@lexical/rich-text": "^0.38.2", + "@lexical/selection": "^0.38.2", + "@lexical/utils": "^0.38.2", "@lightninglabs/lnc-web": "^0.3.2-alpha", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", @@ -44,6 +53,7 @@ "cross-fetch": "^4.0.0", "csv-parser": "^3.0.0", "domino": "^2.1.6", + "dompurify": "^3.2.6", "formik": "^2.4.6", "github-slugger": "^2.0.0", "google-protobuf": "^3.21.4", @@ -52,6 +62,9 @@ "graphql-tag": "^2.12.6", "graphql-type-json": "^0.3.2", "isomorphic-ws": "^5.0.0", + "katex": "^0.16.22", + "lexical": "^0.38.2", + "linkedom": "^0.18.12", "ln-service": "^57.22.0", "macaroon": "^3.0.4", "mathjs": "^13.2.0", @@ -3089,20 +3102,22 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", - "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.8" + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", - "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.8" + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react": { @@ -3120,11 +3135,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", @@ -3132,9 +3148,10 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", - "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" }, "node_modules/@graphile/depth-limit": { "version": "0.3.1", @@ -4286,6 +4303,331 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@lexical/clipboard": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.38.2.tgz", + "integrity": "sha512-dDShUplCu8/o6BB9ousr3uFZ9bltR+HtleF/Tl8FXFNPpZ4AXhbLKUoJuucRuIr+zqT7RxEv/3M6pk/HEoE6NQ==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.38.2", + "@lexical/list": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/code": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.38.2.tgz", + "integrity": "sha512-wpqgbmPsfi/+8SYP0zI2kml09fGPRhzO5litR9DIbbSGvcbawMbRNcKLO81DaTbsJRnBJiQvbBBBJAwZKRqgBw==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.38.2", + "lexical": "0.38.2", + "prismjs": "^1.30.0" + } + }, + "node_modules/@lexical/code-shiki": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/code-shiki/-/code-shiki-0.38.2.tgz", + "integrity": "sha512-zt1RQabP6FmCR3ysjbCOct1ly5Lmqe5zZ5zE6HbLuxhGnIKaKM6M+P8P1ckZKwosWslhDOB5eQlPd1BRT+t86w==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.38.2", + "@lexical/utils": "0.38.2", + "@shikijs/core": "^3.7.0", + "@shikijs/engine-javascript": "^3.7.0", + "@shikijs/langs": "^3.7.0", + "@shikijs/themes": "^3.7.0", + "lexical": "0.38.2", + "shiki": "^3.7.0" + } + }, + "node_modules/@lexical/code/node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.38.2.tgz", + "integrity": "sha512-hlN0q7taHNzG47xKynQLCAFEPOL8l6IP79C2M18/FE1+htqNP35q4rWhYhsptGlKo4me4PtiME7mskvr7T4yqA==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.38.2", + "@lexical/link": "0.38.2", + "@lexical/mark": "0.38.2", + "@lexical/table": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.38.2.tgz", + "integrity": "sha512-riOhgo+l4oN50RnLGhcqeUokVlMZRc+NDrxRNs2lyKSUdC4vAhAmAVUHDqYPyb4K4ZSw4ebZ3j8hI2zO4O3BbA==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/extension": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.38.2.tgz", + "integrity": "sha512-qbUNxEVjAC0kxp7hEMTzktj0/51SyJoIJWK6Gm790b4yNBq82fEPkksfuLkRg9VQUteD0RT1Nkjy8pho8nNamw==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.38.2", + "@preact/signals-core": "^1.11.0", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.38.2.tgz", + "integrity": "sha512-jNI4Pv+plth39bjOeeQegMypkjDmoMWBMZtV0lCynBpkkPFlfMnyL9uzW/IxkZnX8LXWSw5mbWk07nqOUNTCrA==", + "license": "MIT", + "dependencies": { + "@lexical/text": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/headless": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/headless/-/headless-0.38.2.tgz", + "integrity": "sha512-HFlQrw1pSSia3hGHz/OnUBAVMAGDBLSIJrqiHTst/LhDWzY5Um6PZ5Yc5DhTOtWaOp89d/Gl1SPKj935akVNZQ==", + "license": "MIT", + "dependencies": { + "happy-dom": "^20.0.0", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/history": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.38.2.tgz", + "integrity": "sha512-QWPwoVDMe/oJ0+TFhy78TDi7TWU/8bcDRFUNk1nWgbq7+2m+5MMoj90LmOFwakQHnCVovgba2qj+atZrab1dsQ==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/html": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.38.2.tgz", + "integrity": "sha512-pC5AV+07bmHistRwgG3NJzBMlIzSdxYO6rJU4eBNzyR4becdiLsI4iuv+aY7PhfSv+SCs7QJ9oc4i5caq48Pkg==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/link": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.38.2.tgz", + "integrity": "sha512-UOKTyYqrdCR9+7GmH6ZVqJTmqYefKGMUHMGljyGks+OjOGZAQs78S1QgcPEqltDy+SSdPSYK7wAo6gjxZfEq9g==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/list": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.38.2.tgz", + "integrity": "sha512-OQm9TzatlMrDZGxMxbozZEHzMJhKxAbH1TOnOGyFfzpfjbnFK2y8oLeVsfQZfZRmiqQS4Qc/rpFnRP2Ax5dsbA==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/mark": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.38.2.tgz", + "integrity": "sha512-U+8KGwc3cP5DxSs15HfkP2YZJDs5wMbWQAwpGqep9bKphgxUgjPViKhdi+PxIt2QEzk7WcoZWUsK1d2ty/vSmg==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.38.2.tgz", + "integrity": "sha512-ykQJ9KUpCs1+Ak6ZhQMP6Slai4/CxfLEGg/rSHNVGbcd7OaH/ICtZN5jOmIe9ExfXMWy1o8PyMu+oAM3+AWFgA==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.38.2", + "@lexical/link": "0.38.2", + "@lexical/list": "0.38.2", + "@lexical/rich-text": "0.38.2", + "@lexical/text": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/offset": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.38.2.tgz", + "integrity": "sha512-uDky2palcY+gE6WTv6q2umm2ioTUnVqcaWlEcchP6A310rI08n6rbpmkaLSIh3mT2GJQN2QcN2x0ct5BQmKIpA==", + "license": "MIT", + "dependencies": { + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.38.2.tgz", + "integrity": "sha512-f6vkTf+YZF0EuKvUK3goh4jrnF+Z0koiNMO+7rhSMLooc5IlD/4XXix4ZLiIktUWq4BhO84b82qtrO+6oPUxtw==", + "license": "MIT", + "dependencies": { + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.38.2.tgz", + "integrity": "sha512-xRYNHJJFCbaQgr0uErW8Im2Phv1nWHIT4VSoAlBYqLuVGZBD4p61dqheBwqXWlGGJFk+MY5C5URLiMicgpol7A==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.38.2", + "@lexical/dragon": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/react": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.38.2.tgz", + "integrity": "sha512-M3z3MkWyw3Msg4Hojr5TnO4TzL71NVPVNGoavESjdgJbTdv1ezcQqjE4feq+qs7H9jytZeuK8wsEOJfSPmNd8w==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@lexical/devtools-core": "0.38.2", + "@lexical/dragon": "0.38.2", + "@lexical/extension": "0.38.2", + "@lexical/hashtag": "0.38.2", + "@lexical/history": "0.38.2", + "@lexical/link": "0.38.2", + "@lexical/list": "0.38.2", + "@lexical/mark": "0.38.2", + "@lexical/markdown": "0.38.2", + "@lexical/overflow": "0.38.2", + "@lexical/plain-text": "0.38.2", + "@lexical/rich-text": "0.38.2", + "@lexical/table": "0.38.2", + "@lexical/text": "0.38.2", + "@lexical/utils": "0.38.2", + "@lexical/yjs": "0.38.2", + "lexical": "0.38.2", + "react-error-boundary": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/react/node_modules/@floating-ui/react": { + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.38.2.tgz", + "integrity": "sha512-eFjeOT7YnDZYpty7Zlwlct0UxUSaYu53uLYG+Prs3NoKzsfEK7e7nYsy/BbQFfk5HoM1pYuYxFR2iIX62+YHGw==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.38.2", + "@lexical/dragon": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/selection": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.38.2.tgz", + "integrity": "sha512-eMFiWlBH6bEX9U9sMJ6PXPxVXTrihQfFeiIlWLuTpEIDF2HRz7Uo1KFRC/yN6q0DQaj7d9NZYA6Mei5DoQuz5w==", + "license": "MIT", + "dependencies": { + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/table": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.38.2.tgz", + "integrity": "sha512-uu0i7yz0nbClmHOO5ZFsinRJE6vQnFz2YPblYHAlNigiBedhqMwSv5bedrzDq8nTTHwych3mC63tcyKIrM+I1g==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.38.2", + "@lexical/extension": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/text": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.38.2.tgz", + "integrity": "sha512-+juZxUugtC4T37aE3P0l4I9tsWbogDUnTI/mgYk4Ht9g+gLJnhQkzSA8chIyfTxbj5i0A8yWrUUSw+/xA7lKUQ==", + "license": "MIT", + "dependencies": { + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/utils": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.38.2.tgz", + "integrity": "sha512-y+3rw15r4oAWIEXicUdNjfk8018dbKl7dWHqGHVEtqzAYefnEYdfD2FJ5KOTXfeoYfxi8yOW7FvzS4NZDi8Bfw==", + "license": "MIT", + "dependencies": { + "@lexical/list": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/table": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.38.2.tgz", + "integrity": "sha512-fg6ZHNrVQmy1AAxaTs8HrFbeNTJCaCoEDPi6pqypHQU3QVfqr4nq0L0EcHU/TRlR1CeduEPvZZIjUUxWTZ0u8g==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.38.2", + "@lexical/selection": "0.38.2", + "lexical": "0.38.2" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, "node_modules/@lightninglabs/lnc-core": { "version": "0.3.2-alpha", "resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.2-alpha.tgz", @@ -5165,6 +5507,16 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@preact/signals-core": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz", + "integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@prisma/client": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.20.0.tgz", @@ -5611,6 +5963,91 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@shikijs/core": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.15.0.tgz", + "integrity": "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.15.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/core/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.15.0.tgz", + "integrity": "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.15.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.3" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz", + "integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.15.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz", + "integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.15.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz", + "integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.15.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz", + "integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/types/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@shocknet/clink-sdk": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@shocknet/clink-sdk/-/clink-sdk-1.4.0.tgz", @@ -6696,6 +7133,12 @@ "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", @@ -8296,6 +8739,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/character-entities-legacy": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", @@ -8895,6 +9348,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", @@ -9508,10 +9967,20 @@ "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", "integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==" }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -11487,6 +11956,35 @@ "graphql": ">=0.8.0" } }, + "node_modules/happy-dom": { + "version": "20.0.10", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.10.tgz", + "integrity": "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g==", + "license": "MIT", + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -11734,6 +12232,48 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/hast-util-to-html/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.2.0.tgz", @@ -11911,6 +12451,47 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http_ece": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", @@ -15105,13 +15686,14 @@ } }, "node_modules/katex": { - "version": "0.16.11", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz", - "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==", + "version": "0.16.25", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz", + "integrity": "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], + "license": "MIT", "dependencies": { "commander": "^8.3.0" }, @@ -15165,6 +15747,12 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.38.2.tgz", + "integrity": "sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==", + "license": "MIT" + }, "node_modules/light-bolt11-decoder": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", @@ -15263,6 +15851,36 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/linkedom/node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, "node_modules/ln-service": { "version": "57.22.0", "resolved": "https://registry.npmjs.org/ln-service/-/ln-service-57.22.0.tgz", @@ -16991,6 +17609,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, "node_modules/openid-client": { "version": "5.4.3", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.3.tgz", @@ -18190,6 +18825,18 @@ "react": "^18.3.1" } }, + "node_modules/react-error-boundary": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", + "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-fast-compare": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", @@ -18506,6 +19153,30 @@ "@babel/runtime": "^7.8.4" } }, + "node_modules/regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", + "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -19319,6 +19990,31 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.15.0.tgz", + "integrity": "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.15.0", + "@shikijs/engine-javascript": "3.15.0", + "@shikijs/engine-oniguruma": "3.15.0", + "@shikijs/langs": "3.15.0", + "@shikijs/themes": "3.15.0", + "@shikijs/types": "3.15.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/shiki/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -20061,6 +20757,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities/node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -20940,6 +21660,12 @@ "integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==", "license": "MIT" }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "license": "ISC" + }, "node_modules/uint8array-tools": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", @@ -22436,9 +23162,10 @@ } }, "node_modules/zwitch": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.2.tgz", - "integrity": "sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" diff --git a/package.json b/package.json index 9bf9841831..6217a0ecad 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,15 @@ "@cashu/cashu-ts": "^2.4.1", "@graphile/depth-limit": "^0.3.1", "@graphql-tools/schema": "^10.0.6", + "@lexical/clipboard": "^0.38.2", + "@lexical/code-shiki": "^0.38.2", + "@lexical/headless": "^0.38.2", + "@lexical/history": "^0.38.2", + "@lexical/html": "^0.38.2", + "@lexical/react": "^0.38.2", + "@lexical/rich-text": "^0.38.2", + "@lexical/selection": "^0.38.2", + "@lexical/utils": "^0.38.2", "@lightninglabs/lnc-web": "^0.3.2-alpha", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", @@ -49,6 +58,7 @@ "cross-fetch": "^4.0.0", "csv-parser": "^3.0.0", "domino": "^2.1.6", + "dompurify": "^3.2.6", "formik": "^2.4.6", "github-slugger": "^2.0.0", "google-protobuf": "^3.21.4", @@ -57,6 +67,9 @@ "graphql-tag": "^2.12.6", "graphql-type-json": "^0.3.2", "isomorphic-ws": "^5.0.0", + "katex": "^0.16.22", + "lexical": "^0.38.2", + "linkedom": "^0.18.12", "ln-service": "^57.22.0", "macaroon": "^3.0.4", "mathjs": "^13.2.0", @@ -139,5 +152,8 @@ "eslint": "^9.12.0", "jest": "^29.7.0", "standard": "^17.1.2" + }, + "overrides": { + "happy-dom": "^20.0.2" } } diff --git a/pages/_app.js b/pages/_app.js index 4745e8fe47..f62db53798 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,4 +1,5 @@ import '@/styles/globals.scss' +import '@/styles/text.scss' import { ApolloProvider, gql } from '@apollo/client' import { MeProvider } from '@/components/me' import PlausibleProvider from 'next-plausible' diff --git a/pages/invites/index.js b/pages/invites/index.js index fbccd5284f..cc6bcded48 100644 --- a/pages/invites/index.js +++ b/pages/invites/index.js @@ -10,7 +10,7 @@ import { inviteSchema } from '@/lib/validate' import { SSR } from '@/lib/constants' import { getGetServerSideProps } from '@/api/ssrApollo' import Info from '@/components/info' -import Text from '@/components/text' +import { LegacyText } from '@/components/text' // force SSR to include CSP nonces export const getServerSideProps = getGetServerSideProps({ query: null }) @@ -91,10 +91,10 @@ function InviteForm () {
    description optional - + A brief description to keep track of the invite purpose, such as "Shared in group chat". This description is private and visible only to you. - +
    diff --git a/prisma/migrations/20251120022830_editor/migration.sql b/prisma/migrations/20251120022830_editor/migration.sql new file mode 100644 index 0000000000..f8eb9bfe5e --- /dev/null +++ b/prisma/migrations/20251120022830_editor/migration.sql @@ -0,0 +1,64 @@ +-- AlterTable - Lexical Editor support +-- lexicalState is the raw JSON state of the editor +-- html is the sanitized HTML result of the editor +ALTER TABLE "Item" ADD COLUMN "lexicalState" JSONB, ADD COLUMN "html" TEXT; + +-- CreateEnum +CREATE TYPE "LexicalMigrationType" AS ENUM ('LEXICAL_CONVERSION', 'HTML_GENERATION', 'UNEXPECTED'); + +-- CreateTable +CREATE TABLE "LexicalMigrationLog" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "itemId" INTEGER NOT NULL, + "type" "LexicalMigrationType" NOT NULL, + "retryCount" INTEGER NOT NULL DEFAULT 0, + "message" TEXT NOT NULL, + + CONSTRAINT "LexicalMigrationLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LexicalBatchMigrationLog" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "durationMs" INTEGER NOT NULL, + "successCount" INTEGER NOT NULL, + "failureCount" INTEGER NOT NULL, + "summary" JSONB, + + CONSTRAINT "LexicalBatchMigrationLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "LexicalMigrationLog_type_idx" ON "LexicalMigrationLog"("type"); + +-- CreateIndex +CREATE INDEX "LexicalMigrationLog_retryCount_idx" ON "LexicalMigrationLog"("retryCount"); + +-- CreateIndex +CREATE INDEX "LexicalMigrationLog_itemId_idx" ON "LexicalMigrationLog"("itemId"); + +-- CreateIndex +CREATE INDEX "LexicalBatchMigrationLog_created_at_idx" ON "LexicalBatchMigrationLog"("created_at"); + +-- AddForeignKey +ALTER TABLE "LexicalMigrationLog" ADD CONSTRAINT "LexicalMigrationLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "LexicalMigrationLog_itemId_key" ON "LexicalMigrationLog"("itemId"); + +-- ensure we can't submit duplicate lexical states within 10 minutes +-- ALTER TABLE "Item" DROP CONSTRAINT "Item_unique_time_constraint"; +-- ALTER TABLE "Item" ADD CONSTRAINT "Item_unique_time_constraint" +-- EXCLUDE USING gist ( +-- "userId" WITH =, +-- COALESCE("parentId", -1) WITH =, +-- md5(COALESCE("title", '')) WITH =, +-- md5(COALESCE("subName", '')) WITH =, +-- md5(COALESCE("text", '')) WITH =, +-- tsrange(created_at, created_at + INTERVAL '10 minutes') WITH && +-- ) +-- -- update constraint date +-- WHERE (created_at > '2025-10-24' AND "deletedAt" IS NULL); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6d080d45f4..c41b3908bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -466,6 +466,8 @@ model Item { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") title String? text String? + lexicalState Json? @db.JsonB + html String? url String? userId Int parentId Int? @@ -546,6 +548,7 @@ model Item { randPollOptions Boolean @default(false) itemPayIns ItemPayIn[] CommentsViewAt CommentsViewAt[] + LexicalMigrationLog LexicalMigrationLog[] @@index([uploadId]) @@index([lastZapAt]) @@ -1986,3 +1989,36 @@ model AggRewards { @@unique([granularity, timeBucket, payInType], map: "AggRewards_unique_key") } + +model LexicalMigrationLog { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + itemId Int @unique + type LexicalMigrationType + retryCount Int @default(0) + message String + + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@index([type]) + @@index([retryCount]) + @@index([itemId]) +} + +enum LexicalMigrationType { + LEXICAL_CONVERSION + HTML_GENERATION + UNEXPECTED +} + +model LexicalBatchMigrationLog { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + durationMs Int + successCount Int + failureCount Int + summary Json? @db.JsonB + + @@index([createdAt]) +} diff --git a/styles/globals.scss b/styles/globals.scss index 8d2d174a49..73cb328a7a 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -448,7 +448,7 @@ a:hover { } select, -div[contenteditable], +div[contenteditable]:not([data-lexical-decorator="true"]):not([data-lexical-editor="true"]), .form-control { background-color: var(--theme-inputBg); color: var(--bs-body-color); @@ -490,7 +490,7 @@ select:focus { } -div[contenteditable]:focus, +div[contenteditable]:not([data-lexical-decorator="true"]):not([data-lexical-editor="true"]):focus, .form-control:focus { background-color: var(--theme-inputBg); color: var(--bs-body-color); @@ -498,7 +498,7 @@ div[contenteditable]:focus, box-shadow: 0 0 0 0.2rem rgb(250 218 94 / 25%); } -div[contenteditable]:disabled, +div[contenteditable]:not([data-lexical-decorator="true"]):not([data-lexical-editor="true"]):disabled, .form-control:disabled, .form-control[readonly] { background-color: var(--theme-inputDisabledBg); @@ -632,8 +632,8 @@ footer { textarea, .form-control, .form-control:focus, - div[contenteditable], - div[contenteditable]:focus, + div[contenteditable]:not([data-lexical-decorator="true"]):not([data-lexical-editor="true"]), + div[contenteditable]:not([data-lexical-decorator="true"]):not([data-lexical-editor="true"]):focus, .input-group-text { font-size: 1rem !important; } @@ -646,7 +646,7 @@ footer { } textarea.form-control, -div[contenteditable] { +div[contenteditable]:not([data-lexical-decorator="true"]):not([data-lexical-editor="true"]) { line-height: 1rem; } @@ -747,7 +747,7 @@ header .navbar:not(:first-child) { text-shadow: 0 0 10px var(--bs-primary); } -div[contenteditable]:focus, +div[contenteditable]:not([data-lexical-decorator="true"]):not([data-lexical-editor="true"]):focus, .form-control:focus { border-color: var(--bs-primary); } diff --git a/styles/text.scss b/styles/text.scss new file mode 100644 index 0000000000..b5754cf7a6 --- /dev/null +++ b/styles/text.scss @@ -0,0 +1,706 @@ +@import 'katex/dist/katex.min.css'; + +.sn__text { + font-size: 94%; + font-family: inherit; + word-break: break-word; + overflow-y: hidden; + overflow-x: hidden; + position: relative; + max-height: 200vh; + --grid-gap: 0.5rem; +} + +.sn__text p { + margin-bottom: 0 !important; +} + +.sn__textTruncated { + max-height: 50vh; +} + +.sn__text[contenteditable="false"] { + background-color: var(--theme-body) !important; +} + +.sn__text.sn__topLevel { + max-height: 200vh; + --grid-gap: 0.75rem; +} + +.sn__textUncontained { + max-height: none !important; +} + +.sn__textContained::before { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 50vh; + pointer-events: none; + z-index: 1; + background: linear-gradient(rgba(255, 255, 255, 0), var(--bs-body-bg) 200%); +} + +.sn__textShowFull { + position: absolute; + bottom: 0; + z-index: 2; + border-radius: 0; +} + +.sn__text.sn__paragraph { + display: block; + white-space: pre-wrap; + word-break: break-word; + padding-top: calc(var(--grid-gap) * 0.5); + padding-bottom: calc(var(--grid-gap) * 0.5); +} + +.sn__text>*:not(.sn__heading, .sn__toc, .sn__spoiler__container, .sn__codeBlock) { + padding-top: calc(var(--grid-gap) * 0.5); + padding-bottom: calc(var(--grid-gap) * 0.5); +} + +.sn__text pre, .sn__text blockquote { + margin-top: calc(var(--grid-gap) * 0.5); + margin-bottom: calc(var(--grid-gap) * 0.5); +} + +.sn__text>*:last-child:not(.sn__textShowFull, .sn__codeBlock) { + padding-bottom: 0 !important; + margin-bottom: 0 !important; +} + +.sn__text>*:first-child:not(.sn__codeBlock) { + padding-top: 0 !important; + margin-top: 0 !important; +} + +.sn__text blockquote, .sn__text.sn__topLevel blockquote { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.sn__text blockquote *:first-child, .sn__text.sn__topLevel blockquote *:first-child { + padding-top: 0; +} + +.sn__text blockquote *:last-child, .sn__text.sn__topLevel blockquote *:last-child { + padding-bottom: 0; +} + +@media screen and (min-width: 767px) { + .sn__text { + line-height: 130% !important; + } +} + +.sn__paragraph { + display: block; + white-space: pre-wrap; + word-break: break-word; +} + +.sn__paragraph mark { + background-color: unset; +} + +.sn__hr { + border-top: 3px solid var(--theme-quoteBar); + padding: 0 !important; + caret-color: transparent; +} + +[contenteditable="true"] .sn__hr.selected { + opacity: 1 !important; +} + +.sn__headings { + margin-top: 0.75rem; + margin-bottom: 0.5rem; + font-size: 1rem; + position: relative; +} + +.sn__topLevel h1.sn__headings { + font-size: 1.6rem; +} + +.sn__topLevel h2.sn__headings { + font-size: 1.45rem; +} + +.sn__topLevel h3.sn__headings { + font-size: 1.3rem; +} + +.sn__topLevel h4.sn__headings { + font-size: 1.15rem; +} + +/* blocks */ +.sn__quote { + border-left: 3px solid var(--theme-quoteBar); + padding-left: .75rem; + padding-top: 0 !important; + padding-bottom: 0 !important; + margin-top: calc(var(--grid-gap) * 0.5); + margin-bottom: calc(var(--grid-gap) * 0.5); +} + +.sn__quote *:first-child { + padding-top: 0; +} + +.sn__quote *:last-child { + padding-bottom: 0; +} + +span.sn__mediaContainer { + cursor: default; + display: inline-block; + position: relative; + user-select: none; + --max-width: 500px; +} + +span.sn__mediaContainer .outlawed { + user-select: text; + cursor: text; +} + +.sn__mediaContainer img, +.sn__mediaContainer video { + max-width: var(--max-width); + max-height: var(--max-width); + display: block; + cursor: default; + aspect-ratio: var(--aspect-ratio); +} + +[contenteditable="false"] .sn__mediaContainer img { + cursor: zoom-in; + min-width: 30%; + object-position: left top; +} + +[contenteditable="true"] .sn__mediaContainer .focused img, [contenteditable="true"] .sn__mediaContainer .focused video { + outline: 2px solid rgb(60, 132, 244); + user-select: none; +} + +[contenteditable="true"] .sn__mediaContainer .focused.draggable img, [contenteditable="true"] .sn__mediaContainer .focused.draggable video { + cursor: grab; +} + +[contenteditable="true"] .sn__mediaContainer .focused.draggable:active img, [contenteditable="true"] .sn__mediaContainer .focused.draggable:active video { + cursor: grabbing; +} + +.sn__image { + cursor: zoom-in; + min-width: 30%; + max-width: 100%; + object-position: left top; +} + +.sn__link { + color: var(--theme-link); + text-decoration: none; +} + +span + .sn__link { + margin-left: 0px; +} + +.sn__userMention, +.sn__territoryMention, +.sn__itemMention { + color: var(--theme-link); + font-weight: bold; +} + +.sn__blockCursor { + display: block; + pointer-events: none; + position: absolute; +} +.sn__blockCursor:after { + content: ''; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid var(--bs-body-color); + animation: CursorBlink 1.1s steps(2, start) infinite; +} +@keyframes CursorBlink { + to { + visibility: hidden; + } +} + +.sn__twitterContainer, +.sn__nostrContainer, +.sn__videoWrapper, +.sn__wavlakeWrapper, +.sn__spotifyWrapper { + margin-top: calc(var(--grid-gap) * 0.5); + margin-bottom: calc(var(--grid-gap) * 0.5); + background-color: var(--theme-body) !important; +} + +.sn__embedWrapper { + user-select: none; + pointer-events: auto; +} + +[contenteditable="true"] .sn__embedWrapperFocus { + outline: 2px solid rgb(60, 132, 244); +} + +.sn__embedWrapper__loading { + display: flex; + flex-flow: row wrap; + max-width: 350px; + width: 100%; + padding-right: 12px; +} + +.sn__embedWrapper__loading__message { + justify-content: center; + display: flex; + flex-direction: column; + border-radius: 0.4rem; + height: 150px; + width: 100%; + padding: 1.5rem; + background-color: var(--theme-commentBg); +} + +.sn__embedWrapper__loading__message svg { + width: 24px; + height: 24px; + margin-bottom: 1rem; + margin-left: -0.15rem; +} + +.sn__listOl, .sn__listUl { + margin-top: 0; + margin-bottom: 0rem; + padding-left: 2rem; + max-width: calc(100% - 1rem); +} + +/* list item padding, lookbehind for nested list items */ +.sn__listItem:has(> span) { + margin-top: .25rem; + + .sn__nestedListItem & { + padding-top: 0; + padding-bottom: 0; + } +} + +.sn__listItem { + margin-top: .25rem; +} + +.sn__listItemChecked, .sn__listItemUnchecked { + position: relative; + margin-left: 0.5em; + margin-right: 0.5em; + padding-left: 1.5em; + padding-right: 1.5em; + list-style-type: none; + outline: none; + display: block; + min-height: 1.5em; +} + +.sn__listItemChecked > *, .sn__listItemUnchecked > * { + margin-left: 0.01em; +} + +.sn__listItemChecked:before, .sn__listItemUnchecked:before { + content: '\200B'; + width: 0.9em; + height: 0.9em; + top: 45%; + left: 0; + cursor: pointer; + display: block; + background-size: cover; + position: absolute; + transform: translateY(-50%); +} + +.sn__listItemChecked { + text-decoration: line-through; +} + +.sn__listItemChecked:focus:before, .sn__listItemUnchecked:focus:before { + box-shadow: 0 0 0 2px #a6cdfe; + border-radius: 2px; +} + +.sn__listItemUnchecked:before { + border: 1px solid #999; + border-radius: 2px; +} + +.sn__listItemChecked:before { + border: 1px solid var(--bs-primary); + border-radius: 2px; + background-color: var(--bs-primary); + background-repeat: no-repeat; +} + +.sn__listItemChecked:after { + content: ''; + cursor: pointer; + border-color: #000; + border-style: solid; + position: absolute; + display: block; + top: 45%; + width: 0.2em; + left: 0.35em; + height: 0.4em; + transform: translateY(-50%) rotate(45deg); + border-width: 0 0.1em 0.1em 0; +} + +[contenteditable="false"] .sn__listItemChecked:before, +[contenteditable="false"] .sn__listItemUnchecked:before { + cursor: default; + pointer-events: none; +} + +[contenteditable="false"] .sn__listItemChecked:before, +[contenteditable="false"] .sn__listItemUnchecked:before { + opacity: 0.5; + filter: grayscale(0.3); +} + +[contenteditable="false"] .sn__listItemChecked:after { + cursor: default; + pointer-events: none; + opacity: 0.5; +} + +[contenteditable="false"] .sn__listItemChecked:focus:before, +[contenteditable="false"] .sn__listItemUnchecked:focus:before { + box-shadow: 0 0 0 0; + border-radius: 2px; +} + +/* shared collapsible for ToC and spoilers */ +.sn__collapsible { + background-color: var(--theme-commentBg); + border: 1px solid var(--theme-borderColor); + border-radius: 0.4rem; + margin-top: calc(var(--grid-gap) * 0.5); + margin-bottom: calc(var(--grid-gap) * 0.5); + max-width: fit-content; +} + +.sn__collapsible__header { + display: flex; + align-items: center; + width: 100%; + padding: 0.5rem 0.5rem 0.5rem 0.25rem; + cursor: pointer; + color: var(--bs-body-color); + transition: background-color 0.15s ease-in-out; +} + +.sn__collapsible__header:hover { + background-color: var(--theme-clickToContextColor); +} + +/* svg is imported like this for 1:1 html-lexical compatibility */ +.sn__collapsible__header::before { + content: ''; + width: 20px; + height: 20px; + display: inline-block; + background-color: var(--bs-body-color); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11.9999 13.1714L16.9497 8.22168L18.3639 9.63589L11.9999 15.9999L5.63599 9.63589L7.0502 8.22168L11.9999 13.1714Z'/%3E%3C/svg%3E") no-repeat center / contain; + transform: rotate(-90deg); + flex-shrink: 0; +} + +.sn__collapsible[open] > .sn__collapsible__header::before { + transform: rotate(0deg); +} + +/* table of contents specific */ +.sn__toc { + padding: 0; +} + +.sn__toc > summary { + font-weight: bold; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + list-style: none; +} + +.sn__toc ul { + margin-left: 2rem; + margin-right: 1rem; + margin-bottom: 1rem; + padding-left: 0; + line-height: 1.6; + list-style-type: disc; +} + +.sn__toc > ul { + padding-left: 0; + margin-top: 0.5rem; +} + +.sn__toc ul ul { + margin-top: 0.25rem; + margin-bottom: 0.25rem; + list-style-type: circle; +} + +.sn__toc li { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +/* can select the entire ToC node if we're inside an editor */ +[contenteditable="true"] [data-lexical-toc].selected .sn__toc { + background-color: var(--theme-clickToContextColor); + outline: 1px solid var(--bs-primary); +} + +/* spoiler specific */ +.sn__spoiler__title { + padding: 0rem 0.5rem 0rem 0.25rem; +} + +.sn__spoiler__content { + padding-left: 0.4rem; + padding-right: 0.4rem; + padding-bottom: 0.15rem; +} + +.sn__spoiler__collapsed .sn__spoiler__content { + display: none; + user-select: none; +} + +.sn__textBold { + font-weight: bold; +} + +.sn__textItalic { + font-style: italic; +} + +.sn__textUnderline { + text-decoration: underline; +} + +.sn__textStrikethrough { + text-decoration: line-through; +} + +.sn__textUnderlineStrikethrough { + text-decoration: underline line-through; +} + +.sn__text sup, +.sn__text sub { + vertical-align: baseline; + font-size: inherit; + position: static; +} + +.sn__textSuperscript { + vertical-align: super; + font-size: smaller; +} + +.sn__textSubscript { + vertical-align: sub; + font-size: smaller; +} + +.sn__textHighlight { + background-color: #fada5e5e; + padding: 0 0.2rem; + color: var(--bs-body-color); +} + +/* inline */ + +.sn__codeBlock { + position: relative; + background-color: var(--theme-commentBg) !important; + font-family: Menlo, Consolas, Monaco, monospace; + display: block; + line-height: 1.53; + font-size: 13px; + margin: 0; + margin-top: 8px; + margin-bottom: 8px; + padding: 0.5em !important; /* conflict with blockquote children padding */ + overflow-y: hidden; + overflow-x: auto; + position: relative; + tab-size: 2; + white-space: pre; + scrollbar-width: thin; + scrollbar-color: var(--theme-borderColor) var(--theme-commentBg); + border-radius: 0.3rem; +} + +.sn__math { + cursor: default; + user-select: none; +} + +.sn__math.focused { + outline: 2px solid rgb(60, 132, 244); +} + +.sn__table { + border-collapse: separate; + border-spacing: 0; + overflow-y: scroll; + overflow-x: scroll; + table-layout: fixed; + width: auto; + margin-top: 10px; + margin-bottom: 10px; +} + +.sn__tableSelection *::selection { + background-color: transparent; +} +.sn__tableSelected { + outline: 2px solid rgb(60, 132, 244); +} +.sn__tableCell { + border: 1px solid var(--theme-borderColor); + min-width: 75px; + vertical-align: top; + text-align: start; + padding: .3rem .75rem; + position: relative; + outline: none; + overflow: auto; + line-height: 1.2; +} + +/* + lexical dev notes: + A firefox workaround to allow scrolling of overflowing table cell + ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1904159 +*/ +.sn__tableCell > * { + overflow: inherit; +} +.sn__tableCellResizer { + position: absolute; + right: -4px; + height: 100%; + width: 8px; + cursor: ew-resize; + z-index: 10; + top: 0; +} +.sn__tableCellHeader { + background-color: var(--theme-commentBg); + text-align: start; +} +.sn__tableCellSelected { + caret-color: transparent; +} +.sn__tableCellSelected::after { + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 0; + background-color: highlight; + mix-blend-mode: multiply; + content: ''; + pointer-events: none; +} + +.sn__tableCellResizeRuler { + display: block; + position: absolute; + width: 1px; + background-color: rgb(60, 132, 244); + height: 100%; + top: 0; +} +.sn__tableCellActionButtonContainer { + display: block; + right: 5px; + top: 6px; + position: absolute; + z-index: 4; + width: 20px; + height: 20px; +} +.sn__tableCellActionButton { + background-color: #eee; + display: block; + border: 0; + border-radius: 20px; + width: 20px; + height: 20px; + color: #222; + cursor: pointer; +} +.sn__tableCellActionButton:hover { + background-color: #ddd; +} +.sn__tableScrollableWrapper { + overflow-x: auto; + overflow-y: hidden; + margin: 10px 0 10px 0; + scrollbar-width: thin; + scrollbar-color: var(--theme-borderColor) transparent; +} + +.sn__tableScrollableWrapper::-webkit-scrollbar { + height: 8px; +} + +.sn__tableScrollableWrapper::-webkit-scrollbar-track { + background: transparent; +} + +.sn__tableScrollableWrapper::-webkit-scrollbar-thumb { + background-color: var(--theme-borderColor); + border-radius: 4px; +} + +.sn__tableScrollableWrapper::-webkit-scrollbar-thumb:hover { + background-color: var(--theme-navLink); +} + +.sn__tableScrollableWrapper > .sn__table { + margin-top: 0; + margin-bottom: 0; +} +.sn__tableAlignmentCenter { + margin-left: auto; + margin-right: auto; +} +.sn__tableAlignmentRight { + margin-left: auto; +} diff --git a/svgs/editor/toolbar/inserts/upload-paperclip.svg b/svgs/editor/toolbar/inserts/upload-paperclip.svg new file mode 100644 index 0000000000..74d8cab2af --- /dev/null +++ b/svgs/editor/toolbar/inserts/upload-paperclip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wallets/client/components/form/index.js b/wallets/client/components/form/index.js index 55dc93e974..f1d3a57ef0 100644 --- a/wallets/client/components/form/index.js +++ b/wallets/client/components/form/index.js @@ -5,7 +5,7 @@ import styles from '@/styles/wallet.module.css' import navStyles from '@/styles/nav.module.css' import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form' import CancelButton from '@/components/cancel-button' -import Text from '@/components/text' +import { LegacyText } from '@/components/text' import Info from '@/components/info' import { useFormState, useMaxSteps, useNext, useStepIndex } from '@/components/multi-step-form' import { isTemplate, isWallet, protocolDisplayName, protocolFormId, protocolLogName, walletLud16Domain } from '@/wallets/lib/util' @@ -203,12 +203,12 @@ function WalletProtocolFormField ({ type, ...props }) { {props.label} {_help && ( - {_help.text} + {_help.text} )} {upperHint - ? {upperHint} + ? {upperHint} : (!props.required ? 'optional' : null)} 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 +}