@lexical/rich-text 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.
@@ -9,11 +9,12 @@
9
9
  'use strict';
10
10
 
11
11
  var clipboard = require('@lexical/clipboard');
12
- var dragon = require('@lexical/dragon');
13
12
  var extension = require('@lexical/extension');
14
13
  var selection = require('@lexical/selection');
15
14
  var utils = require('@lexical/utils');
16
15
  var lexical = require('lexical');
16
+ var dragon = require('@lexical/dragon');
17
+ var html = require('@lexical/html');
17
18
 
18
19
  /**
19
20
  * Copyright (c) Meta Platforms, Inc. and affiliates.
@@ -23,32 +24,82 @@ var lexical = require('lexical');
23
24
  *
24
25
  */
25
26
 
26
- function caretFromPoint(x, y) {
27
- if (typeof document.caretRangeFromPoint !== 'undefined') {
28
- const range = document.caretRangeFromPoint(x, y);
29
- if (range === null) {
30
- return null;
31
- }
32
- return {
33
- node: range.startContainer,
34
- offset: range.startOffset
35
- };
36
- // @ts-ignore
37
- } else if (document.caretPositionFromPoint !== 'undefined') {
38
- // @ts-ignore FF - no types
39
- const range = document.caretPositionFromPoint(x, y);
40
- if (range === null) {
41
- return null;
27
+
28
+ /**
29
+ * Configuration for {@link RichTextExtension}.
30
+ *
31
+ * @property escapeFormatTriggers - Per-format trigger configuration that
32
+ * controls which text formats are automatically cleared from the selection
33
+ * on specific user interactions.
34
+ *
35
+ * Defaults to:
36
+ * ```ts
37
+ * {
38
+ * capitalize: {enter: true, space: true, tab: true},
39
+ * lowercase: {enter: true, space: true, tab: true},
40
+ * uppercase: {enter: true, space: true, tab: true},
41
+ * }
42
+ * ```
43
+ *
44
+ * To opt in to escaping `code` formatting at text node boundaries:
45
+ * ```ts
46
+ * configExtension(RichTextExtension, {
47
+ * escapeFormatTriggers: {
48
+ * code: {onlyAtBoundary: true, enter: true, click: true, arrow: true},
49
+ * },
50
+ * })
51
+ * ```
52
+ */
53
+
54
+ const DEFAULT_RICH_TEXT_CONFIG = {
55
+ escapeFormatTriggers: {
56
+ capitalize: {
57
+ enter: true,
58
+ space: true,
59
+ tab: true
60
+ },
61
+ lowercase: {
62
+ enter: true,
63
+ space: true,
64
+ tab: true
65
+ },
66
+ uppercase: {
67
+ enter: true,
68
+ space: true,
69
+ tab: true
42
70
  }
43
- return {
44
- node: range.offsetNode,
45
- offset: range.offset
46
- };
47
- } else {
48
- // Gracefully handle IE
49
- return null;
50
71
  }
72
+ };
73
+ function mergeTriggerConfig(config, override) {
74
+ if (!config || override === null) {
75
+ return override;
76
+ }
77
+ return lexical.shallowMergeConfig(config, override);
51
78
  }
79
+ function mergeEscapeFormatTriggers(config, overrides) {
80
+ const merged = lexical.shallowMergeConfig(config, overrides);
81
+ for (const k of Object.keys(overrides)) {
82
+ merged[k] = mergeTriggerConfig(config[k], overrides[k]);
83
+ }
84
+ return merged;
85
+ }
86
+ function mergeRichTextConfig(config, overrides) {
87
+ const merged = lexical.shallowMergeConfig(config, overrides);
88
+ if (overrides.escapeFormatTriggers) {
89
+ merged.escapeFormatTriggers = mergeEscapeFormatTriggers(config.escapeFormatTriggers, overrides.escapeFormatTriggers);
90
+ }
91
+ return merged;
92
+ }
93
+ const RichTextExtension = lexical.defineExtension({
94
+ build: (_editor, config) => extension.namedSignals(config),
95
+ config: lexical.safeCast(DEFAULT_RICH_TEXT_CONFIG),
96
+ conflictsWith: ['@lexical/plain-text'],
97
+ dependencies: [dragon.DragonExtension, extension.NormalizeInlineElementsExtension, extension.NormalizeTripleClickSelectionExtension],
98
+ mergeConfig: mergeRichTextConfig,
99
+ name: '@lexical/rich-text',
100
+ nodes: () => [HeadingNode, QuoteNode],
101
+ register: (editor, _config, state) => extension.effect(() => registerRichText(editor, state.getOutput().escapeFormatTriggers))
102
+ });
52
103
 
53
104
  /**
54
105
  * Copyright (c) Meta Platforms, Inc. and affiliates.
@@ -58,30 +109,90 @@ function caretFromPoint(x, y) {
58
109
  *
59
110
  */
60
111
 
61
- const CAN_USE_DOM = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined';
62
112
 
63
113
  /**
64
- * Copyright (c) Meta Platforms, Inc. and affiliates.
65
- *
66
- * This source code is licensed under the MIT license found in the
67
- * LICENSE file in the root directory of this source tree.
68
- *
114
+ * Heuristic copied (in spirit) from the legacy `isGoogleDocsTitle`:
115
+ * Google Docs serializes the document title as a `<span style="font-size:
116
+ * 26pt">` (sometimes wrapped in a `<p>`). The presence of a 26pt span at
117
+ * the start of a `<p>` is treated as a heading-1 marker.
69
118
  */
119
+ function isGoogleDocsTitleSpan(node) {
120
+ return lexical.isHTMLElement(node) && node.nodeName === 'SPAN' && node.style.fontSize === '26pt';
121
+ }
122
+ const HeadingRule = html.defineImportRule({
123
+ $import: (ctx, el) => {
124
+ const tag = el.nodeName.toLowerCase();
125
+ const node = $createHeadingNode(tag);
126
+ lexical.setNodeIndentFromDOM(el, node);
127
+ lexical.$setFormatFromDOM(node, el);
128
+ lexical.$setDirectionFromDOM(node, el);
129
+ return [node.splice(0, 0, ctx.$importChildren(el))];
130
+ },
131
+ match: html.sel.tag('h1', 'h2', 'h3', 'h4', 'h5', 'h6'),
132
+ name: '@lexical/rich-text/heading'
133
+ });
134
+ const QuoteRule = html.defineImportRule({
135
+ $import: (ctx, el) => {
136
+ const node = $createQuoteNode();
137
+ lexical.$setFormatFromDOM(node, el);
138
+ lexical.setNodeIndentFromDOM(el, node);
139
+ lexical.$setDirectionFromDOM(node, el);
140
+ return [node.splice(0, 0, ctx.$importChildren(el))];
141
+ },
142
+ match: html.sel.tag('blockquote'),
143
+ name: '@lexical/rich-text/blockquote'
144
+ });
70
145
 
71
- const documentMode = CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
72
- const IS_APPLE = CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
73
- const CAN_USE_BEFORE_INPUT = CAN_USE_DOM && 'InputEvent' in window && !documentMode ? 'getTargetRanges' in new window.InputEvent('input') : false;
74
- const IS_IOS = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
75
- const IS_ANDROID = CAN_USE_DOM && /Android/.test(navigator.userAgent);
146
+ /**
147
+ * Google-Docs paragraph wrapper around a title span: drop the paragraph,
148
+ * let the span rule below promote to a heading. The body deliberately
149
+ * returns the children unwrapped (no schema, no own node) so the
150
+ * descendant rules including {@link GoogleDocsTitleSpanRule} — fire and
151
+ * produce the heading at this level.
152
+ */
153
+ const GoogleDocsTitleParagraphRule = html.defineImportRule({
154
+ $import: (ctx, el, $next) => {
155
+ const first = el.firstChild;
156
+ if (first && isGoogleDocsTitleSpan(first)) {
157
+ return ctx.$importChildren(el);
158
+ }
159
+ return $next();
160
+ },
161
+ match: html.sel.tag('p'),
162
+ name: '@lexical/rich-text/google-docs-title-p'
163
+ });
164
+ const GoogleDocsTitleSpanRule = html.defineImportRule({
165
+ $import: (ctx, el, $next) => el.style.fontSize !== '26pt' ? $next() : [$createHeadingNode('h1').splice(0, 0, ctx.$importChildren(el))],
166
+ match: html.sel.tag('span'),
167
+ name: '@lexical/rich-text/google-docs-title-span'
168
+ });
76
169
 
77
- // Exclude Android — Android WebView's UA contains "Version/X.X ... Safari/537.36"
78
- // which falsely matches the Safari regex, activating wrong composition code paths.
79
- const IS_SAFARI = CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent) && !IS_ANDROID;
170
+ /**
171
+ * Import rules for {@link HeadingNode} and {@link QuoteNode}, including
172
+ * the Google Docs title heuristic that the legacy `HeadingNode.importDOM`
173
+ * declared. The Google-Docs rules are registered last (highest priority)
174
+ * so they precede the generic `<p>` and `<span>` rules from
175
+ * {@link CoreImportRules}.
176
+ *
177
+ * @experimental
178
+ */
179
+ const RichTextImportRules = [HeadingRule, QuoteRule, GoogleDocsTitleParagraphRule, GoogleDocsTitleSpanRule];
80
180
 
81
- // Keep these in case we need to use them in the future.
82
- // export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
83
- const IS_CHROME = CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent);
84
- const IS_APPLE_WEBKIT = CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && IS_APPLE && !IS_CHROME;
181
+ /**
182
+ * Bundles {@link RichTextImportRules} together with the runtime
183
+ * {@link RichTextExtension}. The application is expected to already
184
+ * have `CoreImportExtension` (or some equivalent) in its dependency
185
+ * graph — the core/text/paragraph/inline-format rules are a shared
186
+ * baseline, not something this leaf importer should re-declare.
187
+ *
188
+ * @experimental
189
+ */
190
+ const RichTextImportExtension = lexical.defineExtension({
191
+ dependencies: [RichTextExtension, lexical.configExtension(html.DOMImportExtension, {
192
+ rules: RichTextImportRules
193
+ })],
194
+ name: '@lexical/rich-text/Import'
195
+ });
85
196
 
86
197
  /**
87
198
  * Copyright (c) Meta Platforms, Inc. and affiliates.
@@ -459,59 +570,6 @@ function $isSelectionCollapsedAtFrontOfIndentedBlock(selection) {
459
570
  * (useful when overriding defaults via `configExtension`).
460
571
  */
461
572
 
462
- /**
463
- * Configuration for {@link RichTextExtension}.
464
- *
465
- * @property escapeFormatTriggers - Per-format trigger configuration that
466
- * controls which text formats are automatically cleared from the selection
467
- * on specific user interactions.
468
- *
469
- * Defaults to:
470
- * ```ts
471
- * {
472
- * capitalize: {enter: true, space: true, tab: true},
473
- * lowercase: {enter: true, space: true, tab: true},
474
- * uppercase: {enter: true, space: true, tab: true},
475
- * }
476
- * ```
477
- *
478
- * To opt in to escaping `code` formatting at text node boundaries:
479
- * ```ts
480
- * configExtension(RichTextExtension, {
481
- * escapeFormatTriggers: {
482
- * code: {onlyAtBoundary: true, enter: true, click: true, arrow: true},
483
- * },
484
- * })
485
- * ```
486
- *
487
- * @property normalizeInlineElements - Adds normalization for each
488
- * subclass of ElementNode, which removes empty inline elements.
489
- * This option is intended to facilitate a smooth migration
490
- * from the plugin API and may be removed in the future
491
- *
492
- * Default: true
493
- *
494
- */
495
-
496
- const DEFAULT_RICH_TEXT_CONFIG = {
497
- escapeFormatTriggers: {
498
- capitalize: {
499
- enter: true,
500
- space: true,
501
- tab: true
502
- },
503
- lowercase: {
504
- enter: true,
505
- space: true,
506
- tab: true
507
- },
508
- uppercase: {
509
- enter: true,
510
- space: true,
511
- tab: true
512
- }
513
- }
514
- };
515
573
  function $escapeFormatsForTrigger(selection, trigger, direction, config) {
516
574
  let isBoundary = false;
517
575
  let anchorNode = null;
@@ -545,27 +603,24 @@ function $escapeFormatsForTrigger(selection, trigger, direction, config) {
545
603
  selection.setStyle('');
546
604
  }
547
605
  }
548
- function mergeEscapeFormatTriggers(config, overrides) {
549
- const merged = lexical.shallowMergeConfig(config, overrides);
550
- for (const k of Object.keys(overrides)) {
551
- merged[k] = mergeTriggerConfig(config[k], overrides[k]);
606
+ const DEFAULT_ESCAPE_FORMAT_TRIGGERS = {
607
+ capitalize: {
608
+ enter: true,
609
+ space: true,
610
+ tab: true
611
+ },
612
+ lowercase: {
613
+ enter: true,
614
+ space: true,
615
+ tab: true
616
+ },
617
+ uppercase: {
618
+ enter: true,
619
+ space: true,
620
+ tab: true
552
621
  }
553
- return merged;
554
- }
555
- function mergeTriggerConfig(config, override) {
556
- if (!config || override === null) {
557
- return override;
558
- }
559
- return lexical.shallowMergeConfig(config, override);
560
- }
561
- function mergeRichTextConfig(config, overrides) {
562
- const merged = lexical.shallowMergeConfig(config, overrides);
563
- if (overrides.escapeFormatTriggers) {
564
- merged.escapeFormatTriggers = mergeEscapeFormatTriggers(config.escapeFormatTriggers, overrides.escapeFormatTriggers);
565
- }
566
- return merged;
567
- }
568
- function registerRichText(editor, escapeFormatTriggers = extension.signal(DEFAULT_RICH_TEXT_CONFIG.escapeFormatTriggers)) {
622
+ };
623
+ function registerRichText(editor, escapeFormatTriggers = extension.signal(DEFAULT_ESCAPE_FORMAT_TRIGGERS)) {
569
624
  const removeListener = utils.mergeRegister(editor.registerCommand(lexical.CLICK_COMMAND, () => {
570
625
  const selection = lexical.$getSelection();
571
626
  if (lexical.$isNodeSelection(selection)) {
@@ -798,7 +853,7 @@ function registerRichText(editor, escapeFormatTriggers = extension.signal(DEFAUL
798
853
  }
799
854
  // Exception handling for iOS native behavior instead of Lexical's behavior when using Korean on iOS devices.
800
855
  // more details - https://github.com/facebook/lexical/issues/5841
801
- if (IS_IOS && navigator.language === 'ko-KR') {
856
+ if (lexical.IS_IOS && navigator.language === 'ko-KR') {
802
857
  return false;
803
858
  }
804
859
  } else if (!lexical.$isNodeSelection(selection)) {
@@ -817,7 +872,20 @@ function registerRichText(editor, escapeFormatTriggers = extension.signal(DEFAUL
817
872
  event.preventDefault();
818
873
  return editor.dispatchCommand(lexical.DELETE_CHARACTER_COMMAND, false);
819
874
  }, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.KEY_ENTER_COMMAND, event => {
820
- const selection = lexical.$getSelection();
875
+ let selection = lexical.$getSelection();
876
+ // When a block-level DecoratorNode is selected as a NodeSelection
877
+ // (e.g. it is the only root child after the user removed all
878
+ // surrounding paragraphs), Enter has no RangeSelection to act on
879
+ // and the default handler bails out, leaving the editor stuck.
880
+ // Convert to a RangeSelection past the decorator so the default
881
+ // RangeSelection handler below inserts a paragraph and places
882
+ // the caret.
883
+ if (lexical.$isNodeSelection(selection)) {
884
+ const nodes = selection.getNodes();
885
+ if (nodes.length === 1 && lexical.$isDecoratorNode(nodes[0]) && !nodes[0].isInline()) {
886
+ selection = nodes[0].selectNext();
887
+ }
888
+ }
821
889
  if (!lexical.$isRangeSelection(selection)) {
822
890
  return false;
823
891
  }
@@ -830,7 +898,7 @@ function registerRichText(editor, escapeFormatTriggers = extension.signal(DEFAUL
830
898
  // This can also cause a strange performance issue in
831
899
  // Safari, where there is a noticeable pause due to
832
900
  // preventing the key down of enter.
833
- if ((IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) && CAN_USE_BEFORE_INPUT) {
901
+ if ((lexical.IS_IOS || lexical.IS_SAFARI || lexical.IS_APPLE_WEBKIT) && lexical.CAN_USE_BEFORE_INPUT) {
834
902
  return false;
835
903
  }
836
904
  event.preventDefault();
@@ -851,7 +919,7 @@ function registerRichText(editor, escapeFormatTriggers = extension.signal(DEFAUL
851
919
  if (files.length > 0) {
852
920
  const x = event.clientX;
853
921
  const y = event.clientY;
854
- const eventRange = caretFromPoint(x, y);
922
+ const eventRange = clipboard.caretFromPoint(x, y);
855
923
  if (eventRange !== null) {
856
924
  const {
857
925
  offset: domOffset,
@@ -902,7 +970,7 @@ function registerRichText(editor, escapeFormatTriggers = extension.signal(DEFAUL
902
970
  }
903
971
  const x = event.clientX;
904
972
  const y = event.clientY;
905
- const eventRange = caretFromPoint(x, y);
973
+ const eventRange = clipboard.caretFromPoint(x, y);
906
974
  if (eventRange !== null) {
907
975
  const node = lexical.$getNearestNodeFromDOMNode(eventRange.node);
908
976
  if (lexical.$isDecoratorNode(node)) {
@@ -950,37 +1018,88 @@ function registerRichText(editor, escapeFormatTriggers = extension.signal(DEFAUL
950
1018
  $escapeFormatsForTrigger(selection, 'tab', 'both', escapeFormatTriggers.peek());
951
1019
  }
952
1020
  return false;
1021
+ }, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.MOVE_TO_END, event => {
1022
+ const selection = lexical.$getSelection();
1023
+ if (!lexical.$isRangeSelection(selection)) {
1024
+ return false;
1025
+ }
1026
+ const {
1027
+ anchor
1028
+ } = selection;
1029
+ if (anchor.type !== 'element' || anchor.offset !== 0) {
1030
+ return false;
1031
+ }
1032
+ const element = anchor.getNode();
1033
+ if (!lexical.$isElementNode(element)) {
1034
+ return false;
1035
+ }
1036
+ const firstChild = element.getFirstChild();
1037
+ if (!lexical.$isDecoratorNode(firstChild) || !firstChild.isInline()) {
1038
+ return false;
1039
+ }
1040
+ const lastDescendant = element.getLastDescendant();
1041
+ if (lastDescendant == null || lexical.$isDecoratorNode(lastDescendant)) {
1042
+ // No selectable text — fall through to native browser behavior.
1043
+ return false;
1044
+ }
1045
+ // Native browser cursor traversal stops at the inline decorator's
1046
+ // contenteditable=false boundary when the caret starts at element
1047
+ // offset 0, so MOVE_TO_END leaves the caret stuck. Move it ourselves.
1048
+ const elementKey = element.getKey();
1049
+ const ending = element.selectEnd();
1050
+ if (event.shiftKey) {
1051
+ ending.anchor.set(elementKey, 0, 'element');
1052
+ }
1053
+ event.preventDefault();
1054
+ event.stopPropagation();
1055
+ return true;
1056
+ }, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.MOVE_TO_START, event => {
1057
+ const selection = lexical.$getSelection();
1058
+ if (!lexical.$isRangeSelection(selection)) {
1059
+ return false;
1060
+ }
1061
+ const {
1062
+ anchor,
1063
+ focus
1064
+ } = selection;
1065
+ const focusBlock = utils.$findMatchingParent(focus.getNode(), node => lexical.$isElementNode(node) && !node.isInline());
1066
+ if (focusBlock === null) {
1067
+ return false;
1068
+ }
1069
+ const firstChild = focusBlock.getFirstChild();
1070
+ if (!lexical.$isDecoratorNode(firstChild) || !firstChild.isInline()) {
1071
+ return false;
1072
+ }
1073
+ const lastDescendant = focusBlock.getLastDescendant();
1074
+ if (lastDescendant == null || lexical.$isDecoratorNode(lastDescendant)) {
1075
+ // No selectable text — fall through to native browser behavior.
1076
+ return false;
1077
+ }
1078
+ // Cross-block selections fall through to native handling. The
1079
+ // Chromium boundary bug only matters when both endpoints sit
1080
+ // inside the block whose first child is the inline decorator.
1081
+ const anchorBlock = utils.$findMatchingParent(anchor.getNode(), node => lexical.$isElementNode(node) && !node.isInline());
1082
+ if (anchorBlock !== focusBlock) {
1083
+ return false;
1084
+ }
1085
+ const blockKey = focusBlock.getKey();
1086
+ if (focus.type === 'element' && focus.key === blockKey && focus.offset === 0) {
1087
+ return false;
1088
+ }
1089
+ // Symmetric to the MOVE_TO_END case: Chromium stops the native
1090
+ // caret at the inline decorator's contenteditable=false boundary
1091
+ // when moving backwards, so element offset 0 is unreachable.
1092
+ selection.focus.set(blockKey, 0, 'element');
1093
+ if (!event.shiftKey) {
1094
+ selection.anchor.set(blockKey, 0, 'element');
1095
+ }
1096
+ event.preventDefault();
1097
+ event.stopPropagation();
1098
+ return true;
953
1099
  }, lexical.COMMAND_PRIORITY_EDITOR));
954
1100
  return removeListener;
955
1101
  }
956
1102
 
957
- /**
958
- * An extension to register \@lexical/rich-text behavior and nodes
959
- * ({@link HeadingNode}, {@link QuoteNode}).
960
- *
961
- * Includes configurable format escape via `escapeFormatTriggers`.
962
- * Use `configExtension` to customize which formats escape on which triggers.
963
- *
964
- * @example
965
- * ```ts
966
- * configExtension(RichTextExtension, {
967
- * escapeFormatTriggers: {
968
- * code: {click: true, arrow: true},
969
- * },
970
- * })
971
- * ```
972
- */
973
- const RichTextExtension = lexical.defineExtension({
974
- build: (_editor, config) => extension.namedSignals(config),
975
- config: lexical.safeCast(DEFAULT_RICH_TEXT_CONFIG),
976
- conflictsWith: ['@lexical/plain-text'],
977
- dependencies: [dragon.DragonExtension, extension.NormalizeInlineElementsExtension],
978
- mergeConfig: mergeRichTextConfig,
979
- name: '@lexical/rich-text',
980
- nodes: () => [HeadingNode, QuoteNode],
981
- register: (editor, _config, state) => extension.effect(() => registerRichText(editor, state.getOutput().escapeFormatTriggers))
982
- });
983
-
984
1103
  exports.$createHeadingNode = $createHeadingNode;
985
1104
  exports.$createQuoteNode = $createQuoteNode;
986
1105
  exports.$isHeadingNode = $isHeadingNode;
@@ -989,5 +1108,7 @@ exports.DRAG_DROP_PASTE = DRAG_DROP_PASTE;
989
1108
  exports.HeadingNode = HeadingNode;
990
1109
  exports.QuoteNode = QuoteNode;
991
1110
  exports.RichTextExtension = RichTextExtension;
1111
+ exports.RichTextImportExtension = RichTextImportExtension;
1112
+ exports.RichTextImportRules = RichTextImportRules;
992
1113
  exports.eventFiles = eventFiles;
993
1114
  exports.registerRichText = registerRichText;