fix: Refactored timeout bind mode handling

This commit is contained in:
Mark Tolmacs
2025-08-21 20:55:45 +02:00
parent 8424836d48
commit c7e43cfc79

View File

@@ -242,7 +242,6 @@ import {
bindOrUnbindBindingElement, bindOrUnbindBindingElement,
getBindingStrategyForDraggingBindingElementEndpoints, getBindingStrategyForDraggingBindingElementEndpoints,
getStartGlobalEndLocalPointsForSimpleArrowBinding, getStartGlobalEndLocalPointsForSimpleArrowBinding,
snapToCenter,
mutateElement, mutateElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -785,7 +784,10 @@ class App extends React.Component<AppProps, AppState> {
// } // }
// if (newState && Object.hasOwn(newState, "selectedLinearElement")) { // if (newState && Object.hasOwn(newState, "selectedLinearElement")) {
// console.trace(!!newState.selectedLinearElement); // //console.trace(!!newState.selectedLinearElement);
// if (!newState.selectedLinearElement?.selectedPointsIndices?.length) {
// console.trace(newState.selectedLinearElement?.selectedPointsIndices);
// }
// } // }
// super.setState(newState, callback); // super.setState(newState, callback);
@@ -863,6 +865,130 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
private handleSkipBindMode() {
if (this.state.bindMode === "orbit") {
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
this.setState({
bindMode: "orbit",
});
}
}
private resetDelayedBindMode() {
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
if (this.state.bindMode !== "orbit") {
// We need this iteration to complete binding and change
// back to orbit mode after that
setTimeout(() =>
this.setState({
bindMode: "orbit",
}),
);
}
}
private handleDelayedBindModeChange(
arrow: ExcalidrawArrowElement,
hoveredElement: NonDeletedExcalidrawElement | null,
) {
if (isElbowArrow(arrow)) {
return;
}
const effector = () => {
this.bindModeHandler = null;
invariant(
this.lastPointerMoveCoords,
"Expected lastPointerMoveCoords to be set",
);
if (!this.state.selectedLinearElement?.selectedPointsIndices?.length) {
return;
}
const startDragged =
this.state.selectedLinearElement.selectedPointsIndices.includes(0);
const endDragged =
this.state.selectedLinearElement.selectedPointsIndices.includes(
arrow.points.length - 1,
);
if ((!startDragged && !endDragged) || (startDragged && endDragged)) {
return;
}
const { x, y } = this.lastPointerMoveCoords;
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(x, y),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
if (hoveredElement) {
flushSync(() => {
invariant(
this.state.selectedLinearElement?.elementId === arrow.id,
"The selectedLinearElement is expected to not change while a bind mode timeout is ticking",
);
// Change the global binding mode
this.setState({
bindMode: "inside",
selectedLinearElement: {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement.pointerDownState,
arrowStartIsInside: true,
},
},
});
// Make the arrow endpoint "jump" to the cursor
const point = LinearElementEditor.createPointAt(
arrow,
this.scene.getNonDeletedElementsMap(),
x,
y,
isBindingEnabled(this.state) ? this.getEffectiveGridSize() : null,
);
this.scene.mutateElement(arrow, {
points: startDragged
? [point, ...arrow.points.slice(1)]
: [...arrow.points.slice(0, -1), point],
});
});
}
};
if (!hoveredElement) {
// Clear the timeout if we're not hovering a bindable
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
// Clear the inside binding mode too
if (this.state.bindMode !== "orbit") {
flushSync(() => {
this.setState({
bindMode: "orbit",
});
});
}
} else if (!this.bindModeHandler) {
// We are hovering a bindable element
this.bindModeHandler = setTimeout(effector, BIND_MODE_TIMEOUT);
}
}
private cacheEmbeddableRef( private cacheEmbeddableRef(
element: ExcalidrawIframeLikeElement, element: ExcalidrawIframeLikeElement,
ref: HTMLIFrameElement | null, ref: HTMLIFrameElement | null,
@@ -4406,16 +4532,8 @@ class App extends React.Component<AppProps, AppState> {
} }
// Handle Alt key for bind mode // Handle Alt key for bind mode
if (event.key === KEYS.ALT && this.state.bindMode === "orbit") { if (event.key === KEYS.ALT) {
// Cancel any pending bind mode timer this.handleSkipBindMode();
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
// Immediately switch to skip bind mode
this.setState({
bindMode: "skip",
});
} }
if (this.actionManager.handleKeyDown(event)) { if (this.actionManager.handleKeyDown(event)) {
@@ -4427,10 +4545,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) {
if (this.bindModeHandler) { this.resetDelayedBindMode();
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
this.setState({ isBindingEnabled: false }); this.setState({ isBindingEnabled: false });
} }
@@ -4743,15 +4858,15 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
if (hoveredElement && !this.bindModeHandler) { if (this.state.selectedLinearElement) {
this.bindModeHandler = setTimeout(() => { const element = LinearElementEditor.getElement(
if (hoveredElement) { this.state.selectedLinearElement.elementId,
this.setState({ this.scene.getNonDeletedElementsMap(),
bindMode: "inside", );
});
} if (isBindingElement(element)) {
this.bindModeHandler = null; this.handleDelayedBindModeChange(element, hoveredElement);
}, BIND_MODE_TIMEOUT); }
} }
} }
} }
@@ -5892,6 +6007,12 @@ class App extends React.Component<AppProps, AppState> {
) => { ) => {
this.savePointer(event.clientX, event.clientY, this.state.cursorButton); this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
this.lastPointerMoveEvent = event.nativeEvent; this.lastPointerMoveEvent = event.nativeEvent;
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
const { x: scenePointerX, y: scenePointerY } = scenePointer;
this.lastPointerMoveCoords = {
x: scenePointerX,
y: scenePointerY,
};
if (gesture.pointers.has(event.pointerId)) { if (gesture.pointers.has(event.pointerId)) {
gesture.pointers.set(event.pointerId, { gesture.pointers.set(event.pointerId, {
@@ -5980,13 +6101,6 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
const { x: scenePointerX, y: scenePointerY } = scenePointer;
this.lastPointerMoveCoords = {
x: scenePointerX,
y: scenePointerY,
};
if ( if (
!this.state.newElement && !this.state.newElement &&
isActiveToolNonLinearSnappable(this.state.activeTool.type) isActiveToolNonLinearSnappable(this.state.activeTool.type)
@@ -6178,54 +6292,7 @@ class App extends React.Component<AppProps, AppState> {
elementsMap, elementsMap,
); );
// Timed bind mode handler for arrow elements this.handleDelayedBindModeChange(multiElement, hoveredElement);
if (this.state.bindMode === "orbit") {
if (this.bindModeHandler && !hoveredElement) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
} else if (!this.bindModeHandler && hoveredElement) {
this.bindModeHandler = setTimeout(() => {
if (hoveredElement) {
flushSync(() => {
this.setState({
bindMode: "inside",
selectedLinearElement: this.state.selectedLinearElement
? {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement
.pointerDownState,
arrowStartIsInside: true,
},
}
: null,
});
});
this.scene.mutateElement(multiElement, {
points: [
...multiElement.points.slice(0, -1),
pointFrom<LocalPoint>(
this.lastPointerMoveCoords!.x - multiElement.x,
this.lastPointerMoveCoords!.y - multiElement.y,
),
],
});
}
this.bindModeHandler = null;
}, BIND_MODE_TIMEOUT);
}
} else if (!hoveredElement) {
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
flushSync(() => {
this.setState({
bindMode: "orbit",
});
});
}
const point = pointFrom<LocalPoint>( const point = pointFrom<LocalPoint>(
scenePointerX - rx, scenePointerX - rx,
@@ -6635,6 +6702,13 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = ( private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
const { x: scenePointerX, y: scenePointerY } = scenePointer;
this.lastPointerMoveCoords = {
x: scenePointerX,
y: scenePointerY,
};
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
// capture subsequent pointer events to the canvas // capture subsequent pointer events to the canvas
// this makes other elements non-interactive until pointer up // this makes other elements non-interactive until pointer up
@@ -7059,29 +7133,19 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerUp = ( private handleCanvasPointerUp = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
) => { ) => {
this.resetDelayedBindMode();
this.removePointer(event); this.removePointer(event);
this.lastPointerUpEvent = event; this.lastPointerUpEvent = event;
// Cancel any pending timeout for bind mode change
if (this.state.bindMode === "inside" || this.state.bindMode === "skip") {
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
// We need this iteration to complete binding and change
// back to orbit mode after that
setTimeout(() =>
this.setState({
bindMode: "orbit",
}),
);
}
const scenePointer = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY }, { clientX: event.clientX, clientY: event.clientY },
this.state, this.state,
); );
const { x: scenePointerX, y: scenePointerY } = scenePointer;
this.lastPointerMoveCoords = {
x: scenePointerX,
y: scenePointerY,
};
const clicklength = const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
@@ -7181,10 +7245,7 @@ class App extends React.Component<AppProps, AppState> {
* pointerup handlers manually * pointerup handlers manually
*/ */
private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => { private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => {
if (this.bindModeHandler) { this.resetDelayedBindMode();
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
this.setState({ this.setState({
bindMode: "orbit", bindMode: "orbit",
@@ -8313,28 +8374,8 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
{ newArrow: true }, { newArrow: true },
); );
}
if (isSimpleArrow(element)) { this.handleDelayedBindModeChange(element, boundElement);
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
this.bindModeHandler = setTimeout(() => {
this.setState({
bindMode: "inside",
selectedLinearElement: this.state.selectedLinearElement
? {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement?.pointerDownState,
arrowStartIsInside: !!boundElement,
},
}
: null,
});
}, BIND_MODE_TIMEOUT);
} }
this.setState((prevState) => { this.setState((prevState) => {
@@ -8354,6 +8395,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y, pointerDownState.origin.y,
), ),
}, },
selectedPointsIndices: [1],
}; };
nextSelectedElementIds = makeNextSelectedElementIds( nextSelectedElementIds = makeNextSelectedElementIds(
{ [element.id]: true }, { [element.id]: true },
@@ -8726,7 +8768,6 @@ class App extends React.Component<AppProps, AppState> {
linearElementEditor.elementId, linearElementEditor.elementId,
elementsMap, elementsMap,
); );
let [x, y] = [pointerCoords.x, pointerCoords.y];
if (isBindingElement(element)) { if (isBindingElement(element)) {
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
@@ -8735,122 +8776,19 @@ class App extends React.Component<AppProps, AppState> {
elementsMap, elementsMap,
); );
// Timed bind mode handler for arrow elements this.handleDelayedBindModeChange(element, hoveredElement);
if (this.state.bindMode === "orbit") {
if (this.bindModeHandler && !hoveredElement) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
} else if (!this.bindModeHandler && hoveredElement) {
this.bindModeHandler = setTimeout(() => {
if (hoveredElement) {
flushSync(() => {
this.setState({
bindMode: "inside",
selectedLinearElement: this.state.selectedLinearElement
? {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement
.pointerDownState,
arrowStartIsInside: true,
},
}
: null,
});
});
const [lastX, lastY] =
hoveredElement && element.startBinding?.mode !== "inside"
? snapToCenter(
hoveredElement,
elementsMap,
pointFrom<GlobalPoint>(
this.lastPointerMoveCoords?.x ??
pointerDownState.origin.x,
this.lastPointerMoveCoords?.y ??
pointerDownState.origin.y,
),
)
: [
this.lastPointerMoveCoords?.x ??
pointerDownState.origin.x,
this.lastPointerMoveCoords?.y ??
pointerDownState.origin.y,
];
const newState = LinearElementEditor.handlePointDragging(
event,
this,
lastX,
lastY,
linearElementEditor,
);
if (newState) {
pointerDownState.lastCoords.x =
this.lastPointerMoveCoords?.x ??
pointerDownState.origin.x;
pointerDownState.lastCoords.y =
this.lastPointerMoveCoords?.y ??
pointerDownState.origin.y;
pointerDownState.drag.hasOccurred = true;
flushSync(() => {
this.setState(newState);
});
}
const selectedPointIndices =
this.state.selectedLinearElement?.selectedPointsIndices;
const nextPoint = pointFrom<LocalPoint>(
(this.lastPointerMoveCoords?.x ??
pointerDownState.origin.x) - element.x,
(this.lastPointerMoveCoords?.y ??
pointerDownState.origin.y) - element.y,
);
if (
selectedPointIndices?.length === 1 &&
selectedPointIndices[0] === 0
) {
this.scene.mutateElement(element, {
points: [nextPoint, ...element.points.slice(1)],
});
} else {
this.scene.mutateElement(element, {
points: [...element.points.slice(0, -1), nextPoint],
});
}
}
this.bindModeHandler = null;
}, BIND_MODE_TIMEOUT);
}
} else if (!hoveredElement) {
flushSync(() => {
this.setState({
bindMode: "orbit",
});
});
}
[x, y] =
hoveredElement && element.startBinding?.mode !== "inside"
? snapToCenter(
hoveredElement,
elementsMap,
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
)
: [pointerCoords.x, pointerCoords.y];
} }
const newState = LinearElementEditor.handlePointDragging( const newState = LinearElementEditor.handlePointDragging(
event, event,
this, this,
x, pointerCoords.x,
y, pointerCoords.y,
linearElementEditor, linearElementEditor,
); );
if (newState) { if (newState) {
pointerDownState.lastCoords.x = x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = y; pointerDownState.lastCoords.y = pointerCoords.y;
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
if ( if (
@@ -9604,7 +9542,6 @@ class App extends React.Component<AppProps, AppState> {
// just in case, tool changes mid drag, always clean up // just in case, tool changes mid drag, always clean up
this.lassoTrail.endPath(); this.lassoTrail.endPath();
this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null); SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null); SnapCache.setVisibleGaps(null);
@@ -9656,10 +9593,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
if (this.bindModeHandler) { this.resetDelayedBindMode();
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
this.setState({ this.setState({
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,