Loop Lock/Unlock

This commit is contained in:
zsviczian 2025-05-04 11:00:19 +00:00
parent 6e655cdb24
commit 54b4a304c9
9 changed files with 254 additions and 7 deletions

View File

@ -475,3 +475,5 @@ export enum UserIdleState {
AWAY = "away",
IDLE = "idle",
}
export const MIN_LOOP_LOCK_DISTANCE = 20;

View File

@ -67,6 +67,8 @@ import {
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import { isLineElement } from "./typeChecks";
import type Scene from "./Scene";
import type { Bounds } from "./bounds";
@ -1249,6 +1251,16 @@ export class LinearElementEditor {
scene: Scene,
pointIndices: readonly number[],
) {
// Handle loop lock behavior
if (isLineElement(element) && element.loopLock) {
if (
pointIndices.includes(0) ||
pointIndices.includes(element.points.length - 1)
) {
scene.mutateElement(element, { loopLock: false });
}
}
let offsetX = 0;
let offsetY = 0;
@ -1315,6 +1327,31 @@ export class LinearElementEditor {
) {
const { points } = element;
// Handle loop lock behavior
if (isLineElement(element) && element.loopLock) {
const firstPointUpdate = targetPoints.find(({ index }) => index === 0);
const lastPointUpdate = targetPoints.find(
({ index }) => index === points.length - 1,
);
if (firstPointUpdate) {
targetPoints.push({
index: points.length - 1,
point: pointFrom(
firstPointUpdate.point[0],
firstPointUpdate.point[1],
),
isDragging: firstPointUpdate.isDragging,
});
} else if (lastPointUpdate) {
targetPoints.push({
index: 0,
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
isDragging: lastPointUpdate.isDragging,
});
}
}
// in case we're moving start point, instead of modifying its position
// which would break the invariant of it being at [0,0], we move
// all the other points in the opposite direction by delta to

View File

@ -25,6 +25,7 @@ import type {
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
@ -356,3 +357,9 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
export const isLineElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLineElement => {
return element != null && element.type === "line";
};

View File

@ -321,6 +321,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null;
}>;
export type ExcalidrawLineElement = ExcalidrawLinearElement &
Readonly<{
type: "line";
loopLock: boolean;
}>;
export type FixedSegment = {
start: LocalPoint;
end: LocalPoint;

View File

@ -1,15 +1,26 @@
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import {
isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import { MIN_LOOP_LOCK_DISTANCE } from "@excalidraw/common";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { pointFrom } from "@excalidraw/math";
import type {
ExcalidrawLinearElement,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons";
import {
lineEditorIcon,
LoopUnlockedIcon,
LoopLockedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
@ -82,3 +93,120 @@ export const actionToggleLinearEditor = register({
);
},
});
export const actionToggleLoopLock = register({
name: "toggleLoopLock",
category: DEFAULT_CATEGORIES.elements,
label: (elements, appState, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
// Check if all selected elements are locked
const allLocked =
selectedElements.length > 0 &&
selectedElements.every(
(element) => isLineElement(element) && element.loopLock,
);
return allLocked ? "labels.loopLock.unlock" : "labels.loopLock.lock";
},
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
return (
selectedElements.length > 0 &&
selectedElements.every(
(element) => isLineElement(element) && element.points.length >= 3,
)
);
},
perform(elements, appState, _, app) {
const selectedElements = app.scene
.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
})
.filter((element) => isLineElement(element)) as ExcalidrawLineElement[];
if (!selectedElements.length) {
return false;
}
// Check if we should lock or unlock based on current state
// If all elements are locked, unlock all. Otherwise, lock all.
const allLocked = selectedElements.every((element) => element.loopLock);
const newLoopLockState = !allLocked;
selectedElements.forEach((element) => {
const updatedPoints = [...element.points];
if (newLoopLockState) {
const firstPoint = updatedPoints[0];
const lastPoint = updatedPoints[updatedPoints.length - 1];
const distance = Math.hypot(
firstPoint[0] - lastPoint[0],
firstPoint[1] - lastPoint[1],
);
if (distance > MIN_LOOP_LOCK_DISTANCE) {
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
} else {
updatedPoints[updatedPoints.length - 1] = pointFrom(
firstPoint[0],
firstPoint[1],
);
}
}
app.scene.mutateElement(element, {
loopLock: newLoopLockState,
points: updatedPoints,
});
});
return {
appState,
elements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
if (
selectedElements.length === 0 ||
!selectedElements.every(
(element) => isLineElement(element) && element.points.length >= 3,
)
) {
return null;
}
// If all are locked, show locked icon. Otherwise show unlocked
const allLocked = selectedElements.every(
(element) => isLineElement(element) && element.loopLock,
);
const label = t(
allLocked ? "labels.loopLock.unlock" : "labels.loopLock.lock",
);
return (
<ToolButton
type="button"
icon={allLocked ? LoopLockedIcon : LoopUnlockedIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});

View File

@ -141,7 +141,8 @@ export type ActionName =
| "cropEditor"
| "wrapSelectionInFrame"
| "toggleLassoTool"
| "toggleShapeSwitch";
| "toggleShapeSwitch"
| "toggleLoopLock";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];

View File

@ -19,6 +19,7 @@ import {
isImageElement,
isLinearElement,
isTextElement,
isLineElement,
} from "@excalidraw/element/typeChecks";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element/comparisons";
@ -26,6 +27,7 @@ import { hasStrokeColor, toolIsArrow } from "@excalidraw/element/comparisons";
import type {
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLineElement,
NonDeletedElementsMap,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
@ -145,6 +147,17 @@ export const SelectedShapeActions = ({
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showLoopLockAction =
targetElements.length > 0 &&
isLineElement(targetElements[0]) &&
targetElements.every(
(element) =>
isLineElement(element) &&
element.points.length >= 4 &&
element.loopLock ===
(targetElements[0] as ExcalidrawLineElement).loopLock,
);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
@ -273,6 +286,7 @@ export const SelectedShapeActions = ({
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
{showLoopLockAction && renderAction("toggleLoopLock")}
</div>
</fieldset>
)}

View File

@ -129,6 +129,54 @@ export const PinIcon = createIcon(
tablerIconProps,
);
export const LoopLockedIcon = createIcon(
<g>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
d="M7.11 11.11h5.17m-5.17 0h5.17m0 0c1.02 0 1.53.51 1.53 1.53m-1.53-1.53c1.02 0 1.53.51 1.53 1.53m0 0v3.07m0-3.07v3.07m0 0c0 1.03-.5 1.54-1.53 1.54m1.53-1.54c0 1.03-.5 1.54-1.53 1.54m0 0H7.11m5.17 0H7.11m0 0c-1.02 0-1.53-.5-1.53-1.54m1.53 1.54c-1.02 0-1.53-.5-1.53-1.54m0 0v-3.07m0 3.07v-3.07m0 0c0-1.02.51-1.53 1.53-1.53m-1.53 1.53c0-1.02.51-1.53 1.53-1.53M7.7 10.86c.07-.57-.1-2.84.43-3.44.55-.6 2.27-.72 2.82-.17.56.55.43 2.9.51 3.47m-3.76.14c.07-.57-.1-2.84.43-3.44.55-.6 2.27-.72 2.82-.17.56.55.43 2.9.51 3.47M10.63 14.55l-.01.06a.16.16 0 0 1-.04.07.43.43 0 0 1-.07.06l-.1.05-.11.04-.13.04-.15.03-.16.01-.16.01-.17-.01-.15-.01-.15-.03a.74.74 0 0 1-.14-.04l-.11-.04-.09-.05-.07-.06a.18.18 0 0 1-.05-.07l-.01-.06.01-.06a.18.18 0 0 1 .05-.07l.07-.05a.35.35 0 0 1 .09-.06l.11-.04a.74.74 0 0 1 .14-.04l.15-.03.15-.01.17-.01.16.01.16.01.15.03.13.04.11.04.1.06.07.05.04.07.01.06c.01.01.01-.01 0 0"
/>
<path
fill="none"
stroke="transparent"
stroke-linecap="round"
d="M0 0h18.09M0 0h18.09m0 0v18.21m0-18.21v18.21m0 0H0m18.09 0H0m0 0V0m0 18.21V0"
/>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
d="M3.13 6.27c.79-.86 2.9-4.72 4.7-5.18 1.8-.46 5.08 2.02 6.09 2.42M3.12 6.27c.8-.86 2.92-4.72 4.72-5.18 1.8-.46 5.07 2.02 6.08 2.42M4.17 8.01l-.02.27-.07.26c-.03.1-.07.17-.12.25-.04.08-.1.15-.15.22l-.2.2c-.07.05-.14.11-.22.15-.08.05-.16.1-.25.12l-.26.07a2.45 2.45 0 0 1-.55 0 1.42 1.42 0 0 1-.51-.19c-.08-.04-.15-.1-.22-.15a1.77 1.77 0 0 1-.35-.42c-.05-.08-.08-.16-.12-.25a1.97 1.97 0 0 1-.07-.26l-.02-.27.02-.28.07-.26c.04-.08.07-.17.12-.25a1.77 1.77 0 0 1 .57-.57c.08-.05.17-.08.25-.12l.26-.07a2.64 2.64 0 0 1 .55 0l.26.07c.09.04.17.07.25.12a1.77 1.77 0 0 1 .42.35c.05.07.11.14.15.22a1.42 1.42 0 0 1 .21.8c.01.03.01-.06 0 0M17.05 4.88l-.02.27-.07.26c-.03.09-.07.17-.12.25-.04.08-.1.15-.15.22l-.2.2-.22.15c-.08.05-.16.09-.25.12l-.26.07a2.45 2.45 0 0 1-.55 0 1.42 1.42 0 0 1-.51-.19c-.08-.04-.15-.1-.22-.15a1.77 1.77 0 0 1-.35-.42c-.05-.08-.08-.16-.12-.25a1.97 1.97 0 0 1-.07-.26l-.02-.27c0-.1 0-.2.02-.28l.07-.26c.04-.08.07-.17.12-.25a1.77 1.77 0 0 1 .57-.57c.08-.05.17-.08.25-.12l.26-.07a2.64 2.64 0 0 1 .55 0l.26.07c.09.04.17.07.25.12a1.77 1.77 0 0 1 .42.35l.15.22a1.42 1.42 0 0 1 .2.79c.02.04.02-.05 0 0M4.4 7.72c3.04-.7 6.06-1.42 9.5-2.22M4.4 7.72l9.5-2.22"
/>
</g>,
modifiedTablerIconProps,
);
export const LoopUnlockedIcon = createIcon(
<g>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
d="M6.74 11.12h5.17m-5.17 0h5.17m0 0c1.02 0 1.53.5 1.53 1.53m-1.53-1.53c1.02 0 1.53.5 1.53 1.53m0 0v3.07m0-3.07v3.07m0 0c0 1.03-.5 1.54-1.53 1.54m1.53-1.54c0 1.03-.5 1.54-1.53 1.54m0 0H6.74m5.17 0H6.74m0 0c-1.02 0-1.53-.51-1.53-1.54m1.53 1.54c-1.02 0-1.53-.51-1.53-1.54m0 0v-3.07m0 3.07v-3.07m0 0c0-1.02.51-1.53 1.53-1.53m-1.53 1.53c0-1.02.51-1.53 1.53-1.53M7.33 10.87c.08-.57-.08-2.8.45-3.44.54-.63 2.2-.53 2.76-.32.57.22.55 1.34.66 1.61m-3.87 2.15c.08-.57-.08-2.8.45-3.44.54-.63 2.2-.53 2.76-.32.57.22.55 1.34.66 1.61M10.26 14.56v.06a.16.16 0 0 1-.05.07.43.43 0 0 1-.07.06c-.03 0-.06.03-.1.05-.03 0-.07.03-.1.04l-.14.04-.15.03H9.5c-.05.02-.1.02-.16.02l-.17-.01-.15-.01-.15-.03a.74.74 0 0 1-.14-.04l-.1-.04-.1-.05-.07-.06a.18.18 0 0 1-.05-.07v-.12a.18.18 0 0 1 .05-.07l.07-.05a.35.35 0 0 1 .1-.06l.1-.04a.74.74 0 0 1 .14-.04l.15-.03.15-.01.17-.01h.16c.05 0 .11 0 .16.02l.15.03c.04 0 .1.02.13.04.04 0 .08.03.11.04l.1.06c.03 0 .05.03.07.05l.04.07.01.06c.01 0 .01-.01 0 0"
/>
<path
fill="none"
stroke="transparent"
stroke-linecap="round"
d="M0 0h18.09M0 0h18.09m0 0v18.21m0-18.21v18.21m0 0H0m18.09 0H0m0 0V0m0 18.21V0"
/>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
d="M3.13 6.28c.79-.86 2.9-4.72 4.7-5.18 1.8-.46 5.08 2.02 6.09 2.42M3.12 6.28c.8-.86 2.92-4.72 4.72-5.18 1.8-.46 5.07 2.02 6.08 2.42M4.17 8.02l-.02.27-.07.26c-.03.09-.07.17-.12.25-.04.08-.1.15-.15.22l-.2.2-.22.15c-.08.05-.16.09-.25.12l-.26.07a2.45 2.45 0 0 1-.55 0 1.42 1.42 0 0 1-.51-.19c-.08-.04-.15-.1-.22-.15a1.77 1.77 0 0 1-.35-.42c-.05-.08-.08-.16-.12-.25a1.97 1.97 0 0 1-.07-.26l-.02-.27.02-.28.07-.26c.04-.08.07-.17.12-.25a1.77 1.77 0 0 1 .57-.57c.08-.05.17-.08.25-.12l.26-.07a2.64 2.64 0 0 1 .55 0l.26.07c.09.04.17.07.25.12a1.77 1.77 0 0 1 .42.35c.05.07.11.14.15.22a1.42 1.42 0 0 1 .21.79c.01.04.01-.05 0 0M17.05 4.89l-.02.27-.07.26c-.03.09-.07.17-.12.25-.04.08-.1.15-.15.22l-.2.2-.22.15c-.08.05-.16.09-.25.12l-.26.07a2.45 2.45 0 0 1-.55 0 1.42 1.42 0 0 1-.51-.19c-.08-.04-.15-.1-.22-.15a1.77 1.77 0 0 1-.35-.42c-.05-.08-.08-.16-.12-.25a1.97 1.97 0 0 1-.07-.26l-.02-.27c0-.1 0-.2.02-.28l.07-.26c.04-.08.07-.17.12-.25a1.77 1.77 0 0 1 .57-.57c.08-.05.17-.08.25-.12l.26-.07a2.64 2.64 0 0 1 .55 0l.26.07c.09.04.17.07.25.12a1.77 1.77 0 0 1 .42.35l.15.22a1.42 1.42 0 0 1 .2.79c.02.04.02-.05 0 0"
/>
</g>,
modifiedTablerIconProps,
);
// tabler-icons: lock-open (via Figma)
export const UnlockedIcon = createIcon(
<g>

View File

@ -141,6 +141,10 @@
"edit": "Edit line",
"editArrow": "Edit arrow"
},
"loopLock": {
"unlock": "Unlock loop",
"lock": "Lock loop"
},
"elementLock": {
"lock": "Lock",
"unlock": "Unlock",