@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.
- package/{LexicalList.dev.js → dist/LexicalList.dev.js} +286 -31
- package/{LexicalList.dev.mjs → dist/LexicalList.dev.mjs} +285 -33
- package/{LexicalList.mjs → dist/LexicalList.mjs} +3 -0
- package/{LexicalList.node.mjs → dist/LexicalList.node.mjs} +3 -0
- package/dist/LexicalList.prod.js +9 -0
- package/dist/LexicalList.prod.mjs +9 -0
- package/dist/LexicalListExtension.d.ts +29 -0
- package/dist/ListImportExtension.d.ts +34 -0
- package/{index.d.ts → dist/index.d.ts} +4 -37
- package/dist/registerList.d.ts +20 -0
- package/package.json +33 -17
- package/src/LexicalListExtension.ts +76 -0
- package/src/LexicalListItemNode.ts +724 -0
- package/src/LexicalListNode.ts +393 -0
- package/src/ListImportExtension.ts +319 -0
- package/src/checkList.ts +469 -0
- package/src/formatList.ts +582 -0
- package/src/index.ts +98 -0
- package/src/registerList.ts +231 -0
- package/src/utils.ts +196 -0
- package/LexicalList.prod.js +0 -9
- package/LexicalList.prod.mjs +0 -9
- /package/{LexicalList.js → dist/LexicalList.js} +0 -0
- /package/{LexicalList.js.flow → dist/LexicalList.js.flow} +0 -0
- /package/{LexicalListItemNode.d.ts → dist/LexicalListItemNode.d.ts} +0 -0
- /package/{LexicalListNode.d.ts → dist/LexicalListNode.d.ts} +0 -0
- /package/{checkList.d.ts → dist/checkList.d.ts} +0 -0
- /package/{formatList.d.ts → dist/formatList.d.ts} +0 -0
- /package/{utils.d.ts → dist/utils.d.ts} +0 -0
|
@@ -0,0 +1,393 @@
|
|
|
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 {
|
|
10
|
+
addClassNamesToElement,
|
|
11
|
+
isHTMLElement,
|
|
12
|
+
removeClassNamesFromElement,
|
|
13
|
+
} from '@lexical/utils';
|
|
14
|
+
import {
|
|
15
|
+
$applyNodeReplacement,
|
|
16
|
+
$createTextNode,
|
|
17
|
+
$isElementNode,
|
|
18
|
+
$setDirectionFromDOM,
|
|
19
|
+
buildImportMap,
|
|
20
|
+
DOMConversionOutput,
|
|
21
|
+
DOMExportOutput,
|
|
22
|
+
EditorConfig,
|
|
23
|
+
EditorThemeClasses,
|
|
24
|
+
ElementNode,
|
|
25
|
+
LexicalEditor,
|
|
26
|
+
LexicalNode,
|
|
27
|
+
LexicalUpdateJSON,
|
|
28
|
+
NodeKey,
|
|
29
|
+
normalizeClassNames,
|
|
30
|
+
SerializedElementNode,
|
|
31
|
+
Spread,
|
|
32
|
+
} from 'lexical';
|
|
33
|
+
|
|
34
|
+
import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
|
|
35
|
+
import {
|
|
36
|
+
mergeNextSiblingListIfSameType,
|
|
37
|
+
updateChildrenListItemValue,
|
|
38
|
+
} from './formatList';
|
|
39
|
+
import {$getListDepth} from './utils';
|
|
40
|
+
|
|
41
|
+
export type SerializedListNode = Spread<
|
|
42
|
+
{
|
|
43
|
+
listType: ListType;
|
|
44
|
+
start: number;
|
|
45
|
+
tag: ListNodeTagType;
|
|
46
|
+
},
|
|
47
|
+
SerializedElementNode
|
|
48
|
+
>;
|
|
49
|
+
|
|
50
|
+
export type ListType = 'number' | 'bullet' | 'check';
|
|
51
|
+
|
|
52
|
+
export type ListNodeTagType = 'ul' | 'ol';
|
|
53
|
+
|
|
54
|
+
/** @noInheritDoc */
|
|
55
|
+
export class ListNode extends ElementNode {
|
|
56
|
+
/** @internal */
|
|
57
|
+
__tag: ListNodeTagType;
|
|
58
|
+
/** @internal */
|
|
59
|
+
__start: number;
|
|
60
|
+
/** @internal */
|
|
61
|
+
__listType: ListType;
|
|
62
|
+
|
|
63
|
+
/** @internal */
|
|
64
|
+
$config() {
|
|
65
|
+
return this.config('list', {
|
|
66
|
+
$transform: (node: ListNode): void => {
|
|
67
|
+
mergeNextSiblingListIfSameType(node);
|
|
68
|
+
updateChildrenListItemValue(node);
|
|
69
|
+
},
|
|
70
|
+
extends: ElementNode,
|
|
71
|
+
importDOM: buildImportMap({
|
|
72
|
+
ol: () => ({
|
|
73
|
+
conversion: $convertListNode,
|
|
74
|
+
priority: 0,
|
|
75
|
+
}),
|
|
76
|
+
ul: () => ({
|
|
77
|
+
conversion: $convertListNode,
|
|
78
|
+
priority: 0,
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
constructor(listType: ListType = 'number', start: number = 1, key?: NodeKey) {
|
|
85
|
+
super(key);
|
|
86
|
+
const _listType = TAG_TO_LIST_TYPE[listType] || listType;
|
|
87
|
+
this.__listType = _listType;
|
|
88
|
+
this.__tag = _listType === 'number' ? 'ol' : 'ul';
|
|
89
|
+
this.__start = start;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
afterCloneFrom(prevNode: this): void {
|
|
93
|
+
super.afterCloneFrom(prevNode);
|
|
94
|
+
this.__listType = prevNode.__listType;
|
|
95
|
+
this.__tag = prevNode.__tag;
|
|
96
|
+
this.__start = prevNode.__start;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
getTag(): ListNodeTagType {
|
|
100
|
+
return this.getLatest().__tag;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setListType(type: ListType): this {
|
|
104
|
+
const writable = this.getWritable();
|
|
105
|
+
writable.__listType = type;
|
|
106
|
+
writable.__tag = type === 'number' ? 'ol' : 'ul';
|
|
107
|
+
return writable;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getListType(): ListType {
|
|
111
|
+
return this.getLatest().__listType;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getStart(): number {
|
|
115
|
+
return this.getLatest().__start;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setStart(start: number): this {
|
|
119
|
+
const self = this.getWritable();
|
|
120
|
+
self.__start = start;
|
|
121
|
+
return self;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// View
|
|
125
|
+
|
|
126
|
+
createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
|
|
127
|
+
const tag = this.__tag;
|
|
128
|
+
const dom = document.createElement(tag);
|
|
129
|
+
|
|
130
|
+
if (this.__start !== 1) {
|
|
131
|
+
dom.setAttribute('start', String(this.__start));
|
|
132
|
+
}
|
|
133
|
+
// @ts-expect-error Internal field.
|
|
134
|
+
dom.__lexicalListType = this.__listType;
|
|
135
|
+
$setListThemeClassNames(dom, config.theme, this);
|
|
136
|
+
|
|
137
|
+
return dom;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
|
|
141
|
+
if (
|
|
142
|
+
prevNode.__tag !== this.__tag ||
|
|
143
|
+
prevNode.__listType !== this.__listType
|
|
144
|
+
) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
$setListThemeClassNames(dom, config.theme, this);
|
|
149
|
+
|
|
150
|
+
if (prevNode.__start !== this.__start) {
|
|
151
|
+
dom.setAttribute('start', String(this.__start));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedListNode>): this {
|
|
158
|
+
return super
|
|
159
|
+
.updateFromJSON(serializedNode)
|
|
160
|
+
.setListType(serializedNode.listType)
|
|
161
|
+
.setStart(serializedNode.start);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
|
165
|
+
const element = this.createDOM(editor._config, editor);
|
|
166
|
+
if (isHTMLElement(element)) {
|
|
167
|
+
if (this.__start !== 1) {
|
|
168
|
+
element.setAttribute('start', String(this.__start));
|
|
169
|
+
}
|
|
170
|
+
if (this.__listType === 'check') {
|
|
171
|
+
element.setAttribute('__lexicalListType', 'check');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
element,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
exportJSON(): SerializedListNode {
|
|
180
|
+
return {
|
|
181
|
+
...super.exportJSON(),
|
|
182
|
+
listType: this.getListType(),
|
|
183
|
+
start: this.getStart(),
|
|
184
|
+
tag: this.getTag(),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
canBeEmpty(): false {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
canIndent(): false {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
splice(
|
|
197
|
+
start: number,
|
|
198
|
+
deleteCount: number,
|
|
199
|
+
nodesToInsert: LexicalNode[],
|
|
200
|
+
): this {
|
|
201
|
+
let listItemNodesToInsert = nodesToInsert;
|
|
202
|
+
for (let i = 0; i < nodesToInsert.length; i++) {
|
|
203
|
+
const node = nodesToInsert[i];
|
|
204
|
+
if (!$isListItemNode(node)) {
|
|
205
|
+
if (listItemNodesToInsert === nodesToInsert) {
|
|
206
|
+
listItemNodesToInsert = [...nodesToInsert];
|
|
207
|
+
}
|
|
208
|
+
listItemNodesToInsert[i] = this.createListItemNode().append(
|
|
209
|
+
$isElementNode(node) && !($isListNode(node) || node.isInline())
|
|
210
|
+
? $createTextNode(node.getTextContent())
|
|
211
|
+
: node,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return super.splice(start, deleteCount, listItemNodesToInsert);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
extractWithChild(child: LexicalNode): boolean {
|
|
219
|
+
return $isListItemNode(child);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Create an appropriate ListItemNode to be a child of this ListNode,
|
|
224
|
+
* {@link $createListItemNode} is the default implementation.
|
|
225
|
+
*
|
|
226
|
+
* @returns A new ListItemNode.
|
|
227
|
+
*/
|
|
228
|
+
createListItemNode(): ListItemNode {
|
|
229
|
+
return $createListItemNode();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function $setListThemeClassNames(
|
|
234
|
+
dom: HTMLElement,
|
|
235
|
+
editorThemeClasses: EditorThemeClasses,
|
|
236
|
+
node: ListNode,
|
|
237
|
+
): void {
|
|
238
|
+
const classesToAdd = [];
|
|
239
|
+
const classesToRemove = [];
|
|
240
|
+
const listTheme = editorThemeClasses.list;
|
|
241
|
+
|
|
242
|
+
if (listTheme !== undefined) {
|
|
243
|
+
const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || [];
|
|
244
|
+
const listDepth = $getListDepth(node) - 1;
|
|
245
|
+
const normalizedListDepth = listDepth % listLevelsClassNames.length;
|
|
246
|
+
const listLevelClassName = listLevelsClassNames[normalizedListDepth];
|
|
247
|
+
const listClassName = listTheme[node.__tag];
|
|
248
|
+
let nestedListClassName;
|
|
249
|
+
const nestedListTheme = listTheme.nested;
|
|
250
|
+
const checklistClassName = listTheme.checklist;
|
|
251
|
+
|
|
252
|
+
if (nestedListTheme !== undefined && nestedListTheme.list) {
|
|
253
|
+
nestedListClassName = nestedListTheme.list;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (listClassName !== undefined) {
|
|
257
|
+
classesToAdd.push(listClassName);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (checklistClassName !== undefined && node.__listType === 'check') {
|
|
261
|
+
classesToAdd.push(checklistClassName);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (listLevelClassName !== undefined) {
|
|
265
|
+
classesToAdd.push(...normalizeClassNames(listLevelClassName));
|
|
266
|
+
for (let i = 0; i < listLevelsClassNames.length; i++) {
|
|
267
|
+
if (i !== normalizedListDepth) {
|
|
268
|
+
classesToRemove.push(node.__tag + i);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (nestedListClassName !== undefined) {
|
|
274
|
+
const nestedListItemClasses = normalizeClassNames(nestedListClassName);
|
|
275
|
+
|
|
276
|
+
if (listDepth > 1) {
|
|
277
|
+
classesToAdd.push(...nestedListItemClasses);
|
|
278
|
+
} else {
|
|
279
|
+
classesToRemove.push(...nestedListItemClasses);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (classesToRemove.length > 0) {
|
|
285
|
+
removeClassNamesFromElement(dom, ...classesToRemove);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (classesToAdd.length > 0) {
|
|
289
|
+
addClassNamesToElement(dom, ...classesToAdd);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/*
|
|
294
|
+
* This function normalizes the children of a ListNode after the conversion from HTML,
|
|
295
|
+
* ensuring that they are all ListItemNodes and contain either a single nested ListNode
|
|
296
|
+
* or some other inline content.
|
|
297
|
+
*/
|
|
298
|
+
function $normalizeChildren(
|
|
299
|
+
nodes: LexicalNode[],
|
|
300
|
+
listNode: ListNode,
|
|
301
|
+
): ListItemNode[] {
|
|
302
|
+
const $createWrapperItem = listNode.createListItemNode.bind(listNode);
|
|
303
|
+
|
|
304
|
+
const normalizedListItems: ListItemNode[] = [];
|
|
305
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
306
|
+
const node = nodes[i];
|
|
307
|
+
if ($isListItemNode(node)) {
|
|
308
|
+
normalizedListItems.push(node);
|
|
309
|
+
const children = node.getChildren();
|
|
310
|
+
if (children.length > 1) {
|
|
311
|
+
children.forEach(child => {
|
|
312
|
+
if ($isListNode(child)) {
|
|
313
|
+
normalizedListItems.push($createWrapperItem().append(child));
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
normalizedListItems.push($createWrapperItem().append(node));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return normalizedListItems;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isDomChecklist(domNode: HTMLElement) {
|
|
325
|
+
if (
|
|
326
|
+
domNode.getAttribute('__lexicallisttype') === 'check' ||
|
|
327
|
+
// is github checklist
|
|
328
|
+
domNode.classList.contains('contains-task-list') ||
|
|
329
|
+
// is joplin checklist
|
|
330
|
+
domNode.getAttribute('data-is-checklist') === '1'
|
|
331
|
+
) {
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
// if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
|
|
335
|
+
for (const child of domNode.childNodes) {
|
|
336
|
+
if (isHTMLElement(child) && child.hasAttribute('aria-checked')) {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isHTMLOListElement(node: unknown): node is HTMLOListElement {
|
|
344
|
+
return isHTMLElement(node) && node.nodeName.toLowerCase() === 'ol';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function $convertListNode(
|
|
348
|
+
domNode: HTMLOListElement | HTMLUListElement,
|
|
349
|
+
): DOMConversionOutput {
|
|
350
|
+
let node: ListNode;
|
|
351
|
+
if (isHTMLOListElement(domNode)) {
|
|
352
|
+
const start = domNode.start;
|
|
353
|
+
node = $createListNode('number', start);
|
|
354
|
+
} else if (isDomChecklist(domNode)) {
|
|
355
|
+
node = $createListNode('check');
|
|
356
|
+
} else {
|
|
357
|
+
node = $createListNode('bullet');
|
|
358
|
+
}
|
|
359
|
+
$setDirectionFromDOM(node, domNode);
|
|
360
|
+
return {
|
|
361
|
+
after: children => $normalizeChildren(children, node),
|
|
362
|
+
node,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const TAG_TO_LIST_TYPE: Record<string, ListType> = {
|
|
367
|
+
ol: 'number',
|
|
368
|
+
ul: 'bullet',
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Creates a ListNode of listType.
|
|
373
|
+
* @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'.
|
|
374
|
+
* @param start - Where an ordered list starts its count, start = 1 if left undefined.
|
|
375
|
+
* @returns The new ListNode
|
|
376
|
+
*/
|
|
377
|
+
export function $createListNode(
|
|
378
|
+
listType: ListType = 'number',
|
|
379
|
+
start = 1,
|
|
380
|
+
): ListNode {
|
|
381
|
+
return $applyNodeReplacement(new ListNode(listType, start));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Checks to see if the node is a ListNode.
|
|
386
|
+
* @param node - The node to be checked.
|
|
387
|
+
* @returns true if the node is a ListNode, false otherwise.
|
|
388
|
+
*/
|
|
389
|
+
export function $isListNode(
|
|
390
|
+
node: LexicalNode | null | undefined,
|
|
391
|
+
): node is ListNode {
|
|
392
|
+
return node instanceof ListNode;
|
|
393
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
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 {ChildSchema, DOMImportContext} from '@lexical/html';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
$isBlockLevel,
|
|
13
|
+
$propagateTextAlignToBlockChildren,
|
|
14
|
+
defineImportRule,
|
|
15
|
+
DOMImportExtension,
|
|
16
|
+
isElementOfTag,
|
|
17
|
+
sel,
|
|
18
|
+
} from '@lexical/html';
|
|
19
|
+
import {
|
|
20
|
+
$createLineBreakNode,
|
|
21
|
+
$isElementNode,
|
|
22
|
+
$isParagraphNode,
|
|
23
|
+
$setDirectionFromDOM,
|
|
24
|
+
$setFormatFromDOM,
|
|
25
|
+
configExtension,
|
|
26
|
+
defineExtension,
|
|
27
|
+
type LexicalNode,
|
|
28
|
+
} from 'lexical';
|
|
29
|
+
|
|
30
|
+
import {ListExtension} from './LexicalListExtension';
|
|
31
|
+
import {
|
|
32
|
+
$createListItemNode,
|
|
33
|
+
$isListItemNode,
|
|
34
|
+
type ListItemNode,
|
|
35
|
+
} from './LexicalListItemNode';
|
|
36
|
+
import {$createListNode, $isListNode} from './LexicalListNode';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mirrors the legacy `isDomChecklist` heuristic from
|
|
40
|
+
* `@lexical/list`.
|
|
41
|
+
*/
|
|
42
|
+
function isDomChecklist(domNode: HTMLElement): boolean {
|
|
43
|
+
return (
|
|
44
|
+
domNode.matches(
|
|
45
|
+
'[__lexicallisttype="check"], .contains-task-list, [data-is-checklist="1"]',
|
|
46
|
+
) || domNode.querySelector(':scope > [aria-checked]') !== null
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lift nested `ListNode`s out of `ListItemNode`s into sibling
|
|
52
|
+
* `ListItemNode`s (the legacy `$normalizeChildren` shape). Also wraps any
|
|
53
|
+
* non-`ListItemNode` children in a new `ListItemNode`.
|
|
54
|
+
*/
|
|
55
|
+
function $normalizeListChildren(children: LexicalNode[]): ListItemNode[] {
|
|
56
|
+
const out: ListItemNode[] = [];
|
|
57
|
+
for (const child of children) {
|
|
58
|
+
if ($isListItemNode(child)) {
|
|
59
|
+
out.push(child);
|
|
60
|
+
const innerChildren = child.getChildren();
|
|
61
|
+
if (innerChildren.length > 1) {
|
|
62
|
+
for (const inner of innerChildren) {
|
|
63
|
+
if ($isListNode(inner)) {
|
|
64
|
+
out.push($createListItemNode().append(inner));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
out.push($createListItemNode().append(child));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ListRule = defineImportRule({
|
|
76
|
+
$import: (ctx, el) => {
|
|
77
|
+
let node;
|
|
78
|
+
if (isElementOfTag(el, 'ol')) {
|
|
79
|
+
node = $createListNode('number', el.start);
|
|
80
|
+
} else if (isDomChecklist(el)) {
|
|
81
|
+
node = $createListNode('check');
|
|
82
|
+
} else {
|
|
83
|
+
node = $createListNode('bullet');
|
|
84
|
+
}
|
|
85
|
+
$setDirectionFromDOM(node, el);
|
|
86
|
+
// Propagate the list's `text-align` onto each `ListItemNode` child
|
|
87
|
+
// (legacy `wrapContinuousInlines` did the same), so pasting
|
|
88
|
+
// `<ul style="text-align: left"><li>…</li></ul>` ends up with the
|
|
89
|
+
// alignment on the list items where the reconciler renders it as
|
|
90
|
+
// `style="text-align: left"`.
|
|
91
|
+
return [
|
|
92
|
+
node.splice(
|
|
93
|
+
0,
|
|
94
|
+
0,
|
|
95
|
+
$propagateTextAlignToBlockChildren(
|
|
96
|
+
$normalizeListChildren(ctx.$importChildren(el)),
|
|
97
|
+
el,
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
];
|
|
101
|
+
},
|
|
102
|
+
match: sel.tag('ol', 'ul'),
|
|
103
|
+
name: '@lexical/list/list',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Apply formatting from the first child paragraph of `<li>` to the list
|
|
108
|
+
* item itself, then unwrap that paragraph (Google Docs sets the alignment
|
|
109
|
+
* of the `<p>` inside the `<li>`). Mirrors the legacy
|
|
110
|
+
* `setFormatFromChildren`.
|
|
111
|
+
*/
|
|
112
|
+
function $liftFormatFromSingleParagraph(
|
|
113
|
+
listItemNode: ListItemNode,
|
|
114
|
+
children: LexicalNode[],
|
|
115
|
+
): LexicalNode[] {
|
|
116
|
+
if (children.length !== 1) {
|
|
117
|
+
return children;
|
|
118
|
+
}
|
|
119
|
+
const firstChild = children[0];
|
|
120
|
+
if (
|
|
121
|
+
$isParagraphNode(firstChild) &&
|
|
122
|
+
!listItemNode.getFormatType() &&
|
|
123
|
+
firstChild.getFormatType()
|
|
124
|
+
) {
|
|
125
|
+
listItemNode.setFormat(firstChild.getFormatType());
|
|
126
|
+
return firstChild.getChildren();
|
|
127
|
+
}
|
|
128
|
+
return children;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Collapse block children of a `<li>` into inline-with-line-break form: a
|
|
133
|
+
* `ListItemNode` is an inline-level container, so any block child marks a
|
|
134
|
+
* boundary. Contiguous inline siblings are kept together as a single run and
|
|
135
|
+
* one {@link $createLineBreakNode} is inserted between runs — reproducing the
|
|
136
|
+
* legacy `wrapContinuousInlines` + `$unwrapArtificialNodes` shape
|
|
137
|
+
* (`<li>1<div>2</div>3</li>` → `1<br>2<br>3`) without the
|
|
138
|
+
* `ArtificialNode__DO_NOT_USE` marker.
|
|
139
|
+
*
|
|
140
|
+
* Boundaries are detected with {@link $isBlockLevel}, NOT `$isParagraphNode`:
|
|
141
|
+
* the `<div>`/`<section>`/… `TransparentBlockRule` happens to emit
|
|
142
|
+
* `ParagraphNode`s, but a `<blockquote>` (`QuoteNode`), heading
|
|
143
|
+
* (`HeadingNode`), or block decorator (`HorizontalRuleNode`, …) is just as
|
|
144
|
+
* much a block boundary and must not be silently spliced into the list item
|
|
145
|
+
* as-is. A nested `ListNode` is the one deliberate exception — it is a valid
|
|
146
|
+
* list-item child that {@link $normalizeListChildren} lifts into a sibling,
|
|
147
|
+
* so it is preserved here rather than unwrapped.
|
|
148
|
+
*/
|
|
149
|
+
function $flattenListItemBlocks(children: LexicalNode[]): LexicalNode[] {
|
|
150
|
+
const $isBoundary = (node: LexicalNode): boolean =>
|
|
151
|
+
$isBlockLevel(node) && !$isListNode(node);
|
|
152
|
+
if (!children.some($isBoundary)) {
|
|
153
|
+
return children;
|
|
154
|
+
}
|
|
155
|
+
// Partition into segments — each maximal run of inline siblings, and each
|
|
156
|
+
// boundary's own content — then join the segments with a single line break.
|
|
157
|
+
const segments: LexicalNode[][] = [];
|
|
158
|
+
let inlineRun: LexicalNode[] = [];
|
|
159
|
+
const flushInlineRun = () => {
|
|
160
|
+
if (inlineRun.length > 0) {
|
|
161
|
+
segments.push(inlineRun);
|
|
162
|
+
inlineRun = [];
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
for (const child of children) {
|
|
166
|
+
if ($isBoundary(child)) {
|
|
167
|
+
flushInlineRun();
|
|
168
|
+
// Unwrap a block ElementNode to its inline content; a childless block
|
|
169
|
+
// DecoratorNode stands on its own line.
|
|
170
|
+
segments.push($isElementNode(child) ? child.getChildren() : [child]);
|
|
171
|
+
} else {
|
|
172
|
+
inlineRun.push(child);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
flushInlineRun();
|
|
176
|
+
const out: LexicalNode[] = [];
|
|
177
|
+
for (const segment of segments) {
|
|
178
|
+
if (out.length > 0) {
|
|
179
|
+
out.push($createLineBreakNode());
|
|
180
|
+
}
|
|
181
|
+
out.push(...segment);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ListItemRule = defineImportRule({
|
|
187
|
+
$import: (ctx, el) => {
|
|
188
|
+
const ariaChecked = el.getAttribute('aria-checked');
|
|
189
|
+
const checked =
|
|
190
|
+
ariaChecked === 'true'
|
|
191
|
+
? true
|
|
192
|
+
: ariaChecked === 'false'
|
|
193
|
+
? false
|
|
194
|
+
: undefined;
|
|
195
|
+
const node = $createListItemNode(checked);
|
|
196
|
+
$setFormatFromDOM(node, el);
|
|
197
|
+
$setDirectionFromDOM(node, el);
|
|
198
|
+
return [
|
|
199
|
+
node.splice(
|
|
200
|
+
0,
|
|
201
|
+
0,
|
|
202
|
+
// Lift a sole wrapping paragraph's format onto the item *before*
|
|
203
|
+
// flattening, otherwise the paragraph would already be unwrapped and
|
|
204
|
+
// its alignment lost.
|
|
205
|
+
$flattenListItemBlocks(
|
|
206
|
+
$liftFormatFromSingleParagraph(node, ctx.$importChildren(el)),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
];
|
|
210
|
+
},
|
|
211
|
+
match: sel.tag('li'),
|
|
212
|
+
name: '@lexical/list/li',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
function $buildChecklistItem(
|
|
216
|
+
ctx: DOMImportContext,
|
|
217
|
+
el: HTMLElement,
|
|
218
|
+
checkboxOwner: Element,
|
|
219
|
+
): LexicalNode[] {
|
|
220
|
+
const checkboxInput = isElementOfTag(checkboxOwner, 'input')
|
|
221
|
+
? checkboxOwner
|
|
222
|
+
: checkboxOwner.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
|
223
|
+
if (!checkboxInput || checkboxInput.getAttribute('type') !== 'checkbox') {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
const checked = checkboxInput.hasAttribute('checked');
|
|
227
|
+
const node = $createListItemNode(checked);
|
|
228
|
+
$setFormatFromDOM(node, el);
|
|
229
|
+
$setDirectionFromDOM(node, el);
|
|
230
|
+
return [
|
|
231
|
+
node.splice(
|
|
232
|
+
0,
|
|
233
|
+
0,
|
|
234
|
+
$flattenListItemBlocks(
|
|
235
|
+
$liftFormatFromSingleParagraph(node, ctx.$importChildren(el)),
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const TaskListItemRule = defineImportRule({
|
|
242
|
+
$import: (ctx, el, $next) => {
|
|
243
|
+
const input = el.querySelector(':scope > input[type="checkbox"]');
|
|
244
|
+
if (!input) {
|
|
245
|
+
return $next();
|
|
246
|
+
}
|
|
247
|
+
return $buildChecklistItem(ctx, el, input);
|
|
248
|
+
},
|
|
249
|
+
match: sel.tag('li').classAll('task-list-item'),
|
|
250
|
+
name: '@lexical/list/li-task-list-item',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const JoplinChecklistItemRule = defineImportRule({
|
|
254
|
+
$import: (ctx, el, $next) => {
|
|
255
|
+
const wrapper = el.querySelector(':scope > .checkbox-wrapper');
|
|
256
|
+
if (!wrapper) {
|
|
257
|
+
return $next();
|
|
258
|
+
}
|
|
259
|
+
const input = wrapper.querySelector(':scope > input[type="checkbox"]');
|
|
260
|
+
if (!input) {
|
|
261
|
+
return $next();
|
|
262
|
+
}
|
|
263
|
+
return $buildChecklistItem(ctx, el, input);
|
|
264
|
+
},
|
|
265
|
+
match: sel.tag('li').classAll('joplin-checkbox'),
|
|
266
|
+
name: '@lexical/list/li-joplin-checkbox',
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A {@link ChildSchema} that enforces ListNode invariants: only
|
|
271
|
+
* `ListItemNode` and (immediately-nested) `ListNode` children are
|
|
272
|
+
* accepted; runs of other children get wrapped in a fresh
|
|
273
|
+
* `ListItemNode`.
|
|
274
|
+
*
|
|
275
|
+
* @experimental
|
|
276
|
+
*/
|
|
277
|
+
export const ListSchema: ChildSchema = {
|
|
278
|
+
$accepts: child => $isListItemNode(child) || $isListNode(child),
|
|
279
|
+
// Inline runs inside a `<ul>`/`<ol>` (e.g. text between two `<li>`s)
|
|
280
|
+
// become the children of a synthetic `ListItemNode`. `ListItemNode`
|
|
281
|
+
// is itself a block-level container of inlines, so no intermediate
|
|
282
|
+
// `ParagraphNode` is needed (and the demoted-paragraph normalization
|
|
283
|
+
// would strip one anyway).
|
|
284
|
+
$packageRun: run => [$createListItemNode().splice(0, 0, run)],
|
|
285
|
+
name: 'ListSchema',
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Import rules for {@link ListNode} and {@link ListItemNode}, including
|
|
290
|
+
* GitHub task-list and Joplin checkbox heuristics.
|
|
291
|
+
*
|
|
292
|
+
* @experimental
|
|
293
|
+
*/
|
|
294
|
+
export const ListImportRules = [
|
|
295
|
+
// More specific rules (class-restricted) must precede the generic `li`
|
|
296
|
+
// rule so they win the dispatch race (lower array index = higher
|
|
297
|
+
// priority).
|
|
298
|
+
TaskListItemRule,
|
|
299
|
+
JoplinChecklistItemRule,
|
|
300
|
+
ListRule,
|
|
301
|
+
ListItemRule,
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Bundles {@link ListImportRules} together with the runtime
|
|
306
|
+
* {@link ListExtension}. The application is expected to already have
|
|
307
|
+
* `CoreImportExtension` (or some equivalent) in its dependency graph —
|
|
308
|
+
* the core/text/paragraph/inline-format rules are a shared baseline,
|
|
309
|
+
* not something this leaf importer should re-declare.
|
|
310
|
+
*
|
|
311
|
+
* @experimental
|
|
312
|
+
*/
|
|
313
|
+
export const ListImportExtension = defineExtension({
|
|
314
|
+
dependencies: [
|
|
315
|
+
ListExtension,
|
|
316
|
+
configExtension(DOMImportExtension, {rules: ListImportRules}),
|
|
317
|
+
],
|
|
318
|
+
name: '@lexical/list/Import',
|
|
319
|
+
});
|