diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9e8a26eed..77c972882 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -349,6 +349,7 @@ import { isElementInFrame, getFrameLikeTitle, getElementsOverlappingFrame, + filterElementsEligibleAsFrameChildren, } from "../frame"; import { excludeElementsInFramesFromSelection, @@ -3107,7 +3108,11 @@ class App extends React.Component { const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y }); if (topLayerFrame) { - addElementsToFrame(allElements, newElements, topLayerFrame); + const eligibleElements = filterElementsEligibleAsFrameChildren( + newElements, + topLayerFrame, + ); + addElementsToFrame(allElements, eligibleElements, topLayerFrame); } this.scene.replaceAllElements(allElements); diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 115cfef06..a5e743711 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -107,17 +107,16 @@ export const elementsAreInFrameBounds = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, ) => { - const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(frame); + const [frameX1, frameY1, frameX2, frameY2] = getElementAbsoluteCoords(frame); const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(elements); return ( - selectionX1 <= elementX1 && - selectionY1 <= elementY1 && - selectionX2 >= elementX2 && - selectionY2 >= elementY2 + frameX1 <= elementX1 && + frameY1 <= elementY1 && + frameX2 >= elementX2 && + frameY2 >= elementY2 ); }; @@ -372,6 +371,56 @@ export const getContainingFrame = ( // --------------------------- Frame Operations ------------------------------- +/** */ +export const filterElementsEligibleAsFrameChildren = ( + elements: readonly ExcalidrawElement[], + frame: ExcalidrawFrameLikeElement, +) => { + const otherFrames = new Set(); + + elements = omitGroupsContainingFrameLikes(elements); + + for (const element of elements) { + if (isFrameLikeElement(element) && element.id !== frame.id) { + otherFrames.add(element.id); + } + } + + const processedGroups = new Set(); + + const eligibleElements: ExcalidrawElement[] = []; + + for (const element of elements) { + // don't add frames or their children + if ( + isFrameLikeElement(element) || + (element.frameId && otherFrames.has(element.frameId)) + ) { + continue; + } + + if (element.groupIds.length) { + const shallowestGroupId = element.groupIds.at(-1)!; + if (!processedGroups.has(shallowestGroupId)) { + processedGroups.add(shallowestGroupId); + const groupElements = getElementsInGroup(elements, shallowestGroupId); + if (groupElements.some((el) => elementOverlapsWithFrame(el, frame))) { + for (const child of groupElements) { + eligibleElements.push(child); + } + } + } + } else { + const overlaps = elementOverlapsWithFrame(element, frame); + if (overlaps) { + eligibleElements.push(element); + } + } + } + + return eligibleElements; +}; + /** * Retains (or repairs for target frame) the ordering invriant where children * elements come right before the parent frame: diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index ce00e2da5..149ebcd1e 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -292,4 +292,141 @@ describe("pasting & frames", () => { expect(h.elements[1].frameId).toBe(frame.id); }); }); + + it("should filter out elements not overlapping frame", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const rect = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + }); + const rect2 = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + x: 100, + y: 100, + }); + + h.elements = [frame]; + + const clipboardJSON = await serializeAsClipboardJSON({ + elements: [rect, rect2], + files: null, + }); + + mouse.moveTo(90, 90); + + pasteWithCtrlCmdV(clipboardJSON); + + await waitFor(() => { + expect(h.elements.length).toBe(3); + expect(h.elements[1].type).toBe(rect.type); + expect(h.elements[1].frameId).toBe(frame.id); + expect(h.elements[2].type).toBe(rect2.type); + expect(h.elements[2].frameId).toBe(null); + }); + }); + + it("should not filter out elements not overlapping frame if part of group", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const rect = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + groupIds: ["g1"], + }); + const rect2 = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + x: 100, + y: 100, + groupIds: ["g1"], + }); + + h.elements = [frame]; + + const clipboardJSON = await serializeAsClipboardJSON({ + elements: [rect, rect2], + files: null, + }); + + mouse.moveTo(90, 90); + + pasteWithCtrlCmdV(clipboardJSON); + + await waitFor(() => { + expect(h.elements.length).toBe(3); + expect(h.elements[1].type).toBe(rect.type); + expect(h.elements[1].frameId).toBe(frame.id); + expect(h.elements[2].type).toBe(rect2.type); + expect(h.elements[2].frameId).toBe(frame.id); + }); + }); + + it("should not filter out other frames and their children", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const rect = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + groupIds: ["g1"], + }); + + const frame2 = API.createElement({ + type: "frame", + width: 75, + height: 75, + x: 0, + y: 0, + }); + const rect2 = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + x: 55, + y: 55, + frameId: frame2.id, + }); + + h.elements = [frame]; + + const clipboardJSON = await serializeAsClipboardJSON({ + elements: [rect, rect2, frame2], + files: null, + }); + + mouse.moveTo(90, 90); + + pasteWithCtrlCmdV(clipboardJSON); + + await waitFor(() => { + expect(h.elements.length).toBe(4); + expect(h.elements[1].type).toBe(rect.type); + expect(h.elements[1].frameId).toBe(frame.id); + expect(h.elements[2].type).toBe(rect2.type); + expect(h.elements[2].frameId).toBe(h.elements[3].id); + expect(h.elements[3].type).toBe(frame2.type); + expect(h.elements[3].frameId).toBe(null); + }); + }); });