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/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/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/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..8bef9703d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -485,6 +485,8 @@ import { Toast } from "./Toast"; import { findShapeByKey } from "./shapes"; +import UnlockPopup from "./UnlockPopup"; + import type { RenderInteractiveSceneCallback, ScrollBars, @@ -1876,6 +1878,12 @@ class App extends React.Component { /> )} {this.renderFrameNames()} + {this.state.activeLockedId && ( + + )} {showShapeSwitchPanel && ( )} @@ -5114,18 +5122,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 +5185,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 +5688,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 +6184,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 +6295,7 @@ class App extends React.Component { selectGroupsForSelectedElements( { editingGroupId: prevState.editingGroupId, - selectedElementIds: { [hitElement.id]: true }, + selectedElementIds: { [hitElement!.id]: true }, }, this.scene.getNonDeletedElements(), prevState, @@ -6772,6 +6809,9 @@ class App extends React.Component { const hitElement = this.getElementAtPosition( scenePointer.x, scenePointer.y, + { + includeLockedElements: true, + }, ); this.hitLinkElement = this.getElementLinkAtPosition( scenePointer, @@ -7207,17 +7247,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 +7327,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 +8143,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 +9030,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, }); 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 2fc05579a..b4137053f 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -178,6 +178,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 991f8c3f8..254f6b8aa 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -377,7 +377,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, ); @@ -394,8 +396,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, @@ -787,6 +789,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), ); @@ -901,8 +914,8 @@ const _renderInteractiveScene = ({ y1, x2, y2, - selectionColors, - dashed: !!remoteClients, + selectionColors: element.locked ? ["#ced4da"] : selectionColors, + dashed: !!remoteClients || element.locked, cx, cy, activeEmbeddable: @@ -926,7 +939,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, @@ -990,7 +1005,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=