From 1fdf8967ed8a686dd76eadab16168cd2a6c6618e Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Sun, 4 May 2025 19:20:27 +0200 Subject: [PATCH] POC: auto-transform to polygon on bg set --- packages/element/src/shapes.ts | 80 +++++++++++++ .../excalidraw/actions/actionLinearEditor.tsx | 113 ++++-------------- .../excalidraw/actions/actionProperties.tsx | 54 +++++++-- 3 files changed, 143 insertions(+), 104 deletions(-) diff --git a/packages/element/src/shapes.ts b/packages/element/src/shapes.ts index 96542c538..25a481e77 100644 --- a/packages/element/src/shapes.ts +++ b/packages/element/src/shapes.ts @@ -5,6 +5,7 @@ import { ROUNDNESS, invariant, elementCenterPoint, + MIN_LOOP_LOCK_DISTANCE, } from "@excalidraw/common"; import { isPoint, @@ -39,6 +40,7 @@ import type { ElementsMap, ExcalidrawElement, ExcalidrawLinearElement, + ExcalidrawLineElement, NonDeleted, } from "./types"; @@ -396,3 +398,81 @@ export const isPathALoop = ( } return false; }; + +export const toggleLinePolygonState = ( + element: ExcalidrawLineElement, + nextPolygonState: boolean, +) => { + const updatedPoints = [...element.points]; + + if (nextPolygonState) { + 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], + ); + } + } else if (element.loopLock) { + // When toggling from loopLock=true to loopLock=false + // We need to dislocate the end point by 15 points + + // When loopLock is true, the last point is the same as the first point + // We'll use the direction from second-to-last point to first point + const firstPoint = updatedPoints[0]; + + if (updatedPoints.length >= 3) { + const secondLastPoint = updatedPoints[updatedPoints.length - 2]; + + // Get direction from second-last to first + const dx = firstPoint[0] - secondLastPoint[0]; + const dy = firstPoint[1] - secondLastPoint[1]; + + // Calculate perpendicular direction (rotate 90 degrees) + // This creates a visible gap perpendicular to the line direction + const perpDx = dy; + const perpDy = -dx; + + // Normalize the perpendicular direction vector + const perpLength = Math.sqrt(perpDx * perpDx + perpDy * perpDy); + let normalizedPerpDx = 0; + let normalizedPerpDy = 0; + + if (perpLength > 0) { + normalizedPerpDx = perpDx / perpLength; + normalizedPerpDy = perpDy / perpLength; + } else { + // Default perpendicular if points are the same + normalizedPerpDx = -0.7071; + normalizedPerpDy = 0.7071; + } + + // Move the end point perpendicular to the line direction + updatedPoints[updatedPoints.length - 1] = pointFrom( + firstPoint[0] + normalizedPerpDx * 15, + firstPoint[1] + normalizedPerpDy * 15, + ); + } else { + // For simple lines with fewer than 3 points + // Just move away from the first point at a 45-degree angle + updatedPoints[updatedPoints.length - 1] = pointFrom( + firstPoint[0] + 10.6, + firstPoint[1] - 10.6, // Different direction to avoid crossing + ); + } + } + + return { + loopLock: nextPolygonState, + points: updatedPoints, + }; +}; diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 50d3580e6..a3cc2015f 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -5,9 +5,6 @@ import { isLineElement, } from "@excalidraw/element/typeChecks"; import { arrayToMap } from "@excalidraw/common"; -import { MIN_LOOP_LOCK_DISTANCE } from "@excalidraw/common"; - -import { pointFrom } from "@excalidraw/math"; import type { ExcalidrawLinearElement, @@ -26,6 +23,10 @@ import { CaptureUpdateAction } from "../store"; import { ButtonIcon } from "../components/ButtonIcon"; +import { newElementWith } from "../../element/src/mutateElement"; + +import { toggleLinePolygonState } from "../../element/src/shapes"; + import { register } from "./register"; export const actionToggleLinearEditor = register({ @@ -96,85 +97,6 @@ export const actionToggleLinearEditor = register({ }, }); -const updateLoopLock = ( - element: ExcalidrawLineElement, - newLoopLockState: boolean, - app: any, -) => { - 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], - ); - } - } else if (element.loopLock) { - // When toggling from loopLock=true to loopLock=false - // We need to dislocate the end point by 15 points - - // When loopLock is true, the last point is the same as the first point - // We'll use the direction from second-to-last point to first point - const firstPoint = updatedPoints[0]; - - if (updatedPoints.length >= 3) { - const secondLastPoint = updatedPoints[updatedPoints.length - 2]; - - // Get direction from second-last to first - const dx = firstPoint[0] - secondLastPoint[0]; - const dy = firstPoint[1] - secondLastPoint[1]; - - // Calculate perpendicular direction (rotate 90 degrees) - // This creates a visible gap perpendicular to the line direction - const perpDx = dy; - const perpDy = -dx; - - // Normalize the perpendicular direction vector - const perpLength = Math.sqrt(perpDx * perpDx + perpDy * perpDy); - let normalizedPerpDx = 0; - let normalizedPerpDy = 0; - - if (perpLength > 0) { - normalizedPerpDx = perpDx / perpLength; - normalizedPerpDy = perpDy / perpLength; - } else { - // Default perpendicular if points are the same - normalizedPerpDx = -0.7071; - normalizedPerpDy = 0.7071; - } - - // Move the end point perpendicular to the line direction - updatedPoints[updatedPoints.length - 1] = pointFrom( - firstPoint[0] + normalizedPerpDx * 15, - firstPoint[1] + normalizedPerpDy * 15, - ); - } else { - // For simple lines with fewer than 3 points - // Just move away from the first point at a 45-degree angle - updatedPoints[updatedPoints.length - 1] = pointFrom( - firstPoint[0] + 10.6, - firstPoint[1] - 10.6, // Different direction to avoid crossing - ); - } - } - - app.scene.mutateElement(element, { - loopLock: newLoopLockState, - points: updatedPoints, - }); -}; - export const actionToggleLoopLock = register({ name: "toggleLoopLock", category: DEFAULT_CATEGORIES.elements, @@ -208,28 +130,33 @@ export const actionToggleLoopLock = register({ ); }, perform(elements, appState, _, app) { - const selectedElements = app.scene - .getSelectedElements({ - selectedElementIds: appState.selectedElementIds, - }) - .filter((element) => isLineElement(element)) as ExcalidrawLineElement[]; + const selectedElements = app.scene.getSelectedElements(appState); - if (!selectedElements.length) { + if (selectedElements.some((element) => !isLineElement(element))) { return false; } + const targetElements = selectedElements as ExcalidrawLineElement[]; + // 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 allLocked = targetElements.every((element) => element.loopLock); const newLoopLockState = !allLocked; - selectedElements.forEach((element) => { - updateLoopLock(element, newLoopLockState, app); - }); + const targetElementsMap = arrayToMap(targetElements); return { + elements: elements.map((element) => { + if (!targetElementsMap.has(element.id) || !isLineElement(element)) { + return element; + } + + return newElementWith( + element, + toggleLinePolygonState(element, newLoopLockState), + ); + }), appState, - elements, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 1749645ce..5f449cdf4 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -20,6 +20,7 @@ import { getShortcutKey, tupleToCoors, getLineHeight, + isTransparent, } from "@excalidraw/common"; import { getNonDeletedElements } from "@excalidraw/element"; @@ -46,6 +47,7 @@ import { isBoundToContainer, isElbowArrow, isLinearElement, + isLineElement, isTextElement, isUsingAdaptiveRadius, } from "@excalidraw/element/typeChecks"; @@ -133,6 +135,8 @@ import { } from "../scene"; import { CaptureUpdateAction } from "../store"; +import { toggleLinePolygonState } from "../../element/src/shapes"; + import { register } from "./register"; import type { CaptureUpdateActionType } from "../store"; @@ -346,22 +350,50 @@ export const actionChangeBackgroundColor = register({ name: "changeBackgroundColor", label: "labels.changeBackground", trackEvent: false, - perform: (elements, appState, value) => { - return { - ...(value.currentItemBackgroundColor && { - elements: changeProperty(elements, appState, (el) => - newElementWith(el, { + perform: (elements, appState, value, app) => { + if (!value.currentItemBackgroundColor) { + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + + const selectedElements = app.scene.getSelectedElements(appState); + const shouldEnablePolygon = selectedElements.every((el) => + isLineElement(el), + ); + + let nextElements; + + if ( + shouldEnablePolygon && + value.currentItemBackgroundColor && + !isTransparent(value.currentItemBackgroundColor) + ) { + const selectedElementsMap = arrayToMap(selectedElements); + nextElements = elements.map((el) => { + if (selectedElementsMap.has(el.id) && isLineElement(el)) { + return newElementWith(el, { backgroundColor: value.currentItemBackgroundColor, - }), - ), - }), + ...toggleLinePolygonState(el, true), + }); + } + return el; + }); + } else { + nextElements = changeProperty(elements, appState, (el) => + newElementWith(el, { + backgroundColor: value.currentItemBackgroundColor, + }), + ); + } + + return { + elements: nextElements, appState: { ...appState, ...value, }, - captureUpdate: !!value.currentItemBackgroundColor - ? CaptureUpdateAction.IMMEDIATELY - : CaptureUpdateAction.EVENTUALLY, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => (