@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.
- package/{DOMRenderExtension.d.ts → dist/DOMRenderExtension.d.ts} +12 -1
- package/dist/DOMRenderRuntime.d.ts +51 -0
- package/dist/LexicalHtml.dev.js +3192 -0
- package/dist/LexicalHtml.dev.mjs +3146 -0
- package/{LexicalHtml.js.flow → dist/LexicalHtml.js.flow} +16 -16
- package/dist/LexicalHtml.mjs +56 -0
- package/dist/LexicalHtml.node.mjs +54 -0
- package/dist/LexicalHtml.prod.js +9 -0
- package/dist/LexicalHtml.prod.mjs +9 -0
- package/dist/RenderContext.d.ts +68 -0
- package/{compileDOMRenderConfigOverrides.d.ts → dist/compileDOMRenderConfigOverrides.d.ts} +1 -1
- package/{constants.d.ts → dist/constants.d.ts} +2 -0
- package/dist/domOverride.d.ts +23 -0
- package/dist/import/CoreImportExtension.d.ts +11 -0
- package/dist/import/DOMImportExtension.d.ts +82 -0
- package/dist/import/HorizontalRuleImportExtension.d.ts +27 -0
- package/dist/import/ImportContext.d.ts +208 -0
- package/dist/import/compileImportRules.d.ts +50 -0
- package/dist/import/coreImportRules.d.ts +25 -0
- package/dist/import/defineImportRule.d.ts +32 -0
- package/dist/import/defineOverlayRules.d.ts +66 -0
- package/dist/import/index.d.ts +38 -0
- package/dist/import/inlineStylesFromStyleSheets.d.ts +28 -0
- package/dist/import/parseCss.d.ts +18 -0
- package/dist/import/runImport.d.ts +19 -0
- package/dist/import/schemas.d.ts +91 -0
- package/dist/import/sel.d.ts +74 -0
- package/dist/import/types.d.ts +394 -0
- package/dist/index.d.ts +44 -0
- package/{types.d.ts → dist/types.d.ts} +96 -8
- package/package.json +33 -18
- package/src/ContextRecord.ts +243 -0
- package/src/DOMRenderExtension.ts +96 -0
- package/src/DOMRenderRuntime.ts +265 -0
- package/src/RenderContext.ts +168 -0
- package/src/compileDOMRenderConfigOverrides.ts +416 -0
- package/src/constants.ts +18 -0
- package/src/domOverride.ts +46 -0
- package/src/import/CoreImportExtension.ts +26 -0
- package/src/import/DOMImportExtension.ts +221 -0
- package/src/import/HorizontalRuleImportExtension.ts +53 -0
- package/src/import/ImportContext.ts +339 -0
- package/src/import/compileImportRules.ts +178 -0
- package/src/import/coreImportRules.ts +485 -0
- package/src/import/defineImportRule.ts +40 -0
- package/src/import/defineOverlayRules.ts +105 -0
- package/src/import/index.ts +96 -0
- package/src/import/inlineStylesFromStyleSheets.ts +104 -0
- package/src/import/parseCss.ts +219 -0
- package/src/import/runImport.ts +245 -0
- package/src/import/schemas.ts +236 -0
- package/src/import/sel.ts +314 -0
- package/src/import/types.ts +471 -0
- package/src/index.ts +555 -0
- package/src/types.ts +470 -0
- package/LexicalHtml.dev.js +0 -914
- package/LexicalHtml.dev.mjs +0 -900
- package/LexicalHtml.mjs +0 -24
- package/LexicalHtml.node.mjs +0 -22
- package/LexicalHtml.prod.js +0 -9
- package/LexicalHtml.prod.mjs +0 -9
- package/RenderContext.d.ts +0 -32
- package/domOverride.d.ts +0 -18
- package/index.d.ts +0 -32
- /package/{ContextRecord.d.ts → dist/ContextRecord.d.ts} +0 -0
- /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
|
+
}
|