Skip to content

Commit a7bfb09

Browse files
committed
chore: useLexicalSelection hook
1 parent 99424fe commit a7bfb09

File tree

3 files changed

+330
-0
lines changed

3 files changed

+330
-0
lines changed

.nvmrc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
lts/hydrogen
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
2+
/**
3+
* @typedef {import('mdast-util-mdx')}
4+
*/
5+
import React from 'react'
6+
import {
7+
$getRoot,
8+
$getSelection,
9+
$isRangeSelection,
10+
COMMAND_PRIORITY_LOW,
11+
ElementNode,
12+
LexicalEditor,
13+
LexicalNode,
14+
RangeSelection,
15+
SELECTION_CHANGE_COMMAND,
16+
TextNode,
17+
} from 'lexical'
18+
import { mergeRegister } from '@lexical/utils'
19+
20+
import { LexicalComposer } from '@lexical/react/LexicalComposer'
21+
import { LinkPlugin as LexicalLinkPlugin } from '@lexical/react/LexicalLinkPlugin'
22+
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
23+
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
24+
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
25+
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
26+
import { $isAtNodeEnd } from '@lexical/selection'
27+
import { importMarkdownToLexical, UsedLexicalNodes } from '@virtuoso.dev/lexical-mdx-import-export'
28+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
29+
import { $isLinkNode } from '@lexical/link'
30+
31+
const initialMarkdown = `
32+
[A link](https://google.com/ "Link To Google")
33+
34+
In commodo tempor lorem, id lobortis purus pharetra nec. Morbi sagittis ultricies lectus ut placerat. Praesent vestibulum ligula non sapien efficitur, sit amet viverra est imperdiet. Nam rutrum massa quam, sit amet convallis erat viverra nec. Donec pharetra urna urna, non malesuada orci consequat cursus. Nulla blandit ligula ac leo dictum fermentum. Fusce augue purus, dignissim ut posuere gravida, laoreet eget quam. Nunc porttitor leo sem, eget posuere magna congue ut. Nunc tempus mi quis efficitur accumsan. Ut efficitur, felis eu lacinia finibus, nisl quam ultrices eros, eget scelerisque dolor diam nec turpis. Nam ultricies, sapien auctor condimentum bibendum, metus est maximus neque, id ornare metus eros quis libero. Nulla facilisi.
35+
In commodo tempor lorem, id lobortis purus pharetra nec. Morbi sagittis ultricies lectus ut placerat. Praesent vestibulum ligula non sapien efficitur, sit amet viverra est imperdiet. Nam rutrum massa quam, sit amet convallis erat viverra nec. Donec pharetra urna urna, non malesuada orci consequat cursus. Nulla blandit ligula ac leo dictum fermentum. Fusce augue purus, dignissim ut posuere gravida, laoreet eget quam. Nunc porttitor leo sem, eget posuere magna congue ut. Nunc tempus mi quis efficitur accumsan. Ut efficitur, felis eu lacinia finibus, nisl quam ultrices eros, eget scelerisque dolor diam nec turpis. Nam ultricies, sapien auctor condimentum bibendum, metus est maximus neque, id ornare metus eros quis libero. Nulla facilisi.
36+
In commodo tempor lorem, id lobortis purus pharetra nec. Morbi sagittis ultricies lectus ut placerat. Praesent vestibulum ligula non sapien efficitur, sit amet viverra est imperdiet. Nam rutrum massa quam, sit amet convallis erat viverra nec. Donec pharetra urna urna, non malesuada orci consequat cursus. Nulla blandit ligula ac leo dictum fermentum. Fusce augue purus, dignissim ut posuere gravida, laoreet eget quam. Nunc porttitor leo sem, eget posuere magna congue ut. Nunc tempus mi quis efficitur accumsan. Ut efficitur, felis eu lacinia finibus, nisl quam ultrices eros, eget scelerisque dolor diam nec turpis. Nam ultricies, sapien auctor condimentum bibendum, metus est maximus neque, id ornare metus eros quis libero. Nulla facilisi.
37+
In commodo tempor lorem, id lobortis purus pharetra nec. Morbi sagittis ultricies lectus ut placerat. Praesent vestibulum ligula non sapien efficitur, sit amet viverra est imperdiet. Nam rutrum massa quam, sit amet convallis erat viverra nec. Donec pharetra urna urna, non malesuada orci consequat cursus. Nulla blandit ligula ac leo dictum fermentum. Fusce augue purus, dignissim ut posuere gravida, laoreet eget quam. Nunc porttitor leo sem, eget posuere magna congue ut. Nunc tempus mi quis efficitur accumsan. Ut efficitur, felis eu lacinia finibus, nisl quam ultrices eros, eget scelerisque dolor diam nec turpis. Nam ultricies, sapien auctor condimentum bibendum, metus est maximus neque, id ornare metus eros quis libero. Nulla facilisi.
38+
In commodo tempor lorem, id lobortis purus pharetra nec. Morbi sagittis ultricies lectus ut placerat. Praesent vestibulum ligula non sapien efficitur, sit amet viverra est imperdiet. Nam rutrum massa quam, sit amet convallis erat viverra nec. Donec pharetra urna urna, non malesuada orci consequat cursus. Nulla blandit ligula ac leo dictum fermentum. Fusce augue purus, dignissim ut posuere gravida, laoreet eget quam. Nunc porttitor leo sem, eget posuere magna congue ut. Nunc tempus mi quis efficitur accumsan. Ut efficitur, felis eu lacinia finibus, nisl quam ultrices eros, eget scelerisque dolor diam nec turpis. Nam ultricies, sapien auctor condimentum bibendum, metus est maximus neque, id ornare metus eros quis libero. Nulla facilisi.
39+
In commodo tempor lorem, id lobortis purus pharetra nec. Morbi sagittis ultricies lectus ut placerat. Praesent vestibulum ligula non sapien efficitur, sit amet viverra est imperdiet. Nam rutrum massa quam, sit amet convallis erat viverra nec. Donec pharetra urna urna, non malesuada orci consequat cursus. Nulla blandit ligula ac leo dictum fermentum. Fusce augue purus, dignissim ut posuere gravida, laoreet eget quam. Nunc porttitor leo sem, eget posuere magna congue ut. Nunc tempus mi quis efficitur accumsan. Ut efficitur, felis eu lacinia finibus, nisl quam ultrices eros, eget scelerisque dolor diam nec turpis. Nam ultricies, sapien auctor condimentum bibendum, metus est maximus neque, id ornare metus eros quis libero. Nulla facilisi.
40+
41+
[A link](https://google.com/ "Link To Google")
42+
`
43+
44+
const theme = {
45+
text: {
46+
bold: 'PlaygroundEditorTheme__textBold',
47+
code: 'PlaygroundEditorTheme__textCode',
48+
italic: 'PlaygroundEditorTheme__textItalic',
49+
strikethrough: 'PlaygroundEditorTheme__textStrikethrough',
50+
subscript: 'PlaygroundEditorTheme__textSubscript',
51+
superscript: 'PlaygroundEditorTheme__textSuperscript',
52+
underline: 'PlaygroundEditorTheme__textUnderline',
53+
underlineStrikethrough: 'PlaygroundEditorTheme__textUnderlineStrikethrough',
54+
},
55+
56+
list: {
57+
nested: {
58+
listitem: 'PlaygroundEditorTheme__nestedListItem',
59+
},
60+
},
61+
}
62+
63+
function onError(error: Error) {
64+
console.error(error)
65+
}
66+
67+
function getSelectedNode(selection: RangeSelection): TextNode | ElementNode {
68+
const anchor = selection.anchor
69+
const focus = selection.focus
70+
const anchorNode = selection.anchor.getNode()
71+
const focusNode = selection.focus.getNode()
72+
if (anchorNode === focusNode) {
73+
return anchorNode
74+
}
75+
const isBackward = selection.isBackward()
76+
if (isBackward) {
77+
return $isAtNodeEnd(focus) ? anchorNode : focusNode
78+
} else {
79+
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
80+
}
81+
}
82+
83+
function getSelectionRectangle(editor: LexicalEditor) {
84+
const selection = $getSelection()
85+
const nativeSelection = window.getSelection()
86+
const activeElement = document.activeElement
87+
88+
const rootElement = editor.getRootElement()
89+
90+
if (
91+
selection !== null &&
92+
nativeSelection !== null &&
93+
rootElement !== null &&
94+
rootElement.contains(nativeSelection.anchorNode) &&
95+
editor.isEditable()
96+
) {
97+
const domRange = nativeSelection.getRangeAt(0)
98+
let rect
99+
if (nativeSelection.anchorNode === rootElement) {
100+
let inner = rootElement
101+
while (inner.firstElementChild != null) {
102+
inner = inner.firstElementChild as HTMLElement
103+
}
104+
rect = inner.getBoundingClientRect()
105+
} else {
106+
rect = domRange.getBoundingClientRect()
107+
}
108+
109+
return rect
110+
} else if (!activeElement || activeElement.className !== 'link-input') {
111+
return null
112+
}
113+
return null
114+
}
115+
116+
function useLexicalSelection() {
117+
const [selectionRectangle, setSelectionRectangle] = React.useState<DOMRect | null>(null)
118+
const [selectedNode, setSelectedNode] = React.useState<LexicalNode | null>(null)
119+
const [editor] = useLexicalComposerContext()
120+
121+
const reportSelection = React.useCallback(() => {
122+
const selection = $getSelection()
123+
if ($isRangeSelection(selection)) {
124+
setSelectionRectangle(getSelectionRectangle(editor))
125+
setSelectedNode(getSelectedNode(selection))
126+
}
127+
}, [editor])
128+
129+
React.useEffect(() => {
130+
const update = () => {
131+
editor.getEditorState().read(() => {
132+
reportSelection()
133+
})
134+
}
135+
136+
update()
137+
window.addEventListener('resize', update)
138+
// TODO: get the right scroller
139+
window.addEventListener('scroll', update)
140+
141+
return () => {
142+
window.removeEventListener('resize', update)
143+
window.removeEventListener('scroll', update)
144+
}
145+
}, [editor, reportSelection])
146+
147+
React.useEffect(() => {
148+
return mergeRegister(
149+
editor.registerUpdateListener(({ editorState }) => {
150+
editorState.read(() => {
151+
reportSelection()
152+
})
153+
}),
154+
155+
editor.registerCommand(
156+
SELECTION_CHANGE_COMMAND,
157+
() => {
158+
reportSelection()
159+
return true
160+
},
161+
COMMAND_PRIORITY_LOW
162+
)
163+
)
164+
}, [editor, reportSelection])
165+
166+
return { selectionRectangle, selectedNode }
167+
}
168+
169+
function SelectionRectanglePlugin() {
170+
const { selectionRectangle } = useLexicalSelection()
171+
if (selectionRectangle?.width === 0) {
172+
return
173+
}
174+
return (
175+
<div
176+
style={{
177+
position: 'absolute',
178+
top: selectionRectangle?.top,
179+
left: selectionRectangle?.left,
180+
width: selectionRectangle?.width,
181+
height: selectionRectangle?.height,
182+
opacity: 0.5,
183+
backgroundColor: 'red',
184+
transform: 'translatey(-30px)',
185+
}}
186+
>
187+
Selection
188+
</div>
189+
)
190+
}
191+
192+
export function BasicSetup() {
193+
const initialConfig = {
194+
editorState: () => {
195+
importMarkdownToLexical($getRoot(), initialMarkdown)
196+
},
197+
namespace: 'MyEditor',
198+
theme,
199+
nodes: UsedLexicalNodes,
200+
onError,
201+
}
202+
203+
return (
204+
<LexicalComposer initialConfig={initialConfig}>
205+
<RichTextPlugin contentEditable={<ContentEditable />} placeholder={<div></div>} ErrorBoundary={LexicalErrorBoundary} />
206+
<LexicalLinkPlugin />
207+
<ListPlugin />
208+
<SelectionRectanglePlugin />
209+
</LexicalComposer>
210+
)
211+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import React from 'react'
2+
import {
3+
$getSelection,
4+
$isRangeSelection,
5+
COMMAND_PRIORITY_LOW,
6+
ElementNode,
7+
LexicalEditor,
8+
LexicalNode,
9+
RangeSelection,
10+
SELECTION_CHANGE_COMMAND,
11+
TextNode,
12+
} from 'lexical'
13+
import { mergeRegister } from '@lexical/utils'
14+
15+
import { $isAtNodeEnd } from '@lexical/selection'
16+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
17+
18+
function getSelectedNode(selection: RangeSelection): TextNode | ElementNode {
19+
const anchor = selection.anchor
20+
const focus = selection.focus
21+
const anchorNode = selection.anchor.getNode()
22+
const focusNode = selection.focus.getNode()
23+
if (anchorNode === focusNode) {
24+
return anchorNode
25+
}
26+
const isBackward = selection.isBackward()
27+
if (isBackward) {
28+
return $isAtNodeEnd(focus) ? anchorNode : focusNode
29+
} else {
30+
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
31+
}
32+
}
33+
34+
function getSelectionRectangle(editor: LexicalEditor) {
35+
const selection = $getSelection()
36+
const nativeSelection = window.getSelection()
37+
const activeElement = document.activeElement
38+
39+
const rootElement = editor.getRootElement()
40+
41+
if (
42+
selection !== null &&
43+
nativeSelection !== null &&
44+
rootElement !== null &&
45+
rootElement.contains(nativeSelection.anchorNode) &&
46+
editor.isEditable()
47+
) {
48+
const domRange = nativeSelection.getRangeAt(0)
49+
let rect
50+
if (nativeSelection.anchorNode === rootElement) {
51+
let inner = rootElement
52+
while (inner.firstElementChild != null) {
53+
inner = inner.firstElementChild as HTMLElement
54+
}
55+
rect = inner.getBoundingClientRect()
56+
} else {
57+
rect = domRange.getBoundingClientRect()
58+
}
59+
60+
return rect
61+
} else if (!activeElement || activeElement.className !== 'link-input') {
62+
return null
63+
}
64+
return null
65+
}
66+
67+
export function useLexicalSelection() {
68+
const [selectionRectangle, setSelectionRectangle] = React.useState<DOMRect | null>(null)
69+
const [selectedNode, setSelectedNode] = React.useState<LexicalNode | null>(null)
70+
const [editor] = useLexicalComposerContext()
71+
72+
const reportSelection = React.useCallback(() => {
73+
const selection = $getSelection()
74+
if ($isRangeSelection(selection)) {
75+
setSelectionRectangle(getSelectionRectangle(editor))
76+
setSelectedNode(getSelectedNode(selection))
77+
}
78+
}, [editor])
79+
80+
React.useEffect(() => {
81+
const update = () => {
82+
editor.getEditorState().read(() => {
83+
reportSelection()
84+
})
85+
}
86+
87+
update()
88+
window.addEventListener('resize', update)
89+
// TODO: get the right scroller
90+
window.addEventListener('scroll', update)
91+
92+
return () => {
93+
window.removeEventListener('resize', update)
94+
window.removeEventListener('scroll', update)
95+
}
96+
}, [editor, reportSelection])
97+
98+
React.useEffect(() => {
99+
return mergeRegister(
100+
editor.registerUpdateListener(({ editorState }) => {
101+
editorState.read(() => {
102+
reportSelection()
103+
})
104+
}),
105+
106+
editor.registerCommand(
107+
SELECTION_CHANGE_COMMAND,
108+
() => {
109+
reportSelection()
110+
return true
111+
},
112+
COMMAND_PRIORITY_LOW
113+
)
114+
)
115+
}, [editor, reportSelection])
116+
117+
return { selectionRectangle, selectedNode }
118+
}

0 commit comments

Comments
 (0)