-
-
Notifications
You must be signed in to change notification settings - Fork 189
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 all 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,270 @@ | ||
| 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, onClose }) { | ||
| 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('') | ||
| onClose() | ||
| } | ||
| }, [open, isEditingRef, onClose]) | ||
|
|
||
| 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) => { | ||
| if (isEditingLangRef.current) return | ||
|
|
||
| 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` | ||
| }) | ||
| } | ||
|
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. Menu not hidden when code node belongs to different editorLow Severity When |
||
| }, [anchorElem, editor]) | ||
|
|
||
| // listen to mouse move | ||
| useEffect(() => { | ||
| if (!shouldListenMouseMove) return | ||
|
|
||
| const onPointerEvent = (event) => { | ||
| mouseEventRef.current = event | ||
| if (rafRef.current) return | ||
|
|
||
| rafRef.current = window.requestAnimationFrame(() => { | ||
| rafRef.current = null | ||
| if (mouseEventRef.current) handleMouseMove(mouseEventRef.current) | ||
| }) | ||
| } | ||
|
|
||
| document.addEventListener('mousemove', onPointerEvent) | ||
| document.addEventListener('mousedown', onPointerEvent) | ||
| return () => { | ||
| setShown(false) | ||
| mouseEventRef.current = null | ||
| if (rafRef.current) { | ||
| window.cancelAnimationFrame(rafRef.current) | ||
| rafRef.current = null | ||
| } | ||
| document.removeEventListener('mousemove', onPointerEvent) | ||
| document.removeEventListener('mousedown', onPointerEvent) | ||
| } | ||
| }, [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} | ||
| onClose={() => { | ||
| if (mouseEventRef.current) handleMouseMove(mouseEventRef.current) | ||
| }} | ||
| /> | ||
| ) | ||
| : <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.
Language close handler can trigger render loop
High Severity
LanguageSelectoreffect depends ononClose, but parent passes a new inlineonCloseeach render. When closed, effect callsonClose, which can callhandleMouseMoveand update position state, causing another render and another newonClose, repeatedly.Additional Locations (1)
components/editor/plugins/code/actions.js#L251-L254