@lexical/html 0.44.1-nightly.20260518.0 → 0.45.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.
Files changed (66) hide show
  1. package/{DOMRenderExtension.d.ts → dist/DOMRenderExtension.d.ts} +12 -1
  2. package/dist/DOMRenderRuntime.d.ts +51 -0
  3. package/dist/LexicalHtml.dev.js +3192 -0
  4. package/dist/LexicalHtml.dev.mjs +3146 -0
  5. package/{LexicalHtml.js.flow → dist/LexicalHtml.js.flow} +16 -16
  6. package/dist/LexicalHtml.mjs +56 -0
  7. package/dist/LexicalHtml.node.mjs +54 -0
  8. package/dist/LexicalHtml.prod.js +9 -0
  9. package/dist/LexicalHtml.prod.mjs +9 -0
  10. package/dist/RenderContext.d.ts +68 -0
  11. package/{compileDOMRenderConfigOverrides.d.ts → dist/compileDOMRenderConfigOverrides.d.ts} +1 -1
  12. package/{constants.d.ts → dist/constants.d.ts} +2 -0
  13. package/dist/domOverride.d.ts +23 -0
  14. package/dist/import/CoreImportExtension.d.ts +11 -0
  15. package/dist/import/DOMImportExtension.d.ts +82 -0
  16. package/dist/import/HorizontalRuleImportExtension.d.ts +27 -0
  17. package/dist/import/ImportContext.d.ts +208 -0
  18. package/dist/import/compileImportRules.d.ts +50 -0
  19. package/dist/import/coreImportRules.d.ts +25 -0
  20. package/dist/import/defineImportRule.d.ts +32 -0
  21. package/dist/import/defineOverlayRules.d.ts +66 -0
  22. package/dist/import/index.d.ts +38 -0
  23. package/dist/import/inlineStylesFromStyleSheets.d.ts +28 -0
  24. package/dist/import/parseCss.d.ts +18 -0
  25. package/dist/import/runImport.d.ts +19 -0
  26. package/dist/import/schemas.d.ts +91 -0
  27. package/dist/import/sel.d.ts +74 -0
  28. package/dist/import/types.d.ts +394 -0
  29. package/dist/index.d.ts +44 -0
  30. package/{types.d.ts → dist/types.d.ts} +96 -8
  31. package/package.json +33 -18
  32. package/src/ContextRecord.ts +243 -0
  33. package/src/DOMRenderExtension.ts +96 -0
  34. package/src/DOMRenderRuntime.ts +265 -0
  35. package/src/RenderContext.ts +168 -0
  36. package/src/compileDOMRenderConfigOverrides.ts +416 -0
  37. package/src/constants.ts +18 -0
  38. package/src/domOverride.ts +46 -0
  39. package/src/import/CoreImportExtension.ts +26 -0
  40. package/src/import/DOMImportExtension.ts +221 -0
  41. package/src/import/HorizontalRuleImportExtension.ts +53 -0
  42. package/src/import/ImportContext.ts +339 -0
  43. package/src/import/compileImportRules.ts +178 -0
  44. package/src/import/coreImportRules.ts +485 -0
  45. package/src/import/defineImportRule.ts +40 -0
  46. package/src/import/defineOverlayRules.ts +105 -0
  47. package/src/import/index.ts +96 -0
  48. package/src/import/inlineStylesFromStyleSheets.ts +104 -0
  49. package/src/import/parseCss.ts +219 -0
  50. package/src/import/runImport.ts +245 -0
  51. package/src/import/schemas.ts +236 -0
  52. package/src/import/sel.ts +314 -0
  53. package/src/import/types.ts +471 -0
  54. package/src/index.ts +555 -0
  55. package/src/types.ts +470 -0
  56. package/LexicalHtml.dev.js +0 -914
  57. package/LexicalHtml.dev.mjs +0 -900
  58. package/LexicalHtml.mjs +0 -24
  59. package/LexicalHtml.node.mjs +0 -22
  60. package/LexicalHtml.prod.js +0 -9
  61. package/LexicalHtml.prod.mjs +0 -9
  62. package/RenderContext.d.ts +0 -32
  63. package/domOverride.d.ts +0 -18
  64. package/index.d.ts +0 -32
  65. /package/{ContextRecord.d.ts → dist/ContextRecord.d.ts} +0 -0
  66. /package/{LexicalHtml.js → dist/LexicalHtml.js} +0 -0
@@ -0,0 +1,178 @@
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
+ import type {AnyDOMImportRule, DOMImportFn} from './types';
9
+
10
+ import {isDOMTextNode, isHTMLElement} from 'lexical';
11
+
12
+ import {getSelectorImpl, type Predicate, type SelectorImpl} from './sel';
13
+
14
+ const __DEV__ = process.env.NODE_ENV !== 'production';
15
+
16
+ /** @internal */
17
+ export interface CompiledRule {
18
+ readonly name: string;
19
+ readonly predicate: Predicate;
20
+ readonly $import: DOMImportFn<Node, Record<string, RegExpMatchArray>>;
21
+ }
22
+
23
+ /** @internal */
24
+ export interface CompiledDispatch {
25
+ /** All rules in registration order. Index = registration order. */
26
+ readonly rules: readonly CompiledRule[];
27
+ /**
28
+ * For each (uppercased) HTML tag name, the ordered list of rule indices
29
+ * considered when dispatching that tag. Includes interleaved wildcard
30
+ * element rules so a single iteration handles both.
31
+ */
32
+ readonly byTag: ReadonlyMap<string, readonly number[]>;
33
+ /** Indices of rules whose match has no tag restriction. */
34
+ readonly wildcardIndices: readonly number[];
35
+ /** Indices of rules whose match is `sel.text()`. */
36
+ readonly textIndices: readonly number[];
37
+ /** Indices of rules whose match is `sel.comment()`. */
38
+ readonly commentIndices: readonly number[];
39
+ }
40
+
41
+ function mergeSortedAsc(a: readonly number[], b: readonly number[]): number[] {
42
+ const out: number[] = [];
43
+ let i = 0;
44
+ let j = 0;
45
+ while (i < a.length && j < b.length) {
46
+ if (a[i] <= b[j]) {
47
+ out.push(a[i++]);
48
+ } else {
49
+ out.push(b[j++]);
50
+ }
51
+ }
52
+ while (i < a.length) {
53
+ out.push(a[i++]);
54
+ }
55
+ while (j < b.length) {
56
+ out.push(b[j++]);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Compile an ordered list of {@link DOMImportRule}s into the dispatch tables
63
+ * used by the import runtime. The rule at index 0 is the highest-priority
64
+ * (`mergeConfig` prepends partial.rules so later-merged extensions land
65
+ * first).
66
+ *
67
+ * @internal
68
+ */
69
+ export function compileImportRules(
70
+ rules: readonly AnyDOMImportRule[],
71
+ ): CompiledDispatch {
72
+ const compiled: CompiledRule[] = [];
73
+ const byTag = new Map<string, number[]>();
74
+ const wildcardIndices: number[] = [];
75
+ const textIndices: number[] = [];
76
+ const commentIndices: number[] = [];
77
+ const seenNames = new Set<string>();
78
+
79
+ rules.forEach((rule, i) => {
80
+ const sel = getSelectorImpl(rule.match);
81
+ const name = rule.name || defaultRuleName(sel, i);
82
+ if (__DEV__ && typeof rule.name === 'string' && seenNames.has(rule.name)) {
83
+ console.warn(
84
+ `[lexical] duplicate DOMImportRule name "${rule.name}" — keep names unique to aid debugging.`,
85
+ );
86
+ }
87
+ if (rule.name) {
88
+ seenNames.add(rule.name);
89
+ }
90
+ compiled.push({
91
+ $import: rule.$import as DOMImportFn<
92
+ Node,
93
+ Record<string, RegExpMatchArray>
94
+ >,
95
+ name,
96
+ predicate: sel.predicate,
97
+ });
98
+
99
+ if (sel.kind === 'text') {
100
+ textIndices.push(i);
101
+ } else if (sel.kind === 'comment') {
102
+ commentIndices.push(i);
103
+ } else if (sel.tags.size === 0) {
104
+ wildcardIndices.push(i);
105
+ } else {
106
+ for (const tag of sel.tags) {
107
+ let list = byTag.get(tag);
108
+ if (!list) {
109
+ list = [];
110
+ byTag.set(tag, list);
111
+ }
112
+ list.push(i);
113
+ }
114
+ }
115
+ });
116
+
117
+ // Interleave wildcard-element indices into each tag's list in registration
118
+ // (ascending-index) order, so iterating a tag bucket visits both tag-
119
+ // specific and wildcard rules in the same priority sequence.
120
+ const finalByTag = new Map<string, readonly number[]>();
121
+ if (wildcardIndices.length === 0) {
122
+ for (const [tag, list] of byTag) {
123
+ finalByTag.set(tag, list);
124
+ }
125
+ } else {
126
+ for (const [tag, list] of byTag) {
127
+ finalByTag.set(tag, mergeSortedAsc(list, wildcardIndices));
128
+ }
129
+ }
130
+
131
+ return {
132
+ byTag: finalByTag,
133
+ commentIndices,
134
+ rules: compiled,
135
+ textIndices,
136
+ wildcardIndices,
137
+ };
138
+ }
139
+
140
+ function defaultRuleName(sel: SelectorImpl, index: number): string {
141
+ if (sel.kind === 'text') {
142
+ return `#text@${index}`;
143
+ }
144
+ if (sel.kind === 'comment') {
145
+ return `#comment@${index}`;
146
+ }
147
+ if (sel.tags.size === 0) {
148
+ return `*@${index}`;
149
+ }
150
+ const tagList = Array.from(sel.tags).join(',').toLowerCase();
151
+ return `${tagList}@${index}`;
152
+ }
153
+
154
+ /**
155
+ * Look up the (already interleaved) rule indices relevant to `node`. Element
156
+ * nodes hit `byTag` (with wildcards merged in) or fall back to the wildcard
157
+ * bucket if no tag-specific rules exist; text and comment nodes use their
158
+ * own buckets.
159
+ *
160
+ * @internal
161
+ */
162
+ export function getDispatchIndices(
163
+ dispatch: CompiledDispatch,
164
+ node: Node,
165
+ ): readonly number[] {
166
+ if (isDOMTextNode(node)) {
167
+ return dispatch.textIndices;
168
+ }
169
+ if (node.nodeType === 8 /* COMMENT_NODE */) {
170
+ return dispatch.commentIndices;
171
+ }
172
+ if (isHTMLElement(node)) {
173
+ return dispatch.byTag.get(node.nodeName) || dispatch.wildcardIndices;
174
+ }
175
+ return EMPTY_INDICES;
176
+ }
177
+
178
+ const EMPTY_INDICES: readonly number[] = Object.freeze([]);
@@ -0,0 +1,485 @@
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
+ import {
9
+ $createLineBreakNode,
10
+ $createParagraphNode,
11
+ $createTextNode,
12
+ $generateNodesFromRawText,
13
+ $isTextNode,
14
+ $setDirectionFromDOM,
15
+ $setFormatFromDOM,
16
+ type ElementFormatType,
17
+ IS_BOLD,
18
+ IS_CODE,
19
+ IS_HIGHLIGHT,
20
+ IS_ITALIC,
21
+ IS_STRIKETHROUGH,
22
+ IS_SUBSCRIPT,
23
+ IS_SUPERSCRIPT,
24
+ IS_UNDERLINE,
25
+ isDOMTextNode,
26
+ type LexicalNode,
27
+ setNodeIndentFromDOM,
28
+ } from 'lexical';
29
+
30
+ import {contextValue} from '../ContextRecord';
31
+ import {defineImportRule} from './defineImportRule';
32
+ import {
33
+ ImportTextFormat,
34
+ ImportTextStyle,
35
+ ImportWhitespaceConfig,
36
+ type WhitespaceImportConfig,
37
+ } from './ImportContext';
38
+ import {selBase} from './sel';
39
+
40
+ const sel = selBase;
41
+
42
+ const ALIGNMENT_VALUES: ReadonlySet<string> = new Set<ElementFormatType>([
43
+ 'center',
44
+ 'end',
45
+ 'justify',
46
+ 'left',
47
+ 'right',
48
+ 'start',
49
+ ]);
50
+
51
+ /**
52
+ * True if `value` is a non-empty {@link ElementFormatType} (matches one of
53
+ * the supported `text-align` / legacy `align`-attribute values).
54
+ *
55
+ * @internal
56
+ */
57
+ export function isAlignmentValue(value: string): value is ElementFormatType {
58
+ return ALIGNMENT_VALUES.has(value);
59
+ }
60
+
61
+ /**
62
+ * A pair of bitmasks describing which {@link TextFormatType} bits to set
63
+ * and which to clear when descending into an element. The clear pass
64
+ * matters for cases the legacy OR-merge mishandled, e.g. `<b
65
+ * style="font-weight: normal">` clearing an inherited bold, or `<sub>` /
66
+ * `<sup>` clearing each other.
67
+ */
68
+ interface FormatOverride {
69
+ readonly set: number;
70
+ readonly clear: number;
71
+ }
72
+
73
+ /**
74
+ * The small subset of inline-style properties that affect text formatting
75
+ * during import. Modeled as a plain object so tag-implicit defaults and
76
+ * the element's own inline `style` can be merged with `{...defaults,
77
+ * ...override-if-set}` semantics rather than relying on CSSStyleDeclaration.
78
+ */
79
+ interface FormatStyle {
80
+ fontWeight?: string;
81
+ fontStyle?: string;
82
+ textDecoration?: string;
83
+ verticalAlign?: string;
84
+ }
85
+
86
+ /**
87
+ * Default style implied by each inline format tag. `<b>`/`<strong>` set
88
+ * font-weight, `<sub>` sets vertical-align, etc. Any of these can be
89
+ * overridden by the element's own inline `style` (so `<b
90
+ * style="font-weight: normal">` ends up with `fontWeight: 'normal'` in
91
+ * the effective style).
92
+ */
93
+ const TAG_DEFAULT_STYLE: Record<string, FormatStyle> = {
94
+ B: {fontWeight: 'bold'},
95
+ EM: {fontStyle: 'italic'},
96
+ I: {fontStyle: 'italic'},
97
+ S: {textDecoration: 'line-through'},
98
+ STRONG: {fontWeight: 'bold'},
99
+ SUB: {verticalAlign: 'sub'},
100
+ SUP: {verticalAlign: 'super'},
101
+ U: {textDecoration: 'underline'},
102
+ };
103
+
104
+ /**
105
+ * Tags whose effect on TextFormat has no CSS analog (so the style-merge
106
+ * path can't reach them). Applied as a pure "set" override.
107
+ */
108
+ const TAG_ONLY_SET: Record<string, number> = {
109
+ CODE: IS_CODE,
110
+ MARK: IS_HIGHLIGHT,
111
+ };
112
+
113
+ function readElementFormatStyle(el: HTMLElement): FormatStyle {
114
+ return {
115
+ fontStyle: el.style.fontStyle,
116
+ fontWeight: el.style.fontWeight,
117
+ textDecoration: el.style.textDecoration,
118
+ verticalAlign: el.style.verticalAlign,
119
+ };
120
+ }
121
+
122
+ function mergeStyles(
123
+ defaults: FormatStyle,
124
+ override: FormatStyle,
125
+ ): FormatStyle {
126
+ return {
127
+ fontStyle: override.fontStyle || defaults.fontStyle,
128
+ fontWeight: override.fontWeight || defaults.fontWeight,
129
+ textDecoration: override.textDecoration || defaults.textDecoration,
130
+ verticalAlign: override.verticalAlign || defaults.verticalAlign,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * The CSS property names {@link styleFormatOverride} reads — these are
136
+ * "owned" by {@link ImportTextFormat} (the bit mask). When the
137
+ * {@link ImportTextStyle} record is materialized onto a TextNode's
138
+ * inline style by {@link styleObjectToCSS}, these are skipped so the
139
+ * bit-mask side is the single source of truth and the same property
140
+ * doesn't end up in both places (where the inline-style version would
141
+ * shadow the format's themed CSS).
142
+ */
143
+ const FORMAT_BIT_STYLE_PROPS: ReadonlySet<string> = new Set([
144
+ 'font-weight',
145
+ 'font-style',
146
+ 'text-decoration',
147
+ 'vertical-align',
148
+ ]);
149
+
150
+ /**
151
+ * Translate a {@link FormatStyle} into a {@link FormatOverride}. Explicit
152
+ * "non-decorating" values (`font-weight: normal`, `text-decoration: none`,
153
+ * `vertical-align: baseline`) produce `clear` bits, so an inner element
154
+ * can remove a format inherited from its ancestors.
155
+ */
156
+ function styleFormatOverride(style: FormatStyle): FormatOverride {
157
+ let set = 0;
158
+ let clear = 0;
159
+
160
+ const {fontWeight, fontStyle, textDecoration, verticalAlign} = style;
161
+
162
+ if (fontWeight === '700' || fontWeight === 'bold') {
163
+ set |= IS_BOLD;
164
+ } else if (fontWeight === 'normal' || fontWeight === '400') {
165
+ clear |= IS_BOLD;
166
+ }
167
+
168
+ if (fontStyle === 'italic') {
169
+ set |= IS_ITALIC;
170
+ } else if (fontStyle === 'normal') {
171
+ clear |= IS_ITALIC;
172
+ }
173
+
174
+ if (textDecoration) {
175
+ const parts = textDecoration.split(' ');
176
+ if (parts.includes('underline')) {
177
+ set |= IS_UNDERLINE;
178
+ }
179
+ if (parts.includes('line-through')) {
180
+ set |= IS_STRIKETHROUGH;
181
+ }
182
+ if (parts.includes('none')) {
183
+ clear |= IS_UNDERLINE | IS_STRIKETHROUGH;
184
+ }
185
+ }
186
+
187
+ if (verticalAlign === 'sub') {
188
+ set |= IS_SUBSCRIPT;
189
+ clear |= IS_SUPERSCRIPT;
190
+ } else if (verticalAlign === 'super') {
191
+ set |= IS_SUPERSCRIPT;
192
+ clear |= IS_SUBSCRIPT;
193
+ } else if (verticalAlign === 'baseline') {
194
+ clear |= IS_SUBSCRIPT | IS_SUPERSCRIPT;
195
+ }
196
+
197
+ return {clear, set};
198
+ }
199
+
200
+ function applyFormatOverride(format: number, ov: FormatOverride): number {
201
+ return (format & ~ov.clear) | ov.set;
202
+ }
203
+
204
+ /**
205
+ * Unified rule for inline-format-bearing tags and `<span>`. The element's
206
+ * effective style is its tag's {@link TAG_DEFAULT_STYLE} merged with its
207
+ * inline `style` (element's own style wins for any property it sets), and
208
+ * the resulting style is translated into a {@link FormatOverride}. Tags
209
+ * with no CSS analog (`<code>`, `<mark>`) contribute their bit as a pure
210
+ * `set` override.
211
+ *
212
+ * This shape lets:
213
+ * - `<b style="font-weight: normal">` clear an inherited IS_BOLD.
214
+ * - `<sub><sup>x</sup></sub>` resolve to IS_SUPERSCRIPT only (sub/sup
215
+ * mutex via the vertical-align clear logic).
216
+ * - `<span style="text-decoration: none">` strip inherited underline /
217
+ * line-through.
218
+ */
219
+ const InlineFormatRule = defineImportRule({
220
+ $import: (ctx, el) => {
221
+ const inherited = ctx.get(ImportTextFormat);
222
+ const tagDefault = TAG_DEFAULT_STYLE[el.nodeName];
223
+ const elStyle = readElementFormatStyle(el);
224
+ const effective = tagDefault ? mergeStyles(tagDefault, elStyle) : elStyle;
225
+ let merged = applyFormatOverride(inherited, styleFormatOverride(effective));
226
+ const tagOnly = TAG_ONLY_SET[el.nodeName];
227
+ if (tagOnly) {
228
+ merged |= tagOnly;
229
+ }
230
+ if (merged === inherited) {
231
+ return ctx.$importChildren(el);
232
+ }
233
+ return ctx.$importChildren(el, {
234
+ context: [contextValue(ImportTextFormat, merged)],
235
+ });
236
+ },
237
+ match: sel.tag(
238
+ 'b',
239
+ 'strong',
240
+ 'em',
241
+ 'i',
242
+ 'code',
243
+ 'mark',
244
+ 's',
245
+ 'sub',
246
+ 'sup',
247
+ 'u',
248
+ 'span',
249
+ ),
250
+ name: '@lexical/html/inline-format',
251
+ });
252
+
253
+ /**
254
+ * Walk up the DOM ancestor chain to determine whether `node` is inside an
255
+ * element whose whitespace should be preserved, per the supplied
256
+ * {@link WhitespaceImportConfig.preservesWhitespace} predicate. Pure
257
+ * ancestor walk, no caching.
258
+ */
259
+ function isInsidePreserveWhitespace(
260
+ node: Node,
261
+ wsConfig: WhitespaceImportConfig,
262
+ ): boolean {
263
+ let current: Node | null = node.parentNode;
264
+ while (current !== null) {
265
+ if (wsConfig.preservesWhitespace(current)) {
266
+ return true;
267
+ }
268
+ current = current.parentNode;
269
+ }
270
+ return false;
271
+ }
272
+
273
+ function findAdjacentTextOnLine(
274
+ text: Text,
275
+ forward: boolean,
276
+ wsConfig: WhitespaceImportConfig,
277
+ ): Text | null {
278
+ let node: Node = text;
279
+ while (true) {
280
+ let sibling: Node | null = null;
281
+ while (
282
+ (sibling = forward ? node.nextSibling : node.previousSibling) === null
283
+ ) {
284
+ const parent: Node | null = node.parentNode;
285
+ if (parent === null) {
286
+ return null;
287
+ }
288
+ node = parent;
289
+ }
290
+ node = sibling;
291
+ if (!wsConfig.isInline(node)) {
292
+ return null;
293
+ }
294
+ let descendant: Node | null = node;
295
+ while ((descendant = forward ? node.firstChild : node.lastChild) !== null) {
296
+ node = descendant;
297
+ }
298
+ if (isDOMTextNode(node)) {
299
+ return node;
300
+ }
301
+ if (node.nodeName === 'BR') {
302
+ return null;
303
+ }
304
+ }
305
+ }
306
+
307
+ function collapseWhitespace(
308
+ textNode: Text,
309
+ wsConfig: WhitespaceImportConfig,
310
+ ): string {
311
+ let textContent = (textNode.textContent || '')
312
+ .replace(/\r/g, '')
313
+ .replace(/[ \t\n]+/g, ' ');
314
+ if (textContent.length === 0) {
315
+ return '';
316
+ }
317
+ if (textContent[0] === ' ') {
318
+ let neighbor: Text | null = textNode;
319
+ let isStartOfLine = true;
320
+ while (
321
+ neighbor !== null &&
322
+ (neighbor = findAdjacentTextOnLine(neighbor, false, wsConfig)) !== null
323
+ ) {
324
+ const neighborContent = neighbor.textContent || '';
325
+ if (neighborContent.length > 0) {
326
+ if (/[ \t\n]$/.test(neighborContent)) {
327
+ textContent = textContent.slice(1);
328
+ }
329
+ isStartOfLine = false;
330
+ break;
331
+ }
332
+ }
333
+ if (isStartOfLine) {
334
+ textContent = textContent.slice(1);
335
+ }
336
+ }
337
+ if (textContent.length > 0 && textContent[textContent.length - 1] === ' ') {
338
+ let neighbor: Text | null = textNode;
339
+ let isEndOfLine = true;
340
+ while (
341
+ neighbor !== null &&
342
+ (neighbor = findAdjacentTextOnLine(neighbor, true, wsConfig)) !== null
343
+ ) {
344
+ const neighborContent = (neighbor.textContent || '').replace(
345
+ /^( |\t|\r?\n)+/,
346
+ '',
347
+ );
348
+ if (neighborContent.length > 0) {
349
+ isEndOfLine = false;
350
+ break;
351
+ }
352
+ }
353
+ if (isEndOfLine) {
354
+ textContent = textContent.slice(0, -1);
355
+ }
356
+ }
357
+ return textContent;
358
+ }
359
+
360
+ function $applyFormat(node: LexicalNode, format: number): LexicalNode {
361
+ return format !== 0 && $isTextNode(node) ? node.setFormat(format) : node;
362
+ }
363
+
364
+ /**
365
+ * Inverse of {@link getStyleObjectFromCSS}: serialize a parsed style
366
+ * record back into a CSS declaration string suitable for
367
+ * `TextNode.setStyle`. Returns the empty string for an empty record.
368
+ */
369
+ function styleObjectToCSS(style: Readonly<Record<string, string>>): string {
370
+ let css = '';
371
+ for (const prop in style) {
372
+ if (FORMAT_BIT_STYLE_PROPS.has(prop)) {
373
+ // Owned by ImportTextFormat (bit mask) — skip so the format-bit
374
+ // CSS is the single source of truth on the rendered TextNode.
375
+ continue;
376
+ }
377
+ css += `${prop}: ${style[prop]}; `;
378
+ }
379
+ return css.trimEnd();
380
+ }
381
+
382
+ function $applyTextStyle(
383
+ node: LexicalNode,
384
+ style: Readonly<Record<string, string>>,
385
+ ): LexicalNode {
386
+ if ($isTextNode(node)) {
387
+ const css = styleObjectToCSS(style);
388
+ if (css !== '') {
389
+ node.setStyle(css);
390
+ }
391
+ }
392
+ return node;
393
+ }
394
+
395
+ /**
396
+ * `#text` rule. Inside a `<pre>` ancestor, preserve whitespace and split
397
+ * on `\n` and `\t` into `LineBreakNode`/`TabNode` siblings. Otherwise
398
+ * collapse whitespace using the same neighbor-aware rules as the legacy
399
+ * `$convertTextDOMNode`.
400
+ */
401
+ const TextRule = defineImportRule({
402
+ $import: (ctx, el) => {
403
+ const format = ctx.get(ImportTextFormat);
404
+ const style = ctx.get(ImportTextStyle);
405
+ const wsConfig = ctx.get(ImportWhitespaceConfig);
406
+ if (isInsidePreserveWhitespace(el, wsConfig)) {
407
+ const out = $generateNodesFromRawText(el.textContent || '');
408
+ for (const node of out) {
409
+ $applyFormat(node, format);
410
+ $applyTextStyle(node, style);
411
+ }
412
+ return out;
413
+ }
414
+ const collapsed = collapseWhitespace(el, wsConfig);
415
+ if (collapsed === '') {
416
+ return [];
417
+ }
418
+ const text = $createTextNode(collapsed);
419
+ $applyFormat(text, format);
420
+ $applyTextStyle(text, style);
421
+ return [text];
422
+ },
423
+ match: sel.text(),
424
+ name: '@lexical/html/#text',
425
+ });
426
+
427
+ /**
428
+ * Drop `<style>` and `<script>` and skip descending into them — matches
429
+ * the legacy `IGNORE_TAGS` set, but as a regular rule so apps can register
430
+ * a higher-priority `<style>` rule to capture stylesheet text into the
431
+ * import session for later use.
432
+ */
433
+ const IgnoreScriptStyleRule = defineImportRule({
434
+ $import: () => [],
435
+ match: sel.tag('script', 'style'),
436
+ name: '@lexical/html/script-style-ignore',
437
+ });
438
+
439
+ const LineBreakRule = defineImportRule({
440
+ $import: () => [$createLineBreakNode()],
441
+ match: sel.tag('br'),
442
+ name: '@lexical/html/br',
443
+ });
444
+
445
+ /**
446
+ * `<p>` rule. Re-applies format, indent, direction, and the legacy
447
+ * `align` attribute fallback.
448
+ */
449
+ const ParagraphRule = defineImportRule({
450
+ $import: (ctx, el) => {
451
+ const p = $createParagraphNode();
452
+ $setFormatFromDOM(p, el);
453
+ setNodeIndentFromDOM(el, p);
454
+ if (p.getFormatType() === '') {
455
+ const align = el.getAttribute('align');
456
+ if (align && isAlignmentValue(align)) {
457
+ p.setFormat(align);
458
+ }
459
+ }
460
+ $setDirectionFromDOM(p, el);
461
+ // We deliberately pass no schema: paragraphs accept any inline run as-is.
462
+ // The enclosing context (root / block) is responsible for ensuring the
463
+ // paragraph itself is a valid block child.
464
+ return [p.splice(0, 0, ctx.$importChildren(el))];
465
+ },
466
+ match: sel.tag('p'),
467
+ name: '@lexical/html/p',
468
+ });
469
+
470
+ /**
471
+ * Rules covering the {@link ParagraphNode}, {@link TextNode},
472
+ * {@link LineBreakNode}, and {@link TabNode} cases that the legacy
473
+ * `importDOM` machinery in `@lexical/lexical` handled. Intended to be
474
+ * registered as a dependency of every editor that uses
475
+ * {@link DOMImportExtension}.
476
+ *
477
+ * @experimental
478
+ */
479
+ export const CoreImportRules = [
480
+ IgnoreScriptStyleRule,
481
+ ParagraphRule,
482
+ TextRule,
483
+ LineBreakRule,
484
+ InlineFormatRule,
485
+ ];
@@ -0,0 +1,40 @@
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
+ import type {CompiledSelector, DOMImportFn, DOMImportRule} from './types';
9
+
10
+ /**
11
+ * Identity helper that infers a rule's matched node type and capture map
12
+ * from its `match` selector and threads them into the `$import` signature.
13
+ * Usage:
14
+ *
15
+ * ```ts
16
+ * defineImportRule({
17
+ * name: '@lexical/list/li',
18
+ * match: sel.tag('li'),
19
+ * $import: (ctx, el, $next) => {
20
+ * // el: HTMLLIElement
21
+ * return [$createListItemNode()];
22
+ * },
23
+ * });
24
+ * ```
25
+ *
26
+ * @experimental
27
+ * @__NO_SIDE_EFFECTS__
28
+ */
29
+ export function defineImportRule<const S extends CompiledSelector>(rule: {
30
+ readonly name?: string;
31
+ readonly match: S;
32
+ readonly $import: DOMImportFn<
33
+ S extends CompiledSelector<infer N, Record<string, RegExpMatchArray>>
34
+ ? N
35
+ : Node,
36
+ S extends CompiledSelector<Node, infer C> ? C : Record<string, never>
37
+ >;
38
+ }): DOMImportRule<S> {
39
+ return rule as DOMImportRule<S>;
40
+ }