feat: Focus indicator (#10613)

* feat: Focus indicator

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snapshot update

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Move visualdebug to utils and introduce volume bindable volume visualization

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Move visualdebug to elements

Due to dep circles

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Possible test timeout

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Incorrect hit test point input

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Add fallback when dragged outside of allowed area

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Elbow arrows don't need focus point mgmt

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: End bound indirect fix

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Show indicator when arrow endpoint dragging

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Update bound arrow endpoint at mid-point drag

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Curve endpoint intersection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Outline focus point is reset on existing arrow drag

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Dragging focus point off

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Don't show the focus indicator when arrow endpoint is dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Drag area for focus handles

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus point size unified

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Size bump for focus knob

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Cache hits and scene lookups

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Consider hit threshold and inside override too

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Other shape switching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* perf: Update tolerance params

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus know line width

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: knob offset

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Full overlap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove Map caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: incorrect threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: threshold setting

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: cache override

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snapshots

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Redesigned focus point handling

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Inside-inside mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove comment

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Allow focus knob outside the shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow endpoint offset

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus knob element distance

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Increase iteration on curve intersection calc

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Handle disabled binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Nested shape focus rewrite

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt + Ctrl + arrow endpoitn

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit ordering for focus points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus point visibility

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* dry out renderFocusPointIndicator

* do not higlight point when dragging & make focus point smaller

* optimize retrieval of selectedLinearElement

* move focus highlighting into renderFocusPointIndicator to DRY out and colocate

* remove `disabled` state from focus highlight

* make focus point stroke color less prominent

* fix: No focus point for multi-point arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow edit mode drag focus point release

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* DRY out arrow point-like drag

* move `focus.ts` to `arrows/focus.ts`

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács
2026-01-31 15:08:28 +01:00
committed by GitHub
co-authored by dwelle
parent 216afc3625
commit b552c60714
16 changed files with 1112 additions and 90 deletions
+140 -12
View File
@@ -250,6 +250,11 @@ import {
maxBindingDistance_simple,
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
handleFocusPointDrag,
handleFocusPointHover,
handleFocusPointPointerDown,
handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -4734,8 +4739,12 @@ class App extends React.Component<AppProps, AppState> {
}
// Handle Alt key for bind mode
if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleSkipBindMode();
if (event.key === KEYS.ALT) {
if (getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleSkipBindMode();
} else {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
}
if (this.actionManager.handleKeyDown(event)) {
@@ -4751,7 +4760,11 @@ class App extends React.Component<AppProps, AppState> {
this.resetDelayedBindMode();
}
this.setState({ isBindingEnabled: false });
flushSync(() => {
this.setState({ isBindingEnabled: false });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (isArrowKey(event.key)) {
@@ -5024,6 +5037,11 @@ class App extends React.Component<AppProps, AppState> {
}
isHoldingSpace = false;
}
if (event.key === KEYS.ALT) {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (
(event.key === KEYS.ALT && this.state.bindMode === "skip") ||
(!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state))
@@ -5034,7 +5052,7 @@ class App extends React.Component<AppProps, AppState> {
});
// Restart the timer if we're creating/editing a linear element and hovering over an element
if (this.lastPointerMoveEvent) {
if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) {
const scenePointer = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerMoveEvent.clientX,
@@ -5055,14 +5073,18 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
);
if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
if (isBindingElement(element)) {
this.handleDelayedBindModeChange(element, hoveredElement);
}
}
}
}
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
this.setState({ isBindingEnabled: true });
flushSync(() => {
this.setState({ isBindingEnabled: true });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (isArrowKey(event.key)) {
bindOrUnbindBindingElements(
@@ -6379,7 +6401,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.setState({
@@ -6410,7 +6432,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", {
@@ -6564,7 +6586,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
maxBindingDistance_simple(this.state.zoom),
);
if (
hit &&
@@ -6923,6 +6945,37 @@ class App extends React.Component<AppProps, AppState> {
},
});
}
// Check for focus point hover
let hoveredFocusPointBinding: "start" | "end" | null = null;
const arrow = element as any;
if (arrow.startBinding || arrow.endBinding) {
hoveredFocusPointBinding = handleFocusPointHover(
element as ExcalidrawArrowElement,
scenePointerX,
scenePointerY,
this.scene,
this.state,
);
}
if (
this.state.selectedLinearElement.hoveredFocusPointBinding !==
hoveredFocusPointBinding
) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
hoveredFocusPointBinding,
},
});
}
// Set cursor to pointer when hovering over a focus point
if (hoveredFocusPointBinding) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
} else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
}
@@ -7844,6 +7897,37 @@ class App extends React.Component<AppProps, AppState> {
if (ret.didAddPoint) {
return true;
}
// Also check at current pointer position if focus point is being hovered
// (in case we're clicking directly without a prior move event)
const elementsMap = this.scene.getNonDeletedElementsMap();
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
if (arrow && isBindingElement(arrow)) {
const { hitFocusPoint, pointerOffset } =
handleFocusPointPointerDown(
arrow,
pointerDownState,
elementsMap,
this.state,
);
// If focus point is hit, update state and prevent element selection
if (hitFocusPoint) {
this.setState({
selectedLinearElement: {
...linearElementEditor,
hoveredFocusPointBinding: hitFocusPoint,
draggedFocusPointBinding: hitFocusPoint,
pointerOffset,
},
});
return false;
}
}
}
const allHitElements = this.getElementsAtPosition(
@@ -8991,6 +9075,31 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.selectedLinearElement) {
const linearElementEditor = this.state.selectedLinearElement;
// Handle focus point dragging if needed
if (linearElementEditor.draggedFocusPointBinding) {
handleFocusPointDrag(
linearElementEditor,
elementsMap,
pointerCoords,
this.scene,
this.state,
this.getEffectiveGridSize(),
event.altKey,
);
this.setState({
selectedLinearElement: {
...linearElementEditor,
isDragging: false,
selectedPointsIndices: [],
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
});
return;
}
if (
LinearElementEditor.shouldAddMidpoint(
this.state.selectedLinearElement,
@@ -9859,12 +9968,14 @@ class App extends React.Component<AppProps, AppState> {
// and sets binding element
if (
this.state.selectedLinearElement?.isEditing &&
!this.state.newElement
!this.state.newElement &&
this.state.selectedLinearElement.draggedFocusPointBinding === null
) {
if (
!pointerDownState.boxSelection.hasOccurred &&
pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId
this.state.selectedLinearElement.elementId &&
this.state.selectedLinearElement.draggedFocusPointBinding === null
) {
this.actionManager.executeAction(actionFinalize);
} else {
@@ -9900,7 +10011,18 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (
if (this.state.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointPointerUp(
this.state.selectedLinearElement,
this.scene,
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
draggedFocusPointBinding: null,
},
});
} else if (
pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId
) {
@@ -9910,6 +10032,12 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ selectedLinearElement: null });
}
} else if (this.state.selectedLinearElement.isDragging) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
},
});
this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent,
sceneCoords,
@@ -135,7 +135,8 @@ describe("binding with linear elements", () => {
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("186"));
UI.updateInput(inputX, String("184"));
expect(linear.startBinding).not.toBe(null);
});