feat: better unlock (#9546)

* change lock label

* feat: add unlock logic for single units on pointer up

* feat: add unlock popup

* fix: linting errors

* style: padding tweaks

* style: remove redundant line

* feat: lock multiple units together

* style: tweak color & position

* feat: add highlight for locked elements

* feat: select groups correctly after unlocking

* test: update snapshots

* fix: lasso from selecting locked elements

* fix: should prevent selecting unlocked elements and setting locked id at the same time

* fix: reset hit locked id immediately when appropriate

* feat: capture locked units in delta

* test: update locking test

* feat: show lock highlight when locking (including undo/redo)

* feat: make locked highlighting consistent

* feat: show correct cursor type when moving over locked elements

* fix history

* remove `lockedUnits.singleUnits`

* tweak button

* do not render UnlockPopup if not locked element selected

* tweak actions

* refactor: simplify type

* refactor: rename type

* refactor: simplify hit element setting & checking

* fix: prefer locked over link

* rename to `activeLockedId`

* refactor: getElementAtPosition takes an optional hitelments array

* fix: avoid setting active locked id after resizing

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di
2025-05-21 21:57:12 +10:00
committed by GitHub
parent 41a7613dff
commit 712f267519
19 changed files with 843 additions and 109 deletions

View File

@@ -485,6 +485,8 @@ import { Toast } from "./Toast";
import { findShapeByKey } from "./shapes";
import UnlockPopup from "./UnlockPopup";
import type {
RenderInteractiveSceneCallback,
ScrollBars,
@@ -1876,6 +1878,12 @@ class App extends React.Component<AppProps, AppState> {
/>
)}
{this.renderFrameNames()}
{this.state.activeLockedId && (
<UnlockPopup
app={this}
activeLockedId={this.state.activeLockedId}
/>
)}
{showShapeSwitchPanel && (
<ConvertElementTypePopup app={this} />
)}
@@ -5114,18 +5122,27 @@ class App extends React.Component<AppProps, AppState> {
private getElementAtPosition(
x: number,
y: number,
opts?: {
opts?: (
| {
includeBoundTextElement?: boolean;
includeLockedElements?: boolean;
}
| {
allHitElements: NonDeleted<ExcalidrawElement>[];
}
) & {
preferSelected?: boolean;
includeBoundTextElement?: boolean;
includeLockedElements?: boolean;
},
): NonDeleted<ExcalidrawElement> | null {
const allHitElements = this.getElementsAtPosition(
x,
y,
opts?.includeBoundTextElement,
opts?.includeLockedElements,
);
let allHitElements: NonDeleted<ExcalidrawElement>[] = [];
if (opts && "allHitElements" in opts) {
allHitElements = opts?.allHitElements || [];
} else {
allHitElements = this.getElementsAtPosition(x, y, {
includeBoundTextElement: opts?.includeBoundTextElement,
includeLockedElements: opts?.includeLockedElements,
});
}
if (allHitElements.length > 1) {
if (opts?.preferSelected) {
@@ -5168,22 +5185,24 @@ class App extends React.Component<AppProps, AppState> {
private getElementsAtPosition(
x: number,
y: number,
includeBoundTextElement: boolean = false,
includeLockedElements: boolean = false,
opts?: {
includeBoundTextElement?: boolean;
includeLockedElements?: boolean;
},
): NonDeleted<ExcalidrawElement>[] {
const iframeLikes: Ordered<ExcalidrawIframeElement>[] = [];
const elementsMap = this.scene.getNonDeletedElementsMap();
const elements = (
includeBoundTextElement && includeLockedElements
opts?.includeBoundTextElement && opts?.includeLockedElements
? this.scene.getNonDeletedElements()
: this.scene
.getNonDeletedElements()
.filter(
(element) =>
(includeLockedElements || !element.locked) &&
(includeBoundTextElement ||
(opts?.includeLockedElements || !element.locked) &&
(opts?.includeBoundTextElement ||
!(isTextElement(element) && element.containerId)),
)
)
@@ -5669,14 +5688,21 @@ class App extends React.Component<AppProps, AppState> {
private getElementLinkAtPosition = (
scenePointer: Readonly<{ x: number; y: number }>,
hitElement: NonDeletedExcalidrawElement | null,
hitElementMightBeLocked: NonDeletedExcalidrawElement | null,
): ExcalidrawElement | undefined => {
if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
return undefined;
}
const elements = this.scene.getNonDeletedElements();
let hitElementIndex = -1;
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (hitElement && element.id === hitElement.id) {
if (
hitElementMightBeLocked &&
element.id === hitElementMightBeLocked.id
) {
hitElementIndex = index;
}
if (
@@ -6158,14 +6184,25 @@ class App extends React.Component<AppProps, AppState> {
}
}
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
const hitElementMightBeLocked = this.getElementAtPosition(
scenePointerX,
scenePointerY,
{
preferSelected: true,
includeLockedElements: true,
},
);
let hitElement: ExcalidrawElement | null = null;
if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
hitElement = null;
} else {
hitElement = hitElementMightBeLocked;
}
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElement,
hitElementMightBeLocked,
);
if (isEraserActive(this.state)) {
return;
@@ -6258,7 +6295,7 @@ class App extends React.Component<AppProps, AppState> {
selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: { [hitElement.id]: true },
selectedElementIds: { [hitElement!.id]: true },
},
this.scene.getNonDeletedElements(),
prevState,
@@ -6772,6 +6809,9 @@ class App extends React.Component<AppProps, AppState> {
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
{
includeLockedElements: true,
},
);
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
@@ -7207,17 +7247,57 @@ class App extends React.Component<AppProps, AppState> {
return true;
}
}
// hitElement may already be set above, so check first
pointerDownState.hit.element =
pointerDownState.hit.element ??
this.getElementAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
const allHitElements = this.getElementsAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
{
includeLockedElements: true,
},
);
const unlockedHitElements = allHitElements.filter((e) => !e.locked);
// Cannot set preferSelected in getElementAtPosition as we do in pointer move; consider:
// A & B: both unlocked, A selected, B on top, A & B overlaps in some way
// we want to select B when clicking on the overlapping area
const hitElementMightBeLocked = this.getElementAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
{
allHitElements,
},
);
if (
!hitElementMightBeLocked ||
hitElementMightBeLocked.id !== this.state.activeLockedId
) {
this.setState({
activeLockedId: null,
});
}
if (
hitElementMightBeLocked &&
hitElementMightBeLocked.locked &&
!unlockedHitElements.some(
(el) => this.state.selectedElementIds[el.id],
)
) {
pointerDownState.hit.element = null;
} else {
// hitElement may already be set above, so check first
pointerDownState.hit.element =
pointerDownState.hit.element ??
this.getElementAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
}
this.hitLinkElement = this.getElementLinkAtPosition(
pointerDownState.origin,
pointerDownState.hit.element,
hitElementMightBeLocked,
);
if (this.hitLinkElement) {
@@ -7247,10 +7327,7 @@ class App extends React.Component<AppProps, AppState> {
// For overlapped elements one position may hit
// multiple elements
pointerDownState.hit.allHitElements = this.getElementsAtPosition(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
pointerDownState.hit.allHitElements = unlockedHitElements;
const hitElement = pointerDownState.hit.element;
const someHitElementIsSelected =
@@ -8066,6 +8143,12 @@ class App extends React.Component<AppProps, AppState> {
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (this.state.activeLockedId) {
this.setState({
activeLockedId: null,
});
}
if (
this.state.selectedLinearElement &&
this.state.selectedLinearElement.elbowed &&
@@ -8947,6 +9030,49 @@ class App extends React.Component<AppProps, AppState> {
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
// if current elements are still selected
// and the pointer is just over a locked element
// do not allow activeLockedId to be set
const hitElements = pointerDownState.hit.allHitElements;
if (
this.state.activeTool.type === "selection" &&
!pointerDownState.boxSelection.hasOccurred &&
!pointerDownState.resize.isResizing &&
!hitElements.some((el) => this.state.selectedElementIds[el.id])
) {
const sceneCoords = viewportCoordsToSceneCoords(
{ clientX: childEvent.clientX, clientY: childEvent.clientY },
this.state,
);
const hitLockedElement = this.getElementAtPosition(
sceneCoords.x,
sceneCoords.y,
{
includeLockedElements: true,
},
);
this.store.scheduleCapture();
if (hitLockedElement?.locked) {
this.setState({
activeLockedId:
hitLockedElement.groupIds.length > 0
? hitLockedElement.groupIds.at(-1) || ""
: hitLockedElement.id,
});
} else {
this.setState({
activeLockedId: null,
});
}
} else {
this.setState({
activeLockedId: null,
});
}
this.setState({
selectedElementsAreBeingDragged: false,
});