Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 163 additions & 141 deletions apps/builder/app/canvas/features/text-editor/text-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions apps/builder/app/canvas/instance-selected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down