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