-
-
Notifications
You must be signed in to change notification settings - Fork 177
code block: copy and language selection #2871
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 6 commits
a6dbdc1
f77ed02
da129b8
f7ae90a
638473d
3a7054c
a6ef0a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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` | ||
| }) | ||
| } | ||
| }, [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()} /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Copy button captures stale code content at renderLow Severity
Additional Locations (1)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lexical selection is not lost by clicking 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) | ||
| } | ||
| 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); | ||
| } |


There was a problem hiding this comment.
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
handleMouseMovefinds acodeDOMNodevia DOM query butgetCodeNodeFromDOMNodereturnsnull(the node isn't in this editor's Lexical tree), the handler falls through without hiding the menu. Additionally,codeDOMNodeRef.currentis 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 pointscodeDOMNodeRefat the wrong code block, causinggetCodeContent()to return an empty string on copy.