Skip to content

Commit 95ea599

Browse files
atsjoetrepum
andauthored
[lexical-list] Feature: export registerCheckList (#7429)
Co-authored-by: Bob Ippolito <[email protected]>
1 parent 6942357 commit 95ea599

File tree

3 files changed

+306
-292
lines changed

3 files changed

+306
-292
lines changed
+300
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
9+
import type {ListItemNode} from './LexicalListItemNode';
10+
import type {LexicalCommand, LexicalEditor} from 'lexical';
11+
12+
import {
13+
$findMatchingParent,
14+
calculateZoomLevel,
15+
isHTMLElement,
16+
mergeRegister,
17+
} from '@lexical/utils';
18+
import {
19+
$getNearestNodeFromDOMNode,
20+
$getSelection,
21+
$isElementNode,
22+
$isRangeSelection,
23+
COMMAND_PRIORITY_LOW,
24+
createCommand,
25+
getNearestEditorFromDOMNode,
26+
KEY_ARROW_DOWN_COMMAND,
27+
KEY_ARROW_LEFT_COMMAND,
28+
KEY_ARROW_UP_COMMAND,
29+
KEY_ESCAPE_COMMAND,
30+
KEY_SPACE_COMMAND,
31+
} from 'lexical';
32+
33+
import {$insertList} from './formatList';
34+
import {$isListItemNode} from './LexicalListItemNode';
35+
import {$isListNode} from './LexicalListNode';
36+
37+
export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = createCommand(
38+
'INSERT_CHECK_LIST_COMMAND',
39+
);
40+
41+
export function registerCheckList(editor: LexicalEditor) {
42+
return mergeRegister(
43+
editor.registerCommand(
44+
INSERT_CHECK_LIST_COMMAND,
45+
() => {
46+
$insertList('check');
47+
return true;
48+
},
49+
COMMAND_PRIORITY_LOW,
50+
),
51+
editor.registerCommand<KeyboardEvent>(
52+
KEY_ARROW_DOWN_COMMAND,
53+
(event) => {
54+
return handleArrowUpOrDown(event, editor, false);
55+
},
56+
COMMAND_PRIORITY_LOW,
57+
),
58+
editor.registerCommand<KeyboardEvent>(
59+
KEY_ARROW_UP_COMMAND,
60+
(event) => {
61+
return handleArrowUpOrDown(event, editor, true);
62+
},
63+
COMMAND_PRIORITY_LOW,
64+
),
65+
editor.registerCommand<KeyboardEvent>(
66+
KEY_ESCAPE_COMMAND,
67+
() => {
68+
const activeItem = getActiveCheckListItem();
69+
70+
if (activeItem != null) {
71+
const rootElement = editor.getRootElement();
72+
73+
if (rootElement != null) {
74+
rootElement.focus();
75+
}
76+
77+
return true;
78+
}
79+
80+
return false;
81+
},
82+
COMMAND_PRIORITY_LOW,
83+
),
84+
editor.registerCommand<KeyboardEvent>(
85+
KEY_SPACE_COMMAND,
86+
(event) => {
87+
const activeItem = getActiveCheckListItem();
88+
89+
if (activeItem != null && editor.isEditable()) {
90+
editor.update(() => {
91+
const listItemNode = $getNearestNodeFromDOMNode(activeItem);
92+
93+
if ($isListItemNode(listItemNode)) {
94+
event.preventDefault();
95+
listItemNode.toggleChecked();
96+
}
97+
});
98+
return true;
99+
}
100+
101+
return false;
102+
},
103+
COMMAND_PRIORITY_LOW,
104+
),
105+
editor.registerCommand<KeyboardEvent>(
106+
KEY_ARROW_LEFT_COMMAND,
107+
(event) => {
108+
return editor.getEditorState().read(() => {
109+
const selection = $getSelection();
110+
111+
if ($isRangeSelection(selection) && selection.isCollapsed()) {
112+
const {anchor} = selection;
113+
const isElement = anchor.type === 'element';
114+
115+
if (isElement || anchor.offset === 0) {
116+
const anchorNode = anchor.getNode();
117+
const elementNode = $findMatchingParent(
118+
anchorNode,
119+
(node) => $isElementNode(node) && !node.isInline(),
120+
);
121+
if ($isListItemNode(elementNode)) {
122+
const parent = elementNode.getParent();
123+
if (
124+
$isListNode(parent) &&
125+
parent.getListType() === 'check' &&
126+
(isElement || elementNode.getFirstDescendant() === anchorNode)
127+
) {
128+
const domNode = editor.getElementByKey(elementNode.__key);
129+
130+
if (domNode != null && document.activeElement !== domNode) {
131+
domNode.focus();
132+
event.preventDefault();
133+
return true;
134+
}
135+
}
136+
}
137+
}
138+
}
139+
140+
return false;
141+
});
142+
},
143+
COMMAND_PRIORITY_LOW,
144+
),
145+
editor.registerRootListener((rootElement, prevElement) => {
146+
if (rootElement !== null) {
147+
rootElement.addEventListener('click', handleClick);
148+
rootElement.addEventListener('pointerdown', handlePointerDown);
149+
}
150+
151+
if (prevElement !== null) {
152+
prevElement.removeEventListener('click', handleClick);
153+
prevElement.removeEventListener('pointerdown', handlePointerDown);
154+
}
155+
}),
156+
);
157+
}
158+
159+
function handleCheckItemEvent(event: PointerEvent, callback: () => void) {
160+
const target = event.target;
161+
162+
if (!isHTMLElement(target)) {
163+
return;
164+
}
165+
166+
// Ignore clicks on LI that have nested lists
167+
const firstChild = target.firstChild;
168+
169+
if (
170+
isHTMLElement(firstChild) &&
171+
(firstChild.tagName === 'UL' || firstChild.tagName === 'OL')
172+
) {
173+
return;
174+
}
175+
176+
const parentNode = target.parentNode;
177+
178+
// @ts-ignore internal field
179+
if (!parentNode || parentNode.__lexicalListType !== 'check') {
180+
return;
181+
}
182+
183+
const rect = target.getBoundingClientRect();
184+
const pageX = event.pageX / calculateZoomLevel(target);
185+
if (
186+
target.dir === 'rtl'
187+
? pageX < rect.right && pageX > rect.right - 20
188+
: pageX > rect.left && pageX < rect.left + 20
189+
) {
190+
callback();
191+
}
192+
}
193+
194+
function handleClick(event: Event) {
195+
handleCheckItemEvent(event as PointerEvent, () => {
196+
if (isHTMLElement(event.target)) {
197+
const domNode = event.target;
198+
const editor = getNearestEditorFromDOMNode(domNode);
199+
200+
if (editor != null && editor.isEditable()) {
201+
editor.update(() => {
202+
const node = $getNearestNodeFromDOMNode(domNode);
203+
204+
if ($isListItemNode(node)) {
205+
domNode.focus();
206+
node.toggleChecked();
207+
}
208+
});
209+
}
210+
}
211+
});
212+
}
213+
214+
function handlePointerDown(event: PointerEvent) {
215+
handleCheckItemEvent(event, () => {
216+
// Prevents caret moving when clicking on check mark
217+
event.preventDefault();
218+
});
219+
}
220+
221+
function getActiveCheckListItem(): HTMLElement | null {
222+
const activeElement = document.activeElement;
223+
224+
return isHTMLElement(activeElement) &&
225+
activeElement.tagName === 'LI' &&
226+
activeElement.parentNode != null &&
227+
// @ts-ignore internal field
228+
activeElement.parentNode.__lexicalListType === 'check'
229+
? activeElement
230+
: null;
231+
}
232+
233+
function findCheckListItemSibling(
234+
node: ListItemNode,
235+
backward: boolean,
236+
): ListItemNode | null {
237+
let sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
238+
let parent: ListItemNode | null = node;
239+
240+
// Going up in a tree to get non-null sibling
241+
while (sibling == null && $isListItemNode(parent)) {
242+
// Get li -> parent ul/ol -> parent li
243+
parent = parent.getParentOrThrow().getParent();
244+
245+
if (parent != null) {
246+
sibling = backward
247+
? parent.getPreviousSibling()
248+
: parent.getNextSibling();
249+
}
250+
}
251+
252+
// Going down in a tree to get first non-nested list item
253+
while ($isListItemNode(sibling)) {
254+
const firstChild = backward
255+
? sibling.getLastChild()
256+
: sibling.getFirstChild();
257+
258+
if (!$isListNode(firstChild)) {
259+
return sibling;
260+
}
261+
262+
sibling = backward ? firstChild.getLastChild() : firstChild.getFirstChild();
263+
}
264+
265+
return null;
266+
}
267+
268+
function handleArrowUpOrDown(
269+
event: KeyboardEvent,
270+
editor: LexicalEditor,
271+
backward: boolean,
272+
) {
273+
const activeItem = getActiveCheckListItem();
274+
275+
if (activeItem != null) {
276+
editor.update(() => {
277+
const listItem = $getNearestNodeFromDOMNode(activeItem);
278+
279+
if (!$isListItemNode(listItem)) {
280+
return;
281+
}
282+
283+
const nextListItem = findCheckListItemSibling(listItem, backward);
284+
285+
if (nextListItem != null) {
286+
nextListItem.selectStart();
287+
const dom = editor.getElementByKey(nextListItem.__key);
288+
289+
if (dom != null) {
290+
event.preventDefault();
291+
setTimeout(() => {
292+
dom.focus();
293+
}, 0);
294+
}
295+
}
296+
});
297+
}
298+
299+
return false;
300+
}

packages/lexical-list/src/index.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
TextNode,
2626
} from 'lexical';
2727

28+
import {INSERT_CHECK_LIST_COMMAND, registerCheckList} from './checkList';
2829
import {
2930
$handleListInsertParagraph,
3031
$insertList,
@@ -47,10 +48,12 @@ export {
4748
$isListItemNode,
4849
$isListNode,
4950
$removeList,
51+
INSERT_CHECK_LIST_COMMAND,
5052
ListItemNode,
5153
ListNode,
5254
ListNodeTagType,
5355
ListType,
56+
registerCheckList,
5457
SerializedListItemNode,
5558
SerializedListNode,
5659
};
@@ -60,9 +63,6 @@ export const INSERT_UNORDERED_LIST_COMMAND: LexicalCommand<void> =
6063
export const INSERT_ORDERED_LIST_COMMAND: LexicalCommand<void> = createCommand(
6164
'INSERT_ORDERED_LIST_COMMAND',
6265
);
63-
export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = createCommand(
64-
'INSERT_CHECK_LIST_COMMAND',
65-
);
6666
export const REMOVE_LIST_COMMAND: LexicalCommand<void> = createCommand(
6767
'REMOVE_LIST_COMMAND',
6868
);

0 commit comments

Comments
 (0)