diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index 46d2dbd46a..25e4c64600 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -78,7 +78,12 @@ import type { } from "./types"; export const shouldTestInside = (element: ExcalidrawElement) => { - if (element.type === "arrow") { + if ( + element.type === "arrow" || + // frame elements should ignore inside hit test even if background is not + // transparent, so we can select children easily + isFrameLikeElement(element) + ) { return false; } diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 96bff999bc..3def2f4e24 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -22,6 +22,7 @@ import { isRTL, getVerticalOffset, invariant, + isTransparent, applyDarkModeFilter, isSafari, } from "@excalidraw/common"; @@ -78,6 +79,7 @@ import type { ExcalidrawFrameLikeElement, NonDeletedSceneElementsMap, ElementsMap, + ExcalidrawFrameElement, } from "./types"; import type { RoughCanvas } from "roughjs/bin/canvas"; @@ -777,6 +779,45 @@ export const renderSelectionElement = ( context.restore(); }; +export const renderFrameBackground = ( + frame: ExcalidrawFrameElement, + context: CanvasRenderingContext2D, + appState: StaticCanvasAppState | InteractiveCanvasAppState, + opts?: { + roundCorners?: boolean; + }, +) => { + if (isTransparent(frame.backgroundColor)) { + return; + } + + context.save(); + context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); + context.fillStyle = + appState.theme === THEME.DARK + ? applyDarkModeFilter(frame.backgroundColor) + : frame.backgroundColor; + + const shouldRoundCorners = opts?.roundCorners ?? true; + + if (shouldRoundCorners && FRAME_STYLE.radius && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + frame.width, + frame.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.fill(); + context.closePath(); + } else { + context.fillRect(0, 0, frame.width, frame.height); + } + + context.restore(); +}; + export const renderElement = ( element: NonDeletedExcalidrawElement, elementsMap: RenderableElementsMap, @@ -808,7 +849,6 @@ export const renderElement = ( element.x + appState.scrollX, element.y + appState.scrollY, ); - context.fillStyle = "rgba(0, 0, 200, 0.04)"; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.strokeStyle = diff --git a/packages/element/tests/collision.test.tsx b/packages/element/tests/collision.test.tsx index 4061a16cb6..b2eaa17de9 100644 --- a/packages/element/tests/collision.test.tsx +++ b/packages/element/tests/collision.test.tsx @@ -38,6 +38,53 @@ describe("check rotated elements can be hit:", () => { }); }); +describe("frame hit testing", () => { + it.each(["transparent", "#ffffff"])( + "does not hit frame inside regardless of background color (%s)", + (backgroundColor) => { + const element = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor, + }); + const elementsMap = arrayToMap([element]); + + expect( + hitElementItself({ + point: pointFrom(50, 50), + element, + threshold: 10, + elementsMap, + }), + ).toBe(false); + }, + ); + + it("hits frame outline", () => { + const element = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "#ffffff", + }); + const elementsMap = arrayToMap([element]); + + expect( + hitElementItself({ + point: pointFrom(0, 50), + element, + threshold: 1, + elementsMap, + }), + ).toBe(true); + }); +}); + describe("hitElementItself cache", () => { beforeEach(async () => { // reset cache diff --git a/packages/excalidraw/actions/actionProperties.test.tsx b/packages/excalidraw/actions/actionProperties.test.tsx index 38419ce827..30a465943c 100644 --- a/packages/excalidraw/actions/actionProperties.test.tsx +++ b/packages/excalidraw/actions/actionProperties.test.tsx @@ -109,6 +109,21 @@ describe("element locking", () => { expect(crossHatchButton).toBe(null); }); + it("should show background color picker for selected frame", () => { + const frame = API.createElement({ + type: "frame", + }); + API.setElements([frame]); + API.setSelectedElements([frame]); + + expect( + queryByTestId( + document.body, + `color-top-pick-${DEFAULT_ELEMENT_BACKGROUND_PICKS[0]}`, + ), + ).not.toBe(null); + }); + it("should highlight common stroke width of selected elements", () => { const rect1 = API.createElement({ type: "rectangle", diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 7fb9ed0e86..994cd3afa4 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -444,7 +444,12 @@ export const actionChangeBackgroundColor = register< (element) => element.backgroundColor, true, (hasSelection) => - !hasSelection ? appState.currentItemBackgroundColor : null, + !hasSelection + ? appState.activeTool.type === "frame" + ? // background default shouldn't apply to new frames + "transparent" + : appState.currentItemBackgroundColor + : null, )} onChange={(color) => updateData({ currentItemBackgroundColor: color }) diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index d9f3415d64..84545cb838 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -20,6 +20,7 @@ import { isArrowElement, hasStrokeColor, toolIsArrow, + isFrameElement, } from "@excalidraw/element"; import type { @@ -129,8 +130,11 @@ export const canChangeBackgroundColor = ( targetElements: ExcalidrawElement[], ) => { return ( + // frame tool shouldn't allow to set background until frame is created hasBackground(appState.activeTool.type) || - targetElements.some((element) => hasBackground(element.type)) + targetElements.some( + (element) => hasBackground(element.type) || isFrameElement(element), + ) ); }; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 14ebca6bce..d0ad28409f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -256,6 +256,7 @@ import { handleFocusPointPointerUp, maybeHandleArrowPointlikeDrag, getUncroppedWidthAndHeight, + isFrameElement, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -5037,6 +5038,8 @@ class App extends React.Component { if ( event.key === KEYS.G && (hasBackground(this.state.activeTool.type) || + this.state.activeTool.type === "frame" || + selectedElements.some((element) => isFrameElement(element)) || selectedElements.some((element) => hasBackground(element.type))) ) { this.setState({ openPopup: "elementBackground" }); diff --git a/packages/excalidraw/renderer/renderNewElementScene.ts b/packages/excalidraw/renderer/renderNewElementScene.ts index 07215dfc07..153d2aac62 100644 --- a/packages/excalidraw/renderer/renderNewElementScene.ts +++ b/packages/excalidraw/renderer/renderNewElementScene.ts @@ -4,7 +4,9 @@ import { getTargetFrame, isInvisiblySmallElement, renderElement, + renderFrameBackground, shouldApplyFrameClip, + isFrameElement, } from "@excalidraw/element"; import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; @@ -67,6 +69,14 @@ const _renderNewElementScene = ({ } } + if ( + isFrameElement(newElement) && + appState.frameRendering.enabled && + appState.frameRendering.outline + ) { + renderFrameBackground(newElement, context, appState); + } + renderElement( newElement, elementsMap, diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index 83e45a1227..e5f64b6747 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -4,21 +4,23 @@ import { THEME, throttleRAF, } from "@excalidraw/common"; -import { isElementLink } from "@excalidraw/element"; +import { isElementLink, isFrameElement } from "@excalidraw/element"; import { createPlaceholderEmbeddableLabel } from "@excalidraw/element"; import { getBoundTextElement } from "@excalidraw/element"; import { isEmbeddableElement, + isFrameLikeElement, isIframeLikeElement, isTextElement, } from "@excalidraw/element"; import { elementOverlapsWithFrame, + getContainingFrame, getTargetFrame, shouldApplyFrameClip, } from "@excalidraw/element"; -import { renderElement } from "@excalidraw/element"; +import { renderElement, renderFrameBackground } from "@excalidraw/element"; import { getElementAbsoluteCoords } from "@excalidraw/element"; @@ -276,6 +278,39 @@ const _renderStaticScene = ({ } const groupsToBeAddedToFrame = new Set(); + const renderedFrameBackgrounds = new Set(); + + const maybeRenderFrameBackground = ( + element: NonDeletedExcalidrawElement | ExcalidrawFrameLikeElement, + ) => { + if ( + !appState.frameRendering.enabled || + (!appState.frameRendering.outline && !renderConfig.exportingFrame) + ) { + return; + } + + const frame = + renderConfig.exportingFrame || + (isFrameLikeElement(element) + ? element + : getContainingFrame(element, elementsMap)); + + if (!isFrameElement(frame)) { + return; + } + + if (!frame || renderedFrameBackgrounds.has(frame.id)) { + return; + } + + renderFrameBackground(frame, context, appState, { + roundCorners: + !renderConfig.exportingFrame || + frame.id !== renderConfig.exportingFrame.id, + }); + renderedFrameBackgrounds.add(frame.id); + }; visibleElements.forEach((element) => { if ( @@ -297,11 +332,19 @@ const _renderStaticScene = ({ const inFrameGroupsMap = new Map(); + if (renderConfig.exportingFrame) { + maybeRenderFrameBackground(renderConfig.exportingFrame); + } + // Paint visible elements visibleElements .filter((el) => !isIframeLikeElement(el)) .forEach((element) => { try { + // TODO: optimize (currently we call this func for each element because + // children come before their frames and we neeed to render the frame + // background at the bottom) + maybeRenderFrameBackground(element); const frameId = element.frameId || appState.frameToHighlight?.id; if ( diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index de9be13c83..2a344e29de 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -8,6 +8,7 @@ import { isRTL, isTestEnv, getVerticalOffset, + isTransparent, applyDarkModeFilter, MIME_TYPES, } from "@excalidraw/common"; @@ -23,6 +24,7 @@ import { getBoundTextElement, getContainerElement } from "@excalidraw/element"; import { getLineHeightInPx } from "@excalidraw/element"; import { isArrowElement, + isFrameLikeElement, isIframeLikeElement, isInitializedImageElement, isTextElement, @@ -38,6 +40,7 @@ import { getElementAbsoluteCoords } from "@excalidraw/element"; import type { ExcalidrawElement, + ExcalidrawFrameLikeElement, ExcalidrawTextElementWithContainer, NonDeletedExcalidrawElement, } from "@excalidraw/element/types"; @@ -717,11 +720,76 @@ export const renderSceneToSvg = ( return; } + const renderedFrameBackgrounds = new Set(); + const maybeRenderFrameBackground = ( + element: NonDeletedExcalidrawElement | ExcalidrawFrameLikeElement, + ) => { + if ( + !renderConfig.frameRendering.enabled || + (!renderConfig.frameRendering.outline && !renderConfig.exportingFrame) + ) { + return; + } + + const frame = + renderConfig.exportingFrame || + (isFrameLikeElement(element) + ? element + : getContainingFrame(element, elementsMap)); + + if ( + !frame || + renderedFrameBackgrounds.has(frame.id) || + !frame.backgroundColor || + isTransparent(frame.backgroundColor) + ) { + return; + } + + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); + const cx = (x2 - x1) / 2 - (frame.x - x1); + const cy = (y2 - y1) / 2 - (frame.y - y1); + const degree = (180 * frame.angle) / Math.PI; + + const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect"); + rect.setAttribute( + "transform", + `translate(${frame.x + renderConfig.offsetX} ${ + frame.y + renderConfig.offsetY + }) rotate(${degree} ${cx} ${cy})`, + ); + rect.setAttribute("width", `${frame.width}px`); + rect.setAttribute("height", `${frame.height}px`); + + const isDirectlyExportedFrame = + !!renderConfig.exportingFrame && + frame.id === renderConfig.exportingFrame.id; + if (!isDirectlyExportedFrame) { + rect.setAttribute("rx", FRAME_STYLE.radius.toString()); + rect.setAttribute("ry", FRAME_STYLE.radius.toString()); + } + rect.setAttribute( + "fill", + renderConfig.theme === THEME.DARK + ? applyDarkModeFilter(frame.backgroundColor) + : frame.backgroundColor, + ); + rect.setAttribute("stroke", "none"); + + svgRoot.appendChild(rect); + renderedFrameBackgrounds.add(frame.id); + }; + + if (renderConfig.exportingFrame) { + maybeRenderFrameBackground(renderConfig.exportingFrame); + } + // render elements elements .filter((el) => !isIframeLikeElement(el)) .forEach((element) => { if (!element.isDeleted) { + maybeRenderFrameBackground(element); if ( isTextElement(element) && element.containerId && diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 7cf111f6ce..b6bf44903b 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -169,6 +169,20 @@ const prepareElementsForRender = ({ return nextElements; }; +const addExportingFrameToElements = ( + elements: readonly ExcalidrawElement[], + exportingFrame?: ExcalidrawFrameLikeElement | null, +) => { + if ( + !exportingFrame || + elements.some((element) => element.id === exportingFrame.id) + ) { + return elements; + } + + return [...elements, exportingFrame]; +}; + export const exportToCanvas = async ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, @@ -216,6 +230,14 @@ export const exportToCanvas = async ( exportWithDarkMode: appState.exportWithDarkMode, frameRendering, }); + const elementsForLookup = addExportingFrameToElements( + elementsForRender, + exportingFrame, + ); + const allElementsForLookup = addExportingFrameToElements( + elements, + exportingFrame, + ); if (exportingFrame) { exportPadding = 0; @@ -242,10 +264,10 @@ export const exportToCanvas = async ( canvas, rc: rough.canvas(canvas), elementsMap: toBrandedType( - arrayToMap(elementsForRender), + arrayToMap(elementsForLookup), ), allElementsMap: toBrandedType( - arrayToMap(syncInvalidIndices(elements)), + arrayToMap(syncInvalidIndices(allElementsForLookup)), ), visibleElements: elementsForRender, scale, @@ -261,6 +283,7 @@ export const exportToCanvas = async ( }, renderConfig: { canvasBackgroundColor: viewBackgroundColor, + exportingFrame, imageCache, renderGrid: false, isExporting: true, @@ -325,6 +348,10 @@ export const exportToSvg = async ( exportWithDarkMode, frameRendering, }); + const elementsForLookup = addExportingFrameToElements( + elementsForRender, + exportingFrame, + ); if (exportingFrame) { exportPadding = 0; @@ -386,10 +413,10 @@ export const exportToSvg = async ( // frame clip paths // --------------------------------------------------------------------------- - const frameElements = getFrameLikeElements(elements); + const frameElements = getFrameLikeElements(elementsForLookup); if (frameElements.length) { - const elementsMap = arrayToMap(elements); + const elementsMap = arrayToMap(elementsForLookup); for (const frame of frameElements) { const clipPath = svgRoot.ownerDocument.createElementNS( @@ -472,7 +499,7 @@ export const exportToSvg = async ( renderSceneToSvg( elementsForRender, - toBrandedType(arrayToMap(elementsForRender)), + toBrandedType(arrayToMap(elementsForLookup)), rsvg, svgRoot, files || {}, @@ -483,6 +510,7 @@ export const exportToSvg = async ( exportWithDarkMode, renderEmbeddables, frameRendering, + exportingFrame, canvasBackgroundColor: viewBackgroundColor, embedsValidationStatus: renderEmbeddables ? new Map( diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 59c421f8aa..6d34a5d8c4 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -1,6 +1,7 @@ import type { UserIdleState, EditorInterface } from "@excalidraw/common"; import type { ExcalidrawElement, + ExcalidrawFrameLikeElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, @@ -26,6 +27,7 @@ export type RenderableElementsMap = NonDeletedElementsMap & export type StaticCanvasRenderConfig = { canvasBackgroundColor: AppState["viewBackgroundColor"]; + exportingFrame?: ExcalidrawFrameLikeElement | null; // extra options passed to the renderer // --------------------------------------------------------------------------- imageCache: AppClassProperties["imageCache"]; @@ -46,6 +48,7 @@ export type SVGRenderConfig = { exportWithDarkMode: boolean; renderEmbeddables: boolean; frameRendering: AppState["frameRendering"]; + exportingFrame?: ExcalidrawFrameLikeElement | null; canvasBackgroundColor: AppState["viewBackgroundColor"]; embedsValidationStatus: EmbedsValidationStatus; /** diff --git a/packages/excalidraw/tests/scene/export.test.ts b/packages/excalidraw/tests/scene/export.test.ts index b97f4619e9..010050222c 100644 --- a/packages/excalidraw/tests/scene/export.test.ts +++ b/packages/excalidraw/tests/scene/export.test.ts @@ -279,6 +279,7 @@ describe("exporting frames", () => { height: 100, x: 0, y: 0, + backgroundColor: "#ffc9c9", }); const frameChild = API.createElement({ type: "rectangle", @@ -311,11 +312,58 @@ describe("exporting frames", () => { expect( svg.querySelector(`[data-id="${rectOverlapping.id}"]`), ).not.toBeNull(); + // exporting frame background should still be rendered + const frameBackgroundNode = svg.querySelector('rect[fill="#ffc9c9"]'); + expect(frameBackgroundNode).not.toBeNull(); + expect(frameBackgroundNode).not.toHaveAttribute("rx"); + expect(frameBackgroundNode).not.toHaveAttribute("ry"); + expect( + frameBackgroundNode!.compareDocumentPosition( + svg.querySelector(`[data-id="${frameChild.id}"]`)!, + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); expect(svg.getAttribute("width")).toBe(frame.width.toString()); expect(svg.getAttribute("height")).toBe(frame.height.toString()); }); + it("should not render SVG frame background for empty/transparent exporting frame bg", async () => { + const transparentFrame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + backgroundColor: "transparent", + }); + const emptyBgFrame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + backgroundColor: "", + }); + + const transparentSvg = await exportToSvg({ + elements: [transparentFrame], + files: null, + exportPadding: 0, + exportingFrame: transparentFrame, + }); + const emptyBgSvg = await exportToSvg({ + elements: [emptyBgFrame], + files: null, + exportPadding: 0, + exportingFrame: emptyBgFrame, + }); + + expect( + transparentSvg.querySelector('rect[fill="transparent"]'), + ).toBeNull(); + expect(emptyBgSvg.querySelector('rect[fill=""]')).toBeNull(); + }); + it("should filter non-overlapping elements when exporting a frame", async () => { const frame = API.createElement({ type: "frame", @@ -469,6 +517,66 @@ describe("exporting frames", () => { ); }); + it("should render frame with its background color", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + backgroundColor: "#ffc9c9", + }); + const frameChild = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + x: 10, + y: 10, + frameId: frame.id, + }); + + const { exportedElements, exportingFrame } = prepareElementsForExport( + [frameChild, frame], + { + selectedElementIds: {}, + }, + false, + ); + + const svg = await exportToSvg({ + elements: exportedElements, + files: null, + exportPadding: 0, + exportingFrame, + }); + + const frameSvgNode = svg.querySelector(`[data-id="${frame.id}"]`); + const childSvgNode = svg.querySelector(`[data-id="${frameChild.id}"]`); + const frameBackgroundNode = svg.querySelector('rect[fill="#ffc9c9"]'); + + expect(frameSvgNode).not.toBeNull(); + expect(childSvgNode).not.toBeNull(); + expect(frameBackgroundNode).not.toBeNull(); + expect(frameSvgNode).toHaveAttribute("fill", "none"); + expect(frameBackgroundNode).toHaveAttribute( + "rx", + FRAME_STYLE.radius.toString(), + ); + expect(frameBackgroundNode).toHaveAttribute( + "ry", + FRAME_STYLE.radius.toString(), + ); + + expect( + frameBackgroundNode!.compareDocumentPosition(childSvgNode!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + childSvgNode!.compareDocumentPosition(frameSvgNode!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it("should not export frame-overlapping elements belonging to different frame", async () => { const frame1 = API.createElement({ type: "frame",