diff --git a/apps/builder/app/canvas/features/text-editor/text-editor.tsx b/apps/builder/app/canvas/features/text-editor/text-editor.tsx index a47467ecf748..80064462fdaa 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -184,8 +184,22 @@ const OnChangeOnBlurPlugin = ({ }) => { const [editor] = useLexicalComposerContext(); const handleChange = useEffectEvent(onChange); + useEffect( () => () => { + // The issue is related to React’s development mode. + // When we set the initial selection in the Editor, we disable Lexical’s internal + // scrolling using the update operation tag tag: "skip-scroll-into-view". + // The problem is that a read operation forces all pending update operations to commit, + // and for some reason, this forced commit does not respect tags. + // In React’s development mode, useEffect runs twice, which causes scrollIntoView + // to be called during the first read. + // To prevent this, we disconnect the editor from the DOM + // by setting editor._rootElement = null;. + // This makes Lexical assume it’s in headless mode, + // preventing it from executing DOM operations. + editor._rootElement = null; + // Safari and FF support as no blur event is triggered in some cases editor.read(() => { handleChange(editor.getEditorState(), "unmount"); @@ -559,192 +573,200 @@ const InitCursorPlugin = () => { return; } - editor.update(() => { - const textEditingInstanceSelector = $textEditingInstanceSelector.get(); - if (textEditingInstanceSelector === undefined) { - return; - } + editor.update( + () => { + const textEditingInstanceSelector = $textEditingInstanceSelector.get(); + if (textEditingInstanceSelector === undefined) { + return; + } - const { reason } = textEditingInstanceSelector; + const { reason } = textEditingInstanceSelector; - if (reason === undefined) { - return; - } + if (reason === undefined) { + return; + } - if (reason === "click") { - const { mouseX, mouseY } = textEditingInstanceSelector; + if (reason === "click") { + const { mouseX, mouseY } = textEditingInstanceSelector; - const eventRange = caretFromPoint(mouseX, mouseY); + const eventRange = caretFromPoint(mouseX, mouseY); - if (eventRange !== null) { - const { offset: domOffset, node: domNode } = eventRange; - const node = $getNearestNodeFromDOMNode(domNode); + if (eventRange !== null) { + const { offset: domOffset, node: domNode } = eventRange; + const node = $getNearestNodeFromDOMNode(domNode); - if (node !== null) { - const selection = $createRangeSelection(); - if ($isTextNode(node)) { - selection.anchor.set(node.getKey(), domOffset, "text"); - selection.focus.set(node.getKey(), domOffset, "text"); - const normalizedSelection = - $normalizeSelection__EXPERIMENTAL(selection); + if (node !== null) { + const selection = $createRangeSelection(); + if ($isTextNode(node)) { + selection.anchor.set(node.getKey(), domOffset, "text"); + selection.focus.set(node.getKey(), domOffset, "text"); + const normalizedSelection = + $normalizeSelection__EXPERIMENTAL(selection); - $setSelection(normalizedSelection); - return; + $setSelection(normalizedSelection); + return; + } } - } - if (domNode instanceof Element) { - const rect = domNode.getBoundingClientRect(); - if (mouseX > rect.right) { - const selection = $getRoot().selectEnd(); - $setSelection(selection); - return; + if (domNode instanceof Element) { + const rect = domNode.getBoundingClientRect(); + if (mouseX > rect.right) { + const selection = $getRoot().selectEnd(); + $setSelection(selection); + return; + } } } } - } - - while (reason === "down" || reason === "up") { - const { cursorX } = textEditingInstanceSelector; - const [topRects, bottomRects] = getTopBottomRects(editor); + while (reason === "down" || reason === "up") { + const { cursorX } = textEditingInstanceSelector; - // Smoodge the cursor a little to the left and right to find the nearest text node - const smoodgeOffsets = [1, 2, 4]; - const maxOffset = Math.max(...smoodgeOffsets); + const [topRects, bottomRects] = getTopBottomRects(editor); - const rects = reason === "down" ? topRects : bottomRects; + // Smoodge the cursor a little to the left and right to find the nearest text node + const smoodgeOffsets = [1, 2, 4]; + const maxOffset = Math.max(...smoodgeOffsets); - rects.sort((a, b) => a.left - b.left); + const rects = reason === "down" ? topRects : bottomRects; - const rectWithText = rects.find( - (rect, index) => - rect.left - (index === 0 ? maxOffset : 0) <= cursorX && - cursorX <= rect.right + (index === rects.length - 1 ? maxOffset : 0) - ); + rects.sort((a, b) => a.left - b.left); - if (rectWithText === undefined) { - break; - } + const rectWithText = rects.find( + (rect, index) => + rect.left - (index === 0 ? maxOffset : 0) <= cursorX && + cursorX <= + rect.right + (index === rects.length - 1 ? maxOffset : 0) + ); - const newCursorY = rectWithText.top + rectWithText.height / 2; + if (rectWithText === undefined) { + break; + } - const eventRanges = [caretFromPoint(cursorX, newCursorY)]; - for (const offset of smoodgeOffsets) { - eventRanges.push(caretFromPoint(cursorX - offset, newCursorY)); - eventRanges.push(caretFromPoint(cursorX + offset, newCursorY)); - } + const newCursorY = rectWithText.top + rectWithText.height / 2; - for (const eventRange of eventRanges) { - if (eventRange === null) { - continue; + const eventRanges = [caretFromPoint(cursorX, newCursorY)]; + for (const offset of smoodgeOffsets) { + eventRanges.push(caretFromPoint(cursorX - offset, newCursorY)); + eventRanges.push(caretFromPoint(cursorX + offset, newCursorY)); } - const { offset: domOffset, node: domNode } = eventRange; - const node = $getNearestNodeFromDOMNode(domNode); - - if (node !== null && $isTextNode(node)) { - const selection = $createRangeSelection(); - selection.anchor.set(node.getKey(), domOffset, "text"); - selection.focus.set(node.getKey(), domOffset, "text"); - const normalizedSelection = - $normalizeSelection__EXPERIMENTAL(selection); - $setSelection(normalizedSelection); + for (const eventRange of eventRanges) { + if (eventRange === null) { + continue; + } - return; - } - } + const { offset: domOffset, node: domNode } = eventRange; + const node = $getNearestNodeFromDOMNode(domNode); - break; - } + if (node !== null && $isTextNode(node)) { + const selection = $createRangeSelection(); + selection.anchor.set(node.getKey(), domOffset, "text"); + selection.focus.set(node.getKey(), domOffset, "text"); + const normalizedSelection = + $normalizeSelection__EXPERIMENTAL(selection); + $setSelection(normalizedSelection); - if ( - reason === "down" || - reason === "right" || - reason === "enter" || - reason === "click" - ) { - const firstNode = $getRoot().getFirstDescendant(); + return; + } + } - if (firstNode === null) { - return; + break; } - if ($isTextNode(firstNode)) { - const selection = $createRangeSelection(); - selection.anchor.set(firstNode.getKey(), 0, "text"); - selection.focus.set(firstNode.getKey(), 0, "text"); - $setSelection(selection); - } + if ( + reason === "down" || + reason === "right" || + reason === "enter" || + reason === "click" + ) { + const firstNode = $getRoot().getFirstDescendant(); - if ($isElementNode(firstNode)) { - // e.g. Box is empty - const selection = $createRangeSelection(); - selection.anchor.set(firstNode.getKey(), 0, "element"); - selection.focus.set(firstNode.getKey(), 0, "element"); - $setSelection(selection); - } + if (firstNode === null) { + return; + } - if ($isLineBreakNode(firstNode)) { - // e.g. Box contains 2+ empty lines - const selection = $createRangeSelection(); - $setSelection(selection); - } + if ($isTextNode(firstNode)) { + const selection = $createRangeSelection(); + selection.anchor.set(firstNode.getKey(), 0, "text"); + selection.focus.set(firstNode.getKey(), 0, "text"); + $setSelection(selection); + } - return; - } + if ($isElementNode(firstNode)) { + // e.g. Box is empty + const selection = $createRangeSelection(); + selection.anchor.set(firstNode.getKey(), 0, "element"); + selection.focus.set(firstNode.getKey(), 0, "element"); + $setSelection(selection); + } - if (reason === "up" || reason === "left") { - const selection = $createRangeSelection(); - const lastNode = $getRoot().getLastDescendant(); + if ($isLineBreakNode(firstNode)) { + // e.g. Box contains 2+ empty lines + const selection = $createRangeSelection(); + $setSelection(selection); + } - if (lastNode === null) { return; } - if ($isTextNode(lastNode)) { - const contentSize = lastNode.getTextContentSize(); - selection.anchor.set(lastNode.getKey(), contentSize, "text"); - selection.focus.set(lastNode.getKey(), contentSize, "text"); - $setSelection(selection); - } - - if ($isElementNode(lastNode)) { - // e.g. Box is empty + if (reason === "up" || reason === "left") { const selection = $createRangeSelection(); - selection.anchor.set(lastNode.getKey(), 0, "element"); - selection.focus.set(lastNode.getKey(), 0, "element"); - $setSelection(selection); - } + const lastNode = $getRoot().getLastDescendant(); - if ($isLineBreakNode(lastNode)) { - // e.g. Box contains 2+ empty lines - const parent = lastNode.getParent(); - if ($isElementNode(parent)) { + if (lastNode === null) { + return; + } + + if ($isTextNode(lastNode)) { + const contentSize = lastNode.getTextContentSize(); + selection.anchor.set(lastNode.getKey(), contentSize, "text"); + selection.focus.set(lastNode.getKey(), contentSize, "text"); + $setSelection(selection); + } + + if ($isElementNode(lastNode)) { + // e.g. Box is empty const selection = $createRangeSelection(); - selection.anchor.set( - parent.getKey(), - parent.getChildrenSize(), - "element" - ); - selection.focus.set( - parent.getKey(), - parent.getChildrenSize(), - "element" - ); + selection.anchor.set(lastNode.getKey(), 0, "element"); + selection.focus.set(lastNode.getKey(), 0, "element"); $setSelection(selection); } + + if ($isLineBreakNode(lastNode)) { + // e.g. Box contains 2+ empty lines + const parent = lastNode.getParent(); + if ($isElementNode(parent)) { + const selection = $createRangeSelection(); + selection.anchor.set( + parent.getKey(), + parent.getChildrenSize(), + "element" + ); + selection.focus.set( + parent.getKey(), + parent.getChildrenSize(), + "element" + ); + $setSelection(selection); + } + } + + return; + } + if (reason === "new") { + $selectAll(); + return; } - return; - } - if (reason === "new") { - $selectAll(); - return; + reason satisfies never; + }, + { + // We are controlling scroll ourself in instance-selected.ts see updateScroll. + // Without skipping we are getting side effects of composition in scrollBy, scrollIntoView calls + tag: "skip-scroll-into-view", } - - reason satisfies never; - }); + ); }, [editor]); return null; diff --git a/apps/builder/app/canvas/instance-selected.ts b/apps/builder/app/canvas/instance-selected.ts index 7916e6c4c82d..e36d94127113 100644 --- a/apps/builder/app/canvas/instance-selected.ts +++ b/apps/builder/app/canvas/instance-selected.ts @@ -204,12 +204,12 @@ const subscribeSelectedInstance = ( // Having that elements can be changed (i.e. div => address tag change, observe again) updateObservers(); - - // update scroll state - updateScroll(); }); }; + // update scroll state + updateScroll(); + // Lightweight update const updateOutline: MutationCallback = (mutationRecords) => { if (hasCollapsedMutationRecord(mutationRecords)) {