diff --git a/.env.development b/.env.development index 387ab6204..bf641c34c 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,5 @@ +MODE="development" + VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ diff --git a/.env.production b/.env.production index 11e9fd84b..72dd24d6e 100644 --- a/.env.production +++ b/.env.production @@ -1,3 +1,5 @@ +MODE="production" + VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..aebac52a0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,45 @@ +# Project coding standards + +## Generic Communication Guidelines + +- Be succint and be aware that expansive generative AI answers are costly and slow +- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert +- Stop apologising if corrected, just provide the correct information or code +- Prefer code unless asked for explanation +- Stop summarizing what you've changed after modifications unless asked for + +## TypeScript Guidelines + +- Use TypeScript for all new code +- Where possible, prefer implementations without allocation +- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles +- Prefer immutable data (const, readonly) +- Use optional chaining (?.) and nullish coalescing (??) operators + +## React Guidelines + +- Use functional components with hooks +- Follow the React hooks rules (no conditional hooks) +- Keep components small and focused +- Use CSS modules for component styling + +## Naming Conventions + +- Use PascalCase for component names, interfaces, and type aliases +- Use camelCase for variables, functions, and methods +- Use ALL_CAPS for constants + +## Error Handling + +- Use try/catch blocks for async operations +- Implement proper error boundaries in React components +- Always log errors with contextual information + +## Testing + +- Always attempt to fix #problems +- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported + +## Types + +- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y} diff --git a/.gitignore b/.gitignore index 6f9407fad..6f3a62bba 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ packages/excalidraw/types coverage dev-dist html -meta*.json \ No newline at end of file +meta*.json +.claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..4faf291ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,34 @@ +# CLAUDE.md + +## Project Structure + +Excalidraw is a **monorepo** with a clear separation between the core library and the application: + +- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw` +- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library +- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils` +- **`examples/`** - Integration examples (NextJS, browser script) + +## Development Workflow + +1. **Package Development**: Work in `packages/*` for editor features +2. **App Development**: Work in `excalidraw-app/` for app-specific features +3. **Testing**: Always run `yarn test:update` before committing +4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript + +## Development Commands + +```bash +yarn test:typecheck # TypeScript type checking +yarn test:update # Run all tests (with snapshot updates) +yarn fix # Auto-fix formatting and linting issues +``` + +## Architecture Notes + +### Package System + +- Uses Yarn workspaces for monorepo management +- Internal packages use path aliases (see `vitest.config.mts`) +- Build system uses esbuild for packages, Vite for the app +- TypeScript throughout with strict configuration diff --git a/README.md b/README.md index 7d8aa622a..f1cf03053 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Chat on Discord + + Ask DeepWiki + Follow Excalidraw on Twitter diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index c6d5fcc6b..45b1fc348 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -187,10 +187,12 @@ export class Delta { return; } - if ( - typeof deleted[property] === "object" || - typeof inserted[property] === "object" - ) { + const isDeletedObject = + deleted[property] !== null && typeof deleted[property] === "object"; + const isInsertedObject = + inserted[property] !== null && typeof inserted[property] === "object"; + + if (isDeletedObject || isInsertedObject) { type RecordLike = Record; const deletedObject: RecordLike = deleted[property] ?? {}; @@ -222,6 +224,9 @@ export class Delta { Reflect.deleteProperty(deleted, property); Reflect.deleteProperty(inserted, property); } + } else if (deleted[property] === inserted[property]) { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); } } @@ -658,6 +663,24 @@ export class AppStateDelta implements DeltaContainer { } break; + case "lockedMultiSelections": { + const prevLockedUnits = prevAppState[key] || {}; + const nextLockedUnits = nextAppState[key] || {}; + + if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) { + visibleDifferenceFlag.value = true; + } + break; + } + case "activeLockedId": { + const prevHitLockedId = prevAppState[key] || null; + const nextHitLockedId = nextAppState[key] || null; + + if (prevHitLockedId !== nextHitLockedId) { + visibleDifferenceFlag.value = true; + } + break; + } default: { assertNever( key, @@ -753,6 +776,8 @@ export class AppStateDelta implements DeltaContainer { editingLinearElementId, selectedLinearElementId, croppingElementId, + lockedMultiSelections, + activeLockedId, ...standaloneProps } = delta as ObservedAppState; @@ -797,6 +822,18 @@ export class AppStateDelta implements DeltaContainer { "selectedGroupIds", (prevValue) => (prevValue ?? false) as ValueOf, ); + Delta.diffObjects( + deleted, + inserted, + "lockedMultiSelections", + (prevValue) => (prevValue ?? {}) as ValueOf, + ); + Delta.diffObjects( + deleted, + inserted, + "activeLockedId", + (prevValue) => (prevValue ?? null) as ValueOf, + ); } catch (e) { // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it console.error(`Couldn't postprocess appstate change deltas.`); diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 95a2aa8ef..73c82a898 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -974,6 +974,25 @@ export const updateElbowArrowPoints = ( ), "Elbow arrow segments must be either horizontal or vertical", ); + + invariant( + updates.fixedSegments?.find( + (segment) => + segment.index === 1 && + pointsEqual(segment.start, (updates.points ?? arrow.points)[0]), + ) == null && + updates.fixedSegments?.find( + (segment) => + segment.index === (updates.points ?? arrow.points).length - 1 && + pointsEqual( + segment.end, + (updates.points ?? arrow.points)[ + (updates.points ?? arrow.points).length - 1 + ], + ), + ) == null, + "The first and last segments cannot be fixed", + ); } const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 397f042f0..1273745d0 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -117,12 +117,6 @@ const getNormalizedPoints = ({ }; }; -const editorMidPointsCache: { - version: number | null; - points: (GlobalPoint | null)[]; - zoom: number | null; -} = { version: null, points: [], zoom: null }; - export class LinearElementEditor { public readonly elementId: ExcalidrawElement["id"] & { _brand: "excalidrawLinearElementId"; @@ -585,7 +579,7 @@ export class LinearElementEditor { element: NonDeleted, elementsMap: ElementsMap, appState: InteractiveCanvasAppState, - ): typeof editorMidPointsCache["points"] => { + ): (GlobalPoint | null)[] => { const boundText = getBoundTextElement(element, elementsMap); // Since its not needed outside editor unless 2 pointer lines or bound text @@ -597,25 +591,7 @@ export class LinearElementEditor { ) { return []; } - if ( - editorMidPointsCache.version === element.version && - editorMidPointsCache.zoom === appState.zoom.value - ) { - return editorMidPointsCache.points; - } - LinearElementEditor.updateEditorMidPointsCache( - element, - elementsMap, - appState, - ); - return editorMidPointsCache.points!; - }; - static updateEditorMidPointsCache = ( - element: NonDeleted, - elementsMap: ElementsMap, - appState: InteractiveCanvasAppState, - ) => { const points = LinearElementEditor.getPointsGlobalCoordinates( element, elementsMap, @@ -647,9 +623,8 @@ export class LinearElementEditor { midpoints.push(segmentMidPoint); index++; } - editorMidPointsCache.points = midpoints; - editorMidPointsCache.version = element.version; - editorMidPointsCache.zoom = appState.zoom.value; + + return midpoints; }; static getSegmentMidpointHitCoords = ( @@ -703,8 +678,11 @@ export class LinearElementEditor { } } let index = 0; - const midPoints: typeof editorMidPointsCache["points"] = - LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ); while (index < midPoints.length) { if (midPoints[index] !== null) { @@ -1678,23 +1656,14 @@ export class LinearElementEditor { y = midPoint[1] - boundTextElement.height / 2; } else { const index = element.points.length / 2 - 1; + const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( + element, + points[index], + points[index + 1], + index + 1, + elementsMap, + ); - let midSegmentMidpoint = editorMidPointsCache.points[index]; - if (element.points.length === 2) { - midSegmentMidpoint = pointCenter(points[0], points[1]); - } - if ( - !midSegmentMidpoint || - editorMidPointsCache.version !== element.version - ) { - midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( - element, - points[index], - points[index + 1], - index + 1, - elementsMap, - ); - } x = midSegmentMidpoint[0] - boundTextElement.width / 2; y = midSegmentMidpoint[1] - boundTextElement.height / 2; } diff --git a/packages/element/src/sizeHelpers.ts b/packages/element/src/sizeHelpers.ts index bd3d3fb0c..a8339b49f 100644 --- a/packages/element/src/sizeHelpers.ts +++ b/packages/element/src/sizeHelpers.ts @@ -3,13 +3,21 @@ import { viewportCoordsToSceneCoords, } from "@excalidraw/common"; +import { pointsEqual } from "@excalidraw/math"; + import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types"; import { getCommonBounds, getElementBounds } from "./bounds"; -import { isFreeDrawElement, isLinearElement } from "./typeChecks"; +import { + isArrowElement, + isFreeDrawElement, + isLinearElement, +} from "./typeChecks"; import type { ElementsMap, ExcalidrawElement } from "./types"; +export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1; + // TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted // - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize' // - could also be part of `_clearElements` @@ -17,8 +25,18 @@ export const isInvisiblySmallElement = ( element: ExcalidrawElement, ): boolean => { if (isLinearElement(element) || isFreeDrawElement(element)) { - return element.points.length < 2; + return ( + element.points.length < 2 || + (element.points.length === 2 && + isArrowElement(element) && + pointsEqual( + element.points[0], + element.points[element.points.length - 1], + INVISIBLY_SMALL_ELEMENT_SIZE, + )) + ); } + return element.width === 0 && element.height === 0; }; diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index e50a94a86..fb8926d88 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -939,6 +939,8 @@ const getDefaultObservedAppState = (): ObservedAppState => { editingLinearElementId: null, selectedLinearElementId: null, croppingElementId: null, + activeLockedId: null, + lockedMultiSelections: {}, }; }; @@ -952,6 +954,8 @@ export const getObservedAppState = (appState: AppState): ObservedAppState => { editingLinearElementId: appState.editingLinearElement?.elementId || null, selectedLinearElementId: appState.selectedLinearElement?.elementId || null, croppingElementId: appState.croppingElementId, + activeLockedId: appState.activeLockedId, + lockedMultiSelections: appState.lockedMultiSelections, }; Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index 50c2dcfc6..9c416f6ef 100644 --- a/packages/element/tests/delta.test.tsx +++ b/packages/element/tests/delta.test.tsx @@ -16,6 +16,8 @@ describe("AppStateDelta", () => { editingGroupId: null, croppingElementId: null, editingLinearElementId: null, + lockedMultiSelections: {}, + activeLockedId: null, }; const prevAppState1: ObservedAppState = { @@ -57,6 +59,8 @@ describe("AppStateDelta", () => { croppingElementId: null, selectedLinearElementId: null, editingLinearElementId: null, + activeLockedId: null, + lockedMultiSelections: {}, }; const prevAppState1: ObservedAppState = { @@ -102,6 +106,8 @@ describe("AppStateDelta", () => { croppingElementId: null, selectedLinearElementId: null, editingLinearElementId: null, + activeLockedId: null, + lockedMultiSelections: {}, }; const prevAppState1: ObservedAppState = { diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 77b99fa7f..85987428e 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -292,7 +292,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` @@ -333,7 +333,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `9`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints( h.elements[0] as ExcalidrawLinearElement, @@ -394,7 +394,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect([line.x, line.y]).toEqual([ points[0][0] + deltaX, @@ -462,7 +462,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `16`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(line.points.length).toEqual(5); @@ -513,7 +513,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -554,7 +554,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -602,7 +602,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `18`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, @@ -660,7 +660,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `16`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) @@ -758,7 +758,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 7d3daaaec..aaecf1c4d 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -1,8 +1,10 @@ -import { KEYS, arrayToMap } from "@excalidraw/common"; +import { KEYS, arrayToMap, randomId } from "@excalidraw/common"; -import { newElementWith } from "@excalidraw/element"; - -import { isFrameLikeElement } from "@excalidraw/element"; +import { + elementsAreInSameGroup, + newElementWith, + selectGroupsFromGivenElements, +} from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; @@ -14,6 +16,8 @@ import { getSelectedElements } from "../scene"; import { register } from "./register"; +import type { AppState } from "../types"; + const shouldLock = (elements: readonly ExcalidrawElement[]) => elements.every((el) => !el.locked); @@ -24,15 +28,10 @@ export const actionToggleElementLock = register({ selectedElementIds: appState.selectedElementIds, includeBoundTextElement: false, }); - if (selected.length === 1 && !isFrameLikeElement(selected[0])) { - return selected[0].locked - ? "labels.elementLock.unlock" - : "labels.elementLock.lock"; - } return shouldLock(selected) - ? "labels.elementLock.lockAll" - : "labels.elementLock.unlockAll"; + ? "labels.elementLock.lock" + : "labels.elementLock.unlock"; }, icon: (appState, elements) => { const selectedElements = getSelectedElements(elements, appState); @@ -59,19 +58,84 @@ export const actionToggleElementLock = register({ const nextLockState = shouldLock(selectedElements); const selectedElementsMap = arrayToMap(selectedElements); - return { - elements: elements.map((element) => { - if (!selectedElementsMap.has(element.id)) { - return element; - } - return newElementWith(element, { locked: nextLockState }); - }), + const isAGroup = + selectedElements.length > 1 && elementsAreInSameGroup(selectedElements); + const isASingleUnit = selectedElements.length === 1 || isAGroup; + const newGroupId = isASingleUnit ? null : randomId(); + + let nextLockedMultiSelections = { ...appState.lockedMultiSelections }; + + if (nextLockState) { + nextLockedMultiSelections = { + ...appState.lockedMultiSelections, + ...(newGroupId ? { [newGroupId]: true } : {}), + }; + } else if (isAGroup) { + const groupId = selectedElements[0].groupIds.at(-1)!; + delete nextLockedMultiSelections[groupId]; + } + + const nextElements = elements.map((element) => { + if (!selectedElementsMap.has(element.id)) { + return element; + } + + let nextGroupIds = element.groupIds; + + // if locking together, add to group + // if unlocking, remove the temporary group + if (nextLockState) { + if (newGroupId) { + nextGroupIds = [...nextGroupIds, newGroupId]; + } + } else { + nextGroupIds = nextGroupIds.filter( + (groupId) => !appState.lockedMultiSelections[groupId], + ); + } + + return newElementWith(element, { + locked: nextLockState, + // do not recreate the array unncessarily + groupIds: + nextGroupIds.length !== element.groupIds.length + ? nextGroupIds + : element.groupIds, + }); + }); + + const nextElementsMap = arrayToMap(nextElements); + const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState + ? {} + : Object.fromEntries(selectedElements.map((el) => [el.id, true])); + const unlockedSelectedElements = selectedElements.map( + (el) => nextElementsMap.get(el.id) || el, + ); + const nextSelectedGroupIds = nextLockState + ? {} + : selectGroupsFromGivenElements(unlockedSelectedElements, appState); + + const activeLockedId = nextLockState + ? newGroupId + ? newGroupId + : isAGroup + ? selectedElements[0].groupIds.at(-1)! + : selectedElements[0].id + : null; + + return { + elements: nextElements, + appState: { ...appState, + selectedElementIds: nextSelectedElementIds, + selectedGroupIds: nextSelectedGroupIds, selectedLinearElement: nextLockState ? null : appState.selectedLinearElement, + lockedMultiSelections: nextLockedMultiSelections, + activeLockedId, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; @@ -104,18 +168,44 @@ export const actionUnlockAllElements = register({ perform: (elements, appState) => { const lockedElements = elements.filter((el) => el.locked); + const nextElements = elements.map((element) => { + if (element.locked) { + // remove the temporary groupId if it exists + const nextGroupIds = element.groupIds.filter( + (gid) => !appState.lockedMultiSelections[gid], + ); + + return newElementWith(element, { + locked: false, + groupIds: + // do not recreate the array unncessarily + element.groupIds.length !== nextGroupIds.length + ? nextGroupIds + : element.groupIds, + }); + } + return element; + }); + + const nextElementsMap = arrayToMap(nextElements); + + const unlockedElements = lockedElements.map( + (el) => nextElementsMap.get(el.id) || el, + ); + return { - elements: elements.map((element) => { - if (element.locked) { - return newElementWith(element, { locked: false }); - } - return element; - }), + elements: nextElements, appState: { ...appState, selectedElementIds: Object.fromEntries( lockedElements.map((el) => [el.id, true]), ), + selectedGroupIds: selectGroupsFromGivenElements( + unlockedElements, + appState, + ), + lockedMultiSelections: {}, + activeLockedId: null, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 7de9c3ce8..ea033073d 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -3,8 +3,9 @@ import { pointFrom } from "@excalidraw/math"; import { maybeBindLinearElement, bindOrUnbindLinearElement, -} from "@excalidraw/element"; -import { LinearElementEditor } from "@excalidraw/element"; + isBindingEnabled, +} from "@excalidraw/element/binding"; +import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { isBindingElement, @@ -21,6 +22,11 @@ import { isInvisiblySmallElement } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; import type { LocalPoint } from "@excalidraw/math"; +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + NonDeleted, +} from "@excalidraw/element/types"; import { t } from "../i18n"; import { resetCursor } from "../cursor"; @@ -35,11 +41,50 @@ export const actionFinalize = register({ name: "finalize", label: "", trackEvent: false, - perform: (elements, appState, _, app) => { + perform: (elements, appState, data, app) => { const { interactiveCanvas, focusContainer, scene } = app; const elementsMap = scene.getNonDeletedElementsMap(); + if (data?.event && appState.selectedLinearElement) { + const linearElementEditor = LinearElementEditor.handlePointerUp( + data.event, + appState.selectedLinearElement, + appState, + app.scene, + ); + + const { startBindingElement, endBindingElement } = linearElementEditor; + const element = app.scene.getElement(linearElementEditor.elementId); + if (isBindingElement(element)) { + bindOrUnbindLinearElement( + element, + startBindingElement, + endBindingElement, + app.scene, + ); + } + + if (linearElementEditor !== appState.selectedLinearElement) { + let newElements = elements; + if (element && isInvisiblySmallElement(element)) { + // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want + newElements = newElements.filter((el) => el.id !== element!.id); + } + return { + elements: newElements, + appState: { + selectedLinearElement: { + ...linearElementEditor, + selectedPointsIndices: null, + }, + suggestedBindings: [], + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } + } + if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; @@ -87,83 +132,92 @@ export const actionFinalize = register({ focusContainer(); } - const multiPointElement = appState.multiElement - ? appState.multiElement - : isFreeDrawElement(appState.newElement) - ? appState.newElement - : null; + let element: NonDeleted | null = null; + if (appState.multiElement) { + element = appState.multiElement; + } else if ( + appState.newElement?.type === "freedraw" || + isBindingElement(appState.newElement) + ) { + element = appState.newElement; + } else if (Object.keys(appState.selectedElementIds).length === 1) { + const candidate = elementsMap.get( + Object.keys(appState.selectedElementIds)[0], + ) as NonDeleted | undefined; + if (candidate) { + element = candidate; + } + } - if (multiPointElement) { + if (element) { // pen and mouse have hover if ( - !isFreeDrawElement(multiPointElement) && + appState.multiElement && + element.type !== "freedraw" && appState.lastPointerDownWith !== "touch" ) { - const { points, lastCommittedPoint } = multiPointElement; + const { points, lastCommittedPoint } = element; if ( !lastCommittedPoint || points[points.length - 1] !== lastCommittedPoint ) { - scene.mutateElement(multiPointElement, { - points: multiPointElement.points.slice(0, -1), + scene.mutateElement(element, { + points: element.points.slice(0, -1), }); } } - if (isInvisiblySmallElement(multiPointElement)) { + if (element && isInvisiblySmallElement(element)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want - newElements = newElements.filter( - (el) => el.id !== multiPointElement.id, - ); + newElements = newElements.filter((el) => el.id !== element!.id); } - // If the multi point line closes the loop, - // set the last point to first point. - // This ensures that loop remains closed at different scales. - const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); - if ( - isLineElement(multiPointElement) || - isFreeDrawElement(multiPointElement) - ) { + if (isLineElement(element) || isFreeDrawElement(element)) { + // If the multi point line closes the loop, + // set the last point to first point. + // This ensures that loop remains closed at different scales. + const isLoop = isPathALoop(element.points, appState.zoom.value); + if (isLoop) { - const linePoints = multiPointElement.points; + const linePoints = element.points; const firstPoint = linePoints[0]; const points: LocalPoint[] = linePoints.map((p, index) => index === linePoints.length - 1 ? pointFrom(firstPoint[0], firstPoint[1]) : p, ); - if (isLineElement(multiPointElement)) { - scene.mutateElement(multiPointElement, { + if (isLineElement(element)) { + scene.mutateElement(element, { points, polygon: true, }); } else { - scene.mutateElement(multiPointElement, { + scene.mutateElement(element, { points, }); } } - } - if ( - isBindingElement(multiPointElement) && - !isLoop && - multiPointElement.points.length > 1 - ) { - const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - multiPointElement, - -1, - arrayToMap(elements), - ); - maybeBindLinearElement(multiPointElement, appState, { x, y }, scene); + if ( + isBindingElement(element) && + !isLoop && + element.points.length > 1 && + isBindingEnabled(appState) + ) { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + -1, + arrayToMap(elements), + ); + maybeBindLinearElement(element, appState, { x, y }, scene); + } } } if ( (!appState.activeTool.locked && appState.activeTool.type !== "freedraw") || - !multiPointElement + !element ) { resetCursor(interactiveCanvas); } @@ -190,7 +244,7 @@ export const actionFinalize = register({ activeTool: (appState.activeTool.locked || appState.activeTool.type === "freedraw") && - multiPointElement + element ? appState.activeTool : activeTool, activeEmbeddable: null, @@ -201,21 +255,18 @@ export const actionFinalize = register({ startBoundElement: null, suggestedBindings: [], selectedElementIds: - multiPointElement && + element && !appState.activeTool.locked && appState.activeTool.type !== "freedraw" ? { ...appState.selectedElementIds, - [multiPointElement.id]: true, + [element.id]: true, } : appState.selectedElementIds, // To select the linear element when user has finished mutipoint editing selectedLinearElement: - multiPointElement && isLinearElement(multiPointElement) - ? new LinearElementEditor( - multiPointElement, - arrayToMap(newElements), - ) + element && isLinearElement(element) + ? new LinearElementEditor(element, arrayToMap(newElements)) : appState.selectedLinearElement, pendingImageElementId: null, }, diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 8d2c1a073..ab971b886 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1520,13 +1520,13 @@ const getArrowheadOptions = (flip: boolean) => { value: "crowfoot_one", text: t("labels.arrowhead_crowfoot_one"), icon: , - keyBinding: "c", + keyBinding: "x", }, { value: "crowfoot_many", text: t("labels.arrowhead_crowfoot_many"), icon: , - keyBinding: "x", + keyBinding: "c", }, { value: "crowfoot_one_or_many", @@ -1760,12 +1760,6 @@ export const actionChangeArrowType = register({ fixedSegments: null, }), }; - - LinearElementEditor.updateEditorMidPointsCache( - newElement, - elementsMap, - app.state, - ); } else { const elementsMap = app.scene.getNonDeletedElementsMap(); if (newElement.startBinding) { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 2e4ae8348..b45a6f7d3 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -122,6 +122,8 @@ export const getDefaultAppState = (): Omit< isCropping: false, croppingElementId: null, searchMatches: null, + lockedMultiSelections: {}, + activeLockedId: null, }; }; @@ -246,6 +248,8 @@ const APP_STATE_STORAGE_CONF = (< isCropping: { browser: false, export: false, server: false }, croppingElementId: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false }, + lockedMultiSelections: { browser: true, export: true, server: true }, + activeLockedId: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b0c43359b..c8f16af9e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -107,13 +107,11 @@ import { import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; import { - bindOrUnbindLinearElement, bindOrUnbindLinearElements, fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, isLinearElementSimpleAndAlreadyBound, - maybeBindLinearElement, shouldEnableBindingForPointerEvent, updateBoundElements, getSuggestedBindingsForArrows, @@ -485,6 +483,8 @@ import { Toast } from "./Toast"; import { findShapeByKey } from "./shapes"; +import UnlockPopup from "./UnlockPopup"; + import type { RenderInteractiveSceneCallback, ScrollBars, @@ -1876,6 +1876,12 @@ class App extends React.Component { /> )} {this.renderFrameNames()} + {this.state.activeLockedId && ( + + )} {showShapeSwitchPanel && ( )} @@ -2789,7 +2795,6 @@ class App extends React.Component { this.updateEmbeddables(); const elements = this.scene.getElementsIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted(); - const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap(); if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); @@ -2936,27 +2941,6 @@ class App extends React.Component { this.setState({ selectedLinearElement: null }); } - const { multiElement } = prevState; - if ( - prevState.activeTool !== this.state.activeTool && - multiElement != null && - isBindingEnabled(this.state) && - isBindingElement(multiElement, false) - ) { - maybeBindLinearElement( - multiElement, - this.state, - tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - multiElement, - -1, - nonDeletedElementsMap, - ), - ), - this.scene, - ); - } - this.store.commit(elementsMap, this.state); // Do not notify consumers if we're still loading the scene. Among other @@ -5114,18 +5098,27 @@ class App extends React.Component { private getElementAtPosition( x: number, y: number, - opts?: { + opts?: ( + | { + includeBoundTextElement?: boolean; + includeLockedElements?: boolean; + } + | { + allHitElements: NonDeleted[]; + } + ) & { preferSelected?: boolean; - includeBoundTextElement?: boolean; - includeLockedElements?: boolean; }, ): NonDeleted | null { - const allHitElements = this.getElementsAtPosition( - x, - y, - opts?.includeBoundTextElement, - opts?.includeLockedElements, - ); + let allHitElements: NonDeleted[] = []; + 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 +5161,24 @@ class App extends React.Component { private getElementsAtPosition( x: number, y: number, - includeBoundTextElement: boolean = false, - includeLockedElements: boolean = false, + opts?: { + includeBoundTextElement?: boolean; + includeLockedElements?: boolean; + }, ): NonDeleted[] { const iframeLikes: Ordered[] = []; 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 +5664,21 @@ class App extends React.Component { 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 +6160,25 @@ class App extends React.Component { } } - 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 +6271,7 @@ class App extends React.Component { selectGroupsForSelectedElements( { editingGroupId: prevState.editingGroupId, - selectedElementIds: { [hitElement.id]: true }, + selectedElementIds: { [hitElement!.id]: true }, }, this.scene.getNonDeletedElements(), prevState, @@ -6772,6 +6785,9 @@ class App extends React.Component { const hitElement = this.getElementAtPosition( scenePointer.x, scenePointer.y, + { + includeLockedElements: true, + }, ); this.hitLinkElement = this.getElementLinkAtPosition( scenePointer, @@ -7207,17 +7223,57 @@ class App extends React.Component { 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 +7303,7 @@ class App extends React.Component { // 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 +8119,12 @@ class App extends React.Component { } const pointerCoords = viewportCoordsToSceneCoords(event, this.state); + if (this.state.activeLockedId) { + this.setState({ + activeLockedId: null, + }); + } + if ( this.state.selectedLinearElement && this.state.selectedLinearElement.elbowed && @@ -8947,6 +9006,49 @@ class App extends React.Component { 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, }); @@ -9017,34 +9119,9 @@ class App extends React.Component { this.setState({ selectedLinearElement: null }); } } else { - const linearElementEditor = LinearElementEditor.handlePointerUp( - childEvent, - this.state.selectedLinearElement, - this.state, - this.scene, - ); - - const { startBindingElement, endBindingElement } = - linearElementEditor; - const element = this.scene.getElement(linearElementEditor.elementId); - if (isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - this.scene, - ); - } - - if (linearElementEditor !== this.state.selectedLinearElement) { - this.setState({ - selectedLinearElement: { - ...linearElementEditor, - selectedPointsIndices: null, - }, - suggestedBindings: [], - }); - } + this.actionManager.executeAction(actionFinalize, "ui", { + event: childEvent, + }); } } @@ -9168,12 +9245,7 @@ class App extends React.Component { isBindingEnabled(this.state) && isBindingElement(newElement, false) ) { - maybeBindLinearElement( - newElement, - this.state, - pointerCoords, - this.scene, - ); + this.actionManager.executeAction(actionFinalize); } this.setState({ suggestedBindings: [], startBoundElement: null }); if (!activeTool.locked) { diff --git a/packages/excalidraw/components/ConvertElementTypePopup.tsx b/packages/excalidraw/components/ConvertElementTypePopup.tsx index b1dd11cb4..8e527d549 100644 --- a/packages/excalidraw/components/ConvertElementTypePopup.tsx +++ b/packages/excalidraw/components/ConvertElementTypePopup.tsx @@ -564,7 +564,7 @@ export const convertElementTypes = ( continue; } const fixedSegments: FixedSegment[] = []; - for (let i = 0; i < nextPoints.length - 1; i++) { + for (let i = 1; i < nextPoints.length - 2; i++) { fixedSegments.push({ start: nextPoints[i], end: nextPoints[i + 1], @@ -581,6 +581,7 @@ export const convertElementTypes = ( ); mutateElement(element, app.scene.getNonDeletedElementsMap(), { ...updates, + endArrowhead: "arrow", }); } else { // if we're converting to non-elbow linear element, check if diff --git a/packages/excalidraw/components/UnlockPopup.scss b/packages/excalidraw/components/UnlockPopup.scss new file mode 100644 index 000000000..3dd7f8c7b --- /dev/null +++ b/packages/excalidraw/components/UnlockPopup.scss @@ -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); + } + } + } +} diff --git a/packages/excalidraw/components/UnlockPopup.tsx b/packages/excalidraw/components/UnlockPopup.tsx new file mode 100644 index 000000000..fe1f328fb --- /dev/null +++ b/packages/excalidraw/components/UnlockPopup.tsx @@ -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; +}) => { + 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 ( +
{ + 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} +
+ ); +}; + +export default UnlockPopup; diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 6505c788a..37c754cb9 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -215,6 +215,7 @@ const getRelevantAppStateProps = ( isCropping: appState.isCropping, croppingElementId: appState.croppingElementId, searchMatches: appState.searchMatches, + activeLockedId: appState.activeLockedId, }); const areEqual = ( diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index b426519d4..29bdc6d3c 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -193,6 +193,16 @@ export const LockedIcon = createIcon( modifiedTablerIconProps, ); +export const LockedIconFilled = createIcon( + + + , + { + width: 24, + height: 24, + }, +); + // custom export const WelcomeScreenMenuArrow = createIcon( <> diff --git a/packages/excalidraw/lasso/utils.ts b/packages/excalidraw/lasso/utils.ts index d05f39998..0721ccf0e 100644 --- a/packages/excalidraw/lasso/utils.ts +++ b/packages/excalidraw/lasso/utils.ts @@ -37,9 +37,10 @@ export const getLassoSelectedElementIds = (input: { if (simplifyDistance) { path = simplify(lassoPath, simplifyDistance) as GlobalPoint[]; } + const unlockedElements = elements.filter((el) => !el.locked); // as the path might not enclose a shape anymore, clear before checking enclosedElements.clear(); - for (const element of elements) { + for (const element of unlockedElements) { if ( !intersectedElements.has(element.id) && !enclosedElements.has(element.id) diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 67ebf1812..ac0cb5269 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -383,7 +383,9 @@ const renderElementsBoxHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, elements: NonDeleted[], + config?: { colors?: string[]; dashed?: boolean }, ) => { + const { colors = ["rgb(0,118,255)"], dashed = false } = config || {}; const individualElements = elements.filter( (element) => element.groupIds.length === 0, ); @@ -400,8 +402,8 @@ const renderElementsBoxHighlight = ( x2, y1, y2, - selectionColors: ["rgb(0,118,255)"], - dashed: false, + selectionColors: colors, + dashed, cx: x1 + (x2 - x1) / 2, cy: y1 + (y2 - y1) / 2, activeEmbeddable: false, @@ -819,6 +821,17 @@ const _renderInteractiveScene = ({ renderElementsBoxHighlight(context, appState, appState.elementsToHighlight); } + if (appState.activeLockedId) { + const element = allElementsMap.get(appState.activeLockedId); + const elements = element + ? [element] + : getElementsInGroup(allElementsMap, appState.activeLockedId); + renderElementsBoxHighlight(context, appState, elements, { + colors: ["#ced4da"], + dashed: true, + }); + } + const isFrameSelected = selectedElements.some((element) => isFrameLikeElement(element), ); @@ -933,8 +946,8 @@ const _renderInteractiveScene = ({ y1, x2, y2, - selectionColors, - dashed: !!remoteClients, + selectionColors: element.locked ? ["#ced4da"] : selectionColors, + dashed: !!remoteClients || element.locked, cx, cy, activeEmbeddable: @@ -958,7 +971,9 @@ const _renderInteractiveScene = ({ x2, y1, y2, - selectionColors: [oc.black], + selectionColors: groupElements.some((el) => el.locked) + ? ["#ced4da"] + : [oc.black], dashed: true, cx: x1 + (x2 - x1) / 2, cy: y1 + (y2 - y1) / 2, @@ -1022,7 +1037,11 @@ const _renderInteractiveScene = ({ ); } } - } else if (selectedElements.length > 1 && !appState.isRotating) { + } else if ( + selectedElements.length > 1 && + !appState.isRotating && + !selectedElements.some((el) => el.locked) + ) { const dashedLinePadding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; context.fillStyle = oc.white; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 6c258c621..23f4ccb4f 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -3,6 +3,7 @@ exports[`contextMenu element > right-clicking on a group should select whole group > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -935,6 +936,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -1078,6 +1080,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -1136,6 +1139,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -1292,6 +1296,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -1350,6 +1355,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -1623,6 +1629,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -1681,6 +1688,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -1954,6 +1962,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -2012,6 +2021,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -2168,6 +2178,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -2226,6 +2237,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -2407,6 +2419,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -2465,6 +2478,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -2707,6 +2721,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -2765,6 +2780,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -3077,6 +3093,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -3135,6 +3152,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -3558,6 +3576,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -3616,6 +3635,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -3881,6 +3901,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -3939,6 +3960,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -4204,6 +4226,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -4262,6 +4285,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -4609,6 +4633,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -5541,6 +5566,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -5828,6 +5854,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -6760,6 +6787,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -7094,6 +7122,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -7693,6 +7722,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -7759,6 +7789,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] und exports[`contextMenu element > shows context menu for element > [end of test] appState 1`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -8691,6 +8722,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, @@ -8748,6 +8780,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap exports[`contextMenu element > shows context menu for element > [end of test] appState 2`] = ` { "activeEmbeddable": null, + "activeLockedId": null, "activeTool": { "customType": null, "fromSelection": false, @@ -9680,6 +9713,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", + "lockedMultiSelections": {}, "multiElement": null, "name": "Untitled-201933152653", "newElement": null, diff --git a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap index a561ad5e0..310527393 100644 --- a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`export > export svg-embedded scene > svg-embdedded scene export output 1`] = ` -"eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTTU/jMFx1MDAxML33V0TmulrSXCL20Fx1MDAxYrtcdTAwMGKCw+5hi8RcdTAwMDFxMPE0XHUwMDE51bUte0JbUCV+xt72L/JcdTAwMTNcdTAwMTi7JW5SNpFcIvnN15vnl5dRUVxi2jhcdTAwMTDTQsC6klx1MDAxYZWXK/El4k/gXHUwMDAzWsOhSTpcdTAwMDfb+iplNkRuenqqLVx1MDAxNzQ20PSsLMtdXHUwMDExaFiCocBp93wuipf05Vxiqlh6kdJcdTAwMTLwMZdgTVx1MDAxOV0zVHanTe+0QkVcciPjb1x1MDAxZNRcdTAwMDDWXHL1MWlqXHK9wkDeLuCH1dbHiSdjiG9cdTAwMWX6KKtF7W1rVJdDXprgpOdlct5cdTAwMWO1ntEmdWc9WC0xmHG3pzhcdTAwMTng/6vioXVjIETBxlx1MDAxZGqdrJDi8uMyb1x1MDAxMVx1MDAxObpcdTAwMWKVtH3InLxcXMJNXHUwMDE017RadzBcdTAwMWFcdTAwMDXrIZhW3E/7uJh8XHUwMDEzZ3tkm7lcdTAwMDOoXHUwMDFlseyJI+y3NVVfdVxmP9lcdTAwMGWUWsylXHUwMDBlkPWOPC6zVXokW6ckXHLmajSLYVx1MDAxZdtv8UnvZCdcdTAwMTb67d/f14Obs4Zm+Fx1MDAxY1x0TspcdTAwMWV6JZeoo9TnvVx1MDAxNlx1MDAxN1x1MDAxYeu4p9AwP3BcdTAwMDAvS8i278JkXY5W3E+iXHUwMDAxf3xcdTAwMWbWY41G6ttP6cmW7Fx1MDAxZlxiO4LkWzjcXHUwMDFjrjuTf52cp8CWv8lcdTAwMDJCOjcj1qu7UbZcdKrBqjuMwOU1XHUwMDEz9MsquDTyUVx1MDAwZnVcdTAwMTRPXGKr78d/xck8PWK0d0n8IyC5aTvavlx1MDAwM9lcdTAwMDIhXHUwMDFjIn0=