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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user