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
co-authored by dwelle
parent 41a7613dff
commit 712f267519
19 changed files with 843 additions and 109 deletions
+159 -33
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,
});
@@ -0,0 +1,40 @@
@import "../css/variables.module.scss";
.excalidraw {
.UnlockPopup {
position: absolute;
z-index: var(--zIndex-interactiveCanvas);
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
border-radius: 0.5rem;
background: var(--island-bg-color);
box-shadow: var(--shadow-island);
padding: 0.8rem;
cursor: pointer;
color: var(--color-gray-60);
&:focus {
outline: none;
}
svg {
display: block;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-60);
}
&:hover {
svg {
color: var(--color-primary);
}
}
&:active {
svg {
transform: scale(0.95);
}
}
}
}
@@ -0,0 +1,75 @@
import {
getCommonBounds,
getElementsInGroup,
selectGroupsFromGivenElements,
} from "@excalidraw/element";
import { sceneCoordsToViewportCoords } from "@excalidraw/common";
import { flushSync } from "react-dom";
import { actionToggleElementLock } from "../actions";
import { t } from "../i18n";
import "./UnlockPopup.scss";
import { LockedIconFilled } from "./icons";
import type App from "./App";
import type { AppState } from "../types";
const UnlockPopup = ({
app,
activeLockedId,
}: {
app: App;
activeLockedId: NonNullable<AppState["activeLockedId"]>;
}) => {
const element = app.scene.getElement(activeLockedId);
const elements = element
? [element]
: getElementsInGroup(app.scene.getNonDeletedElementsMap(), activeLockedId);
if (elements.length === 0) {
return null;
}
const [x, y] = getCommonBounds(elements);
const { x: viewX, y: viewY } = sceneCoordsToViewportCoords(
{ sceneX: x, sceneY: y },
app.state,
);
return (
<div
className="UnlockPopup"
style={{
bottom: `${app.state.height + 12 - viewY + app.state.offsetTop}px`,
left: `${viewX - app.state.offsetLeft}px`,
}}
onClick={() => {
flushSync(() => {
const groupIds = selectGroupsFromGivenElements(elements, app.state);
app.setState({
selectedElementIds: elements.reduce(
(acc, element) => ({
...acc,
[element.id]: true,
}),
{},
),
selectedGroupIds: groupIds,
activeLockedId: null,
});
});
app.actionManager.executeAction(actionToggleElementLock);
}}
title={t("labels.elementLock.unlock")}
>
{LockedIconFilled}
</div>
);
};
export default UnlockPopup;
@@ -215,6 +215,7 @@ const getRelevantAppStateProps = (
isCropping: appState.isCropping,
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches,
activeLockedId: appState.activeLockedId,
});
const areEqual = (
+10
View File
@@ -178,6 +178,16 @@ export const LockedIcon = createIcon(
modifiedTablerIconProps,
);
export const LockedIconFilled = createIcon(
<g fill="currentColor">
<path d="M12 2a5 5 0 0 1 5 5v3a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-10a3 3 0 0 1 -3 -3v-6a3 3 0 0 1 3 -3v-3a5 5 0 0 1 5 -5m0 12a2 2 0 0 0 -1.995 1.85l-.005 .15a2 2 0 1 0 2 -2m0 -10a3 3 0 0 0 -3 3v3h6v-3a3 3 0 0 0 -3 -3" />
</g>,
{
width: 24,
height: 24,
},
);
// custom
export const WelcomeScreenMenuArrow = createIcon(
<>