Revert "After triggering markdown to bold text, the caret should appear after the space character. (#1293)" (#1303)

This reverts commit e217ca81131813034ab71bca8f9d26108c89742b.
This commit is contained in:
Dominic Gannaway
2022-02-16 10:41:45 +00:00
committed by acywatson
parent 864d518462
commit c9fbe99309
9 changed files with 142 additions and 236 deletions

View File

@ -118,40 +118,28 @@ export function $findNodeWithOffsetFromJoinedText(
const children = elementNode.getChildren();
const childrenLength = children.length;
let runningLength = 0;
let isPriorNodeTextNode = false;
for (let i = 0; i < childrenLength; ++i) {
// We must examine the offsetInJoinedText that is located
// at the length of the string.
// For example, given "hello", the length is 5, yet
// the caller still wants the node + offset at the
// right edge of the "o".
if (runningLength > joinedTextLength) {
if (runningLength >= joinedTextLength) {
break;
}
const child = children[i];
const isChildNodeTestNode = $isTextNode(child);
const childContentLength = isChildNodeTestNode
const childContentLength = $isTextNode(child)
? child.getTextContent().length
: separatorLength;
const newRunningLength = runningLength + childContentLength;
const isJoinedOffsetWithinNode =
(isPriorNodeTextNode === false && runningLength === offsetInJoinedText) ||
(runningLength === 0 && runningLength === offsetInJoinedText) ||
(runningLength < offsetInJoinedText &&
offsetInJoinedText <= newRunningLength);
if (isJoinedOffsetWithinNode && $isTextNode(child)) {
// Check isTextNode again for flow.
if (
runningLength <= offsetInJoinedText &&
offsetInJoinedText < newRunningLength &&
$isTextNode(child)
) {
return {
node: child,
offset: offsetInJoinedText - runningLength,
};
}
runningLength = newRunningLength;
isPriorNodeTextNode = isChildNodeTestNode;
}
return null;
}

View File

@ -137,7 +137,7 @@ function useTypeahead(editor: LexicalEditor): void {
}
},
{
tag: 'history-merge',
tag: 'without-history',
},
);
}, [editor, getTypeaheadTextNode, selectionCollapsed, suggestion]);

View File

@ -53,7 +53,6 @@ export type AutoFormatTriggerState = $ReadOnly<{
// 2. Convert the text formatting: e.g. "**hello**" converts to bold "hello".
export type NodeTransformationKind =
| 'noTransformation'
| 'paragraphH1'
| 'paragraphH2'
| 'paragraphH3'
@ -63,20 +62,6 @@ export type NodeTransformationKind =
| 'paragraphCodeBlock'
| 'textBold';
// The scanning context provides the overall data structure for
// locating a auto formatting candidate and then transforming that candidate
// into the newly formatted stylized text.
// The context is filled out lazily to avoid redundant or up-front expensive
// calculations. For example, this includes the parent element's getTextContent() which
// ultimately gets deposited into the joinedText field.
export type ScanningContext = {
autoFormatCriteria: AutoFormatCriteria,
joinedText: ?string,
matchResultContext: MatchResultContext,
textNodeWithOffset: TextNodeWithOffset,
triggerState: AutoFormatTriggerState,
};
// The auto formatter runs these steps:
// 1. Examine the current and prior editor states to see if a potential auto format is triggered.
// 2. If triggered, examine the current editor state to see if it matches a particular
@ -89,12 +74,26 @@ export type ScanningContext = {
// // //
// Capture groups are defined by the regEx pattern. Certain groups must be removed,
// For example "*hello*", will require that the "*" be removed and the "hello" become bolded.
// We can specify ahead of time which gapture groups shoud be removed using the regExCaptureGroupsToDelete.
export type AutoFormatCriteria = $ReadOnly<{
nodeTransformationKind: ?NodeTransformationKind,
regEx: RegExp,
regExCaptureGroupsToDelete: ?Array<number>,
regExExpectedCaptureGroupCount: number,
requiresParagraphStart: ?boolean,
}>;
// While scanning over the text, comparing each
// auto format criteria against the text, certain
// details may be captured rather than re-calculated.
// For example, scanning over each AutoFormatCriteria,
// building up the ParagraphNode's text by calling getTextContent()
// may be expensive. Rather, load this value lazily and store it for later use.
export type ScanningContext = {
joinedText: ?string,
textNodeWithOffset: TextNodeWithOffset,
};
// RegEx returns the discovered pattern matches in an array of capture groups.
// Using this array, we can extract the sub-string per pattern, determine its
// offset within the parent (except for non-textNode children) within the overall string and determine its length. Typically the length
@ -111,8 +110,8 @@ type CaptureGroupDetail = {
// This type stores the result details when a particular
// match is found.
export type MatchResultContext = {
offsetInJoinedTextForCollapsedSelection: number, // The expected location for the blinking caret.
regExCaptureGroups: Array<CaptureGroupDetail>,
triggerState: ?AutoFormatTriggerState,
};
export type AutoFormatCriteriaWithMatchResultContext = {
@ -129,6 +128,8 @@ const SEPARATOR_BETWEEN_TEXT_AND_NON_TEXT_NODES = '\u0004'; // Select an unused
const autoFormatBase: AutoFormatCriteria = {
nodeTransformationKind: null,
regEx: /(?:)/,
regExCaptureGroupsToDelete: null,
regExExpectedCaptureGroupCount: 1,
requiresParagraphStart: false,
};
@ -183,6 +184,7 @@ const markdownOrderedList: AutoFormatCriteria = {
...paragraphStartBase,
nodeTransformationKind: 'paragraphOrderedList',
regEx: /^(\d+)\.\s/,
regExExpectedCaptureGroupCount: 2 /*e.g. '321. ' returns '321. ' & '321'*/,
};
const markdownBold: AutoFormatCriteria = {
@ -192,6 +194,10 @@ const markdownBold: AutoFormatCriteria = {
regEx: /(\*)(\s*\b)([^\*]*)(\b\s*)(\*\s)$/,
// Remove the first and last capture groups. Remeber, the 0th capture group is the entire string.
// e.g. "*Hello* " requires removing both "*" as well as bolding "Hello".
regExCaptureGroupsToDelete: [1, 5],
// The $ will find the target at the end of the string.
regExExpectedCaptureGroupCount: 6,
};
const allAutoFormatCriteriaForTextNodes = [markdownBold];
@ -216,47 +222,28 @@ export function getAllAutoFormatCriteria(): AutoFormatCriteriaArray {
return allAutoFormatCriteria;
}
export function getInitialScanningContext(
textNodeWithOffset: TextNodeWithOffset,
triggerState: AutoFormatTriggerState,
): ScanningContext {
return {
autoFormatCriteria: {
nodeTransformationKind: 'noTransformation',
regEx: /(?:)/, // Empty reg ex will do until the precise criteria is discovered.
requiresParagraphStart: null,
},
joinedText: null,
matchResultContext: {
offsetInJoinedTextForCollapsedSelection: 0,
regExCaptureGroups: [],
},
textNodeWithOffset,
triggerState,
};
}
function getMatchResultContextWithRegEx(
textToSearch: string,
matchMustAppearAtStartOfString: boolean,
matchMustAppearAtEndOfString: boolean,
regEx: RegExp,
regExExpectedCaptureGroupCount: number,
scanningContext: ScanningContext,
): null | MatchResultContext {
const matchResultContext: MatchResultContext = {
offsetInJoinedTextForCollapsedSelection: 0,
regExCaptureGroups: [],
triggerState: null,
};
const regExMatches = textToSearch.match(regEx);
if (
regExMatches !== null &&
regExMatches.length > 0 &&
regExMatches.length === regExExpectedCaptureGroupCount &&
(matchMustAppearAtStartOfString === false || regExMatches.index === 0) &&
(matchMustAppearAtEndOfString === false ||
regExMatches.index + regExMatches[0].length === textToSearch.length)
) {
matchResultContext.offsetInJoinedTextForCollapsedSelection =
textToSearch.length;
const captureGroupsCount = regExMatches.length;
let runningLength = regExMatches.index;
for (
@ -305,6 +292,8 @@ function getMatchResultContextForParagraphs(
true,
false,
autoFormatCriteria.regEx,
autoFormatCriteria.regExExpectedCaptureGroupCount,
scanningContext,
);
}
@ -332,6 +321,8 @@ function getMatchResultContextForText(
false,
true,
autoFormatCriteria.regEx,
autoFormatCriteria.regExExpectedCaptureGroupCount,
scanningContext,
);
} else {
invariant(
@ -362,13 +353,12 @@ export function getMatchResultContextForCriteria(
}
function getNewNodeForCriteria(
scanningContext: ScanningContext,
autoFormatCriteria: AutoFormatCriteria,
matchResultContext: MatchResultContext,
children: Array<LexicalNode>,
): null | ElementNode {
let newNode = null;
const autoFormatCriteria = scanningContext.autoFormatCriteria;
const matchResultContext = scanningContext.matchResultContext;
if (autoFormatCriteria.nodeTransformationKind != null) {
switch (autoFormatCriteria.nodeTransformationKind) {
case 'paragraphH1': {
@ -415,8 +405,8 @@ function getNewNodeForCriteria(
case 'paragraphCodeBlock': {
// Toggle code and paragraph nodes.
if (
scanningContext.triggerState != null &&
scanningContext.triggerState.isCodeBlock
matchResultContext.triggerState != null &&
matchResultContext.triggerState.isCodeBlock
) {
newNode = $createParagraphNode();
} else {
@ -443,22 +433,37 @@ function updateTextNode(node: TextNode, count: number): void {
export function transformTextNodeForAutoFormatCriteria(
scanningContext: ScanningContext,
autoFormatCriteria: AutoFormatCriteria,
matchResultContext: MatchResultContext,
) {
if (scanningContext.autoFormatCriteria.requiresParagraphStart) {
transformTextNodeForParagraphs(scanningContext);
if (autoFormatCriteria.requiresParagraphStart) {
transformTextNodeForParagraphs(
scanningContext,
autoFormatCriteria,
matchResultContext,
);
} else {
transformTextNodeForText(scanningContext);
transformTextNodeForText(
scanningContext,
autoFormatCriteria,
matchResultContext,
);
}
}
function transformTextNodeForParagraphs(scanningContext: ScanningContext) {
function transformTextNodeForParagraphs(
scanningContext: ScanningContext,
autoFormatCriteria: AutoFormatCriteria,
matchResultContext: MatchResultContext,
) {
const textNodeWithOffset = scanningContext.textNodeWithOffset;
const element = textNodeWithOffset.node.getParentOrThrow();
const text = scanningContext.matchResultContext.regExCaptureGroups[0].text;
const text = matchResultContext.regExCaptureGroups[0].text;
updateTextNode(textNodeWithOffset.node, text.length);
const elementNode = getNewNodeForCriteria(
scanningContext,
autoFormatCriteria,
matchResultContext,
element.getChildren(),
);
@ -467,29 +472,29 @@ function transformTextNodeForParagraphs(scanningContext: ScanningContext) {
}
}
function transformTextNodeForText(scanningContext: ScanningContext) {
const autoFormatCriteria = scanningContext.autoFormatCriteria;
const matchResultContext = scanningContext.matchResultContext;
function transformTextNodeForText(
scanningContext: ScanningContext,
autoFormatCriteria: AutoFormatCriteria,
matchResultContext: MatchResultContext,
) {
if (autoFormatCriteria.nodeTransformationKind != null) {
switch (autoFormatCriteria.nodeTransformationKind) {
case 'textBold': {
if (matchResultContext.regExCaptureGroups.length !== 6) {
// The expected reg ex pattern for bold should have 6 groups.
// If it does not, then break and fail silently.
// e2e tests validate the regEx pattern.
break;
}
matchResultContext.regExCaptureGroups =
getCaptureGroupsByResolvingAllDetails(scanningContext);
// Remove unwanted text in reg ex pattern.
removeTextInCaptureGroups([1, 5], matchResultContext);
formatTextInCaptureGroupIndex('bold', 3, matchResultContext);
makeCollapsedSelectionAtOffsetInJoinedText(
matchResultContext.offsetInJoinedTextForCollapsedSelection,
matchResultContext.offsetInJoinedTextForCollapsedSelection + 1,
scanningContext.textNodeWithOffset.node.getParentOrThrow(),
getCaptureGroupsByResolvingAllDetails(
scanningContext,
autoFormatCriteria,
matchResultContext,
);
if (autoFormatCriteria.regExCaptureGroupsToDelete != null) {
// Remove unwanted text in reg ex patterh.
removeTextInCaptureGroups(
autoFormatCriteria.regExCaptureGroupsToDelete,
matchResultContext,
);
formatTextInCaptureGroupIndex('bold', 3, matchResultContext);
}
break;
}
default:
@ -503,10 +508,9 @@ function transformTextNodeForText(scanningContext: ScanningContext) {
// known, the details may be fully resolved without incurring unwasted performance cost.
function getCaptureGroupsByResolvingAllDetails(
scanningContext: ScanningContext,
autoFormatCriteria: AutoFormatCriteria,
matchResultContext: MatchResultContext,
): Array<CaptureGroupDetail> {
const autoFormatCriteria = scanningContext.autoFormatCriteria;
const matchResultContext = scanningContext.matchResultContext;
const textNodeWithOffset = scanningContext.textNodeWithOffset;
const regExCaptureGroups = matchResultContext.regExCaptureGroups;
const captureGroupsCount = regExCaptureGroups.length;
@ -633,12 +637,6 @@ function shiftCaptureGroupOffsets(
startingCaptureGroupIndex: number,
matchResultContext: MatchResultContext,
) {
matchResultContext.offsetInJoinedTextForCollapsedSelection += delta;
invariant(
matchResultContext.offsetInJoinedTextForCollapsedSelection > 0,
'The text content string length does not correlate with insertions/deletions of new text.',
);
const regExCaptureGroups = matchResultContext.regExCaptureGroups;
const regExCaptureGroupsCount = regExCaptureGroups.length;
for (
@ -707,52 +705,6 @@ function formatTextInCaptureGroupIndex(
const currentSelection = $getSelection();
if (currentSelection != null) {
currentSelection.formatText(formatType);
const finalSelection = $createRangeSelection();
finalSelection.anchor.set(
focusTextNodeWithOffset.node.getKey(),
focusTextNodeWithOffset.offset + 1,
'text',
);
finalSelection.focus.set(
focusTextNodeWithOffset.node.getKey(),
focusTextNodeWithOffset.offset + 1,
'text',
);
$setSelection(finalSelection);
}
}
}
function makeCollapsedSelectionAtOffsetInJoinedText(
offsetInJoinedText: number,
joinedTextLength: number,
parentElementNode: ElementNode,
) {
const textNodeWithOffset = $findNodeWithOffsetFromJoinedText(
parentElementNode,
joinedTextLength,
offsetInJoinedText,
TRIGGER_STRING_LENGTH,
);
if (textNodeWithOffset != null) {
const newSelection = $createRangeSelection();
newSelection.anchor.set(
textNodeWithOffset.node.getKey(),
textNodeWithOffset.offset,
'text',
);
newSelection.focus.set(
textNodeWithOffset.node.getKey(),
textNodeWithOffset.offset,
'text',
);
$setSelection(newSelection);
}
}

View File

@ -24,7 +24,6 @@ import {useEffect} from 'react';
import {
getAllAutoFormatCriteria,
getAllAutoFormatCriteriaForTextNodes,
getInitialScanningContext,
getMatchResultContextForCriteria,
transformTextNodeForAutoFormatCriteria,
TRIGGER_STRING,
@ -32,18 +31,16 @@ import {
function getCriteriaWithMatchResultContext(
autoFormatCriteriaArray: AutoFormatCriteriaArray,
currentTriggerState: AutoFormatTriggerState,
scanningContext: ScanningContext,
): AutoFormatCriteriaWithMatchResultContext {
const currentTriggerState = scanningContext.triggerState;
const count = autoFormatCriteriaArray.length;
for (let i = 0; i < count; i++) {
const autoFormatCriteria = autoFormatCriteriaArray[i];
// Skip code block nodes, unless the nodeTransformationKind calls for toggling the code block.
if (
(currentTriggerState != null &&
currentTriggerState.isCodeBlock === false) ||
currentTriggerState.isCodeBlock === false ||
autoFormatCriteria.nodeTransformationKind === 'paragraphCodeBlock'
) {
const matchResultContext = getMatchResultContextForCriteria(
@ -51,6 +48,7 @@ function getCriteriaWithMatchResultContext(
scanningContext,
);
if (matchResultContext != null) {
matchResultContext.triggerState = currentTriggerState;
return {
autoFormatCriteria: autoFormatCriteria,
matchResultContext,
@ -78,24 +76,9 @@ function getTextNodeForAutoFormatting(
function updateAutoFormatting(
editor: LexicalEditor,
scanningContext: ScanningContext,
): void {
editor.update(
() => {
transformTextNodeForAutoFormatCriteria(scanningContext);
},
{
tag: 'history-push',
},
);
}
function findScanningContextWithValidMatch(
editorState: EditorState,
currentTriggerState: AutoFormatTriggerState,
): null | ScanningContext {
let scanningContext = null;
editorState.read(() => {
): void {
editor.update(() => {
const textNodeWithOffset = getTextNodeForAutoFormatting($getSelection());
if (textNodeWithOffset === null) {
@ -103,17 +86,17 @@ function findScanningContextWithValidMatch(
}
// Please see the declaration of ScanningContext for a detailed explanation.
const initialScanningContext = getInitialScanningContext(
const scanningContext: ScanningContext = {
joinedText: null,
textNodeWithOffset,
currentTriggerState,
);
};
const criteriaWithMatchResultContext = getCriteriaWithMatchResultContext(
// Do not apply paragraph node changes like blockQuote or H1 to listNodes. Also, do not attempt to transform a list into a list using * or -.
currentTriggerState.isParentAListItemNode === false
? getAllAutoFormatCriteria()
: getAllAutoFormatCriteriaForTextNodes(),
initialScanningContext,
currentTriggerState,
scanningContext,
);
if (
@ -122,23 +105,21 @@ function findScanningContextWithValidMatch(
) {
return;
}
scanningContext = initialScanningContext;
// Lazy fill-in the particular format criteria and any matching result information.
scanningContext.autoFormatCriteria =
criteriaWithMatchResultContext.autoFormatCriteria;
scanningContext.matchResultContext =
criteriaWithMatchResultContext.matchResultContext;
transformTextNodeForAutoFormatCriteria(
scanningContext,
criteriaWithMatchResultContext.autoFormatCriteria,
criteriaWithMatchResultContext.matchResultContext,
);
});
return scanningContext;
}
function findScanningContext(
editorState: EditorState,
function shouldAttemptToAutoFormat(
currentTriggerState: null | AutoFormatTriggerState,
priorTriggerState: null | AutoFormatTriggerState,
): null | ScanningContext {
): boolean {
if (currentTriggerState == null || priorTriggerState == null) {
return null;
return false;
}
// The below checks needs to execute relativey quickly, so perform the light-weight ones first.
@ -148,8 +129,8 @@ function findScanningContext(
const currentTextContentLength = currentTriggerState.textContent.length;
const triggerOffset = currentTriggerState.anchorOffset - triggerStringLength;
if (
(currentTriggerState.hasParentNode === true &&
return (
currentTriggerState.hasParentNode === true &&
currentTriggerState.isSimpleText &&
currentTriggerState.isSelectionCollapsed &&
currentTriggerState.nodeKey === priorTriggerState.nodeKey &&
@ -160,13 +141,8 @@ function findScanningContext(
triggerOffset,
triggerStringLength,
) === TRIGGER_STRING &&
currentTriggerState.textContent !== priorTriggerState.textContent) ===
false
) {
return null;
}
return findScanningContextWithValidMatch(editorState, currentTriggerState);
currentTriggerState.textContent !== priorTriggerState.textContent
);
}
function getTriggerState(
@ -210,21 +186,16 @@ export default function useAutoFormatter(editor: LexicalEditor): void {
// However, given "#A B", where the user delets "A" should not.
let priorTriggerState: null | AutoFormatTriggerState = null;
return editor.addListener('update', ({tags}) => {
editor.addListener('update', ({tags}) => {
// Examine historic so that we are not running autoformatting within markdown.
if (tags.has('historic') === false) {
const editorState = editor.getEditorState();
const currentTriggerState = getTriggerState(editorState);
const scanningContext =
currentTriggerState == null
? null
: findScanningContext(
editorState,
currentTriggerState,
priorTriggerState,
);
if (scanningContext != null) {
updateAutoFormatting(editor, scanningContext);
const currentTriggerState = getTriggerState(editor.getEditorState());
if (
shouldAttemptToAutoFormat(currentTriggerState, priorTriggerState) &&
currentTriggerState != null
) {
updateAutoFormatting(editor, currentTriggerState);
}
priorTriggerState = currentTriggerState;
} else {

View File

@ -81,7 +81,7 @@ export function useCharacterLimit(
$wrapOverflowedNodes(offset);
},
{
tag: 'history-merge',
tag: 'without-history',
},
);
}

View File

@ -22,9 +22,9 @@ import {$getSelection, $isRootNode, $isTextNode} from 'lexical';
import {useCallback, useEffect, useMemo} from 'react';
type MergeAction = 0 | 1 | 2;
const HISTORY_MERGE = 0;
const HISTORY_PUSH = 1;
const DISCARD_HISTORY_CANDIDATE = 2;
const MERGE = 0;
const NO_MERGE = 1;
const DISCARD = 2;
type ChangeType = 0 | 1 | 2 | 3 | 4;
const OTHER = 0;
@ -204,7 +204,7 @@ function createMergeActionGetter(
if (tags.has('historic')) {
prevChangeType = OTHER;
prevChangeTime = changeTime;
return DISCARD_HISTORY_CANDIDATE;
return DISCARD;
}
const changeType = getChangeType(
@ -216,40 +216,35 @@ function createMergeActionGetter(
);
const mergeAction = (() => {
const shouldPushHistory = tags.has('history-push');
const shouldMergeHistory =
!shouldPushHistory && tags.has('history-merge');
if (shouldMergeHistory) {
return HISTORY_MERGE;
if (tags.has('without-history')) {
return MERGE;
}
if (prevEditorState === null) {
return HISTORY_PUSH;
return NO_MERGE;
}
const selection = nextEditorState._selection;
const prevSelection = prevEditorState._selection;
const hasDirtyNodes = dirtyLeaves.size > 0 || dirtyElements.size > 0;
if (!hasDirtyNodes) {
if (prevSelection === null && selection !== null) {
return HISTORY_MERGE;
return MERGE;
}
return DISCARD_HISTORY_CANDIDATE;
return DISCARD;
}
const isSameEditor =
currentHistoryEntry === null || currentHistoryEntry.editor === editor;
if (
shouldPushHistory === false &&
changeType !== OTHER &&
changeType === prevChangeType &&
changeTime < prevChangeTime + delay &&
isSameEditor
) {
return HISTORY_MERGE;
return MERGE;
}
return HISTORY_PUSH;
return NO_MERGE;
})();
prevChangeTime = changeTime;
@ -302,7 +297,7 @@ export function useHistory(
tags,
);
if (mergeAction === HISTORY_PUSH) {
if (mergeAction === NO_MERGE) {
if (redoStack.length !== 0) {
historyState.redoStack = [];
}
@ -313,7 +308,7 @@ export function useHistory(
});
editor.execCommand('canUndo', true);
}
} else if (mergeAction === DISCARD_HISTORY_CANDIDATE) {
} else if (mergeAction === DISCARD) {
return;
}

View File

@ -490,7 +490,7 @@ class BaseLexicalEditor {
nextRootElement.setAttribute('data-lexical-editor', 'true');
this._dirtyType = FULL_RECONCILE;
initMutationObserver(getSelf(this));
this._updateTags.add('history-merge');
this._updateTags.add('without-history');
commitPendingUpdates(getSelf(this));
// TODO: remove this flag once we no longer use UEv2 internally
if (!this._config.disableEvents) {

View File

@ -339,7 +339,7 @@ export function markAllNodesAsDirty(editor: LexicalEditor, type: string): void {
true,
editor._pendingEditorState === null
? {
tag: 'history-merge',
tag: 'without-history',
}
: undefined,
);

View File

@ -62,7 +62,7 @@ export class DecoratorEditor {
this.editorState = editorState;
if (editorState !== null) {
editor.setEditorState(editorState, {
tag: 'history-merge',
tag: 'without-history',
});
}
}