From 54b4a304c9664efba9bc8c2d78edc9314fa7e9e7 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 4 May 2025 11:00:19 +0000 Subject: [PATCH] Loop Lock/Unlock --- packages/common/src/constants.ts | 2 + packages/element/src/linearElementEditor.ts | 37 +++++ packages/element/src/typeChecks.ts | 7 + packages/element/src/types.ts | 6 + .../excalidraw/actions/actionLinearEditor.tsx | 140 +++++++++++++++++- packages/excalidraw/actions/types.ts | 3 +- packages/excalidraw/components/Actions.tsx | 14 ++ packages/excalidraw/components/icons.tsx | 48 ++++++ packages/excalidraw/locales/en.json | 4 + 9 files changed, 254 insertions(+), 7 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index f50d86e4f..02999a05a 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -475,3 +475,5 @@ export enum UserIdleState { AWAY = "away", IDLE = "idle", } + +export const MIN_LOOP_LOCK_DISTANCE = 20; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 55e3f5c4f..130c1448b 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -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 diff --git a/packages/element/src/typeChecks.ts b/packages/element/src/typeChecks.ts index aed06c812..79d335710 100644 --- a/packages/element/src/typeChecks.ts +++ b/packages/element/src/typeChecks.ts @@ -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"; +}; diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index 1c8c22811..0a4d01122 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -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; diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 1645554bf..163bc47ad 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -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 ( + updateData(null)} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c4a4d2cce..cdfdc9131 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -141,7 +141,8 @@ export type ActionName = | "cropEditor" | "wrapSelectionInFrame" | "toggleLassoTool" - | "toggleShapeSwitch"; + | "toggleShapeSwitch" + | "toggleLoopLock"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 3a7df37a8..89dd50ee6 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -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")} )} diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index f3808a69d..02e6b6077 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -129,6 +129,54 @@ export const PinIcon = createIcon( tablerIconProps, ); +export const LoopLockedIcon = createIcon( + + + + + , + modifiedTablerIconProps, +); + +export const LoopUnlockedIcon = createIcon( + + + + + , + modifiedTablerIconProps, +); + // tabler-icons: lock-open (via Figma) export const UnlockedIcon = createIcon( diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 9a5211027..dda899bb2 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -141,6 +141,10 @@ "edit": "Edit line", "editArrow": "Edit arrow" }, + "loopLock": { + "unlock": "Unlock loop", + "lock": "Lock loop" + }, "elementLock": { "lock": "Lock", "unlock": "Unlock",