mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-12 00:34:21 +01:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user