@lexical/list 0.44.1-nightly.20260519.0 → 0.45.1-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,469 @@
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 {Signal} from '@lexical/extension';
13
+ import {
14
+ $findMatchingParent,
15
+ calculateZoomLevel,
16
+ isHTMLElement,
17
+ mergeRegister,
18
+ } from '@lexical/utils';
19
+ import {
20
+ $addUpdateTag,
21
+ $getNearestNodeFromDOMNode,
22
+ $getSelection,
23
+ $isElementNode,
24
+ $isRangeSelection,
25
+ COMMAND_PRIORITY_LOW,
26
+ createCommand,
27
+ getNearestEditorFromDOMNode,
28
+ KEY_ARROW_DOWN_COMMAND,
29
+ KEY_ARROW_LEFT_COMMAND,
30
+ KEY_ARROW_UP_COMMAND,
31
+ KEY_ESCAPE_COMMAND,
32
+ KEY_SPACE_COMMAND,
33
+ SKIP_DOM_SELECTION_TAG,
34
+ SKIP_SELECTION_FOCUS_TAG,
35
+ } from 'lexical';
36
+
37
+ import {$insertList} from './formatList';
38
+ import {$isListItemNode} from './LexicalListItemNode';
39
+ import {$isListNode} from './LexicalListNode';
40
+
41
+ export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = createCommand(
42
+ 'INSERT_CHECK_LIST_COMMAND',
43
+ );
44
+
45
+ /**
46
+ * Registers the checklist plugin with the editor.
47
+ * @param editor The LexicalEditor instance.
48
+ * @param options Optional configuration.
49
+ * - disableTakeFocusOnClick: If true, clicking a checklist item will not focus the editor (useful for mobile).
50
+ */
51
+ export function registerCheckList(
52
+ editor: LexicalEditor,
53
+ options?: {disableTakeFocusOnClick?: boolean | Signal<boolean>},
54
+ ) {
55
+ const disableTakeFocusOnClick =
56
+ (options && options.disableTakeFocusOnClick) || false;
57
+ const peekDisableTakeFocusOnClick =
58
+ typeof disableTakeFocusOnClick === 'boolean'
59
+ ? () => disableTakeFocusOnClick
60
+ : disableTakeFocusOnClick.peek.bind(disableTakeFocusOnClick);
61
+
62
+ // Mobile tap fix: the touchstart listener registered below calls
63
+ // event.preventDefault() to keep the caret away from the marker. On iOS
64
+ // Safari and Android Chrome that suppression also cancels the synthesized
65
+ // click, so handleClick never runs and the checkbox cannot be toggled by
66
+ // tap. We additionally listen for pointerup with pointerType === 'touch'
67
+ // and run the same toggle logic, deduplicating against any click that
68
+ // does fire on browsers where preventDefault doesn't suppress it.
69
+ //
70
+ // Dedup state is per-target: recorded as `__lexicalCheckListLastHandled`
71
+ // on the target element. A global window would
72
+ // block tapping a second checkbox within 500ms of toggling the first.
73
+ const DEDUP_WINDOW_MS = 500;
74
+ const isWithinDedupWindow = (
75
+ event: PointerEvent | MouseEvent | TouchEvent,
76
+ ): boolean => {
77
+ const target = event.target;
78
+ if (!isHTMLElement(target)) {
79
+ return false;
80
+ }
81
+ // @ts-ignore internal field
82
+ const last = target.__lexicalCheckListLastHandled as number | undefined;
83
+ return last !== undefined && event.timeStamp - last < DEDUP_WINDOW_MS;
84
+ };
85
+ const recordHandled = (event: PointerEvent | MouseEvent | TouchEvent) => {
86
+ const target = event.target;
87
+ if (isHTMLElement(target)) {
88
+ // @ts-ignore internal field
89
+ target.__lexicalCheckListLastHandled = event.timeStamp;
90
+ }
91
+ };
92
+ const configHandleClick = (event: PointerEvent | MouseEvent | TouchEvent) => {
93
+ if (isWithinDedupWindow(event)) {
94
+ return;
95
+ }
96
+ recordHandled(event);
97
+ handleClick(event, peekDisableTakeFocusOnClick());
98
+ };
99
+ const configHandlePointerUp = (event: PointerEvent) => {
100
+ if (event.pointerType !== 'touch') {
101
+ return;
102
+ }
103
+ if (isWithinDedupWindow(event)) {
104
+ return;
105
+ }
106
+ recordHandled(event);
107
+ handleClick(event, peekDisableTakeFocusOnClick());
108
+ };
109
+ const configHandleSelectDefaults = (
110
+ event: PointerEvent | MouseEvent | TouchEvent,
111
+ ) => {
112
+ handleSelectDefaults(event, peekDisableTakeFocusOnClick());
113
+ };
114
+ return mergeRegister(
115
+ editor.registerCommand(
116
+ INSERT_CHECK_LIST_COMMAND,
117
+ () => {
118
+ $insertList('check');
119
+ return true;
120
+ },
121
+ COMMAND_PRIORITY_LOW,
122
+ ),
123
+ editor.registerCommand<KeyboardEvent>(
124
+ KEY_ARROW_DOWN_COMMAND,
125
+ event => {
126
+ return handleArrowUpOrDown(event, editor, false);
127
+ },
128
+ COMMAND_PRIORITY_LOW,
129
+ ),
130
+ editor.registerCommand<KeyboardEvent>(
131
+ KEY_ARROW_UP_COMMAND,
132
+ event => {
133
+ return handleArrowUpOrDown(event, editor, true);
134
+ },
135
+ COMMAND_PRIORITY_LOW,
136
+ ),
137
+ editor.registerCommand<KeyboardEvent>(
138
+ KEY_ESCAPE_COMMAND,
139
+ () => {
140
+ const activeItem = getActiveCheckListItem();
141
+
142
+ if (activeItem != null) {
143
+ const rootElement = editor.getRootElement();
144
+
145
+ if (rootElement != null) {
146
+ rootElement.focus();
147
+ }
148
+
149
+ return true;
150
+ }
151
+
152
+ return false;
153
+ },
154
+ COMMAND_PRIORITY_LOW,
155
+ ),
156
+ editor.registerCommand<KeyboardEvent>(
157
+ KEY_SPACE_COMMAND,
158
+ event => {
159
+ const activeItem = getActiveCheckListItem();
160
+
161
+ if (activeItem != null && editor.isEditable()) {
162
+ editor.update(() => {
163
+ const listItemNode = $getNearestNodeFromDOMNode(activeItem);
164
+
165
+ if ($isListItemNode(listItemNode)) {
166
+ event.preventDefault();
167
+ listItemNode.toggleChecked();
168
+ }
169
+ });
170
+ return true;
171
+ }
172
+
173
+ return false;
174
+ },
175
+ COMMAND_PRIORITY_LOW,
176
+ ),
177
+ editor.registerCommand<KeyboardEvent>(
178
+ KEY_ARROW_LEFT_COMMAND,
179
+ event => {
180
+ return editor.getEditorState().read(() => {
181
+ const selection = $getSelection();
182
+
183
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
184
+ const {anchor} = selection;
185
+ const isElement = anchor.type === 'element';
186
+
187
+ if (isElement || anchor.offset === 0) {
188
+ const anchorNode = anchor.getNode();
189
+ const elementNode = $findMatchingParent(
190
+ anchorNode,
191
+ node => $isElementNode(node) && !node.isInline(),
192
+ );
193
+ if ($isListItemNode(elementNode)) {
194
+ const parent = elementNode.getParent();
195
+ if (
196
+ $isListNode(parent) &&
197
+ parent.getListType() === 'check' &&
198
+ (isElement || elementNode.getFirstDescendant() === anchorNode)
199
+ ) {
200
+ const domNode = editor.getElementByKey(elementNode.__key);
201
+
202
+ if (domNode != null && document.activeElement !== domNode) {
203
+ domNode.focus();
204
+ event.preventDefault();
205
+ return true;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ return false;
213
+ });
214
+ },
215
+ COMMAND_PRIORITY_LOW,
216
+ ),
217
+
218
+ editor.registerRootListener(rootElement => {
219
+ if (rootElement !== null) {
220
+ rootElement.addEventListener('click', configHandleClick);
221
+ rootElement.addEventListener('pointerup', configHandlePointerUp);
222
+ // Use capture so we run before other listeners that might move focus.
223
+ rootElement.addEventListener(
224
+ 'pointerdown',
225
+ configHandleSelectDefaults,
226
+ {
227
+ capture: true,
228
+ },
229
+ );
230
+ // Some browsers / integrations still generate mousedown events; handle them too.
231
+ rootElement.addEventListener('mousedown', configHandleSelectDefaults, {
232
+ capture: true,
233
+ });
234
+ // Intercept touchstart to stop the mobile browser from placing the caret
235
+ // and opening the keyboard when tapping the checklist marker.
236
+ rootElement.addEventListener('touchstart', configHandleSelectDefaults, {
237
+ capture: true,
238
+ passive: false,
239
+ });
240
+ return () => {
241
+ rootElement.removeEventListener('click', configHandleClick);
242
+ rootElement.removeEventListener('pointerup', configHandlePointerUp);
243
+ rootElement.removeEventListener(
244
+ 'pointerdown',
245
+ configHandleSelectDefaults,
246
+ {
247
+ capture: true,
248
+ },
249
+ );
250
+ rootElement.removeEventListener(
251
+ 'mousedown',
252
+ configHandleSelectDefaults,
253
+ {
254
+ capture: true,
255
+ },
256
+ );
257
+ rootElement.removeEventListener(
258
+ 'touchstart',
259
+ configHandleSelectDefaults,
260
+ {
261
+ capture: true,
262
+ },
263
+ );
264
+ };
265
+ }
266
+ }),
267
+ );
268
+ }
269
+
270
+ function handleCheckItemEvent(
271
+ event: PointerEvent | MouseEvent | TouchEvent,
272
+ callback: () => void,
273
+ ) {
274
+ const target = event.target;
275
+
276
+ if (!isHTMLElement(target)) {
277
+ return;
278
+ }
279
+
280
+ // Ignore clicks on LI that have nested lists
281
+ const firstChild = target.firstChild;
282
+
283
+ if (
284
+ isHTMLElement(firstChild) &&
285
+ (firstChild.tagName === 'UL' || firstChild.tagName === 'OL')
286
+ ) {
287
+ return;
288
+ }
289
+
290
+ const parentNode = target.parentNode;
291
+
292
+ // @ts-ignore internal field
293
+ if (!parentNode || parentNode.__lexicalListType !== 'check') {
294
+ return;
295
+ }
296
+ let clientX: number | null = null;
297
+ let pointerType: string | null = null;
298
+
299
+ if ('clientX' in event) {
300
+ clientX = event.clientX;
301
+ } else if ('touches' in event) {
302
+ const touches = event.touches;
303
+ if (touches.length > 0) {
304
+ clientX = touches[0].clientX;
305
+ pointerType = 'touch';
306
+ }
307
+ }
308
+
309
+ // If we couldn't resolve a clientX (unexpected input), bail out.
310
+ if (clientX == null) {
311
+ return;
312
+ }
313
+
314
+ const rect = target.getBoundingClientRect();
315
+ const zoom = calculateZoomLevel(target);
316
+ const clientXInPixels = clientX / zoom;
317
+
318
+ // Use getComputedStyle if available, otherwise fallback to 0px width
319
+ const beforeStyles = window.getComputedStyle
320
+ ? window.getComputedStyle(target, '::before')
321
+ : ({width: '0px'} as CSSStyleDeclaration);
322
+ const beforeWidthInPixels = parseFloat(beforeStyles.width);
323
+
324
+ // Make click area slightly larger for touch devices to improve accessibility
325
+ // Determine whether this is a touch event; some environments may supply
326
+ // pointerType on PointerEvent while touch events use the `touches` API above.
327
+ const isTouchEvent =
328
+ pointerType === 'touch' ||
329
+ ('pointerType' in event && event.pointerType === 'touch');
330
+ const clickAreaPadding = isTouchEvent ? 32 : 0; // Add 32px padding for touch events
331
+
332
+ if (
333
+ target.dir === 'rtl'
334
+ ? clientXInPixels < rect.right + clickAreaPadding &&
335
+ clientXInPixels > rect.right - beforeWidthInPixels - clickAreaPadding
336
+ : clientXInPixels > rect.left - clickAreaPadding &&
337
+ clientXInPixels < rect.left + beforeWidthInPixels + clickAreaPadding
338
+ ) {
339
+ callback();
340
+ }
341
+ }
342
+
343
+ function handleClick(
344
+ event: PointerEvent | MouseEvent | TouchEvent,
345
+ disableFocusOnClick: boolean,
346
+ ) {
347
+ handleCheckItemEvent(event, () => {
348
+ if (isHTMLElement(event.target)) {
349
+ const domNode = event.target;
350
+ const editor = getNearestEditorFromDOMNode(domNode);
351
+
352
+ if (editor != null && editor.isEditable()) {
353
+ editor.update(() => {
354
+ const node = $getNearestNodeFromDOMNode(domNode);
355
+
356
+ if ($isListItemNode(node)) {
357
+ if (disableFocusOnClick) {
358
+ $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
359
+ $addUpdateTag(SKIP_DOM_SELECTION_TAG);
360
+ } else {
361
+ domNode.focus();
362
+ }
363
+ node.toggleChecked();
364
+ }
365
+ });
366
+ }
367
+ }
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Prevents default focus switch behavior
373
+ *
374
+ * @param event might be of type PointerEvent, MouseEvent, or TouchEvent, hence the generic Event type
375
+ *
376
+ */
377
+ function handleSelectDefaults(
378
+ event: PointerEvent | MouseEvent | TouchEvent,
379
+ disableTakeFocusOnClick: boolean,
380
+ ) {
381
+ handleCheckItemEvent(event, () => {
382
+ // Prevents caret moving when clicking on check mark.
383
+ event.preventDefault();
384
+ if (disableTakeFocusOnClick) {
385
+ event.stopPropagation();
386
+ }
387
+ });
388
+ }
389
+
390
+ function getActiveCheckListItem(): HTMLElement | null {
391
+ const activeElement = document.activeElement;
392
+
393
+ return isHTMLElement(activeElement) &&
394
+ activeElement.tagName === 'LI' &&
395
+ activeElement.parentNode != null &&
396
+ // @ts-ignore internal field
397
+ activeElement.parentNode.__lexicalListType === 'check'
398
+ ? activeElement
399
+ : null;
400
+ }
401
+
402
+ function findCheckListItemSibling(
403
+ node: ListItemNode,
404
+ backward: boolean,
405
+ ): ListItemNode | null {
406
+ let sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
407
+ let parent: ListItemNode | null = node;
408
+
409
+ // Going up in a tree to get non-null sibling
410
+ while (sibling == null && $isListItemNode(parent)) {
411
+ // Get li -> parent ul/ol -> parent li
412
+ parent = parent.getParentOrThrow().getParent();
413
+
414
+ if (parent != null) {
415
+ sibling = backward
416
+ ? parent.getPreviousSibling()
417
+ : parent.getNextSibling();
418
+ }
419
+ }
420
+
421
+ // Going down in a tree to get first non-nested list item
422
+ while ($isListItemNode(sibling)) {
423
+ const firstChild = backward
424
+ ? sibling.getLastChild()
425
+ : sibling.getFirstChild();
426
+
427
+ if (!$isListNode(firstChild)) {
428
+ return sibling;
429
+ }
430
+
431
+ sibling = backward ? firstChild.getLastChild() : firstChild.getFirstChild();
432
+ }
433
+
434
+ return null;
435
+ }
436
+
437
+ function handleArrowUpOrDown(
438
+ event: KeyboardEvent,
439
+ editor: LexicalEditor,
440
+ backward: boolean,
441
+ ) {
442
+ const activeItem = getActiveCheckListItem();
443
+
444
+ if (activeItem != null) {
445
+ editor.update(() => {
446
+ const listItem = $getNearestNodeFromDOMNode(activeItem);
447
+
448
+ if (!$isListItemNode(listItem)) {
449
+ return;
450
+ }
451
+
452
+ const nextListItem = findCheckListItemSibling(listItem, backward);
453
+
454
+ if (nextListItem != null) {
455
+ nextListItem.selectStart();
456
+ const dom = editor.getElementByKey(nextListItem.__key);
457
+
458
+ if (dom != null) {
459
+ event.preventDefault();
460
+ setTimeout(() => {
461
+ dom.focus();
462
+ }, 0);
463
+ }
464
+ }
465
+ });
466
+ }
467
+
468
+ return false;
469
+ }