From 971ff13ba64f1f7e983e2e2320ae67db4448bfc2 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Mon, 24 Nov 2025 14:52:23 +0100 Subject: [PATCH 1/8] Lexical Plain Text Editor with Lexical Rich Text preview; custom extensible MDAST transformer for lossless conversions --- components/comment.js | 6 +- components/editor/contexts/item.js | 28 + components/editor/contexts/toolbar.js | 36 + components/editor/editor.js | 106 +++ components/editor/index.js | 31 + components/editor/plugins/code-theme.js | 18 + .../editor/plugins/content/media/index.js | 41 + components/editor/plugins/decorative/toc.js | 52 ++ .../editor/plugins/formatting/math/index.js | 28 + components/editor/plugins/formik.js | 45 + components/editor/plugins/local-draft.js | 56 ++ components/editor/plugins/max-length.js | 95 +++ .../editor/plugins/mention/autocompleter.js | 139 ++++ components/editor/plugins/mention/index.js | 108 +++ components/editor/plugins/preview/index.js | 45 + components/editor/plugins/switch.js | 37 + .../editor/plugins/tinytoolbar/index.js | 52 ++ components/editor/plugins/upload.js | 248 ++++++ components/editor/reader.js | 63 ++ components/editor/theme/index.js | 96 +++ components/editor/theme/media.module.css | 150 ++++ components/editor/theme/theme.module.css | 685 +++++++++++++++ components/form.js | 15 +- components/item-full.js | 4 +- components/katex-renderer.js | 44 + components/notifications.js | 6 +- components/reply.js | 8 +- components/territory-header.js | 4 +- components/text.js | 97 ++- lib/dompurify.js | 69 ++ lib/lexical/exts/media-check.js | 163 ++++ lib/lexical/exts/shiki.js | 35 + lib/lexical/html/customs/imgproxy.js | 44 + lib/lexical/html/customs/index.js | 27 + lib/lexical/html/customs/outlawed.js | 64 ++ lib/lexical/mdast/transformer.js | 218 +++++ lib/lexical/mdast/transformers/index.js | 17 + .../mdast/transformers/plugins/formatting.js | 119 +++ .../mdast/transformers/plugins/links.js | 137 +++ .../mdast/transformers/plugins/mentions.js | 91 ++ .../mdast/transformers/plugins/misc.js | 72 ++ .../mdast/transformers/plugins/structure.js | 177 ++++ lib/lexical/nodes/content/embeds/index.jsx | 122 +++ .../nodes/content/embeds/placeholder.jsx | 57 ++ lib/lexical/nodes/content/media.jsx | 203 +++++ .../nodes/decorative/mentions/item.jsx | 128 +++ .../nodes/decorative/mentions/territory.jsx | 113 +++ .../nodes/decorative/mentions/user.jsx | 126 +++ lib/lexical/nodes/formatting/math.jsx | 146 ++++ .../nodes/formatting/spoiler/container.jsx | 157 ++++ .../nodes/formatting/spoiler/content.jsx | 98 +++ .../nodes/formatting/spoiler/title.jsx | 102 +++ lib/lexical/nodes/formatting/spoiler/utils.js | 8 + lib/lexical/nodes/index.js | 50 ++ lib/lexical/nodes/misc/heading.jsx | 170 ++++ lib/lexical/nodes/misc/toc.jsx | 187 +++++ lib/lexical/utils/index.js | 29 + next.config.js | 6 + package-lock.json | 777 +++++++++++++++++- package.json | 16 + pages/_app.js | 1 + pages/invites/index.js | 6 +- .../20251120022830_editor/migration.sql | 64 ++ prisma/schema.prisma | 36 + styles/globals.scss | 14 +- styles/text.scss | 621 ++++++++++++++ .../toolbar/inserts/upload-paperclip.svg | 1 + wallets/client/components/form/index.js | 6 +- 68 files changed, 6764 insertions(+), 56 deletions(-) create mode 100644 components/editor/contexts/item.js create mode 100644 components/editor/contexts/toolbar.js create mode 100644 components/editor/editor.js create mode 100644 components/editor/index.js create mode 100644 components/editor/plugins/code-theme.js create mode 100644 components/editor/plugins/content/media/index.js create mode 100644 components/editor/plugins/decorative/toc.js create mode 100644 components/editor/plugins/formatting/math/index.js create mode 100644 components/editor/plugins/formik.js create mode 100644 components/editor/plugins/local-draft.js create mode 100644 components/editor/plugins/max-length.js create mode 100644 components/editor/plugins/mention/autocompleter.js create mode 100644 components/editor/plugins/mention/index.js create mode 100644 components/editor/plugins/preview/index.js create mode 100644 components/editor/plugins/switch.js create mode 100644 components/editor/plugins/tinytoolbar/index.js create mode 100644 components/editor/plugins/upload.js create mode 100644 components/editor/reader.js create mode 100644 components/editor/theme/index.js create mode 100644 components/editor/theme/media.module.css create mode 100644 components/editor/theme/theme.module.css create mode 100644 components/katex-renderer.js create mode 100644 lib/dompurify.js create mode 100644 lib/lexical/exts/media-check.js create mode 100644 lib/lexical/exts/shiki.js create mode 100644 lib/lexical/html/customs/imgproxy.js create mode 100644 lib/lexical/html/customs/index.js create mode 100644 lib/lexical/html/customs/outlawed.js create mode 100644 lib/lexical/mdast/transformer.js create mode 100644 lib/lexical/mdast/transformers/index.js create mode 100644 lib/lexical/mdast/transformers/plugins/formatting.js create mode 100644 lib/lexical/mdast/transformers/plugins/links.js create mode 100644 lib/lexical/mdast/transformers/plugins/mentions.js create mode 100644 lib/lexical/mdast/transformers/plugins/misc.js create mode 100644 lib/lexical/mdast/transformers/plugins/structure.js create mode 100644 lib/lexical/nodes/content/embeds/index.jsx create mode 100644 lib/lexical/nodes/content/embeds/placeholder.jsx create mode 100644 lib/lexical/nodes/content/media.jsx create mode 100644 lib/lexical/nodes/decorative/mentions/item.jsx create mode 100644 lib/lexical/nodes/decorative/mentions/territory.jsx create mode 100644 lib/lexical/nodes/decorative/mentions/user.jsx create mode 100644 lib/lexical/nodes/formatting/math.jsx create mode 100644 lib/lexical/nodes/formatting/spoiler/container.jsx create mode 100644 lib/lexical/nodes/formatting/spoiler/content.jsx create mode 100644 lib/lexical/nodes/formatting/spoiler/title.jsx create mode 100644 lib/lexical/nodes/formatting/spoiler/utils.js create mode 100644 lib/lexical/nodes/index.js create mode 100644 lib/lexical/nodes/misc/heading.jsx create mode 100644 lib/lexical/nodes/misc/toc.jsx create mode 100644 lib/lexical/utils/index.js create mode 100644 prisma/migrations/20251120022830_editor/migration.sql create mode 100644 styles/text.scss create mode 100644 svgs/editor/toolbar/inserts/upload-paperclip.svg diff --git a/components/comment.js b/components/comment.js index 99af3b8ebb..81d5452e50 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 { LegacyText, SearchText } from './text' import Link from 'next/link' import Reply from './reply' import { useEffect, useMemo, useRef, useState } from 'react' @@ -287,11 +287,11 @@ 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} - )} + )} )} 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..fb0213aa51 --- /dev/null +++ b/components/editor/editor.js @@ -0,0 +1,106 @@ +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/theme.module.css' +import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin' +import { ToolbarPlugin } from './plugins/tinytoolbar' +import { ToolbarContextProvider } from './contexts/toolbar' + +/** + * 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) => { + // existing lexical state + if (values.lexicalState) { + try { + const state = editor.parseEditorState(values.lexicalState) + if (!state.isEmpty()) { + editor.setEditorState(state) + } + } catch (error) { + console.error('failed to load initial state:', error) + } + } + }, + name: 'editor', + namespace: 'sn', + dependencies: [ + configExtension(AutoFocusExtension, { disabled: !autoFocus }) + ], + theme: { ...theme, topLevel: topLevel ? '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 [floatingAnchorElem, setFloatingAnchorElem] = useState(null) + + const onRef = (_floatingAnchorElem) => { + if (_floatingAnchorElem !== null) { + setFloatingAnchorElem(_floatingAnchorElem) + } + } + + return ( + <> +
+ + {/* we only need a plain text editor for markdown */} + + {placeholder}
} + /> + + } + ErrorBoundary={LexicalErrorBoundary} + /> + {floatingAnchorElem && } + + + + + + + + ) +} 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..8e6473689a --- /dev/null +++ b/components/editor/plugins/mention/autocompleter.js @@ -0,0 +1,139 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode, $isLineBreakNode, $isParagraphNode } from 'lexical' +import { useEffect, useState, useCallback } 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) { + 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 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 = entityData.matchLength + + // 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, entityData]) + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + setEntityData(null) + return + } + + const textUpToCursor = extractTextUpToCursor(selection) + const match = checkForMentionPattern(textUpToCursor) + + if (match) { + // calculate dropdown position from DOM + const domSelection = window.getSelection() + const range = domSelection.getRangeAt(0) + const rect = range.getBoundingClientRect() + + 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` + } + }) + } else { + setEntityData(null) + } + }) + }) + }, [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..966bacff86 --- /dev/null +++ b/components/editor/plugins/preview/index.js @@ -0,0 +1,45 @@ +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/theme.module.css' +import { useToolbarState } from '../../contexts/toolbar' + +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..b4432c39a6 --- /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/theme.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..b9203f1a36 --- /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/theme.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..0598df4399 --- /dev/null +++ b/components/editor/plugins/upload.js @@ -0,0 +1,248 @@ +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/theme.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 ({ anchorElem = document.body }) { + 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 + ), + 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 (anchorElem) { + anchorElem.addEventListener('dragleave', onDragLeave) + } + return () => { + unregisters() + if (anchorElem) { + anchorElem.removeEventListener('dragleave', onDragLeave) + } + } + }, [editor, anchorElem]) + + 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..89aae7b190 --- /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 { fromMarkdown } from '@/lib/lexical/mdast/transformer' + +const initiateLexical = (editor, lexicalState, markdown) => { + if (markdown) { + fromMarkdown(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 && 'topLevel' + }, + $initialEditorState: (editor) => initiateLexical(editor, lexicalState, markdown), + onError: (error) => console.error(error) + }), [topLevel, markdown]) + + return ( + +
    + + {children} +
    + +
    + ) +}) 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/editor/theme/theme.module.css b/components/editor/theme/theme.module.css new file mode 100644 index 0000000000..a4b55f05ea --- /dev/null +++ b/components/editor/theme/theme.module.css @@ -0,0 +1,685 @@ +/* text container - used by lexical reader and editor contenteditable */ + +.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; +} + +.text p { + margin-bottom: 0 !important; +} + +.textTruncated { + max-height: 50vh; +} + +.text[contenteditable="false"] { + background-color: var(--theme-body) !important; +} + +.text:global(.topLevel) { + max-height: 200vh; + --grid-gap: 0.75rem; +} + +.textUncontained { + max-height: none !important; +} + +.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%); +} + +.textShowFull { + position: absolute; + bottom: 0; + z-index: 2; + border-radius: 0; +} + +.text :global(.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); +} + +.text>*:not(:global(.sn__heading), :global(.sn__toc), :global(.sn__spoiler__container), :global(.sn__codeBlock)) { + padding-top: calc(var(--grid-gap) * 0.5); + padding-bottom: calc(var(--grid-gap) * 0.5); +} + +.text pre, .text blockquote { + margin-top: calc(var(--grid-gap) * 0.5); + margin-bottom: calc(var(--grid-gap) * 0.5); +} + +.text>*:last-child:not(.textShowFull, :global(.sn__codeBlock)) { + padding-bottom: 0 !important; + margin-bottom: 0 !important; +} + +.text>*:first-child:not(:global(.sn__codeBlock)) { + padding-top: 0 !important; + margin-top: 0 !important; +} + +.text blockquote, .text:global(.topLevel) blockquote { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.text blockquote *:first-child, .text:global(.topLevel) blockquote *:first-child { + padding-top: 0; +} + +.text blockquote *:last-child, .text:global(.topLevel) blockquote *:last-child { + padding-bottom: 0; +} + +@media screen and (min-width: 767px) { + .text { + line-height: 130% !important; + } +} + +/* 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; +} + +.text hr { + border-top: 3px solid var(--theme-quoteBar); + padding: 0 !important; + caret-color: transparent; +} + +.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/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..86bee75bbd 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 { LegacyText, SearchText } from './text' import MediaOrLink from './media-or-link' import Comments from './comments' import styles from '@/styles/item.module.css' @@ -157,7 +157,7 @@ function TopLevelItem ({ item, noReply, ...props }) { function ItemText ({ item }) { return item.searchText ? - : {item.text} + : {item.text} } export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) { 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..551af713df 100644 --- a/components/text.js +++ b/components/text.js @@ -1,4 +1,5 @@ import styles from './text.module.css' +import lexicalStyles from './editor/theme/theme.module.css' import ReactMarkdown from 'react-markdown' import gfm from 'remark-gfm' import dynamic from 'next/dynamic' @@ -21,6 +22,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 +54,101 @@ 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( + lexicalStyles.text, + topLevel && 'topLevel', + show ? lexicalStyles.textUncontained : overflowing && lexicalStyles.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/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..bf9955448d --- /dev/null +++ b/lib/lexical/exts/shiki.js @@ -0,0 +1,35 @@ +import { $getRoot } from 'lexical' +import { defineExtension } from '@lexical/extension' +import { registerCodeHighlighting, ShikiTokenizer } from '@lexical/code-shiki' +import { CodeExtension, $isCodeNode } 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(() => { + const root = $getRoot() + + root.getChildren().forEach(child => { + if ($isCodeNode(child)) { + child.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/transformer.js b/lib/lexical/mdast/transformer.js new file mode 100644 index 0000000000..9661c95d10 --- /dev/null +++ b/lib/lexical/mdast/transformer.js @@ -0,0 +1,218 @@ +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 { fromMarkdown as parseMarkdown } from 'mdast-util-from-markdown' +import TRANSFORMERS, { MENTION_EXTENSIONS } from './transformers' + +// html tags we convert to lexical formats (sup -> superscript, etc) +const HTML_FORMAT_TAGS = { + sup: 'superscript', + sub: 'subscript', + u: 'underline', + ins: 'underline', + s: 'strikethrough', + del: 'strikethrough', + mark: 'highlight' +} + +const MICROMARK_EXTENSIONS = [ + gfm(), + math(), + gfmFootnote(), + MENTION_EXTENSIONS.micromark +] + +const MDAST_EXTENSIONS = [ + gfmFromMarkdown(), + mathFromMarkdown(), + gfmFootnoteFromMarkdown(), + MENTION_EXTENSIONS.fromMarkdown +] + +export function toMarkdown (editor) { + return serializeMdast(toMdast(editor)) +} + +export function fromMarkdown (editor, markdown) { + const mdast = parseMarkdownToMdast(markdown) + editor.update(() => { + $getRoot().clear().append(...fromMdast(mdast)) + }) +} + +export function toMdast (editor) { + const root = { type: 'root', children: [] } + editor.getEditorState().read(() => { + root.children = $getRoot().getChildren().flatMap(lexicalNodeToMdast) + }) + return root +} + +export function fromMdast (mdast) { + const visit = (node) => { + // formattedText is our extended type for multi-format text (bold+italic, superscript, etc) + // we unwrap it and apply the formats to the resulting lexical nodes + if (node.type === 'formattedText') { + return unwrapFormattedText(node, visit) + } + + // find a transformer that can handle this node + for (const t of TRANSFORMERS) { + if (!t.fromMdast) continue + const result = t.fromMdast(node, visitChildren) + if (result) return result + } + + if (process.env.NODE_ENV === 'development') { + console.warn(`[mdast] no transformer for: ${node.type}`) + } + return null + } + + const visitChildren = (children) => children.flatMap(visit).filter(Boolean) + + return mdast.type === 'root' + ? visitChildren(mdast.children) + : [visit(mdast)].filter(Boolean) +} + +export function serializeMdast (node) { + if (Array.isArray(node)) { + return node.map(serializeMdast).join('') + } + + if (node.type === 'root') { + return serializeMdast(node.children) + } + + // find a transformer that can serialize this node type + for (const t of TRANSFORMERS) { + if (t.toMarkdown && matchesMdastType(node, t.mdastType)) { + return t.toMarkdown(node, serializeMdast) + } + } + + // fallback: just serialize children + if (node.children) { + return serializeMdast(node.children) + } + + if (process.env.NODE_ENV === 'development') { + console.warn(`[mdast] no serializer for: ${node.type}`) + } + return '' +} + +function lexicalNodeToMdast (node) { + const nodeType = node.getType() + + for (const t of TRANSFORMERS) { + if (t.type === nodeType && t.toMdast) { + const result = t.toMdast(node, lexicalNodeToMdast) + if (result) return result + } + } + + // no transformer found - try to recurse into children + if (node.getChildren) { + return node.getChildren().flatMap(lexicalNodeToMdast) + } + + if (process.env.NODE_ENV === 'development') { + console.warn(`[mdast] no transformer for lexical type: ${nodeType}`) + } + return [] +} + +function parseMarkdownToMdast (markdown) { + const mdast = parseMarkdown(markdown, { + extensions: MICROMARK_EXTENSIONS, + mdastExtensions: MDAST_EXTENSIONS + }) + + // convert html format tags (, , etc) into our formattedText nodes + processHtmlFormats(mdast) + + return mdast +} + +// walk the tree and convert text-like patterns into formattedText nodes +// this lets us handle html format tags that markdown syntax can't express +function processHtmlFormats (node) { + if (!node.children) return + + for (let i = 0; i < node.children.length; i++) { + // recurse first so we process innermost tags first + processHtmlFormats(node.children[i]) + + const opening = parseOpeningTag(node.children[i]) + if (!opening) continue + + const closeIdx = findClosingTag(node.children, opening.tag, i + 1) + if (closeIdx === -1) continue + + // grab everything between and , wrap it + const innerNodes = node.children.slice(i + 1, closeIdx) + const wrapped = { + type: 'formattedText', + formats: [opening.format], + children: innerNodes, + data: { htmlTag: opening.tag } + } + + // replace ...content... with the single wrapped node + node.children.splice(i, closeIdx - i + 1, wrapped) + } +} + +function parseOpeningTag (node) { + if (node.type !== 'html') return null + + const match = node.value?.match(/^<([a-z]+)>$/i) + if (!match) return null + + const tag = match[1].toLowerCase() + const format = HTML_FORMAT_TAGS[tag] + return format ? { tag, format } : null +} + +function findClosingTag (children, tag, startIdx) { + for (let i = startIdx; i < children.length; i++) { + if (children[i].type === 'html' && children[i].value === ``) { + return i + } + } + return -1 +} + +function unwrapFormattedText (node, visit) { + const results = [] + + for (const child of node.children) { + const lexicalNode = visit(child) + if (!lexicalNode) continue + + // apply each format (bold, italic, superscript, etc) to the lexical node + const nodes = Array.isArray(lexicalNode) ? lexicalNode : [lexicalNode] + for (const n of nodes) { + if (n.toggleFormat) { + for (const format of node.formats || []) { + n.toggleFormat(format) + } + } + results.push(n) + } + } + + return results +} + +function matchesMdastType (node, mdastType) { + if (!mdastType) return false + if (Array.isArray(mdastType)) return mdastType.includes(node.type) + return node.type === mdastType +} diff --git a/lib/lexical/mdast/transformers/index.js b/lib/lexical/mdast/transformers/index.js new file mode 100644 index 0000000000..47f6221ce6 --- /dev/null +++ b/lib/lexical/mdast/transformers/index.js @@ -0,0 +1,17 @@ +import mentions, { micromark, fromMarkdown } from './plugins/mentions' +import links from './plugins/links' +import formatting from './plugins/formatting' +import structure from './plugins/structure' +import misc from './plugins/misc' + +// micromark/mdast extensions +export const MENTION_EXTENSIONS = { micromark, fromMarkdown } + +// every transformer +export default [ + ...mentions, + ...links, + ...formatting, + ...structure, + ...misc +].sort((a, b) => (b.priority || 0) - (a.priority || 0)) diff --git a/lib/lexical/mdast/transformers/plugins/formatting.js b/lib/lexical/mdast/transformers/plugins/formatting.js new file mode 100644 index 0000000000..2a0fcdc4c5 --- /dev/null +++ b/lib/lexical/mdast/transformers/plugins/formatting.js @@ -0,0 +1,119 @@ +import { $createTextNode } from 'lexical' +import { $createCodeNode } from '@lexical/code' + +const FORMAT_TYPES = ['bold', 'italic', 'strikethrough', 'code', 'subscript', 'superscript', 'underline'] + +function getFormats (node) { + return FORMAT_TYPES.filter(format => node.hasFormat(format)) +} + +export function wrapFormats (text, formats) { + let result = text + if (formats.includes('code')) return `\`${text}\`` + if (formats.includes('bold')) result = `**${result}**` + if (formats.includes('italic')) result = `*${result}*` + if (formats.includes('strikethrough')) result = `~~${result}~~` + if (formats.includes('superscript')) result = `${result}` + if (formats.includes('subscript')) result = `${result}` + if (formats.includes('underline')) result = `${result}` + return result +} + +// multi-format text +export const FORMATTED_TEXT = { + mdastType: 'formattedText', + toMarkdown: (node, serialize) => { + const inner = serialize(node.children) + return wrapFormats(inner, node.formats || []) + } +} + +// plain text and inline formatting +export const TEXT = { + type: 'text', + mdastType: ['text', 'strong', 'emphasis', 'delete', 'inlineCode'], + toMdast: (node) => { + const text = node.getTextContent() + const formats = getFormats(node) + + // multi-format: use extended type + if (formats.length > 1 || formats.some(f => ['superscript', 'subscript', 'underline'].includes(f))) { + return { + type: 'formattedText', + formats, + children: [{ type: 'text', value: text }], + data: { lexicalFormat: node.getFormat() } + } + } + + // standard mdast types + if (formats.includes('bold')) { + return { type: 'strong', children: [{ type: 'text', value: text }] } + } + if (formats.includes('italic')) { + return { type: 'emphasis', children: [{ type: 'text', value: text }] } + } + if (formats.includes('strikethrough')) { + return { type: 'delete', children: [{ type: 'text', value: text }] } + } + if (formats.includes('code')) { + return { type: 'inlineCode', value: text } + } + + return { type: 'text', value: text } + }, + fromMdast: (node, visitChildren) => { + if (node.type === 'text') return $createTextNode(node.value) + if (node.type === 'inlineCode') return $createTextNode(node.value).toggleFormat('code') + + // for nested formatting, recursively visit children and apply format to each + if (node.type === 'strong') { + const children = visitChildren(node.children || []) + return children.map(child => { + if (child.toggleFormat) child.toggleFormat('bold') + return child + }) + } + if (node.type === 'emphasis') { + const children = visitChildren(node.children || []) + return children.map(child => { + if (child.toggleFormat) child.toggleFormat('italic') + return child + }) + } + if (node.type === 'delete') { + const children = visitChildren(node.children || []) + return children.map(child => { + if (child.toggleFormat) child.toggleFormat('strikethrough') + return child + }) + } + return null + }, + toMarkdown: (node, serialize) => { + if (node.type === 'text') return node.value + if (node.type === 'inlineCode') return `\`${node.value}\`` + if (node.type === 'strong') return `**${serialize(node.children)}**` + if (node.type === 'emphasis') return `*${serialize(node.children)}*` + if (node.type === 'delete') return `~~${serialize(node.children)}~~` + return '' + } +} + +// code blocks +export const CODE_BLOCK = { + type: 'code', + mdastType: 'code', + toMdast: (node) => ({ + type: 'code', + lang: node.getLanguage() || null, + value: node.getTextContent() + }), + fromMdast: (node) => { + if (node.type !== 'code') return null + return $createCodeNode(node.lang).append($createTextNode(node.value)) + }, + toMarkdown: (node) => `\`\`\`${node.lang || ''}\n${node.value}\n\`\`\`\n\n` +} + +export default [FORMATTED_TEXT, TEXT, CODE_BLOCK] diff --git a/lib/lexical/mdast/transformers/plugins/links.js b/lib/lexical/mdast/transformers/plugins/links.js new file mode 100644 index 0000000000..a2b0f6ccd8 --- /dev/null +++ b/lib/lexical/mdast/transformers/plugins/links.js @@ -0,0 +1,137 @@ +import { $createTextNode } from 'lexical' +import { $createLinkNode } from '@lexical/link' +import { $createMediaNode } from '@/lib/lexical/nodes/content/media' +import { $createItemMentionNode } from '@/lib/lexical/nodes/decorative/mentions/item' +import { $createEmbedNode } from '@/lib/lexical/nodes/content/embeds' +import { parseInternalLinks, parseEmbedUrl, ensureProtocol } from '@/lib/url' + +const getEmbed = (src) => { + const href = ensureProtocol(src) + const embed = parseEmbedUrl(href) + return embed ? { ...embed, src: href } : { provider: null } +} + +// bare link to item mention +export const ITEM_MENTION_FROM_LINK = { + priority: 10, + mdastType: 'link', + fromMdast: (node) => { + if (node.type !== 'link') return null + const linkText = node.children?.[0]?.value + if (linkText !== node.url) return null + try { + const { itemId, commentId, linkText: itemText } = parseInternalLinks(node.url) + if (itemId || commentId) { + return $createItemMentionNode({ id: commentId || itemId, text: itemText, url: node.url }) + } + } catch {} + return null + } +} + +// bare link to embed +export const EMBED_FROM_LINK = { + priority: 10, + mdastType: 'link', + fromMdast: (node) => { + if (node.type !== 'link') return null + const linkText = node.children?.[0]?.value + if (linkText !== node.url) return null + const embed = getEmbed(node.url) + if (embed.provider) { + return $createEmbedNode(embed.provider, embed.src, embed.id, embed.meta) + } + return null + } +} + +// bare link to media +export const MEDIA_FROM_LINK = { + priority: 5, + mdastType: 'link', + fromMdast: (node) => { + if (node.type !== 'link') return null + const linkText = node.children?.[0]?.value + if (linkText !== node.url) return null + return $createMediaNode({ src: node.url }) + } +} + +// default link +export const LINK = { + type: 'link', + mdastType: 'link', + toMdast: (node, visitChildren) => ({ + type: 'link', + url: node.getURL(), + title: node.getTitle() || null, + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'link') return null + const link = $createLinkNode(node.url, { + title: node.title, + target: '_blank', + rel: 'noopener noreferrer' + }) + link.append(...(node.children?.length ? visitChildren(node.children) : [$createTextNode(node.url)])) + return link + }, + toMarkdown: (node, serialize) => { + const title = node.title ? ` "${node.title}"` : '' + return `[${serialize(node.children)}](${node.url}${title})` + } +} + +// media/images +export const MEDIA = { + type: 'media', + mdastType: 'image', + toMdast: (node) => ({ + type: 'image', + url: node.getSrc(), + alt: node.getAltText() || '', + title: null + }), + fromMdast: (node) => { + if (node.type !== 'image') return null + return $createMediaNode({ src: node.url, altText: node.alt || '' }) + }, + toMarkdown: (node) => `![${node.alt || ''}](${node.url})\n\n` +} + +// embed output +export const EMBED = { + type: 'embed', + mdastType: 'embed', + toMdast: (node) => ({ + type: 'embed', + url: node.getSrc(), + provider: node.getProvider?.(), + data: { id: node.getId?.(), meta: node.getMeta?.() } + }), + toMarkdown: (node) => `${node.url}\n\n` +} + +// item mention output +export const ITEM_MENTION = { + type: 'item-mention', + mdastType: 'itemMention', + toMdast: (node) => ({ + type: 'itemMention', + url: node.getURL(), + id: node.getID?.(), + text: node.getText?.() + }), + toMarkdown: (node) => `${node.url}\n\n` +} + +export default [ + ITEM_MENTION_FROM_LINK, + EMBED_FROM_LINK, + MEDIA_FROM_LINK, + LINK, + MEDIA, + EMBED, + ITEM_MENTION +] diff --git a/lib/lexical/mdast/transformers/plugins/mentions.js b/lib/lexical/mdast/transformers/plugins/mentions.js new file mode 100644 index 0000000000..0dbad50b50 --- /dev/null +++ b/lib/lexical/mdast/transformers/plugins/mentions.js @@ -0,0 +1,91 @@ +import { $createUserMentionNode } from '@/lib/lexical/nodes/decorative/mentions/user' +import { $createTerritoryMentionNode } from '@/lib/lexical/nodes/decorative/mentions/territory' + +// micromark tokenizer for prefix-based syntax +function prefixTokenizer (prefix, pattern, typeName) { + return function (effects, ok, nok) { + return start + function start (code) { + if (code !== prefix.charCodeAt(0)) return nok(code) + effects.enter(typeName) + effects.consume(code) + return content + } + function content (code) { + if (code === -1 || code === null) { + effects.exit(typeName) + return ok(code) + } + if (pattern.test(String.fromCharCode(code))) { + effects.consume(code) + return content + } + effects.exit(typeName) + return ok(code) + } + } +} + +// micromark/mdast extensions +export const micromark = { + text: { + 64: { tokenize: prefixTokenizer('@', /[a-zA-Z0-9_/]/, 'userMention') }, + 126: { tokenize: prefixTokenizer('~', /[a-zA-Z0-9_]/, 'territoryMention') } + } +} + +export const fromMarkdown = { + enter: { + userMention: function (token) { + this.enter({ type: 'userMention', value: null }, token) + }, + territoryMention: function (token) { + this.enter({ type: 'territoryMention', value: null }, token) + } + }, + exit: { + userMention: function (token) { + const node = this.stack[this.stack.length - 1] + const raw = this.sliceSerialize(token).slice(1) + const [name, ...pathParts] = raw.split('/') + node.value = { name, path: pathParts.length ? '/' + pathParts.join('/') : '' } + this.exit(token) + }, + territoryMention: function (token) { + const node = this.stack[this.stack.length - 1] + node.value = this.sliceSerialize(token).slice(1) + this.exit(token) + } + } +} + +// mentions +export const USER_MENTION = { + type: 'user-mention', + mdastType: 'userMention', + toMdast: (node) => ({ + type: 'userMention', + value: { name: node.getUserMentionName(), path: node.getPath() || '' } + }), + fromMdast: (node) => { + if (node.type !== 'userMention') return null + return $createUserMentionNode({ name: node.value.name, path: node.value.path || '' }) + }, + toMarkdown: (node) => `@${node.value.name}${node.value.path || ''}` +} + +export const TERRITORY_MENTION = { + type: 'territory-mention', + mdastType: 'territoryMention', + toMdast: (node) => ({ + type: 'territoryMention', + value: node.getTerritoryMentionName() + }), + fromMdast: (node) => { + if (node.type !== 'territoryMention') return null + return $createTerritoryMentionNode(node.value) + }, + toMarkdown: (node) => `~${node.value}` +} + +export default [USER_MENTION, TERRITORY_MENTION] diff --git a/lib/lexical/mdast/transformers/plugins/misc.js b/lib/lexical/mdast/transformers/plugins/misc.js new file mode 100644 index 0000000000..1540d46b74 --- /dev/null +++ b/lib/lexical/mdast/transformers/plugins/misc.js @@ -0,0 +1,72 @@ +import { $createParagraphNode, $createTextNode } from 'lexical' +import { $createMathNode } from '@/lib/lexical/nodes/formatting/math' +import { $createTableOfContentsNode } from '@/lib/lexical/nodes/misc/toc' + +// table of contents +export const TABLE_OF_CONTENTS = { + priority: 10, + type: 'tableofcontents', + mdastType: ['tableOfContents', 'paragraph'], + toMdast: () => ({ type: 'tableOfContents' }), + fromMdast: (node) => { + if (node.type === 'paragraph' && + node.children?.length === 1 && + node.children[0].type === 'text' && + node.children[0].value.trim() === '{:toc}') { + return $createTableOfContentsNode() + } + return null + }, + toMarkdown: () => '{:toc}\n\n' +} + +// math (inline and block) +export const MATH = { + type: 'math', + mdastType: ['math', 'inlineMath'], + toMdast: (node) => { + const inline = node.getInline?.() + return { + type: inline ? 'inlineMath' : 'math', + value: node.getValue?.() || node.getTextContent() + } + }, + fromMdast: (node) => { + if (node.type === 'inlineMath') return $createMathNode(node.value, true) + if (node.type === 'math') return $createMathNode(node.value, false) + return null + }, + toMarkdown: (node) => { + if (node.type === 'inlineMath') return `$$${node.value}$$` + return `$$\n${node.value}\n$$\n\n` + } +} + +// footnotes (renders as text) +// TODO: this +export const FOOTNOTE = { + mdastType: ['footnoteReference', 'footnoteDefinition'], + fromMdast: (node) => { + if (node.type === 'footnoteReference') { + return $createTextNode(`[^${node.identifier}]`) + } + if (node.type === 'footnoteDefinition') { + const content = node.children?.map(c => c.value || '').join(' ') || '' + return $createParagraphNode().append($createTextNode(`[^${node.identifier}]: ${content}`)) + } + return null + }, + toMarkdown: (node) => { + if (node.type === 'footnoteReference') return `[^${node.identifier}]` + if (node.type === 'footnoteDefinition') { + return `[^${node.identifier}]: ${node.children?.map(c => c.value || '').join(' ')}\n\n` + } + return '' + } +} + +export default [ + TABLE_OF_CONTENTS, + MATH, + FOOTNOTE +] diff --git a/lib/lexical/mdast/transformers/plugins/structure.js b/lib/lexical/mdast/transformers/plugins/structure.js new file mode 100644 index 0000000000..645b8e9941 --- /dev/null +++ b/lib/lexical/mdast/transformers/plugins/structure.js @@ -0,0 +1,177 @@ +import { $createParagraphNode, $createTextNode } from 'lexical' +import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text' +import { $createListNode, $createListItemNode } from '@lexical/list' +import { $createTableNode, $createTableRowNode, $createTableCellNode } from '@lexical/table' +import { $createHorizontalRuleNode } from '@lexical/extension' + +// headings +export const HEADING = { + type: 'heading', + mdastType: 'heading', + toMdast: (node, visitChildren) => ({ + type: 'heading', + depth: parseInt(node.getTag()[1]), + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'heading') return null + return $createHeadingNode(`h${node.depth}`).append(...visitChildren(node.children)) + }, + toMarkdown: (node, serialize) => + `${'#'.repeat(node.depth)} ${serialize(node.children)}\n\n` +} + +// lists +export const LIST = { + type: 'list', + mdastType: 'list', + toMdast: (node, visitChildren) => ({ + type: 'list', + ordered: node.getListType() === 'number', + spread: false, + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'list') return null + return $createListNode(node.ordered ? 'number' : 'bullet').append(...visitChildren(node.children)) + }, + toMarkdown: (node, serialize) => { + const items = node.children.map((item, i) => { + const marker = node.ordered ? `${i + 1}.` : '-' + const content = serialize(item.children).replace(/\n\n$/, '') + return `${marker} ${content}` + }) + return items.join('\n') + '\n\n' + } +} + +export const LIST_ITEM = { + type: 'listitem', + mdastType: 'listItem', + toMdast: (node, visitChildren) => ({ + type: 'listItem', + checked: node.getChecked?.() ?? null, + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'listItem') return null + const item = $createListItemNode(node.checked) + if (node.children?.length) item.append(...visitChildren(node.children)) + return item + } +} + +// blockquotes +export const BLOCKQUOTE = { + type: 'quote', + mdastType: 'blockquote', + toMdast: (node, visitChildren) => ({ + type: 'blockquote', + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'blockquote') return null + return $createQuoteNode().append(...visitChildren(node.children)) + }, + toMarkdown: (node, serialize) => { + const content = serialize(node.children).trim() + return content.split('\n').map(line => `> ${line}`).join('\n') + '\n\n' + } +} + +// tables +export const TABLE = { + type: 'table', + mdastType: 'table', + toMdast: (node, visitChildren) => ({ + type: 'table', + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'table') return null + return $createTableNode().append(...visitChildren(node.children)) + }, + toMarkdown: (node, serialize) => { + const rows = node.children.map(row => + `| ${row.children.map(cell => serialize(cell.children)).join(' | ')} |` + ) + if (rows.length > 0) { + const colCount = node.children[0]?.children?.length || 1 + rows.splice(1, 0, `|${' --- |'.repeat(colCount)}`) + } + return rows.join('\n') + '\n\n' + } +} + +export const TABLE_ROW = { + type: 'tablerow', + mdastType: 'tableRow', + toMdast: (node, visitChildren) => ({ + type: 'tableRow', + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'tableRow') return null + return $createTableRowNode().append(...visitChildren(node.children)) + } +} + +export const TABLE_CELL = { + type: 'tablecell', + mdastType: 'tableCell', + toMdast: (node, visitChildren) => ({ + type: 'tableCell', + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'tableCell') return null + return $createTableCellNode().append(...visitChildren(node.children)) + } +} + +// horizontal rule +export const HORIZONTAL_RULE = { + type: 'horizontalrule', + mdastType: 'thematicBreak', + toMdast: () => ({ type: 'thematicBreak' }), + fromMdast: (node) => node.type === 'thematicBreak' && $createHorizontalRuleNode(), + toMarkdown: () => '---\n\n' +} + +// html fallback +export const HTML_FALLBACK = { + mdastType: 'html', + fromMdast: (node) => { + if (node.type !== 'html') return null + return $createParagraphNode().append($createTextNode(node.value)) + }, + toMarkdown: (node) => node.value + '\n\n' +} + +// paragraph +export const PARAGRAPH = { + type: 'paragraph', + mdastType: 'paragraph', + toMdast: (node, visitChildren) => ({ + type: 'paragraph', + children: node.getChildren().flatMap(visitChildren) + }), + fromMdast: (node, visitChildren) => { + if (node.type !== 'paragraph') return null + return $createParagraphNode().append(...visitChildren(node.children)) + }, + toMarkdown: (node, serialize) => `${serialize(node.children)}\n\n` +} + +export default [ + HEADING, + LIST, + LIST_ITEM, + BLOCKQUOTE, + TABLE, + TABLE_ROW, + TABLE_CELL, + HORIZONTAL_RULE, + HTML_FALLBACK, + PARAGRAPH +] diff --git a/lib/lexical/nodes/content/embeds/index.jsx b/lib/lexical/nodes/content/embeds/index.jsx new file mode 100644 index 0000000000..241ee1f9f0 --- /dev/null +++ b/lib/lexical/nodes/content/embeds/index.jsx @@ -0,0 +1,122 @@ +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, id, src, meta) + return { node } +} + +export class EmbedNode extends DecoratorBlockNode { + __provider + __id + __src + __meta + + $config () { + return this.config('embed', { + extends: DecoratorBlockNode, + importDOM: { + 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: (domNode) => $convertEmbedElement(domNode), + priority: 2 + } + }, + div: (domNode) => { + return this.importDOM().span(domNode) + } + } + }) + } + + constructor (provider = null, src = null, id = null, meta = null, key) { + super(key) + this.__provider = provider + this.__id = id + this.__src = src + this.__meta = meta + } + + updateDOM (prevNode, domNode) { + return false + } + + exportDOM () { + return { + element: placeholderNode({ + provider: this.__provider || '', + id: this.__id || '', + src: this.__src || '', + meta: this.__meta || {} + }) + } + } + + getTextContent () { + return this.__src || this.__meta?.href + } + + decorate (_editor, config) { + const Embed = require('@/components/embed').default + const embedBlockTheme = config.theme.embeds || {} + const className = { + base: embedBlockTheme.base || '', + focus: embedBlockTheme.focus || '' + } + + return ( + // this allows us to subject the embed blocks to formatting + // and also select them, show text cursors, etc. + + + + ) + } +} + +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/utils/index.js b/lib/lexical/utils/index.js new file mode 100644 index 0000000000..315396ba39 --- /dev/null +++ b/lib/lexical/utils/index.js @@ -0,0 +1,29 @@ +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)) +} diff --git a/next.config.js b/next.config.js index 14caff9ea3..778cba8509 100644 --- a/next.config.js +++ b/next.config.js @@ -295,6 +295,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..cd19f22f33 --- /dev/null +++ b/styles/text.scss @@ -0,0 +1,621 @@ +@import 'katex/dist/katex.min.css'; + +.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; +} + +.topLevel h1.sn__headings { + font-size: 1.6rem; +} + +.topLevel h2.sn__headings { + font-size: 1.45rem; +} + +.topLevel h3.sn__headings { + font-size: 1.3rem; +} + +.topLevel h4.sn__headings { + font-size: 1.15rem; +} + +/* workaround to make headings clickable, see SNHeadingNode + quirk: makes the whole line clickable +*/ +.sn__headings > a { + position: absolute; + inset: 0; + text-decoration: none; +} + +.sn__headings > a:focus { + outline: none; +} + +/* 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__nestedListItem { + list-style-type: none; +} + +.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__textSubscript, .sn__textSuperscript { + position: relative; + line-height: 0; + font-size: 0.75em; + vertical-align: baseline; +} + +.sn__textSuperscript { + top: -0.5em; +} + +.sn__textSubscript { + bottom: -0.25em; +} +.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: 8px; + 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); +} + +.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)} From 979278f7bf14383961c0089e184cfe5a05202ac0 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 26 Nov 2025 14:39:06 +0100 Subject: [PATCH 2/8] wip: Lexical MDAST docs --- docs/dev/lexical-mdast.md | 137 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/dev/lexical-mdast.md diff --git a/docs/dev/lexical-mdast.md b/docs/dev/lexical-mdast.md new file mode 100644 index 0000000000..b40f9203d6 --- /dev/null +++ b/docs/dev/lexical-mdast.md @@ -0,0 +1,137 @@ +The Lexical markdown regex-based transformers resulted in numerous bugs while trying to transform to and from a Lexical state. +MDAST provides a dead-simple way to map Lexical to Markdown (and vice versa). + +***one of the problems*** + +In Lexical, a text node that is bold and superscript looks something like this: + +``` +TextNode { + text: "some text" + format: 65 // bold + superscript bitmask +} +``` + +Markdown instead: + +`**some text**` + +The canonical `lexical/markdown` way is to create a transformer that scans for TextNodes, gets the text formatting, and builds a string that can be similar to the markdown showed before. + +The same transformer should also take care of the opposite transformation direction (this is where things get tricky). +The markdown text I showed before is something that `lexical/markdown` doesn't natively support: it can't handle multi-format combinations reliably and supporting HTML-only formats is chaotic. + +Lexical tried to make it simpler, by creating multi-line, text-match, etc. types of transformers. The problem with this approach is maintenance, as this is very clearly a time-ticking bomb. + +--- + +**MDAST** + +Using MDAST as a canonical intermediate between Lexical and Markdown felt way safer and maintainable: + +- Standardized: it follows the markdown spec +- Extensible: can implement new custom node types and we can persist custom node data between transformations +- Lossless: it's Lexical mapped to Markdown, not some kind of string interpretation. + +#### how it works + +Each transformer is an object with round-trip methods: +```javascript +{ + type: 'heading' // export lexical node type + mdastType: 'heading', // import mdast node type + priority: 0, // can have multiple heading type transformers orchestrated via priority + toMdast(lexicalNode, visit), // lexical to mdast + fromMdast(mdastNode, visit) // mdast to lexical + toMarkdown(mdastNode, serialize) // mdast to markdown string +} +``` + +Example import flow (markdown to lexical) with the `userMention` custom micromark extension: + +this text: `# hello @sox` +`parseMarkdownToMdast()`: +```javascript +{ + type: 'root', + children: [{ + type: 'heading', + depth: 1, + children: [ + { type: 'text', value: 'hello ' }, + { type: 'userMention', value: { name: 'sox' } } + ] + }] +} +``` + +We can see that the markdown mdast parser recognized an heading with two children: `text` and `userMention`. + +This is exactly what Lexical represents in its own JSON lexical state. +``` +- HeadingNode('h1') + - TextNode('hello ') + - UserMentionNode({ name: 'sox' }) +``` + +Example export flow (lexical to markdown) + +Lexical: +``` +- HeadingNode('h1') + - TextNode('hello ') + - UserMentionNode({ name: 'sox' }) +``` + +`toMdast()`: +```javascript +{ + type: 'root', + children: [{ + type: 'heading', + depth: 1, + children: [ + { type: 'text', value: 'hello ' }, + { type: 'userMention', value: { name: 'sox' } } + ] + }] +} +``` + +Now that we have the mdast version we can just serialize it into markdown + +`serializeMdast()`: `# hello @sox` + +--- + +### creating a custom type + +Now that we have an extensible MDAST system we can create custom extensions for custom types. +Let's see what do we have to do to implement user mentions + +The `prefixTokenizer(prefix, pattern, typeName)` creates a micromark tokenizer for prefix-based syntaxes. +A micromark extension will then just be: + +```javascript +64: { // 64 is @ + tokenize: prefixTokenizer('@', /[a-zA-Z0-9_/]/, 'userMention') +} +``` + +And a mapping will look like: + +```javascript +export const USER_MENTION = { + type: 'user-mention', + mdastType: 'userMention', + toMdast: (node) => ({ + type: 'userMention', + value: { name: node.getUserMentionName(), path: node.getPath() || '' } + }), + fromMdast: (node) => { + if (node.type !== 'userMention') return null + return $createUserMentionNode({ name: node.value.name, path: node.value.path || '' }) + }, + toMarkdown: (node) => `@${node.value.name}${node.value.path || ''}` +} +``` \ No newline at end of file From 53fd7df11670496bf13c5db65a01369ff6edccde Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 26 Nov 2025 16:03:31 +0100 Subject: [PATCH 3/8] enable Lexical reader on items and comments; re-implement distributed migration, debug 1-click migration; adapt interpolator to text -> lexicalState only --- api/resolvers/item.js | 87 +++- api/resolvers/sub.js | 8 + api/typeDefs/item.js | 8 + components/comment.js | 16 +- components/item-full.js | 6 +- components/item-info.js | 95 +++++ fragments/comments.js | 6 + fragments/items.js | 2 + lib/auth.js | 2 +- .../mdast/transformers/plugins/structure.js | 45 +- lib/lexical/server/headless.js | 64 +++ lib/lexical/server/html.js | 53 +++ lib/lexical/server/interpolator.js | 57 +++ lib/lexical/server/media/check.js | 45 ++ lib/lexical/utils/index.js | 30 ++ worker/index.js | 5 + worker/lexical/migrate.js | 389 ++++++++++++++++++ 17 files changed, 895 insertions(+), 23 deletions(-) create mode 100644 lib/lexical/server/headless.js create mode 100644 lib/lexical/server/html.js create mode 100644 lib/lexical/server/interpolator.js create mode 100644 lib/lexical/server/media/check.js create mode 100644 worker/lexical/migrate.js 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.js b/components/comment.js index 81d5452e50..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 { LegacyText, 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/item-full.js b/components/item-full.js index 86bee75bbd..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 { LegacyText, 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/fragments/comments.js b/fragments/comments.js index c2337d094f..192d30d2d9 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 @@ -109,6 +113,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/auth.js b/lib/auth.js index 70dec13abf..0657d68cee 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -6,7 +6,7 @@ import { encode as encodeJWT, decode as decodeJWT } from 'next-auth/jwt' const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64') const b64Decode = s => JSON.parse(Buffer.from(s, 'base64')) -export const HTTPS = process.env.NODE_ENV === 'production' +export const HTTPS = true const secureCookie = (name) => HTTPS diff --git a/lib/lexical/mdast/transformers/plugins/structure.js b/lib/lexical/mdast/transformers/plugins/structure.js index 645b8e9941..e73871cbb9 100644 --- a/lib/lexical/mdast/transformers/plugins/structure.js +++ b/lib/lexical/mdast/transformers/plugins/structure.js @@ -1,5 +1,6 @@ import { $createParagraphNode, $createTextNode } from 'lexical' -import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text' +import { $createQuoteNode } from '@lexical/rich-text' +import { $createSNHeadingNode } from '@/lib/lexical/nodes/misc/heading' import { $createListNode, $createListItemNode } from '@lexical/list' import { $createTableNode, $createTableRowNode, $createTableCellNode } from '@lexical/table' import { $createHorizontalRuleNode } from '@lexical/extension' @@ -10,12 +11,15 @@ export const HEADING = { mdastType: 'heading', toMdast: (node, visitChildren) => ({ type: 'heading', - depth: parseInt(node.getTag()[1]), + depth: () => { + const tag = node.getTag() + return parseInt(tag.substring(1)) || 1 + }, children: node.getChildren().flatMap(visitChildren) }), fromMdast: (node, visitChildren) => { if (node.type !== 'heading') return null - return $createHeadingNode(`h${node.depth}`).append(...visitChildren(node.children)) + return $createSNHeadingNode(`h${node.depth}`).append(...visitChildren(node.children)) }, toMarkdown: (node, serialize) => `${'#'.repeat(node.depth)} ${serialize(node.children)}\n\n` @@ -25,19 +29,38 @@ export const HEADING = { export const LIST = { type: 'list', mdastType: 'list', - toMdast: (node, visitChildren) => ({ - type: 'list', - ordered: node.getListType() === 'number', - spread: false, - children: node.getChildren().flatMap(visitChildren) - }), + toMdast: (node, visitChildren) => { + const listType = node.getListType() + const children = node.getChildren().flatMap(visitChildren) + return { + type: 'list', + ordered: listType === 'number', + spread: false, + children + } + }, fromMdast: (node, visitChildren) => { if (node.type !== 'list') return null - return $createListNode(node.ordered ? 'number' : 'bullet').append(...visitChildren(node.children)) + // detect checklist: if any item has checked !== null, it's a checklist + const isChecklist = node.children?.some(item => item.checked !== null) + let listType = 'bullet' + if (node.ordered) { + listType = 'number' + } else if (isChecklist) { + listType = 'check' + } + return $createListNode(listType).append(...visitChildren(node.children)) }, toMarkdown: (node, serialize) => { const items = node.children.map((item, i) => { - const marker = node.ordered ? `${i + 1}.` : '-' + let marker + if (node.ordered) { + marker = `${i + 1}.` + } else if (item.checked !== null) { + marker = item.checked ? '- [x]' : '- [ ]' + } else { + marker = '-' + } const content = serialize(item.children).replace(/\n\n$/, '') return `${marker} ${content}` }) 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..594cfe2f75 --- /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 { fromMarkdown } from '@/lib/lexical/mdast/transformer' + +/** + * 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 + editor.update(() => { + fromMarkdown(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 index 315396ba39..5eb5caacb5 100644 --- a/lib/lexical/utils/index.js +++ b/lib/lexical/utils/index.js @@ -27,3 +27,33 @@ export function $initializeEditorState (editor, initialValue = '') { // 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/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 +} From 7a0de7fb91c1c449029be5a388af096d28b1e245 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 26 Nov 2025 16:39:55 +0100 Subject: [PATCH 4/8] revert Embed node to a classic Lexical node klass; styling bits --- lib/lexical/nodes/content/embeds/index.jsx | 100 ++++++++++++++------- styles/text.scss | 13 --- 2 files changed, 70 insertions(+), 43 deletions(-) diff --git a/lib/lexical/nodes/content/embeds/index.jsx b/lib/lexical/nodes/content/embeds/index.jsx index 241ee1f9f0..53e8d8cfe2 100644 --- a/lib/lexical/nodes/content/embeds/index.jsx +++ b/lib/lexical/nodes/content/embeds/index.jsx @@ -19,7 +19,7 @@ export function $convertEmbedElement (domNode) { } } - const node = $createEmbedNode(provider, id, src, meta) + const node = $createEmbedNode(provider, src, id, meta) return { node } } @@ -29,44 +29,66 @@ export class EmbedNode extends DecoratorBlockNode { __src __meta - $config () { - return this.config('embed', { - extends: DecoratorBlockNode, - importDOM: { - 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: (domNode) => $convertEmbedElement(domNode), - priority: 2 - } - }, - div: (domNode) => { - return this.importDOM().span(domNode) + 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.__id = id this.__src = src + this.__id = id this.__meta = meta } - updateDOM (prevNode, domNode) { - return false + exportJSON () { + return { + ...super.exportJSON(), + provider: this.__provider, + src: this.__src, + id: this.__id, + meta: this.__meta + } } exportDOM () { @@ -80,10 +102,30 @@ export class EmbedNode extends DecoratorBlockNode { } } + 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 || {} @@ -93,8 +135,6 @@ export class EmbedNode extends DecoratorBlockNode { } return ( - // this allows us to subject the embed blocks to formatting - // and also select them, show text cursors, etc. a { - position: absolute; - inset: 0; - text-decoration: none; -} - -.sn__headings > a:focus { - outline: none; -} - /* blocks */ .sn__quote { border-left: 3px solid var(--theme-quoteBar); From 9dfab64691f264489a0ac85040ef70112a2c77b0 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 27 Nov 2025 19:52:56 +0100 Subject: [PATCH 5/8] support MD hard breaks, support multiple children in list item nodes and divide with line breaks; efficient transformer search by mdastType rather than blind-search; fix: set the code theme for every CodeNode instead of just the direct descendants; fix: insert default values from formik; add support for new editor item edits; tweaks to styling --- components/comment-edit.js | 6 ++--- components/editor/editor.js | 6 +++++ components/editor/theme/theme.module.css | 1 + lib/lexical/exts/shiki.js | 12 +++------ lib/lexical/mdast/transformer.js | 2 +- .../mdast/transformers/plugins/structure.js | 26 +++++++++++++++++-- styles/text.scss | 7 ++--- 7 files changed, 40 insertions(+), 20 deletions(-) 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/editor/editor.js b/components/editor/editor.js index fb0213aa51..2fbaf0ffba 100644 --- a/components/editor/editor.js +++ b/components/editor/editor.js @@ -17,6 +17,7 @@ import styles from './theme/theme.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 @@ -41,6 +42,11 @@ export default function SNEditor ({ name, appendValue, autoFocus, topLevel, ...p } catch (error) { console.error('failed to load initial state:', error) } + // or existing markdown text + } else if (values.text) { + editor.update(() => { + $initializeEditorState(editor, values.text) + }) } }, name: 'editor', diff --git a/components/editor/theme/theme.module.css b/components/editor/theme/theme.module.css index a4b55f05ea..2742bffd95 100644 --- a/components/editor/theme/theme.module.css +++ b/components/editor/theme/theme.module.css @@ -51,6 +51,7 @@ border-radius: 0; } +/* spacing for paragraphs and list item spans (paragraphs inside list items are rendered as inline content) */ .text :global(.sn__paragraph) { display: block; white-space: pre-wrap; diff --git a/lib/lexical/exts/shiki.js b/lib/lexical/exts/shiki.js index bf9955448d..2ab616e6ef 100644 --- a/lib/lexical/exts/shiki.js +++ b/lib/lexical/exts/shiki.js @@ -1,7 +1,7 @@ -import { $getRoot } from 'lexical' +import { $nodesOfType } from 'lexical' import { defineExtension } from '@lexical/extension' import { registerCodeHighlighting, ShikiTokenizer } from '@lexical/code-shiki' -import { CodeExtension, $isCodeNode } from '@lexical/code' +import { CodeExtension, CodeNode } from '@lexical/code' export const CodeShikiSNExtension = defineExtension({ name: 'CodeShikiSNExtension', @@ -15,13 +15,7 @@ export const CodeShikiSNExtension = defineExtension({ cleanup() // set theme on all code nodes editor.update(() => { - const root = $getRoot() - - root.getChildren().forEach(child => { - if ($isCodeNode(child)) { - child.setTheme(newTheme) - } - }) + $nodesOfType(CodeNode).forEach(node => node.setTheme(newTheme)) }) return registerCodeHighlighting(editor, { ...tokenizer, defaultTheme: newTheme }) diff --git a/lib/lexical/mdast/transformer.js b/lib/lexical/mdast/transformer.js index 9661c95d10..9fc5709ec3 100644 --- a/lib/lexical/mdast/transformer.js +++ b/lib/lexical/mdast/transformer.js @@ -62,7 +62,7 @@ export function fromMdast (mdast) { // find a transformer that can handle this node for (const t of TRANSFORMERS) { - if (!t.fromMdast) continue + if (!t.fromMdast || !matchesMdastType(node, t.mdastType)) continue const result = t.fromMdast(node, visitChildren) if (result) return result } diff --git a/lib/lexical/mdast/transformers/plugins/structure.js b/lib/lexical/mdast/transformers/plugins/structure.js index e73871cbb9..a463566485 100644 --- a/lib/lexical/mdast/transformers/plugins/structure.js +++ b/lib/lexical/mdast/transformers/plugins/structure.js @@ -1,4 +1,4 @@ -import { $createParagraphNode, $createTextNode } from 'lexical' +import { $createParagraphNode, $createTextNode, $createLineBreakNode } from 'lexical' import { $createQuoteNode } from '@lexical/rich-text' import { $createSNHeadingNode } from '@/lib/lexical/nodes/misc/heading' import { $createListNode, $createListItemNode } from '@lexical/list' @@ -79,7 +79,18 @@ export const LIST_ITEM = { fromMdast: (node, visitChildren) => { if (node.type !== 'listItem') return null const item = $createListItemNode(node.checked) - if (node.children?.length) item.append(...visitChildren(node.children)) + // insert line breaks between paragraph children to prevent inline rendering + // Lexical does not support paragraphs inside list items, it expects inline content + // this will emulate the behavior of a paragraph by inserting line breaks between the children + const lexicalChildren = visitChildren(node.children) + lexicalChildren.forEach((child, index) => { + item.append(child) + + // line break between children, but not after the last one + if (index < lexicalChildren.length - 1) { + item.append($createLineBreakNode()) + } + }) return item } } @@ -161,6 +172,16 @@ export const HORIZONTAL_RULE = { toMarkdown: () => '---\n\n' } +// hard line break (two trailing spaces + newline in markdown) +export const HARD_BREAK = { + type: 'linebreak', + mdastType: 'break', + toMdast: () => ({ type: 'break' }), + // a
    between two spans (what lexical generates for text nodes) is not enough, we need two
    s + fromMdast: (node) => node.type === 'break' && [$createLineBreakNode(), $createLineBreakNode()], + toMarkdown: () => ' \n' +} + // html fallback export const HTML_FALLBACK = { mdastType: 'html', @@ -195,6 +216,7 @@ export default [ TABLE_ROW, TABLE_CELL, HORIZONTAL_RULE, + HARD_BREAK, HTML_FALLBACK, PARAGRAPH ] diff --git a/styles/text.scss b/styles/text.scss index 8007fb7961..3f63066b2f 100644 --- a/styles/text.scss +++ b/styles/text.scss @@ -190,10 +190,6 @@ span + .sn__link { margin-left: -0.15rem; } -.sn__nestedListItem { - list-style-type: none; -} - .sn__listOl, .sn__listUl { margin-top: 0; margin-bottom: 0rem; @@ -456,7 +452,7 @@ span + .sn__link { margin: 0; margin-top: 8px; margin-bottom: 8px; - padding: 8px; + padding: 0.5em !important; /* conflict with blockquote children padding */ overflow-y: hidden; overflow-x: auto; position: relative; @@ -464,6 +460,7 @@ span + .sn__link { white-space: pre; scrollbar-width: thin; scrollbar-color: var(--theme-borderColor) var(--theme-commentBg); + border-radius: 0.3rem; } .sn__math { From 5683168c4ec81c6d7e9a043874feaf3b60800454 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sun, 30 Nov 2025 12:16:56 +0100 Subject: [PATCH 6/8] separate content CSS from editor CSS --- components/editor/editor.js | 6 +- components/editor/plugins/preview/index.js | 5 +- components/editor/plugins/switch.js | 2 +- .../editor/plugins/tinytoolbar/index.js | 2 +- components/editor/plugins/upload.js | 2 +- components/editor/reader.js | 2 +- .../{theme.module.css => editor.module.css} | 107 ------------------ components/text.js | 33 +++--- styles/text.scss | 106 ++++++++++++++++- 9 files changed, 127 insertions(+), 138 deletions(-) rename components/editor/theme/{theme.module.css => editor.module.css} (82%) diff --git a/components/editor/editor.js b/components/editor/editor.js index 2fbaf0ffba..5312a35cb6 100644 --- a/components/editor/editor.js +++ b/components/editor/editor.js @@ -13,7 +13,7 @@ import { useFormikContext } from 'formik' import { configExtension, defineExtension } from 'lexical' import { useMemo, useState } from 'react' import theme from './theme' -import styles from './theme/theme.module.css' +import styles from './theme/editor.module.css' import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin' import { ToolbarPlugin } from './plugins/tinytoolbar' import { ToolbarContextProvider } from './contexts/toolbar' @@ -54,7 +54,7 @@ export default function SNEditor ({ name, appendValue, autoFocus, topLevel, ...p dependencies: [ configExtension(AutoFocusExtension, { disabled: !autoFocus }) ], - theme: { ...theme, topLevel: topLevel ? 'topLevel' : '' }, + theme: { ...theme, topLevel: topLevel ? 'sn__topLevel' : '' }, onError: (error) => console.error('editor has encountered an error:', error) }), [autoFocus, topLevel]) @@ -93,7 +93,7 @@ function EditorContent ({ name, placeholder, lengthOptions, topLevel }) { contentEditable={
    {placeholder}
    } /> diff --git a/components/editor/plugins/preview/index.js b/components/editor/plugins/preview/index.js index 966bacff86..3b5f2126f6 100644 --- a/components/editor/plugins/preview/index.js +++ b/components/editor/plugins/preview/index.js @@ -3,8 +3,9 @@ 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/theme.module.css' +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') @@ -37,7 +38,7 @@ export default function PreviewPlugin ({ editorRef, topLevel }) {
    diff --git a/components/editor/plugins/switch.js b/components/editor/plugins/switch.js index b4432c39a6..ac16497df4 100644 --- a/components/editor/plugins/switch.js +++ b/components/editor/plugins/switch.js @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useFormikContext } from 'formik' -import styles from '@/components/editor/theme/theme.module.css' +import styles from '@/components/editor/theme/editor.module.css' import Nav from 'react-bootstrap/Nav' import { useToolbarState } from '../contexts/toolbar' diff --git a/components/editor/plugins/tinytoolbar/index.js b/components/editor/plugins/tinytoolbar/index.js index b9203f1a36..b882c42277 100644 --- a/components/editor/plugins/tinytoolbar/index.js +++ b/components/editor/plugins/tinytoolbar/index.js @@ -1,6 +1,6 @@ import ActionTooltip from '@/components/action-tooltip' import classNames from 'classnames' -import styles from '@/components/editor/theme/theme.module.css' +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' diff --git a/components/editor/plugins/upload.js b/components/editor/plugins/upload.js index 0598df4399..485fd86db1 100644 --- a/components/editor/plugins/upload.js +++ b/components/editor/plugins/upload.js @@ -17,7 +17,7 @@ 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/theme.module.css' +import styles from '@/components/editor/theme/editor.module.css' export const SN_UPLOAD_FILES_COMMAND = createCommand('SN_UPLOAD_FILES_COMMAND') diff --git a/components/editor/reader.js b/components/editor/reader.js index 89aae7b190..8075a6f03d 100644 --- a/components/editor/reader.js +++ b/components/editor/reader.js @@ -45,7 +45,7 @@ export default forwardRef(function Reader ({ className, contentRef, topLevel, le ], theme: { ...theme, - topLevel: topLevel && 'topLevel' + topLevel: topLevel && 'sn__topLevel' }, $initialEditorState: (editor) => initiateLexical(editor, lexicalState, markdown), onError: (error) => console.error(error) diff --git a/components/editor/theme/theme.module.css b/components/editor/theme/editor.module.css similarity index 82% rename from components/editor/theme/theme.module.css rename to components/editor/theme/editor.module.css index 2742bffd95..65fbe84ad3 100644 --- a/components/editor/theme/theme.module.css +++ b/components/editor/theme/editor.module.css @@ -1,104 +1,3 @@ -/* text container - used by lexical reader and editor contenteditable */ - -.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; -} - -.text p { - margin-bottom: 0 !important; -} - -.textTruncated { - max-height: 50vh; -} - -.text[contenteditable="false"] { - background-color: var(--theme-body) !important; -} - -.text:global(.topLevel) { - max-height: 200vh; - --grid-gap: 0.75rem; -} - -.textUncontained { - max-height: none !important; -} - -.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%); -} - -.textShowFull { - position: absolute; - bottom: 0; - z-index: 2; - border-radius: 0; -} - -/* spacing for paragraphs and list item spans (paragraphs inside list items are rendered as inline content) */ -.text :global(.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); -} - -.text>*:not(:global(.sn__heading), :global(.sn__toc), :global(.sn__spoiler__container), :global(.sn__codeBlock)) { - padding-top: calc(var(--grid-gap) * 0.5); - padding-bottom: calc(var(--grid-gap) * 0.5); -} - -.text pre, .text blockquote { - margin-top: calc(var(--grid-gap) * 0.5); - margin-bottom: calc(var(--grid-gap) * 0.5); -} - -.text>*:last-child:not(.textShowFull, :global(.sn__codeBlock)) { - padding-bottom: 0 !important; - margin-bottom: 0 !important; -} - -.text>*:first-child:not(:global(.sn__codeBlock)) { - padding-top: 0 !important; - margin-top: 0 !important; -} - -.text blockquote, .text:global(.topLevel) blockquote { - padding-top: 0 !important; - padding-bottom: 0 !important; -} - -.text blockquote *:first-child, .text:global(.topLevel) blockquote *:first-child { - padding-top: 0; -} - -.text blockquote *:last-child, .text:global(.topLevel) blockquote *:last-child { - padding-bottom: 0; -} - -@media screen and (min-width: 767px) { - .text { - line-height: 130% !important; - } -} - /* editor container */ .editor { @@ -651,12 +550,6 @@ padding: 0.25rem 0; } -.text hr { - border-top: 3px solid var(--theme-quoteBar); - padding: 0 !important; - caret-color: transparent; -} - .dragOver { box-shadow: 0 0 10px var(--bs-info); } diff --git a/components/text.js b/components/text.js index 551af713df..73922171f3 100644 --- a/components/text.js +++ b/components/text.js @@ -1,5 +1,4 @@ import styles from './text.module.css' -import lexicalStyles from './editor/theme/theme.module.css' import ReactMarkdown from 'react-markdown' import gfm from 'remark-gfm' import dynamic from 'next/dynamic' @@ -96,7 +95,7 @@ export function useOverflow ({ element, truncated = false }) {