mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 21:15:18 +09:00
fix: Strange behaviour on scroll with tooltip content (#4934)
## Description https://discord.com/channels/955905230107738152/1345038669496586310/1345038669496586310 - [x] This fix introduces other bug https://p-0f63c6ab-adfe-4052-b065-d87223ef2b03-dot-fix-scroll-tooltip.development.webstudio.is/ start editing text in content block, up arrow, scroll on button is broken <img width="290" alt="image" src="https://github.com/user-attachments/assets/ed2dff1e-ef01-4844-9123-e2212f9853a6" /> ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
1 parent
8891748af7
commit
36afe0cf93
2 files changed
+183
-161
No files matched your search
@@ -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)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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 rects = reason === "down" ? topRects : bottomRects;
|
||||
|
||||
rects.sort((a, b) => a.left - b.left);
|
||||
|
||||
const rectWithText = rects.find(
|
||||
(rect, index) =>
|
||||
rect.left - (index === 0 ? maxOffset : 0) <= cursorX &&
|
||||
cursorX <=
|
||||
rect.right + (index === rects.length - 1 ? maxOffset : 0)
|
||||
);
|
||||
|
||||
if (rectWithText === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const newCursorY = rectWithText.top + rectWithText.height / 2;
|
||||
|
||||
const eventRanges = [caretFromPoint(cursorX, newCursorY)];
|
||||
for (const offset of smoodgeOffsets) {
|
||||
eventRanges.push(caretFromPoint(cursorX - offset, newCursorY));
|
||||
eventRanges.push(caretFromPoint(cursorX + offset, newCursorY));
|
||||
}
|
||||
|
||||
for (const eventRange of eventRanges) {
|
||||
if (eventRange === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
// 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 rects = reason === "down" ? topRects : bottomRects;
|
||||
|
||||
rects.sort((a, b) => a.left - b.left);
|
||||
|
||||
const rectWithText = rects.find(
|
||||
(rect, index) =>
|
||||
rect.left - (index === 0 ? maxOffset : 0) <= cursorX &&
|
||||
cursorX <= rect.right + (index === rects.length - 1 ? maxOffset : 0)
|
||||
);
|
||||
|
||||
if (rectWithText === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const newCursorY = rectWithText.top + rectWithText.height / 2;
|
||||
|
||||
const eventRanges = [caretFromPoint(cursorX, newCursorY)];
|
||||
for (const offset of smoodgeOffsets) {
|
||||
eventRanges.push(caretFromPoint(cursorX - offset, newCursorY));
|
||||
eventRanges.push(caretFromPoint(cursorX + offset, newCursorY));
|
||||
}
|
||||
|
||||
for (const eventRange of eventRanges) {
|
||||
if (eventRange === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
if (
|
||||
reason === "down" ||
|
||||
reason === "right" ||
|
||||
reason === "enter" ||
|
||||
reason === "click"
|
||||
) {
|
||||
const firstNode = $getRoot().getFirstDescendant();
|
||||
|
||||
if (firstNode === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
reason === "down" ||
|
||||
reason === "right" ||
|
||||
reason === "enter" ||
|
||||
reason === "click"
|
||||
) {
|
||||
const firstNode = $getRoot().getFirstDescendant();
|
||||
|
||||
if (firstNode === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isTextNode(firstNode)) {
|
||||
const selection = $createRangeSelection();
|
||||
selection.anchor.set(firstNode.getKey(), 0, "text");
|
||||
selection.focus.set(firstNode.getKey(), 0, "text");
|
||||
$setSelection(selection);
|
||||
}
|
||||
|
||||
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 ($isLineBreakNode(firstNode)) {
|
||||
// e.g. Box contains 2+ empty lines
|
||||
const selection = $createRangeSelection();
|
||||
$setSelection(selection);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason === "up" || reason === "left") {
|
||||
const selection = $createRangeSelection();
|
||||
const lastNode = $getRoot().getLastDescendant();
|
||||
|
||||
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(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)) {
|
||||
if ($isTextNode(firstNode)) {
|
||||
const selection = $createRangeSelection();
|
||||
selection.anchor.set(
|
||||
parent.getKey(),
|
||||
parent.getChildrenSize(),
|
||||
"element"
|
||||
);
|
||||
selection.focus.set(
|
||||
parent.getKey(),
|
||||
parent.getChildrenSize(),
|
||||
"element"
|
||||
);
|
||||
selection.anchor.set(firstNode.getKey(), 0, "text");
|
||||
selection.focus.set(firstNode.getKey(), 0, "text");
|
||||
$setSelection(selection);
|
||||
}
|
||||
|
||||
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 ($isLineBreakNode(firstNode)) {
|
||||
// e.g. Box contains 2+ empty lines
|
||||
const selection = $createRangeSelection();
|
||||
$setSelection(selection);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (reason === "new") {
|
||||
$selectAll();
|
||||
return;
|
||||
}
|
||||
if (reason === "up" || reason === "left") {
|
||||
const selection = $createRangeSelection();
|
||||
const lastNode = $getRoot().getLastDescendant();
|
||||
|
||||
reason satisfies never;
|
||||
});
|
||||
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(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;
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user