Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions components/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { CodeShikiSNExtension } from '@/lib/lexical/exts/shiki'
import { CodeThemePlugin } from './plugins/core/code-theme'
import LinkEditorPlugin from './plugins/link'
import { DecoratorClickZonesExtension } from '@/lib/lexical/exts/decorator-click-zones'
import CodeActionMenuPlugin from './plugins/code/actions'

const EDITOR_MARKDOWN_MODE = {
name: 'editor-markdown',
Expand Down Expand Up @@ -183,6 +184,7 @@ function EditorContent ({
<>
<CodeThemePlugin />
<LinkEditorPlugin anchorElem={containerRef} />
<CodeActionMenuPlugin anchorElem={containerRef} />
</>
)}
{isMarkdown && (
Expand Down
260 changes: 260 additions & 0 deletions components/editor/plugins/code/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CodeNode, $isCodeNode, getLanguageFriendlyName, getCodeLanguageOptions } from '@lexical/code'
import { $getNearestNodeFromDOMNode } from 'lexical'
import { createPortal } from 'react-dom'
import { isHTMLElement } from '@lexical/utils'
import Dropdown from 'react-bootstrap/Dropdown'
import classNames from 'classnames'
import { CopyButton } from '@/components/form'
import { MenuAlternateDimension } from '@/components/editor/utils'
import codeStyles from './code.module.css'

const CODE_PADDING = 8
const MAX_SUGGESTIONS = 5
const LANGUAGE_OPTIONS = getCodeLanguageOptions()

function getCodeNodeFromDOMNode (domNode) {
const node = $getNearestNodeFromDOMNode(domNode)
return $isCodeNode(node) ? node : null
}

function getRandomLanguageSuggestions () {
return [...LANGUAGE_OPTIONS]
.sort(() => Math.random() - 0.5)
.slice(0, MAX_SUGGESTIONS)
}

function getMouseInfo (event) {
const { target } = event
if (!isHTMLElement(target)) return { codeDOMNode: null, isOutside: true }

const codeDOMNode = target.closest('code.sn-code-block')
const isOutside = !codeDOMNode && !target.closest('span.' + codeStyles.codeActionMenuContainer)
return { codeDOMNode, isOutside }
}

function LanguageSelector ({ lang, editor, codeDOMNodeRef, isEditingRef, onLanguageChange }) {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(0)

// random placeholder suggestions until the user types
const randomized = useMemo(() => getRandomLanguageSuggestions(), [open])

// show 5 suggestions based on the input
// fallback to random suggestions
const suggestions = useMemo(() => {
const q = filter.toLowerCase()
if (!q) return randomized
return LANGUAGE_OPTIONS
.filter(([, name]) => name.toLowerCase().includes(q))
.slice(0, MAX_SUGGESTIONS)
}, [filter, randomized])

// reset highlighted index when filter changes
useEffect(() => setHighlightedIndex(0), [filter])

// track language selector state
useEffect(() => {
isEditingRef.current = open
if (!open) setFilter('')
}, [open, isEditingRef])

const selectLanguage = useCallback((langKey) => {
editor.update(() => {
const domNode = codeDOMNodeRef.current
if (!domNode) return
const codeNode = getCodeNodeFromDOMNode(domNode)
if (codeNode) codeNode.setLanguage(langKey)
})
onLanguageChange(langKey)
setOpen(false)
}, [editor, codeDOMNodeRef, onLanguageChange])

const handleKeyDown = useCallback((e) => {
e.stopPropagation()
if (!suggestions.length && e.key !== 'Escape') return

switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex(i => Math.min(i + 1, suggestions.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex(i => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
if (suggestions[highlightedIndex]) selectLanguage(suggestions[highlightedIndex][0])
break
case 'Escape':
e.preventDefault()
setOpen(false)
break
}
}, [suggestions, highlightedIndex, selectLanguage])

return (
<Dropdown drop='down' as='span' onToggle={setOpen} show={open}>
<Dropdown.Toggle
as='span'
className={classNames(codeStyles.codeActionLanguage, codeStyles.editable)}
onPointerDown={e => e.preventDefault()}
>
{getLanguageFriendlyName(lang)}
</Dropdown.Toggle>
<Dropdown.Menu as={MenuAlternateDimension} className={codeStyles.languageDropdown}>
<div onMouseDown={e => e.stopPropagation()}>
<input
className={codeStyles.languageInput}
value={filter}
onChange={e => setFilter(e.target.value)}
onKeyDown={handleKeyDown}
placeholder='search'
/>
</div>
{suggestions.map(([key, name], i) => (
<div
key={key}
className={classNames(codeStyles.languageOption, i === highlightedIndex && codeStyles.languageOptionHighlighted)}
onMouseEnter={() => setHighlightedIndex(i)}
onPointerDown={e => e.preventDefault()}
onClick={() => selectLanguage(key)}
>
{name}
</div>
))}
</Dropdown.Menu>
</Dropdown>
)
}

function CodeActionMenuContainer ({ anchorElem }) {
const [editor] = useLexicalComposerContext()
const [lang, setLang] = useState('')
const [isShown, setShown] = useState(false)
const [shouldListenMouseMove, setShouldListenMouseMove] = useState(false)
const [position, setPosition] = useState({ right: '0', top: '0' })
const isEditable = useLexicalEditable()
const codeSetRef = useRef(new Set())
const codeDOMNodeRef = useRef(null)
const isEditingLangRef = useRef(false)
const rafRef = useRef(null)
const mouseEventRef = useRef(null)

// read code node's text content
const getCodeContent = useCallback(() => {
const domNode = codeDOMNodeRef.current
if (!domNode) return ''
let content = ''
editor.read(() => {
const codeNode = getCodeNodeFromDOMNode(domNode)
if (codeNode) content = codeNode.getTextContent()
})
return content
}, [editor])

// show code actions on code block hover
const handleMouseMove = useCallback((event) => {
const { codeDOMNode, isOutside } = getMouseInfo(event)
if (isOutside) {
if (!isEditingLangRef.current) setShown(false)
return
}
if (!codeDOMNode) return

codeDOMNodeRef.current = codeDOMNode

let codeNode = null
let nodeLang = ''
editor.read(() => {
codeNode = getCodeNodeFromDOMNode(codeDOMNode)
if (codeNode) nodeLang = codeNode.getLanguage() || ''
})

if (codeNode) {
const { y: editorY, right: editorRight } = anchorElem.getBoundingClientRect()
const { y, right } = codeDOMNode.getBoundingClientRect()
setLang(nodeLang)
setShown(true)
setPosition({
right: `${editorRight - right + CODE_PADDING}px`,
top: `${y - editorY}px`
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Menu not hidden when code node belongs to different editor

Low Severity

When handleMouseMove finds a codeDOMNode via DOM query but getCodeNodeFromDOMNode returns null (the node isn't in this editor's Lexical tree), the handler falls through without hiding the menu. Additionally, codeDOMNodeRef.current is updated on line 169 before validating the node belongs to this editor. In multi-reader pages (e.g., a feed with multiple posts containing code blocks), hovering from one reader's code block to another's leaves a stale menu visible and points codeDOMNodeRef at the wrong code block, causing getCodeContent() to return an empty string on copy.

Fix in Cursor Fix in Web

}, [anchorElem, editor])

// listen to mouse move
useEffect(() => {
if (!shouldListenMouseMove) return

const onMouseMove = (event) => {
mouseEventRef.current = event
if (rafRef.current) return

rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null
if (mouseEventRef.current) handleMouseMove(mouseEventRef.current)
})
}

document.addEventListener('mousemove', onMouseMove)
return () => {
setShown(false)
mouseEventRef.current = null
if (rafRef.current) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
document.removeEventListener('mousemove', onMouseMove)
}
}, [shouldListenMouseMove, handleMouseMove])

// track code node mutations to toggle mouse listener
useEffect(() => {
return editor.registerMutationListener(
CodeNode,
(mutations) => {
editor.getEditorState().read(() => {
for (const [key, type] of mutations) {
if (type === 'created') codeSetRef.current.add(key)
else if (type === 'destroyed') codeSetRef.current.delete(key)
}
})
setShouldListenMouseMove(codeSetRef.current.size > 0)
},
{ skipInitialization: false }
)
}, [editor])

return (
<>
{isShown && (
<span className={codeStyles.codeActionMenuContainer} style={position}>
{isEditable
? (
<LanguageSelector
lang={lang}
editor={editor}
codeDOMNodeRef={codeDOMNodeRef}
isEditingRef={isEditingLangRef}
onLanguageChange={setLang}
/>
)
: <span className={codeStyles.codeActionLanguage}>{getLanguageFriendlyName(lang)}</span>}
<CopyButton icon value={getCodeContent()} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy button captures stale code content at render

Low Severity

getCodeContent() is eagerly evaluated at render time and passed as a static value prop to CopyButton. The CodeActionMenuContainer only re-renders when isShown, lang, or position state changes — not when the code block's text content changes. If the user edits the code block content while the action menu is visible (e.g., via keyboard while mouse hovers over the copy button area), clicking copy will capture stale content from the last render rather than current content.

Additional Locations (1)
Fix in Cursor Fix in Web

@Soxasora Soxasora Mar 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lexical selection is not lost by clicking quote reply and getCodeContent() creates a live read transaction in the editor (the reader in this case):

 const getCodeContent = useCallback(() => {
    const domNode = codeDOMNodeRef.current
    if (!domNode) return ''
    let content = ''
    editor.read(() => {
      const codeNode = getCodeNodeFromDOMNode(domNode)
      if (codeNode) content = codeNode.getTextContent()
    })
    return content
  }, [editor])

</span>
)}
</>
)
}

export default function CodeActionMenuPlugin ({ anchorElem = document.body }) {
if (!anchorElem) return null

// portal outside of the editor to avoid overflow
return createPortal(<CodeActionMenuContainer anchorElem={anchorElem} />, anchorElem)
}
92 changes: 92 additions & 0 deletions components/editor/plugins/code/code.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
.codeActionMenuContainer {
position: absolute;
display: flex;
align-items: center;
padding-top: 6px;
font-size: 10px;
color: var(--theme-navLink);
user-select: none;
}

.codeActionLanguage {
margin-right: 4px;
}

.editable {
cursor: pointer;
}

.editable:hover {
color: var(--theme-navLinkFocus);
}

.editable:active {
color: var(--theme-navLinkActive);
}

.codeActionMenuContainer :global(.input-group-text) {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}

.codeActionMenuContainer :global(.input-group-text):hover {
color: var(--theme-navLinkFocus);
}

.codeActionMenuContainer :global(.input-group-text):active {
color: var(--theme-navLinkActive);
}

.codeActionMenuContainer svg {
width: 16px;
height: 16px;
margin: 0;
padding: 0;
fill: currentColor;
}

.languageInput {
width: 100%;
font-size: 12px;
padding: 0.25rem 0.35rem;
margin-bottom: 0.25rem;
border: 0;
border-radius: 3px;
background: var(--theme-inputBg);
color: var(--theme-color);
outline: none;
}

.languageInput:focus {
outline: 1px solid var(--bs-primary);
}

.languageDropdown {
min-width: 120px;
background: var(--theme-inputBg);
border: 1px solid var(--theme-borderColor);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 2000;
padding: 4px;
}

.languageOption {
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
border-radius: 3px;
color: var(--theme-color);
}

.languageOptionHighlighted {
background-color: var(--theme-toolbarActive);
}
Loading
Loading