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