@lexical/markdown 0.35.1-nightly.20250919.0 → 0.35.1-nightly.20250923.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.
@@ -729,320 +729,6 @@ function formatDevErrorMessage(message) {
729
729
  throw new Error(message);
730
730
  }
731
731
 
732
- function runElementTransformers(parentNode, anchorNode, anchorOffset, elementTransformers) {
733
- const grandParentNode = parentNode.getParent();
734
- if (!lexical.$isRootOrShadowRoot(grandParentNode) || parentNode.getFirstChild() !== anchorNode) {
735
- return false;
736
- }
737
- const textContent = anchorNode.getTextContent();
738
-
739
- // Checking for anchorOffset position to prevent any checks for cases when caret is too far
740
- // from a line start to be a part of block-level markdown trigger.
741
- //
742
- // TODO:
743
- // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
744
- // since otherwise it won't be a markdown shortcut, but tables are exception
745
- if (textContent[anchorOffset - 1] !== ' ') {
746
- return false;
747
- }
748
- for (const {
749
- regExp,
750
- replace
751
- } of elementTransformers) {
752
- const match = textContent.match(regExp);
753
- if (match && match[0].length === (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1)) {
754
- const nextSiblings = anchorNode.getNextSiblings();
755
- const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
756
- const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings;
757
- if (replace(parentNode, siblings, match, false) !== false) {
758
- leadingNode.remove();
759
- return true;
760
- }
761
- }
762
- }
763
- return false;
764
- }
765
- function runMultilineElementTransformers(parentNode, anchorNode, anchorOffset, elementTransformers) {
766
- const grandParentNode = parentNode.getParent();
767
- if (!lexical.$isRootOrShadowRoot(grandParentNode) || parentNode.getFirstChild() !== anchorNode) {
768
- return false;
769
- }
770
- const textContent = anchorNode.getTextContent();
771
-
772
- // Checking for anchorOffset position to prevent any checks for cases when caret is too far
773
- // from a line start to be a part of block-level markdown trigger.
774
- //
775
- // TODO:
776
- // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
777
- // since otherwise it won't be a markdown shortcut, but tables are exception
778
- if (textContent[anchorOffset - 1] !== ' ') {
779
- return false;
780
- }
781
- for (const {
782
- regExpStart,
783
- replace,
784
- regExpEnd
785
- } of elementTransformers) {
786
- if (regExpEnd && !('optional' in regExpEnd) || regExpEnd && 'optional' in regExpEnd && !regExpEnd.optional) {
787
- continue;
788
- }
789
- const match = textContent.match(regExpStart);
790
- if (match && match[0].length === (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1)) {
791
- const nextSiblings = anchorNode.getNextSiblings();
792
- const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
793
- const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings;
794
- if (replace(parentNode, siblings, match, null, null, false) !== false) {
795
- leadingNode.remove();
796
- return true;
797
- }
798
- }
799
- }
800
- return false;
801
- }
802
- function runTextMatchTransformers(anchorNode, anchorOffset, transformersByTrigger) {
803
- let textContent = anchorNode.getTextContent();
804
- const lastChar = textContent[anchorOffset - 1];
805
- const transformers = transformersByTrigger[lastChar];
806
- if (transformers == null) {
807
- return false;
808
- }
809
-
810
- // If typing in the middle of content, remove the tail to do
811
- // reg exp match up to a string end (caret position)
812
- if (anchorOffset < textContent.length) {
813
- textContent = textContent.slice(0, anchorOffset);
814
- }
815
- for (const transformer of transformers) {
816
- if (!transformer.replace || !transformer.regExp) {
817
- continue;
818
- }
819
- const match = textContent.match(transformer.regExp);
820
- if (match === null) {
821
- continue;
822
- }
823
- const startIndex = match.index || 0;
824
- const endIndex = startIndex + match[0].length;
825
- let replaceNode;
826
- if (startIndex === 0) {
827
- [replaceNode] = anchorNode.splitText(endIndex);
828
- } else {
829
- [, replaceNode] = anchorNode.splitText(startIndex, endIndex);
830
- }
831
- replaceNode.selectNext(0, 0);
832
- transformer.replace(replaceNode, match);
833
- return true;
834
- }
835
- return false;
836
- }
837
- function $runTextFormatTransformers(anchorNode, anchorOffset, textFormatTransformers) {
838
- const textContent = anchorNode.getTextContent();
839
- const closeTagEndIndex = anchorOffset - 1;
840
- const closeChar = textContent[closeTagEndIndex];
841
- // Quick check if we're possibly at the end of inline markdown style
842
- const matchers = textFormatTransformers[closeChar];
843
- if (!matchers) {
844
- return false;
845
- }
846
- for (const matcher of matchers) {
847
- const {
848
- tag
849
- } = matcher;
850
- const tagLength = tag.length;
851
- const closeTagStartIndex = closeTagEndIndex - tagLength + 1;
852
-
853
- // If tag is not single char check if rest of it matches with text content
854
- if (tagLength > 1) {
855
- if (!isEqualSubString(textContent, closeTagStartIndex, tag, 0, tagLength)) {
856
- continue;
857
- }
858
- }
859
-
860
- // Space before closing tag cancels inline markdown
861
- if (textContent[closeTagStartIndex - 1] === ' ') {
862
- continue;
863
- }
864
-
865
- // Some tags can not be used within words, hence should have newline/space/punctuation after it
866
- const afterCloseTagChar = textContent[closeTagEndIndex + 1];
867
- if (matcher.intraword === false && afterCloseTagChar && !PUNCTUATION_OR_SPACE.test(afterCloseTagChar)) {
868
- continue;
869
- }
870
- const closeNode = anchorNode;
871
- let openNode = closeNode;
872
- let openTagStartIndex = getOpenTagStartIndex(textContent, closeTagStartIndex, tag);
873
-
874
- // Go through text node siblings and search for opening tag
875
- // if haven't found it within the same text node as closing tag
876
- let sibling = openNode;
877
- while (openTagStartIndex < 0 && (sibling = sibling.getPreviousSibling())) {
878
- if (lexical.$isLineBreakNode(sibling)) {
879
- break;
880
- }
881
- if (lexical.$isTextNode(sibling)) {
882
- if (sibling.hasFormat('code')) {
883
- continue;
884
- }
885
- const siblingTextContent = sibling.getTextContent();
886
- openNode = sibling;
887
- openTagStartIndex = getOpenTagStartIndex(siblingTextContent, siblingTextContent.length, tag);
888
- }
889
- }
890
-
891
- // Opening tag is not found
892
- if (openTagStartIndex < 0) {
893
- continue;
894
- }
895
-
896
- // No content between opening and closing tag
897
- if (openNode === closeNode && openTagStartIndex + tagLength === closeTagStartIndex) {
898
- continue;
899
- }
900
-
901
- // Checking longer tags for repeating chars (e.g. *** vs **)
902
- const prevOpenNodeText = openNode.getTextContent();
903
- if (openTagStartIndex > 0 && prevOpenNodeText[openTagStartIndex - 1] === closeChar) {
904
- continue;
905
- }
906
-
907
- // Some tags can not be used within words, hence should have newline/space/punctuation before it
908
- const beforeOpenTagChar = prevOpenNodeText[openTagStartIndex - 1];
909
- if (matcher.intraword === false && beforeOpenTagChar && !PUNCTUATION_OR_SPACE.test(beforeOpenTagChar)) {
910
- continue;
911
- }
912
-
913
- // Clean text from opening and closing tags (starting from closing tag
914
- // to prevent any offset shifts if we start from opening one)
915
- const prevCloseNodeText = closeNode.getTextContent();
916
- const closeNodeText = prevCloseNodeText.slice(0, closeTagStartIndex) + prevCloseNodeText.slice(closeTagEndIndex + 1);
917
- closeNode.setTextContent(closeNodeText);
918
- const openNodeText = openNode === closeNode ? closeNodeText : prevOpenNodeText;
919
- openNode.setTextContent(openNodeText.slice(0, openTagStartIndex) + openNodeText.slice(openTagStartIndex + tagLength));
920
- const selection = lexical.$getSelection();
921
- const nextSelection = lexical.$createRangeSelection();
922
- lexical.$setSelection(nextSelection);
923
- // Adjust offset based on deleted chars
924
- const newOffset = closeTagEndIndex - tagLength * (openNode === closeNode ? 2 : 1) + 1;
925
- nextSelection.anchor.set(openNode.__key, openTagStartIndex, 'text');
926
- nextSelection.focus.set(closeNode.__key, newOffset, 'text');
927
-
928
- // Apply formatting to selected text
929
- for (const format of matcher.format) {
930
- if (!nextSelection.hasFormat(format)) {
931
- nextSelection.formatText(format);
932
- }
933
- }
934
-
935
- // Collapse selection up to the focus point
936
- nextSelection.anchor.set(nextSelection.focus.key, nextSelection.focus.offset, nextSelection.focus.type);
937
-
938
- // Remove formatting from collapsed selection
939
- for (const format of matcher.format) {
940
- if (nextSelection.hasFormat(format)) {
941
- nextSelection.toggleFormat(format);
942
- }
943
- }
944
- if (lexical.$isRangeSelection(selection)) {
945
- nextSelection.format = selection.format;
946
- }
947
- return true;
948
- }
949
- return false;
950
- }
951
- function getOpenTagStartIndex(string, maxIndex, tag) {
952
- const tagLength = tag.length;
953
- for (let i = maxIndex; i >= tagLength; i--) {
954
- const startIndex = i - tagLength;
955
- if (isEqualSubString(string, startIndex, tag, 0, tagLength) &&
956
- // Space after opening tag cancels transformation
957
- string[startIndex + tagLength] !== ' ') {
958
- return startIndex;
959
- }
960
- }
961
- return -1;
962
- }
963
- function isEqualSubString(stringA, aStart, stringB, bStart, length) {
964
- for (let i = 0; i < length; i++) {
965
- if (stringA[aStart + i] !== stringB[bStart + i]) {
966
- return false;
967
- }
968
- }
969
- return true;
970
- }
971
- function registerMarkdownShortcuts(editor, transformers = TRANSFORMERS) {
972
- const byType = transformersByType(transformers);
973
- const textFormatTransformersByTrigger = indexBy(byType.textFormat, ({
974
- tag
975
- }) => tag[tag.length - 1]);
976
- const textMatchTransformersByTrigger = indexBy(byType.textMatch, ({
977
- trigger
978
- }) => trigger);
979
- for (const transformer of transformers) {
980
- const type = transformer.type;
981
- if (type === 'element' || type === 'text-match' || type === 'multiline-element') {
982
- const dependencies = transformer.dependencies;
983
- for (const node of dependencies) {
984
- if (!editor.hasNode(node)) {
985
- {
986
- formatDevErrorMessage(`MarkdownShortcuts: missing dependency ${node.getType()} for transformer. Ensure node dependency is included in editor initial config.`);
987
- }
988
- }
989
- }
990
- }
991
- }
992
- const $transform = (parentNode, anchorNode, anchorOffset) => {
993
- if (runElementTransformers(parentNode, anchorNode, anchorOffset, byType.element)) {
994
- return;
995
- }
996
- if (runMultilineElementTransformers(parentNode, anchorNode, anchorOffset, byType.multilineElement)) {
997
- return;
998
- }
999
- if (runTextMatchTransformers(anchorNode, anchorOffset, textMatchTransformersByTrigger)) {
1000
- return;
1001
- }
1002
- $runTextFormatTransformers(anchorNode, anchorOffset, textFormatTransformersByTrigger);
1003
- };
1004
- return editor.registerUpdateListener(({
1005
- tags,
1006
- dirtyLeaves,
1007
- editorState,
1008
- prevEditorState
1009
- }) => {
1010
- // Ignore updates from collaboration and undo/redo (as changes already calculated)
1011
- if (tags.has(lexical.COLLABORATION_TAG) || tags.has(lexical.HISTORIC_TAG)) {
1012
- return;
1013
- }
1014
-
1015
- // If editor is still composing (i.e. backticks) we must wait before the user confirms the key
1016
- if (editor.isComposing()) {
1017
- return;
1018
- }
1019
- const selection = editorState.read(lexical.$getSelection);
1020
- const prevSelection = prevEditorState.read(lexical.$getSelection);
1021
-
1022
- // We expect selection to be a collapsed range and not match previous one (as we want
1023
- // to trigger transforms only as user types)
1024
- if (!lexical.$isRangeSelection(prevSelection) || !lexical.$isRangeSelection(selection) || !selection.isCollapsed() || selection.is(prevSelection)) {
1025
- return;
1026
- }
1027
- const anchorKey = selection.anchor.key;
1028
- const anchorOffset = selection.anchor.offset;
1029
- const anchorNode = editorState._nodeMap.get(anchorKey);
1030
- if (!lexical.$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey) || anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1) {
1031
- return;
1032
- }
1033
- editor.update(() => {
1034
- if (!canContainTransformableMarkdown(anchorNode)) {
1035
- return;
1036
- }
1037
- const parentNode = anchorNode.getParent();
1038
- if (parentNode === null || code.$isCodeNode(parentNode)) {
1039
- return;
1040
- }
1041
- $transform(parentNode, anchorNode, selection.anchor.offset);
1042
- });
1043
- });
1044
- }
1045
-
1046
732
  /**
1047
733
  * Copyright (c) Meta Platforms, Inc. and affiliates.
1048
734
  *
@@ -1324,87 +1010,411 @@ const ITALIC_UNDERSCORE = {
1324
1010
  type: 'text-format'
1325
1011
  };
1326
1012
 
1327
- // Order of text transformers matters:
1328
- //
1329
- // - code should go first as it prevents any transformations inside
1330
- // - then longer tags match (e.g. ** or __ should go before * or _)
1331
- const LINK = {
1332
- dependencies: [link.LinkNode],
1333
- export: (node, exportChildren, exportFormat) => {
1334
- if (!link.$isLinkNode(node) || link.$isAutoLinkNode(node)) {
1335
- return null;
1013
+ // Order of text transformers matters:
1014
+ //
1015
+ // - code should go first as it prevents any transformations inside
1016
+ // - then longer tags match (e.g. ** or __ should go before * or _)
1017
+ const LINK = {
1018
+ dependencies: [link.LinkNode],
1019
+ export: (node, exportChildren, exportFormat) => {
1020
+ if (!link.$isLinkNode(node) || link.$isAutoLinkNode(node)) {
1021
+ return null;
1022
+ }
1023
+ const title = node.getTitle();
1024
+ const textContent = exportChildren(node);
1025
+ const linkContent = title ? `[${textContent}](${node.getURL()} "${title}")` : `[${textContent}](${node.getURL()})`;
1026
+ return linkContent;
1027
+ },
1028
+ importRegExp: /(?:\[(.*?)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))/,
1029
+ regExp: /(?:\[(.*?)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))$/,
1030
+ replace: (textNode, match) => {
1031
+ const [, linkText, linkUrl, linkTitle] = match;
1032
+ const linkNode = link.$createLinkNode(linkUrl, {
1033
+ title: linkTitle
1034
+ });
1035
+ const openBracketAmount = linkText.split('[').length - 1;
1036
+ const closeBracketAmount = linkText.split(']').length - 1;
1037
+ let parsedLinkText = linkText;
1038
+ let outsideLinkText = '';
1039
+ if (openBracketAmount < closeBracketAmount) {
1040
+ return;
1041
+ } else if (openBracketAmount > closeBracketAmount) {
1042
+ const linkTextParts = linkText.split('[');
1043
+ outsideLinkText = '[' + linkTextParts[0];
1044
+ parsedLinkText = linkTextParts.slice(1).join('[');
1045
+ }
1046
+ const linkTextNode = lexical.$createTextNode(parsedLinkText);
1047
+ linkTextNode.setFormat(textNode.getFormat());
1048
+ linkNode.append(linkTextNode);
1049
+ textNode.replace(linkNode);
1050
+ if (outsideLinkText) {
1051
+ linkNode.insertBefore(lexical.$createTextNode(outsideLinkText));
1052
+ }
1053
+ return linkTextNode;
1054
+ },
1055
+ trigger: ')',
1056
+ type: 'text-match'
1057
+ };
1058
+ const ELEMENT_TRANSFORMERS = [HEADING, QUOTE, UNORDERED_LIST, ORDERED_LIST];
1059
+ const MULTILINE_ELEMENT_TRANSFORMERS = [CODE];
1060
+
1061
+ // Order of text format transformers matters:
1062
+ //
1063
+ // - code should go first as it prevents any transformations inside
1064
+ // - then longer tags match (e.g. ** or __ should go before * or _)
1065
+ const TEXT_FORMAT_TRANSFORMERS = [INLINE_CODE, BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, BOLD_UNDERSCORE, HIGHLIGHT, ITALIC_STAR, ITALIC_UNDERSCORE, STRIKETHROUGH];
1066
+ const TEXT_MATCH_TRANSFORMERS = [LINK];
1067
+ const TRANSFORMERS = [...ELEMENT_TRANSFORMERS, ...MULTILINE_ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS];
1068
+ function normalizeMarkdown(input, shouldMergeAdjacentLines = false) {
1069
+ const lines = input.split('\n');
1070
+ let inCodeBlock = false;
1071
+ const sanitizedLines = [];
1072
+ for (let i = 0; i < lines.length; i++) {
1073
+ const line = lines[i];
1074
+ const lastLine = sanitizedLines[sanitizedLines.length - 1];
1075
+
1076
+ // Code blocks of ```single line``` don't toggle the inCodeBlock flag
1077
+ if (CODE_SINGLE_LINE_REGEX.test(line)) {
1078
+ sanitizedLines.push(line);
1079
+ continue;
1080
+ }
1081
+
1082
+ // Detect the start or end of a code block
1083
+ if (CODE_START_REGEX.test(line) || CODE_END_REGEX.test(line)) {
1084
+ inCodeBlock = !inCodeBlock;
1085
+ sanitizedLines.push(line);
1086
+ continue;
1087
+ }
1088
+
1089
+ // If we are inside a code block, keep the line unchanged
1090
+ if (inCodeBlock) {
1091
+ sanitizedLines.push(line);
1092
+ continue;
1093
+ }
1094
+
1095
+ // In markdown the concept of "empty paragraphs" does not exist.
1096
+ // Blocks must be separated by an empty line. Non-empty adjacent lines must be merged.
1097
+ if (line === '' || lastLine === '' || !lastLine || HEADING_REGEX.test(lastLine) || HEADING_REGEX.test(line) || QUOTE_REGEX.test(line) || ORDERED_LIST_REGEX.test(line) || UNORDERED_LIST_REGEX.test(line) || CHECK_LIST_REGEX.test(line) || TABLE_ROW_REG_EXP.test(line) || TABLE_ROW_DIVIDER_REG_EXP.test(line) || !shouldMergeAdjacentLines) {
1098
+ sanitizedLines.push(line);
1099
+ } else {
1100
+ sanitizedLines[sanitizedLines.length - 1] = lastLine + line;
1101
+ }
1102
+ }
1103
+ return sanitizedLines.join('\n');
1104
+ }
1105
+
1106
+ function runElementTransformers(parentNode, anchorNode, anchorOffset, elementTransformers) {
1107
+ const grandParentNode = parentNode.getParent();
1108
+ if (!lexical.$isRootOrShadowRoot(grandParentNode) || parentNode.getFirstChild() !== anchorNode) {
1109
+ return false;
1110
+ }
1111
+ const textContent = anchorNode.getTextContent();
1112
+
1113
+ // Checking for anchorOffset position to prevent any checks for cases when caret is too far
1114
+ // from a line start to be a part of block-level markdown trigger.
1115
+ //
1116
+ // TODO:
1117
+ // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
1118
+ // since otherwise it won't be a markdown shortcut, but tables are exception
1119
+ if (textContent[anchorOffset - 1] !== ' ') {
1120
+ return false;
1121
+ }
1122
+ for (const {
1123
+ regExp,
1124
+ replace
1125
+ } of elementTransformers) {
1126
+ const match = textContent.match(regExp);
1127
+ if (match && match[0].length === (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1)) {
1128
+ const nextSiblings = anchorNode.getNextSiblings();
1129
+ const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
1130
+ const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings;
1131
+ if (replace(parentNode, siblings, match, false) !== false) {
1132
+ leadingNode.remove();
1133
+ return true;
1134
+ }
1135
+ }
1136
+ }
1137
+ return false;
1138
+ }
1139
+ function runMultilineElementTransformers(parentNode, anchorNode, anchorOffset, elementTransformers) {
1140
+ const grandParentNode = parentNode.getParent();
1141
+ if (!lexical.$isRootOrShadowRoot(grandParentNode) || parentNode.getFirstChild() !== anchorNode) {
1142
+ return false;
1143
+ }
1144
+ const textContent = anchorNode.getTextContent();
1145
+
1146
+ // Checking for anchorOffset position to prevent any checks for cases when caret is too far
1147
+ // from a line start to be a part of block-level markdown trigger.
1148
+ //
1149
+ // TODO:
1150
+ // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
1151
+ // since otherwise it won't be a markdown shortcut, but tables are exception
1152
+ if (textContent[anchorOffset - 1] !== ' ') {
1153
+ return false;
1154
+ }
1155
+ for (const {
1156
+ regExpStart,
1157
+ replace,
1158
+ regExpEnd
1159
+ } of elementTransformers) {
1160
+ if (regExpEnd && !('optional' in regExpEnd) || regExpEnd && 'optional' in regExpEnd && !regExpEnd.optional) {
1161
+ continue;
1162
+ }
1163
+ const match = textContent.match(regExpStart);
1164
+ if (match && match[0].length === (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1)) {
1165
+ const nextSiblings = anchorNode.getNextSiblings();
1166
+ const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
1167
+ const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings;
1168
+ if (replace(parentNode, siblings, match, null, null, false) !== false) {
1169
+ leadingNode.remove();
1170
+ return true;
1171
+ }
1172
+ }
1173
+ }
1174
+ return false;
1175
+ }
1176
+ function runTextMatchTransformers(anchorNode, anchorOffset, transformersByTrigger) {
1177
+ let textContent = anchorNode.getTextContent();
1178
+ const lastChar = textContent[anchorOffset - 1];
1179
+ const transformers = transformersByTrigger[lastChar];
1180
+ if (transformers == null) {
1181
+ return false;
1182
+ }
1183
+
1184
+ // If typing in the middle of content, remove the tail to do
1185
+ // reg exp match up to a string end (caret position)
1186
+ if (anchorOffset < textContent.length) {
1187
+ textContent = textContent.slice(0, anchorOffset);
1188
+ }
1189
+ for (const transformer of transformers) {
1190
+ if (!transformer.replace || !transformer.regExp) {
1191
+ continue;
1192
+ }
1193
+ const match = textContent.match(transformer.regExp);
1194
+ if (match === null) {
1195
+ continue;
1196
+ }
1197
+ const startIndex = match.index || 0;
1198
+ const endIndex = startIndex + match[0].length;
1199
+ let replaceNode;
1200
+ if (startIndex === 0) {
1201
+ [replaceNode] = anchorNode.splitText(endIndex);
1202
+ } else {
1203
+ [, replaceNode] = anchorNode.splitText(startIndex, endIndex);
1204
+ }
1205
+ replaceNode.selectNext(0, 0);
1206
+ transformer.replace(replaceNode, match);
1207
+ return true;
1208
+ }
1209
+ return false;
1210
+ }
1211
+ function $runTextFormatTransformers(anchorNode, anchorOffset, textFormatTransformers) {
1212
+ const textContent = anchorNode.getTextContent();
1213
+ const closeTagEndIndex = anchorOffset - 1;
1214
+ const closeChar = textContent[closeTagEndIndex];
1215
+ // Quick check if we're possibly at the end of inline markdown style
1216
+ const matchers = textFormatTransformers[closeChar];
1217
+ if (!matchers) {
1218
+ return false;
1219
+ }
1220
+ for (const matcher of matchers) {
1221
+ const {
1222
+ tag
1223
+ } = matcher;
1224
+ const tagLength = tag.length;
1225
+ const closeTagStartIndex = closeTagEndIndex - tagLength + 1;
1226
+
1227
+ // If tag is not single char check if rest of it matches with text content
1228
+ if (tagLength > 1) {
1229
+ if (!isEqualSubString(textContent, closeTagStartIndex, tag, 0, tagLength)) {
1230
+ continue;
1231
+ }
1336
1232
  }
1337
- const title = node.getTitle();
1338
- const textContent = exportChildren(node);
1339
- const linkContent = title ? `[${textContent}](${node.getURL()} "${title}")` : `[${textContent}](${node.getURL()})`;
1340
- return linkContent;
1341
- },
1342
- importRegExp: /(?:\[(.*?)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))/,
1343
- regExp: /(?:\[(.*?)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))$/,
1344
- replace: (textNode, match) => {
1345
- const [, linkText, linkUrl, linkTitle] = match;
1346
- const linkNode = link.$createLinkNode(linkUrl, {
1347
- title: linkTitle
1348
- });
1349
- const openBracketAmount = linkText.split('[').length - 1;
1350
- const closeBracketAmount = linkText.split(']').length - 1;
1351
- let parsedLinkText = linkText;
1352
- let outsideLinkText = '';
1353
- if (openBracketAmount < closeBracketAmount) {
1354
- return;
1355
- } else if (openBracketAmount > closeBracketAmount) {
1356
- const linkTextParts = linkText.split('[');
1357
- outsideLinkText = '[' + linkTextParts[0];
1358
- parsedLinkText = linkTextParts.slice(1).join('[');
1233
+
1234
+ // Space before closing tag cancels inline markdown
1235
+ if (textContent[closeTagStartIndex - 1] === ' ') {
1236
+ continue;
1359
1237
  }
1360
- const linkTextNode = lexical.$createTextNode(parsedLinkText);
1361
- linkTextNode.setFormat(textNode.getFormat());
1362
- linkNode.append(linkTextNode);
1363
- textNode.replace(linkNode);
1364
- if (outsideLinkText) {
1365
- linkNode.insertBefore(lexical.$createTextNode(outsideLinkText));
1238
+
1239
+ // Some tags can not be used within words, hence should have newline/space/punctuation after it
1240
+ const afterCloseTagChar = textContent[closeTagEndIndex + 1];
1241
+ if (matcher.intraword === false && afterCloseTagChar && !PUNCTUATION_OR_SPACE.test(afterCloseTagChar)) {
1242
+ continue;
1366
1243
  }
1367
- return linkTextNode;
1368
- },
1369
- trigger: ')',
1370
- type: 'text-match'
1371
- };
1372
- function normalizeMarkdown(input, shouldMergeAdjacentLines = false) {
1373
- const lines = input.split('\n');
1374
- let inCodeBlock = false;
1375
- const sanitizedLines = [];
1376
- for (let i = 0; i < lines.length; i++) {
1377
- const line = lines[i];
1378
- const lastLine = sanitizedLines[sanitizedLines.length - 1];
1244
+ const closeNode = anchorNode;
1245
+ let openNode = closeNode;
1246
+ let openTagStartIndex = getOpenTagStartIndex(textContent, closeTagStartIndex, tag);
1379
1247
 
1380
- // Code blocks of ```single line``` don't toggle the inCodeBlock flag
1381
- if (CODE_SINGLE_LINE_REGEX.test(line)) {
1382
- sanitizedLines.push(line);
1248
+ // Go through text node siblings and search for opening tag
1249
+ // if haven't found it within the same text node as closing tag
1250
+ let sibling = openNode;
1251
+ while (openTagStartIndex < 0 && (sibling = sibling.getPreviousSibling())) {
1252
+ if (lexical.$isLineBreakNode(sibling)) {
1253
+ break;
1254
+ }
1255
+ if (lexical.$isTextNode(sibling)) {
1256
+ if (sibling.hasFormat('code')) {
1257
+ continue;
1258
+ }
1259
+ const siblingTextContent = sibling.getTextContent();
1260
+ openNode = sibling;
1261
+ openTagStartIndex = getOpenTagStartIndex(siblingTextContent, siblingTextContent.length, tag);
1262
+ }
1263
+ }
1264
+
1265
+ // Opening tag is not found
1266
+ if (openTagStartIndex < 0) {
1383
1267
  continue;
1384
1268
  }
1385
1269
 
1386
- // Detect the start or end of a code block
1387
- if (CODE_START_REGEX.test(line) || CODE_END_REGEX.test(line)) {
1388
- inCodeBlock = !inCodeBlock;
1389
- sanitizedLines.push(line);
1270
+ // No content between opening and closing tag
1271
+ if (openNode === closeNode && openTagStartIndex + tagLength === closeTagStartIndex) {
1390
1272
  continue;
1391
1273
  }
1392
1274
 
1393
- // If we are inside a code block, keep the line unchanged
1394
- if (inCodeBlock) {
1395
- sanitizedLines.push(line);
1275
+ // Checking longer tags for repeating chars (e.g. *** vs **)
1276
+ const prevOpenNodeText = openNode.getTextContent();
1277
+ if (openTagStartIndex > 0 && prevOpenNodeText[openTagStartIndex - 1] === closeChar) {
1396
1278
  continue;
1397
1279
  }
1398
1280
 
1399
- // In markdown the concept of "empty paragraphs" does not exist.
1400
- // Blocks must be separated by an empty line. Non-empty adjacent lines must be merged.
1401
- if (line === '' || lastLine === '' || !lastLine || HEADING_REGEX.test(lastLine) || HEADING_REGEX.test(line) || QUOTE_REGEX.test(line) || ORDERED_LIST_REGEX.test(line) || UNORDERED_LIST_REGEX.test(line) || CHECK_LIST_REGEX.test(line) || TABLE_ROW_REG_EXP.test(line) || TABLE_ROW_DIVIDER_REG_EXP.test(line) || !shouldMergeAdjacentLines) {
1402
- sanitizedLines.push(line);
1403
- } else {
1404
- sanitizedLines[sanitizedLines.length - 1] = lastLine + line;
1281
+ // Some tags can not be used within words, hence should have newline/space/punctuation before it
1282
+ const beforeOpenTagChar = prevOpenNodeText[openTagStartIndex - 1];
1283
+ if (matcher.intraword === false && beforeOpenTagChar && !PUNCTUATION_OR_SPACE.test(beforeOpenTagChar)) {
1284
+ continue;
1285
+ }
1286
+
1287
+ // Clean text from opening and closing tags (starting from closing tag
1288
+ // to prevent any offset shifts if we start from opening one)
1289
+ const prevCloseNodeText = closeNode.getTextContent();
1290
+ const closeNodeText = prevCloseNodeText.slice(0, closeTagStartIndex) + prevCloseNodeText.slice(closeTagEndIndex + 1);
1291
+ closeNode.setTextContent(closeNodeText);
1292
+ const openNodeText = openNode === closeNode ? closeNodeText : prevOpenNodeText;
1293
+ openNode.setTextContent(openNodeText.slice(0, openTagStartIndex) + openNodeText.slice(openTagStartIndex + tagLength));
1294
+ const selection = lexical.$getSelection();
1295
+ const nextSelection = lexical.$createRangeSelection();
1296
+ lexical.$setSelection(nextSelection);
1297
+ // Adjust offset based on deleted chars
1298
+ const newOffset = closeTagEndIndex - tagLength * (openNode === closeNode ? 2 : 1) + 1;
1299
+ nextSelection.anchor.set(openNode.__key, openTagStartIndex, 'text');
1300
+ nextSelection.focus.set(closeNode.__key, newOffset, 'text');
1301
+
1302
+ // Apply formatting to selected text
1303
+ for (const format of matcher.format) {
1304
+ if (!nextSelection.hasFormat(format)) {
1305
+ nextSelection.formatText(format);
1306
+ }
1307
+ }
1308
+
1309
+ // Collapse selection up to the focus point
1310
+ nextSelection.anchor.set(nextSelection.focus.key, nextSelection.focus.offset, nextSelection.focus.type);
1311
+
1312
+ // Remove formatting from collapsed selection
1313
+ for (const format of matcher.format) {
1314
+ if (nextSelection.hasFormat(format)) {
1315
+ nextSelection.toggleFormat(format);
1316
+ }
1317
+ }
1318
+ if (lexical.$isRangeSelection(selection)) {
1319
+ nextSelection.format = selection.format;
1405
1320
  }
1321
+ return true;
1406
1322
  }
1407
- return sanitizedLines.join('\n');
1323
+ return false;
1324
+ }
1325
+ function getOpenTagStartIndex(string, maxIndex, tag) {
1326
+ const tagLength = tag.length;
1327
+ for (let i = maxIndex; i >= tagLength; i--) {
1328
+ const startIndex = i - tagLength;
1329
+ if (isEqualSubString(string, startIndex, tag, 0, tagLength) &&
1330
+ // Space after opening tag cancels transformation
1331
+ string[startIndex + tagLength] !== ' ') {
1332
+ return startIndex;
1333
+ }
1334
+ }
1335
+ return -1;
1336
+ }
1337
+ function isEqualSubString(stringA, aStart, stringB, bStart, length) {
1338
+ for (let i = 0; i < length; i++) {
1339
+ if (stringA[aStart + i] !== stringB[bStart + i]) {
1340
+ return false;
1341
+ }
1342
+ }
1343
+ return true;
1344
+ }
1345
+ function registerMarkdownShortcuts(editor, transformers = TRANSFORMERS) {
1346
+ const byType = transformersByType(transformers);
1347
+ const textFormatTransformersByTrigger = indexBy(byType.textFormat, ({
1348
+ tag
1349
+ }) => tag[tag.length - 1]);
1350
+ const textMatchTransformersByTrigger = indexBy(byType.textMatch, ({
1351
+ trigger
1352
+ }) => trigger);
1353
+ for (const transformer of transformers) {
1354
+ const type = transformer.type;
1355
+ if (type === 'element' || type === 'text-match' || type === 'multiline-element') {
1356
+ const dependencies = transformer.dependencies;
1357
+ for (const node of dependencies) {
1358
+ if (!editor.hasNode(node)) {
1359
+ {
1360
+ formatDevErrorMessage(`MarkdownShortcuts: missing dependency ${node.getType()} for transformer. Ensure node dependency is included in editor initial config.`);
1361
+ }
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+ const $transform = (parentNode, anchorNode, anchorOffset) => {
1367
+ if (runElementTransformers(parentNode, anchorNode, anchorOffset, byType.element)) {
1368
+ return;
1369
+ }
1370
+ if (runMultilineElementTransformers(parentNode, anchorNode, anchorOffset, byType.multilineElement)) {
1371
+ return;
1372
+ }
1373
+ if (runTextMatchTransformers(anchorNode, anchorOffset, textMatchTransformersByTrigger)) {
1374
+ return;
1375
+ }
1376
+ $runTextFormatTransformers(anchorNode, anchorOffset, textFormatTransformersByTrigger);
1377
+ };
1378
+ return editor.registerUpdateListener(({
1379
+ tags,
1380
+ dirtyLeaves,
1381
+ editorState,
1382
+ prevEditorState
1383
+ }) => {
1384
+ // Ignore updates from collaboration and undo/redo (as changes already calculated)
1385
+ if (tags.has(lexical.COLLABORATION_TAG) || tags.has(lexical.HISTORIC_TAG)) {
1386
+ return;
1387
+ }
1388
+
1389
+ // If editor is still composing (i.e. backticks) we must wait before the user confirms the key
1390
+ if (editor.isComposing()) {
1391
+ return;
1392
+ }
1393
+ const selection = editorState.read(lexical.$getSelection);
1394
+ const prevSelection = prevEditorState.read(lexical.$getSelection);
1395
+
1396
+ // We expect selection to be a collapsed range and not match previous one (as we want
1397
+ // to trigger transforms only as user types)
1398
+ if (!lexical.$isRangeSelection(prevSelection) || !lexical.$isRangeSelection(selection) || !selection.isCollapsed() || selection.is(prevSelection)) {
1399
+ return;
1400
+ }
1401
+ const anchorKey = selection.anchor.key;
1402
+ const anchorOffset = selection.anchor.offset;
1403
+ const anchorNode = editorState._nodeMap.get(anchorKey);
1404
+ if (!lexical.$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey) || anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1) {
1405
+ return;
1406
+ }
1407
+ editor.update(() => {
1408
+ if (!canContainTransformableMarkdown(anchorNode)) {
1409
+ return;
1410
+ }
1411
+ const parentNode = anchorNode.getParent();
1412
+ if (parentNode === null || code.$isCodeNode(parentNode)) {
1413
+ return;
1414
+ }
1415
+ $transform(parentNode, anchorNode, selection.anchor.offset);
1416
+ });
1417
+ });
1408
1418
  }
1409
1419
 
1410
1420
  /**
@@ -1415,16 +1425,6 @@ function normalizeMarkdown(input, shouldMergeAdjacentLines = false) {
1415
1425
  *
1416
1426
  */
1417
1427
 
1418
- const ELEMENT_TRANSFORMERS = [HEADING, QUOTE, UNORDERED_LIST, ORDERED_LIST];
1419
- const MULTILINE_ELEMENT_TRANSFORMERS = [CODE];
1420
-
1421
- // Order of text format transformers matters:
1422
- //
1423
- // - code should go first as it prevents any transformations inside
1424
- // - then longer tags match (e.g. ** or __ should go before * or _)
1425
- const TEXT_FORMAT_TRANSFORMERS = [INLINE_CODE, BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, BOLD_UNDERSCORE, HIGHLIGHT, ITALIC_STAR, ITALIC_UNDERSCORE, STRIKETHROUGH];
1426
- const TEXT_MATCH_TRANSFORMERS = [LINK];
1427
- const TRANSFORMERS = [...ELEMENT_TRANSFORMERS, ...MULTILINE_ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS];
1428
1428
 
1429
1429
  /**
1430
1430
  * Renders markdown from a string. The selection is moved to the start after the operation.