diff --git a/excalidraw-app/tests/reconciliation.test.ts b/excalidraw-app/tests/reconciliation.test.ts index c3e24740..26fb5b8c 100644 --- a/excalidraw-app/tests/reconciliation.test.ts +++ b/excalidraw-app/tests/reconciliation.test.ts @@ -8,6 +8,7 @@ import { } from "../../excalidraw-app/collab/reconciliation"; import { randomInteger } from "../../src/random"; import { AppState } from "../../src/types"; +import { cloneJSON } from "../../src/utils"; type Id = string; type ElementLike = { @@ -93,8 +94,6 @@ const cleanElements = (elements: ReconciledElements) => { }); }; -const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data)); - const test = ( local: (Id | ElementLike)[], remote: (Id | ElementLike)[], @@ -115,15 +114,15 @@ const test = ( "remote reconciliation", ); - const __local = cleanElements(cloneDeep(_remote)); - const __remote = addParents(cleanElements(cloneDeep(remoteReconciled))); + const __local = cleanElements(cloneJSON(_remote) as ReconciledElements); + const __remote = addParents(cleanElements(cloneJSON(remoteReconciled))); if (bidirectional) { try { expect( cleanElements( reconcileElements( - cloneDeep(__local), - cloneDeep(__remote), + cloneJSON(__local), + cloneJSON(__remote), {} as AppState, ), ), diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index 03fdc6a4..dadc6101 100644 --- a/src/actions/actionClipboard.tsx +++ b/src/actions/actionClipboard.tsx @@ -9,8 +9,8 @@ import { readSystemClipboard, } from "../clipboard"; import { actionDeleteSelected } from "./actionDeleteSelected"; -import { exportCanvas } from "../data/index"; -import { getNonDeletedElements, isTextElement } from "../element"; +import { exportCanvas, prepareElementsForExport } from "../data/index"; +import { isTextElement } from "../element"; import { t } from "../i18n"; import { isFirefox } from "../constants"; @@ -122,20 +122,23 @@ export const actionCopyAsSvg = register({ commitToHistory: false, }; } - const selectedElements = app.scene.getSelectedElements({ - selectedElementIds: appState.selectedElementIds, - includeBoundTextElement: true, - includeElementsInFrames: true, - }); + + const { exportedElements, exportingFrame } = prepareElementsForExport( + elements, + appState, + true, + ); + try { await exportCanvas( "clipboard-svg", - selectedElements.length - ? selectedElements - : getNonDeletedElements(elements), + exportedElements, appState, app.files, - appState, + { + ...appState, + exportingFrame, + }, ); return { commitToHistory: false, @@ -171,16 +174,17 @@ export const actionCopyAsPng = register({ includeBoundTextElement: true, includeElementsInFrames: true, }); + + const { exportedElements, exportingFrame } = prepareElementsForExport( + elements, + appState, + true, + ); try { - await exportCanvas( - "clipboard", - selectedElements.length - ? selectedElements - : getNonDeletedElements(elements), - appState, - app.files, - appState, - ); + await exportCanvas("clipboard", exportedElements, appState, app.files, { + ...appState, + exportingFrame, + }); return { appState: { ...appState, diff --git a/src/actions/actionDuplicateSelection.tsx b/src/actions/actionDuplicateSelection.tsx index a21260d5..060a2868 100644 --- a/src/actions/actionDuplicateSelection.tsx +++ b/src/actions/actionDuplicateSelection.tsx @@ -25,7 +25,7 @@ import { normalizeElementOrder } from "../element/sortElements"; import { DuplicateIcon } from "../components/icons"; import { bindElementsToFramesAfterDuplication, - getFrameElements, + getFrameChildren, } from "../frame"; import { excludeElementsInFramesFromSelection, @@ -155,7 +155,7 @@ const duplicateElements = ( groupId, ).flatMap((element) => isFrameElement(element) - ? [...getFrameElements(elements, element.id), element] + ? [...getFrameChildren(elements, element.id), element] : [element], ); @@ -181,7 +181,7 @@ const duplicateElements = ( continue; } if (isElementAFrame) { - const elementsInFrame = getFrameElements(sortedElements, element.id); + const elementsInFrame = getFrameChildren(sortedElements, element.id); elementsWithClones.push( ...markAsProcessed([ diff --git a/src/actions/actionFrame.ts b/src/actions/actionFrame.ts index 1266920e..9e8c16c2 100644 --- a/src/actions/actionFrame.ts +++ b/src/actions/actionFrame.ts @@ -1,7 +1,7 @@ import { getNonDeletedElements } from "../element"; import { ExcalidrawElement } from "../element/types"; import { removeAllElementsFromFrame } from "../frame"; -import { getFrameElements } from "../frame"; +import { getFrameChildren } from "../frame"; import { KEYS } from "../keys"; import { AppClassProperties, AppState } from "../types"; import { updateActiveTool } from "../utils"; @@ -21,7 +21,7 @@ export const actionSelectAllElementsInFrame = register({ const selectedFrame = app.scene.getSelectedElements(appState)[0]; if (selectedFrame && selectedFrame.type === "frame") { - const elementsInFrame = getFrameElements( + const elementsInFrame = getFrameChildren( getNonDeletedElements(elements), selectedFrame.id, ).filter((element) => !(element.type === "text" && element.containerId)); diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index 88d4b4c4..2b656b05 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -21,8 +21,10 @@ import { canApplyRoundnessTypeToElement, getDefaultRoundnessTypeForElement, isFrameElement, + isArrowElement, } from "../element/typeChecks"; import { getSelectedElements } from "../scene"; +import { ExcalidrawTextElement } from "../element/types"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -99,16 +101,19 @@ export const actionPasteStyles = register({ if (isTextElement(newElement)) { const fontSize = - elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE; + (elementStylesToCopyFrom as ExcalidrawTextElement).fontSize || + DEFAULT_FONT_SIZE; const fontFamily = - elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY; + (elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily || + DEFAULT_FONT_FAMILY; newElement = newElementWith(newElement, { fontSize, fontFamily, textAlign: - elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN, + (elementStylesToCopyFrom as ExcalidrawTextElement).textAlign || + DEFAULT_TEXT_ALIGN, lineHeight: - elementStylesToCopyFrom.lineHeight || + (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || getDefaultLineHeight(fontFamily), }); let container = null; @@ -123,7 +128,10 @@ export const actionPasteStyles = register({ redrawTextBoundingBox(newElement, container); } - if (newElement.type === "arrow") { + if ( + newElement.type === "arrow" && + isArrowElement(elementStylesToCopyFrom) + ) { newElement = newElementWith(newElement, { startArrowhead: elementStylesToCopyFrom.startArrowhead, endArrowhead: elementStylesToCopyFrom.endArrowhead, diff --git a/src/components/App.tsx b/src/components/App.tsx index 889c816d..493877c2 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -87,7 +87,7 @@ import { ZOOM_STEP, POINTER_EVENTS, } from "../constants"; -import { exportCanvas, loadFromBlob } from "../data"; +import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restore, restoreElements } from "../data/restore"; import { @@ -317,7 +317,7 @@ import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; import { Fonts } from "../scene/Fonts"; import { - getFrameElements, + getFrameChildren, isCursorInFrame, bindElementsToFramesAfterDuplication, addElementsToFrame, @@ -1048,12 +1048,6 @@ class App extends React.Component { this.state, ); - const { x: x2 } = sceneCoordsToViewportCoords( - { sceneX: f.x + f.width, sceneY: f.y + f.height }, - this.state, - ); - - const FRAME_NAME_GAP = 20; const FRAME_NAME_EDIT_PADDING = 6; const reset = () => { @@ -1098,13 +1092,12 @@ class App extends React.Component { boxShadow: "inset 0 0 0 1px var(--color-primary)", fontFamily: "Assistant", fontSize: "14px", - transform: `translateY(-${FRAME_NAME_EDIT_PADDING}px)`, + transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`, color: "var(--color-gray-80)", overflow: "hidden", - maxWidth: `${Math.min( - x2 - x1 - FRAME_NAME_EDIT_PADDING, - document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING, - )}px`, + maxWidth: `${ + document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING + }px`, }} size={frameNameInEdit.length + 1 || 1} dir="auto" @@ -1126,19 +1119,26 @@ class App extends React.Component { key={f.id} style={{ position: "absolute", - top: `${y1 - FRAME_NAME_GAP - this.state.offsetTop}px`, - left: `${ - x1 - - this.state.offsetLeft - - (this.state.editingFrame === f.id ? FRAME_NAME_EDIT_PADDING : 0) + // Positioning from bottom so that we don't to either + // calculate text height or adjust using transform (which) + // messes up input position when editing the frame name. + // This makes the positioning deterministic and we can calculate + // the same position when rendering to canvas / svg. + bottom: `${ + this.state.height + + FRAME_STYLE.nameOffsetY - + y1 + + this.state.offsetTop }px`, + left: `${x1 - this.state.offsetLeft}px`, zIndex: 2, - fontSize: "14px", + fontSize: FRAME_STYLE.nameFontSize, color: isDarkTheme - ? "var(--color-gray-60)" - : "var(--color-gray-50)", + ? FRAME_STYLE.nameColorDarkTheme + : FRAME_STYLE.nameColorLightTheme, + lineHeight: FRAME_STYLE.nameLineHeight, width: "max-content", - maxWidth: `${x2 - x1 + FRAME_NAME_EDIT_PADDING * 2}px`, + maxWidth: `${f.width}px`, overflow: f.id === this.state.editingFrame ? "visible" : "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", @@ -1370,7 +1370,8 @@ class App extends React.Component { public onExportImage = async ( type: keyof typeof EXPORT_IMAGE_TYPES, - elements: readonly NonDeletedExcalidrawElement[], + elements: ExportedElements, + opts: { exportingFrame: ExcalidrawFrameElement | null }, ) => { trackEvent("export", type, "ui"); const fileHandle = await exportCanvas( @@ -1382,6 +1383,7 @@ class App extends React.Component { exportBackground: this.state.exportBackground, name: this.state.name, viewBackgroundColor: this.state.viewBackgroundColor, + exportingFrame: opts.exportingFrame, }, ) .catch(muteFSAbortError) @@ -5330,7 +5332,7 @@ class App extends React.Component { // if hitElement is frame, deselect all of its elements if they are selected if (hitElement.type === "frame") { - getFrameElements( + getFrameChildren( previouslySelectedElements, hitElement.id, ).forEach((element) => { @@ -8194,7 +8196,7 @@ class App extends React.Component { >(); selectedFrames.forEach((frame) => { - const elementsInFrame = getFrameElements( + const elementsInFrame = getFrameChildren( this.scene.getNonDeletedElements(), frame.id, ); @@ -8264,7 +8266,7 @@ class App extends React.Component { const elementsToHighlight = new Set(); selectedFrames.forEach((frame) => { - const elementsInFrame = getFrameElements( + const elementsInFrame = getFrameChildren( this.scene.getNonDeletedElements(), frame.id, ); diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 960c87f2..c85272ab 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -22,7 +22,7 @@ import { canvasToBlob } from "../data/blob"; import { nativeFileSystemSupported } from "../data/filesystem"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import { getSelectedElements, isSomeElementSelected } from "../scene"; +import { isSomeElementSelected } from "../scene"; import { exportToCanvas } from "../packages/utils"; import { copyIcon, downloadIcon, helpIcon } from "./icons"; @@ -34,6 +34,8 @@ import { Tooltip } from "./Tooltip"; import "./ImageExportDialog.scss"; import { useAppProps } from "./App"; import { FilledButton } from "./FilledButton"; +import { cloneJSON } from "../utils"; +import { prepareElementsForExport } from "../data"; const supportsContextFilters = "filter" in document.createElement("canvas").getContext("2d")!; @@ -51,44 +53,47 @@ export const ErrorCanvasPreview = () => { }; type ImageExportModalProps = { - appState: UIAppState; - elements: readonly NonDeletedExcalidrawElement[]; + appStateSnapshot: Readonly; + elementsSnapshot: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles; actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; }; const ImageExportModal = ({ - appState, - elements, + appStateSnapshot, + elementsSnapshot, files, actionManager, onExportImage, }: ImageExportModalProps) => { + const hasSelection = isSomeElementSelected( + elementsSnapshot, + appStateSnapshot, + ); + const appProps = useAppProps(); - const [projectName, setProjectName] = useState(appState.name); - - const someElementIsSelected = isSomeElementSelected(elements, appState); - - const [exportSelected, setExportSelected] = useState(someElementIsSelected); + const [projectName, setProjectName] = useState(appStateSnapshot.name); + const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection); const [exportWithBackground, setExportWithBackground] = useState( - appState.exportBackground, + appStateSnapshot.exportBackground, ); const [exportDarkMode, setExportDarkMode] = useState( - appState.exportWithDarkMode, + appStateSnapshot.exportWithDarkMode, ); - const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene); - const [exportScale, setExportScale] = useState(appState.exportScale); + const [embedScene, setEmbedScene] = useState( + appStateSnapshot.exportEmbedScene, + ); + const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale); const previewRef = useRef(null); const [renderError, setRenderError] = useState(null); - const exportedElements = exportSelected - ? getSelectedElements(elements, appState, { - includeBoundTextElement: true, - includeElementsInFrames: true, - }) - : elements; + const { exportedElements, exportingFrame } = prepareElementsForExport( + elementsSnapshot, + appStateSnapshot, + exportSelectionOnly, + ); useEffect(() => { const previewNode = previewRef.current; @@ -102,10 +107,18 @@ const ImageExportModal = ({ } exportToCanvas({ elements: exportedElements, - appState, + appState: { + ...appStateSnapshot, + name: projectName, + exportBackground: exportWithBackground, + exportWithDarkMode: exportDarkMode, + exportScale, + exportEmbedScene: embedScene, + }, files, exportPadding: DEFAULT_EXPORT_PADDING, maxWidthOrHeight: Math.max(maxWidth, maxHeight), + exportingFrame, }) .then((canvas) => { setRenderError(null); @@ -119,7 +132,17 @@ const ImageExportModal = ({ console.error(error); setRenderError(error); }); - }, [appState, files, exportedElements]); + }, [ + appStateSnapshot, + files, + exportedElements, + exportingFrame, + projectName, + exportWithBackground, + exportDarkMode, + exportScale, + embedScene, + ]); return (
@@ -136,7 +159,8 @@ const ImageExportModal = ({ value={projectName} style={{ width: "30ch" }} disabled={ - typeof appProps.name !== "undefined" || appState.viewModeEnabled + typeof appProps.name !== "undefined" || + appStateSnapshot.viewModeEnabled } onChange={(event) => { setProjectName(event.target.value); @@ -152,16 +176,16 @@ const ImageExportModal = ({

{t("imageExportDialog.header")}

- {someElementIsSelected && ( + {hasSelection && ( { - setExportSelected(checked); + setExportSelectionOnly(checked); }} /> @@ -243,7 +267,9 @@ const ImageExportModal = ({ className="ImageExportModal__settings__buttons__button" label={t("imageExportDialog.title.exportToPng")} onClick={() => - onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements) + onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, { + exportingFrame, + }) } startIcon={downloadIcon} > @@ -253,7 +279,9 @@ const ImageExportModal = ({ className="ImageExportModal__settings__buttons__button" label={t("imageExportDialog.title.exportToSvg")} onClick={() => - onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements) + onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, { + exportingFrame, + }) } startIcon={downloadIcon} > @@ -264,7 +292,9 @@ const ImageExportModal = ({ className="ImageExportModal__settings__buttons__button" label={t("imageExportDialog.title.copyPngToClipboard")} onClick={() => - onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements) + onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, { + exportingFrame, + }) } startIcon={copyIcon} > @@ -325,15 +355,20 @@ export const ImageExportDialog = ({ onExportImage: AppClassProperties["onExportImage"]; onCloseRequest: () => void; }) => { - if (appState.openDialog !== "imageExport") { - return null; - } + // we need to take a snapshot so that the exported state can't be modified + // while the dialog is open + const [{ appStateSnapshot, elementsSnapshot }] = useState(() => { + return { + appStateSnapshot: cloneJSON(appState), + elementsSnapshot: cloneJSON(elements), + }; + }); return ( { - if (!UIOptions.canvasActions.saveAsImage) { + if ( + !UIOptions.canvasActions.saveAsImage || + appState.openDialog !== "imageExport" + ) { return null; } diff --git a/src/constants.ts b/src/constants.ts index ffea0122..fca1c0d2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -105,6 +105,7 @@ export const FONT_FAMILY = { Virgil: 1, Helvetica: 2, Cascadia: 3, + Assistant: 4, }; export const THEME = { @@ -114,13 +115,18 @@ export const THEME = { export const FRAME_STYLE = { strokeColor: "#bbb" as ExcalidrawElement["strokeColor"], - strokeWidth: 1 as ExcalidrawElement["strokeWidth"], + strokeWidth: 2 as ExcalidrawElement["strokeWidth"], strokeStyle: "solid" as ExcalidrawElement["strokeStyle"], fillStyle: "solid" as ExcalidrawElement["fillStyle"], roughness: 0 as ExcalidrawElement["roughness"], roundness: null as ExcalidrawElement["roundness"], backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"], radius: 8, + nameOffsetY: 3, + nameColorLightTheme: "#999999", + nameColorDarkTheme: "#7a7a7a", + nameFontSize: 14, + nameLineHeight: 1.25, }; export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; diff --git a/src/data/index.ts b/src/data/index.ts index 29eedb2a..7554c1cb 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -3,11 +3,19 @@ import { copyTextToSystemClipboard, } from "../clipboard"; import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import { getNonDeletedElements, isFrameElement } from "../element"; +import { + ExcalidrawElement, + ExcalidrawFrameElement, + NonDeletedExcalidrawElement, +} from "../element/types"; import { t } from "../i18n"; +import { elementsOverlappingBBox } from "../packages/withinBounds"; +import { isSomeElementSelected, getSelectedElements } from "../scene"; import { exportToCanvas, exportToSvg } from "../scene/export"; import { ExportType } from "../scene/types"; import { AppState, BinaryFiles } from "../types"; +import { cloneJSON } from "../utils"; import { canvasToBlob } from "./blob"; import { fileSave, FileSystemHandle } from "./filesystem"; import { serializeAsJSON } from "./json"; @@ -15,9 +23,61 @@ import { serializeAsJSON } from "./json"; export { loadFromBlob } from "./blob"; export { loadFromJSON, saveAsJSON } from "./json"; +export type ExportedElements = readonly NonDeletedExcalidrawElement[] & { + _brand: "exportedElements"; +}; + +export const prepareElementsForExport = ( + elements: readonly ExcalidrawElement[], + { selectedElementIds }: Pick, + exportSelectionOnly: boolean, +) => { + elements = getNonDeletedElements(elements); + + const isExportingSelection = + exportSelectionOnly && + isSomeElementSelected(elements, { selectedElementIds }); + + let exportingFrame: ExcalidrawFrameElement | null = null; + let exportedElements = isExportingSelection + ? getSelectedElements( + elements, + { selectedElementIds }, + { + includeBoundTextElement: true, + }, + ) + : elements; + + if (isExportingSelection) { + if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) { + exportingFrame = exportedElements[0]; + exportedElements = elementsOverlappingBBox({ + elements, + bounds: exportingFrame, + type: "overlap", + }); + } else if (exportedElements.length > 1) { + exportedElements = getSelectedElements( + elements, + { selectedElementIds }, + { + includeBoundTextElement: true, + includeElementsInFrames: true, + }, + ); + } + } + + return { + exportingFrame, + exportedElements: cloneJSON(exportedElements) as ExportedElements, + }; +}; + export const exportCanvas = async ( type: Omit, - elements: readonly NonDeletedExcalidrawElement[], + elements: ExportedElements, appState: AppState, files: BinaryFiles, { @@ -26,12 +86,14 @@ export const exportCanvas = async ( viewBackgroundColor, name, fileHandle = null, + exportingFrame = null, }: { exportBackground: boolean; exportPadding?: number; viewBackgroundColor: string; name: string; fileHandle?: FileSystemHandle | null; + exportingFrame: ExcalidrawFrameElement | null; }, ) => { if (elements.length === 0) { @@ -49,6 +111,7 @@ export const exportCanvas = async ( exportEmbedScene: appState.exportEmbedScene && type === "svg", }, files, + { exportingFrame }, ); if (type === "svg") { return await fileSave( @@ -70,6 +133,7 @@ export const exportCanvas = async ( exportBackground, viewBackgroundColor, exportPadding, + exportingFrame, }); if (type === "png") { diff --git a/src/data/library.ts b/src/data/library.ts index 381caed1..7b936efc 100644 --- a/src/data/library.ts +++ b/src/data/library.ts @@ -23,6 +23,7 @@ import { LIBRARY_SIDEBAR_TAB, } from "../constants"; import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; +import { cloneJSON } from "../utils"; export const libraryItemsAtom = atom<{ status: "loading" | "loaded"; @@ -31,7 +32,7 @@ export const libraryItemsAtom = atom<{ }>({ status: "loaded", isInitialized: true, libraryItems: [] }); const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems => - JSON.parse(JSON.stringify(libraryItems)); + cloneJSON(libraryItems); /** * checks if library item does not exist already in current library diff --git a/src/data/resave.ts b/src/data/resave.ts index ede6f424..0998fd3c 100644 --- a/src/data/resave.ts +++ b/src/data/resave.ts @@ -1,7 +1,6 @@ import { ExcalidrawElement } from "../element/types"; import { AppState, BinaryFiles } from "../types"; -import { exportCanvas } from "."; -import { getNonDeletedElements } from "../element"; +import { exportCanvas, prepareElementsForExport } from "."; import { getFileHandleType, isImageFileHandleType } from "./blob"; export const resaveAsImageWithScene = async ( @@ -23,18 +22,19 @@ export const resaveAsImageWithScene = async ( exportEmbedScene: true, }; - await exportCanvas( - fileHandleType, - getNonDeletedElements(elements), + const { exportedElements, exportingFrame } = prepareElementsForExport( + elements, appState, - files, - { - exportBackground, - viewBackgroundColor, - name, - fileHandle, - }, + false, ); + await exportCanvas(fileHandleType, exportedElements, appState, files, { + exportBackground, + viewBackgroundColor, + name, + fileHandle, + exportingFrame, + }); + return { fileHandle }; }; diff --git a/src/data/transform.ts b/src/data/transform.ts index 10208261..954ab5f7 100644 --- a/src/data/transform.ts +++ b/src/data/transform.ts @@ -40,7 +40,7 @@ import { VerticalAlign, } from "../element/types"; import { MarkOptional } from "../utility-types"; -import { assertNever, getFontString } from "../utils"; +import { assertNever, cloneJSON, getFontString } from "../utils"; import { getSizeFromPoints } from "../points"; import { randomId } from "../random"; @@ -368,7 +368,8 @@ const bindLinearElementToElement = ( // Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates. const endPointIndex = linearElement.points.length - 1; const delta = 0.5; - const newPoints = JSON.parse(JSON.stringify(linearElement.points)); + + const newPoints = cloneJSON(linearElement.points) as [number, number][]; // left to right so shift the arrow towards right if ( linearElement.points[endPointIndex][0] > @@ -439,9 +440,7 @@ export const convertToExcalidrawElements = ( if (!elementsSkeleton) { return []; } - const elements: ExcalidrawElementSkeleton[] = JSON.parse( - JSON.stringify(elementsSkeleton), - ); + const elements = cloneJSON(elementsSkeleton); const elementStore = new ElementStore(); const elementsWithIds = new Map(); const oldToNewElementIdMap = new Map(); diff --git a/src/element/embeddable.ts b/src/element/embeddable.ts index 29aac1bc..c0ade4fe 100644 --- a/src/element/embeddable.ts +++ b/src/element/embeddable.ts @@ -200,7 +200,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => { return { link, aspectRatio, type }; }; -export const isEmbeddableOrFrameLabel = ( +export const isEmbeddableOrLabel = ( element: NonDeletedExcalidrawElement, ): Boolean => { if (isEmbeddableElement(element)) { diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index a9c568a4..6d581a49 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -1,6 +1,7 @@ import { ROUNDNESS } from "../constants"; import { AppState } from "../types"; import { MarkNonNullable } from "../utility-types"; +import { assertNever } from "../utils"; import { ExcalidrawElement, ExcalidrawTextElement, @@ -140,17 +141,32 @@ export const isTextBindableContainer = ( ); }; -export const isExcalidrawElement = (element: any): boolean => { - return ( - element?.type === "text" || - element?.type === "diamond" || - element?.type === "rectangle" || - element?.type === "embeddable" || - element?.type === "ellipse" || - element?.type === "arrow" || - element?.type === "freedraw" || - element?.type === "line" - ); +export const isExcalidrawElement = ( + element: any, +): element is ExcalidrawElement => { + const type: ExcalidrawElement["type"] | undefined = element?.type; + if (!type) { + return false; + } + switch (type) { + case "text": + case "diamond": + case "rectangle": + case "embeddable": + case "ellipse": + case "arrow": + case "freedraw": + case "line": + case "frame": + case "image": + case "selection": { + return true; + } + default: { + assertNever(type, null); + return false; + } + } }; export const hasBoundTextElement = ( diff --git a/src/frame.ts b/src/frame.ts index 0e7bc93a..eda384f6 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -201,24 +201,52 @@ export const groupByFrames = (elements: readonly ExcalidrawElement[]) => { for (const element of elements) { const frameId = isFrameElement(element) ? element.id : element.frameId; if (frameId && !frameElementsMap.has(frameId)) { - frameElementsMap.set(frameId, getFrameElements(elements, frameId)); + frameElementsMap.set(frameId, getFrameChildren(elements, frameId)); } } return frameElementsMap; }; -export const getFrameElements = ( +export const getFrameChildren = ( allElements: ExcalidrawElementsIncludingDeleted, frameId: string, ) => allElements.filter((element) => element.frameId === frameId); +export const getFrameElements = ( + allElements: ExcalidrawElementsIncludingDeleted, +): ExcalidrawFrameElement[] => { + return allElements.filter((element) => + isFrameElement(element), + ) as ExcalidrawFrameElement[]; +}; + +/** + * Returns ExcalidrawFrameElements and non-frame-children elements. + * + * Considers children as root elements if they point to a frame parent + * non-existing in the elements set. + * + * Considers non-frame bound elements (container or arrow labels) as root. + */ +export const getRootElements = ( + allElements: ExcalidrawElementsIncludingDeleted, +) => { + const frameElements = arrayToMap(getFrameElements(allElements)); + return allElements.filter( + (element) => + frameElements.has(element.id) || + !element.frameId || + !frameElements.has(element.frameId), + ); +}; + export const getElementsInResizingFrame = ( allElements: ExcalidrawElementsIncludingDeleted, frame: ExcalidrawFrameElement, appState: AppState, ): ExcalidrawElement[] => { - const prevElementsInFrame = getFrameElements(allElements, frame.id); + const prevElementsInFrame = getFrameChildren(allElements, frame.id); const nextElementsInFrame = new Set(prevElementsInFrame); const elementsCompletelyInFrame = new Set([ @@ -449,7 +477,7 @@ export const removeAllElementsFromFrame = ( frame: ExcalidrawFrameElement, appState: AppState, ) => { - const elementsInFrame = getFrameElements(allElements, frame.id); + const elementsInFrame = getFrameChildren(allElements, frame.id); return removeElementsFromFrame(allElements, elementsInFrame, appState); }; diff --git a/src/packages/utils.ts b/src/packages/utils.ts index 2d896ed3..b23cab32 100644 --- a/src/packages/utils.ts +++ b/src/packages/utils.ts @@ -4,7 +4,11 @@ import { } from "../scene/export"; import { getDefaultAppState } from "../appState"; import { AppState, BinaryFiles } from "../types"; -import { ExcalidrawElement, NonDeleted } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawFrameElement, + NonDeleted, +} from "../element/types"; import { restore } from "../data/restore"; import { MIME_TYPES } from "../constants"; import { encodePngMetadata } from "../data/image"; @@ -14,24 +18,6 @@ import { copyTextToSystemClipboard, copyToClipboard, } from "../clipboard"; -import Scene from "../scene/Scene"; -import { duplicateElements } from "../element/newElement"; - -// getContainerElement and getBoundTextElement and potentially other helpers -// depend on `Scene` which will not be available when these pure utils are -// called outside initialized Excalidraw editor instance or even if called -// from inside Excalidraw if the elements were never cached by Scene (e.g. -// for library elements). -// -// As such, before passing the elements down, we need to initialize a custom -// Scene instance and assign them to it. -// -// FIXME This is a super hacky workaround and we'll need to rewrite this soon. -const passElementsSafely = (elements: readonly ExcalidrawElement[]) => { - const scene = new Scene(); - scene.replaceAllElements(duplicateElements(elements)); - return scene.getNonDeletedElements(); -}; export { MIME_TYPES }; @@ -40,6 +26,7 @@ type ExportOpts = { appState?: Partial>; files: BinaryFiles | null; maxWidthOrHeight?: number; + exportingFrame?: ExcalidrawFrameElement | null; getDimensions?: ( width: number, height: number, @@ -53,6 +40,7 @@ export const exportToCanvas = ({ maxWidthOrHeight, getDimensions, exportPadding, + exportingFrame, }: ExportOpts & { exportPadding?: number; }) => { @@ -63,10 +51,10 @@ export const exportToCanvas = ({ ); const { exportBackground, viewBackgroundColor } = restoredAppState; return _exportToCanvas( - passElementsSafely(restoredElements), + restoredElements, { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 }, files || {}, - { exportBackground, exportPadding, viewBackgroundColor }, + { exportBackground, exportPadding, viewBackgroundColor, exportingFrame }, (width: number, height: number) => { const canvas = document.createElement("canvas"); @@ -135,10 +123,8 @@ export const exportToBlob = async ( }; } - const canvas = await exportToCanvas({ - ...opts, - elements: passElementsSafely(opts.elements), - }); + const canvas = await exportToCanvas(opts); + quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8; return new Promise((resolve, reject) => { @@ -179,6 +165,7 @@ export const exportToSvg = async ({ files = {}, exportPadding, renderEmbeddables, + exportingFrame, }: Omit & { exportPadding?: number; renderEmbeddables?: boolean; @@ -194,20 +181,10 @@ export const exportToSvg = async ({ exportPadding, }; - return _exportToSvg( - passElementsSafely(restoredElements), - exportAppState, - files, - { - renderEmbeddables, - // NOTE as long as we're using the Scene hack, we need to ensure - // we pass the original, uncloned elements when serializing - // so that we keep ids stable. Hence adding the serializeAsJSON helper - // support into the downstream exportToSvg function. - serializeAsJSON: () => - serializeAsJSON(restoredElements, exportAppState, files || {}, "local"), - }, - ); + return _exportToSvg(restoredElements, exportAppState, files, { + exportingFrame, + renderEmbeddables, + }); }; export const exportToClipboard = async ( diff --git a/src/packages/withinBounds.ts b/src/packages/withinBounds.ts index 75ea10b7..417ec36d 100644 --- a/src/packages/withinBounds.ts +++ b/src/packages/withinBounds.ts @@ -6,13 +6,14 @@ import type { } from "../element/types"; import { isArrowElement, + isExcalidrawElement, isFreeDrawElement, isLinearElement, isTextElement, } from "../element/typeChecks"; import { isValueInRange, rotatePoint } from "../math"; import type { Point } from "../types"; -import { Bounds } from "../element/bounds"; +import { Bounds, getElementBounds } from "../element/bounds"; type Element = NonDeletedExcalidrawElement; type Elements = readonly NonDeletedExcalidrawElement[]; @@ -146,7 +147,7 @@ export const elementsOverlappingBBox = ({ errorMargin = 0, }: { elements: Elements; - bounds: Bounds; + bounds: Bounds | ExcalidrawElement; /** safety offset. Defaults to 0. */ errorMargin?: number; /** @@ -156,6 +157,9 @@ export const elementsOverlappingBBox = ({ **/ type: "overlap" | "contain" | "inside"; }) => { + if (isExcalidrawElement(bounds)) { + bounds = getElementBounds(bounds); + } const adjustedBBox: Bounds = [ bounds[0] - errorMargin, bounds[1] - errorMargin, diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 9b040e9a..d346fa6a 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -20,7 +20,13 @@ import type { Drawable } from "roughjs/bin/core"; import type { RoughSVG } from "roughjs/bin/svg"; import { StaticCanvasRenderConfig } from "../scene/types"; -import { distance, getFontString, getFontFamilyString, isRTL } from "../utils"; +import { + distance, + getFontString, + getFontFamilyString, + isRTL, + isTestEnv, +} from "../utils"; import { getCornerRadius, isPathALoop, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; import { @@ -589,11 +595,7 @@ export const renderElement = ( ) => { switch (element.type) { case "frame": { - if ( - !renderConfig.isExporting && - appState.frameRendering.enabled && - appState.frameRendering.outline - ) { + if (appState.frameRendering.enabled && appState.frameRendering.outline) { context.save(); context.translate( element.x + appState.scrollX, @@ -601,7 +603,7 @@ export const renderElement = ( ); context.fillStyle = "rgba(0, 0, 200, 0.04)"; - context.lineWidth = 2 / appState.zoom.value; + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.strokeStyle = FRAME_STYLE.strokeColor; if (FRAME_STYLE.radius && context.roundRect) { @@ -841,10 +843,13 @@ const maybeWrapNodesInFrameClipPath = ( element: NonDeletedExcalidrawElement, root: SVGElement, nodes: SVGElement[], - exportedFrameId?: string | null, + frameRendering: AppState["frameRendering"], ) => { + if (!frameRendering.enabled || !frameRendering.clip) { + return null; + } const frame = getContainingFrame(element); - if (frame && frame.id === exportedFrameId) { + if (frame) { const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); nodes.forEach((node) => g.appendChild(node)); @@ -861,9 +866,11 @@ export const renderElementToSvg = ( files: BinaryFiles, offsetX: number, offsetY: number, - exportWithDarkMode?: boolean, - exportingFrameId?: string | null, - renderEmbeddables?: boolean, + renderConfig: { + exportWithDarkMode: boolean; + renderEmbeddables: boolean; + frameRendering: AppState["frameRendering"]; + }, ) => { const offset = { x: offsetX, y: offsetY }; const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); @@ -897,6 +904,13 @@ export const renderElementToSvg = ( root = anchorTag; } + const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { + if (isTestEnv()) { + node.setAttribute("data-id", element.id); + } + root.appendChild(node); + }; + const opacity = ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; @@ -931,10 +945,10 @@ export const renderElementToSvg = ( element, root, [node], - exportingFrameId, + renderConfig.frameRendering, ); - g ? root.appendChild(g) : root.appendChild(node); + addToRoot(g || node, element); break; } case "embeddable": { @@ -957,7 +971,7 @@ export const renderElementToSvg = ( offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); - root.appendChild(node); + addToRoot(node, element); const label: ExcalidrawElement = createPlaceholderEmbeddableLabel(element); @@ -968,9 +982,7 @@ export const renderElementToSvg = ( files, label.x + offset.x - element.x, label.y + offset.y - element.y, - exportWithDarkMode, - exportingFrameId, - renderEmbeddables, + renderConfig, ); // render embeddable element + iframe @@ -999,7 +1011,10 @@ export const renderElementToSvg = ( // if rendering embeddables explicitly disabled or // embedding documents via srcdoc (which doesn't seem to work for SVGs) // replace with a link instead - if (renderEmbeddables === false || embedLink?.type === "document") { + if ( + renderConfig.renderEmbeddables === false || + embedLink?.type === "document" + ) { const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); anchorTag.setAttribute("href", normalizeLink(element.link || "")); anchorTag.setAttribute("target", "_blank"); @@ -1033,8 +1048,7 @@ export const renderElementToSvg = ( embeddableNode.appendChild(foreignObject); } - - root.appendChild(embeddableNode); + addToRoot(embeddableNode, element); break; } case "line": @@ -1119,12 +1133,13 @@ export const renderElementToSvg = ( element, root, [group, maskPath], - exportingFrameId, + renderConfig.frameRendering, ); if (g) { + addToRoot(g, element); root.appendChild(g); } else { - root.appendChild(group); + addToRoot(group, element); root.append(maskPath); } break; @@ -1158,10 +1173,10 @@ export const renderElementToSvg = ( element, root, [node], - exportingFrameId, + renderConfig.frameRendering, ); - g ? root.appendChild(g) : root.appendChild(node); + addToRoot(g || node, element); break; } case "image": { @@ -1191,7 +1206,10 @@ export const renderElementToSvg = ( use.setAttribute("href", `#${symbolId}`); // in dark theme, revert the image color filter - if (exportWithDarkMode && fileData.mimeType !== MIME_TYPES.svg) { + if ( + renderConfig.exportWithDarkMode && + fileData.mimeType !== MIME_TYPES.svg + ) { use.setAttribute("filter", IMAGE_INVERT_FILTER); } @@ -1227,14 +1245,39 @@ export const renderElementToSvg = ( element, root, [g], - exportingFrameId, + renderConfig.frameRendering, ); - clipG ? root.appendChild(clipG) : root.appendChild(g); + addToRoot(clipG || g, element); } break; } // frames are not rendered and only acts as a container case "frame": { + if ( + renderConfig.frameRendering.enabled && + renderConfig.frameRendering.outline + ) { + const rect = document.createElementNS(SVG_NS, "rect"); + + rect.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + rect.setAttribute("width", `${element.width}px`); + rect.setAttribute("height", `${element.height}px`); + // Rounded corners + rect.setAttribute("rx", FRAME_STYLE.radius.toString()); + rect.setAttribute("ry", FRAME_STYLE.radius.toString()); + + rect.setAttribute("fill", "none"); + rect.setAttribute("stroke", FRAME_STYLE.strokeColor); + rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); + + addToRoot(rect, element); + } break; } default: { @@ -1288,10 +1331,10 @@ export const renderElementToSvg = ( element, root, [node], - exportingFrameId, + renderConfig.frameRendering, ); - g ? root.appendChild(g) : root.appendChild(node); + addToRoot(g || node, element); } else { // @ts-ignore throw new Error(`Unimplemented type ${element.type}`); diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 6395ed0e..2a183777 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -60,7 +60,7 @@ import { TransformHandles, TransformHandleType, } from "../element/transformHandles"; -import { throttleRAF, isOnlyExportingSingleFrame } from "../utils"; +import { throttleRAF } from "../utils"; import { UserIdleState } from "../types"; import { FRAME_STYLE, THEME_FILTER } from "../constants"; import { @@ -74,7 +74,7 @@ import { isLinearElement, } from "../element/typeChecks"; import { - isEmbeddableOrFrameLabel, + isEmbeddableOrLabel, createPlaceholderEmbeddableLabel, } from "../element/embeddable"; import { @@ -369,7 +369,7 @@ const frameClip = ( ) => { context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); context.beginPath(); - if (context.roundRect && !renderConfig.isExporting) { + if (context.roundRect) { context.roundRect( 0, 0, @@ -963,20 +963,15 @@ const _renderStaticScene = ({ // Paint visible elements visibleElements - .filter((el) => !isEmbeddableOrFrameLabel(el)) + .filter((el) => !isEmbeddableOrLabel(el)) .forEach((element) => { try { - // - when exporting the whole canvas, we DO NOT apply clipping - // - when we are exporting a particular frame, apply clipping - // if the containing frame is not selected, apply clipping const frameId = element.frameId || appState.frameToHighlight?.id; if ( frameId && - ((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) || - (!renderConfig.isExporting && - appState.frameRendering.enabled && - appState.frameRendering.clip)) + appState.frameRendering.enabled && + appState.frameRendering.clip ) { context.save(); @@ -1001,7 +996,7 @@ const _renderStaticScene = ({ // render embeddables on top visibleElements - .filter((el) => isEmbeddableOrFrameLabel(el)) + .filter((el) => isEmbeddableOrLabel(el)) .forEach((element) => { try { const render = () => { @@ -1027,10 +1022,8 @@ const _renderStaticScene = ({ if ( frameId && - ((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) || - (!renderConfig.isExporting && - appState.frameRendering.enabled && - appState.frameRendering.clip)) + appState.frameRendering.enabled && + appState.frameRendering.clip ) { context.save(); @@ -1298,7 +1291,7 @@ const renderFrameHighlight = ( const height = y2 - y1; context.strokeStyle = "rgb(0,118,255)"; - context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / appState.zoom.value; + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.save(); context.translate(appState.scrollX, appState.scrollY); @@ -1454,24 +1447,29 @@ export const renderSceneToSvg = ( { offsetX = 0, offsetY = 0, - exportWithDarkMode = false, - exportingFrameId = null, + exportWithDarkMode, renderEmbeddables, + frameRendering, }: { offsetX?: number; offsetY?: number; - exportWithDarkMode?: boolean; - exportingFrameId?: string | null; - renderEmbeddables?: boolean; - } = {}, + exportWithDarkMode: boolean; + renderEmbeddables: boolean; + frameRendering: AppState["frameRendering"]; + }, ) => { if (!svgRoot) { return; } + const renderConfig = { + exportWithDarkMode, + renderEmbeddables, + frameRendering, + }; // render elements elements - .filter((el) => !isEmbeddableOrFrameLabel(el)) + .filter((el) => !isEmbeddableOrLabel(el)) .forEach((element) => { if (!element.isDeleted) { try { @@ -1482,9 +1480,7 @@ export const renderSceneToSvg = ( files, element.x + offsetX, element.y + offsetY, - exportWithDarkMode, - exportingFrameId, - renderEmbeddables, + renderConfig, ); } catch (error: any) { console.error(error); @@ -1505,9 +1501,7 @@ export const renderSceneToSvg = ( files, element.x + offsetX, element.y + offsetY, - exportWithDarkMode, - exportingFrameId, - renderEmbeddables, + renderConfig, ); } catch (error: any) { console.error(error); diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index e64da78f..9c1b3d32 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -66,16 +66,29 @@ class Scene { private static sceneMapByElement = new WeakMap(); private static sceneMapById = new Map(); - static mapElementToScene(elementKey: ElementKey, scene: Scene) { + static mapElementToScene( + elementKey: ElementKey, + scene: Scene, + /** + * needed because of frame exporting hack. + * elementId:Scene mapping will be removed completely, soon. + */ + mapElementIds = true, + ) { if (isIdKey(elementKey)) { + if (!mapElementIds) { + return; + } // for cases where we don't have access to the element object // (e.g. restore serialized appState with id references) this.sceneMapById.set(elementKey, scene); } else { this.sceneMapByElement.set(elementKey, scene); - // if mapping element objects, also cache the id string when later - // looking up by id alone - this.sceneMapById.set(elementKey.id, scene); + if (!mapElementIds) { + // if mapping element objects, also cache the id string when later + // looking up by id alone + this.sceneMapById.set(elementKey.id, scene); + } } } @@ -217,7 +230,10 @@ class Scene { return didChange; } - replaceAllElements(nextElements: readonly ExcalidrawElement[]) { + replaceAllElements( + nextElements: readonly ExcalidrawElement[], + mapElementIds = true, + ) { this.elements = nextElements; const nextFrames: ExcalidrawFrameElement[] = []; this.elementsMap.clear(); diff --git a/src/scene/export.ts b/src/scene/export.ts index 3aa0cec6..694c162d 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -1,24 +1,144 @@ import rough from "roughjs/bin/rough"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawFrameElement, + ExcalidrawTextElement, + NonDeletedExcalidrawElement, +} from "../element/types"; import { Bounds, getCommonBounds, getElementAbsoluteCoords, } from "../element/bounds"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; -import { distance, isOnlyExportingSingleFrame } from "../utils"; +import { distance, getFontString } from "../utils"; import { AppState, BinaryFiles } from "../types"; -import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants"; +import { + DEFAULT_EXPORT_PADDING, + FRAME_STYLE, + SVG_NS, + THEME_FILTER, +} from "../constants"; import { getDefaultAppState } from "../appState"; import { serializeAsJSON } from "../data/json"; import { getInitializedImageElements, updateImageCache, } from "../element/image"; +import { elementsOverlappingBBox } from "../packages/withinBounds"; +import { getFrameElements, getRootElements } from "../frame"; +import { isFrameElement, newTextElement } from "../element"; +import { Mutable } from "../utility-types"; +import { newElementWith } from "../element/mutateElement"; import Scene from "./Scene"; const SVG_EXPORT_TAG = ``; +// getContainerElement and getBoundTextElement and potentially other helpers +// depend on `Scene` which will not be available when these pure utils are +// called outside initialized Excalidraw editor instance or even if called +// from inside Excalidraw if the elements were never cached by Scene (e.g. +// for library elements). +// +// As such, before passing the elements down, we need to initialize a custom +// Scene instance and assign them to it. +// +// FIXME This is a super hacky workaround and we'll need to rewrite this soon. +const __createSceneForElementsHack__ = ( + elements: readonly ExcalidrawElement[], +) => { + const scene = new Scene(); + // we can't duplicate elements to regenerate ids because we need the + // orig ids when embedding. So we do another hack of not mapping element + // ids to Scene instances so that we don't override the editor elements + // mapping + scene.replaceAllElements(elements, false); + return scene; +}; + +const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { + if (element.width <= maxWidth) { + return element; + } + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + ctx.font = getFontString({ + fontFamily: element.fontFamily, + fontSize: element.fontSize, + }); + + let text = element.text; + + const metrics = ctx.measureText(text); + + if (metrics.width > maxWidth) { + // we iterate from the right, removing characters one by one instead + // of bulding the string up. This assumes that it's more likely + // your frame names will overflow by not that many characters + // (if ever), so it sohuld be faster this way. + for (let i = text.length; i > 0; i--) { + const newText = `${text.slice(0, i)}...`; + if (ctx.measureText(newText).width <= maxWidth) { + text = newText; + break; + } + } + } + return newElementWith(element, { text, width: maxWidth }); +}; + +/** + * When exporting frames, we need to render frame labels which are currently + * being rendered in DOM when editing. Adding the labels as regular text + * elements seems like a simple hack. In the future we'll want to move to + * proper canvas rendering, even within editor (instead of DOM). + */ +const addFrameLabelsAsTextElements = ( + elements: readonly NonDeletedExcalidrawElement[], + opts: Pick, +) => { + const nextElements: NonDeletedExcalidrawElement[] = []; + let frameIdx = 0; + for (const element of elements) { + if (isFrameElement(element)) { + frameIdx++; + let textElement: Mutable = newTextElement({ + x: element.x, + y: element.y - FRAME_STYLE.nameOffsetY, + fontFamily: 4, + fontSize: FRAME_STYLE.nameFontSize, + lineHeight: + FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"], + strokeColor: opts.exportWithDarkMode + ? FRAME_STYLE.nameColorDarkTheme + : FRAME_STYLE.nameColorLightTheme, + text: element.name || `Frame ${frameIdx}`, + }); + textElement.y -= textElement.height; + + textElement = truncateText(textElement, element.width); + + nextElements.push(textElement); + } + nextElements.push(element); + } + + return nextElements; +}; + +const getFrameRenderingConfig = ( + exportingFrame: ExcalidrawFrameElement | null, + frameRendering: AppState["frameRendering"] | null, +): AppState["frameRendering"] => { + frameRendering = frameRendering || getDefaultAppState().frameRendering; + return { + enabled: exportingFrame ? true : frameRendering.enabled, + outline: exportingFrame ? false : frameRendering.outline, + name: exportingFrame ? false : frameRendering.name, + clip: exportingFrame ? true : frameRendering.clip, + }; +}; + export const exportToCanvas = async ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, @@ -27,10 +147,12 @@ export const exportToCanvas = async ( exportBackground, exportPadding = DEFAULT_EXPORT_PADDING, viewBackgroundColor, + exportingFrame, }: { exportBackground: boolean; exportPadding?: number; viewBackgroundColor: string; + exportingFrame?: ExcalidrawFrameElement | null; }, createCanvas: ( width: number, @@ -42,7 +164,26 @@ export const exportToCanvas = async ( return { canvas, scale: appState.exportScale }; }, ) => { - const [minX, minY, width, height] = getCanvasSize(elements, exportPadding); + const tempScene = __createSceneForElementsHack__(elements); + elements = tempScene.getNonDeletedElements(); + + let nextElements: ExcalidrawElement[]; + + if (exportingFrame) { + exportPadding = 0; + nextElements = elementsOverlappingBBox({ + elements, + bounds: exportingFrame, + type: "overlap", + }); + } else { + nextElements = addFrameLabelsAsTextElements(elements, appState); + } + + const [minX, minY, width, height] = getCanvasSize( + exportingFrame ? [exportingFrame] : getRootElements(nextElements), + exportPadding, + ); const { canvas, scale = 1 } = createCanvas(width, height); @@ -50,25 +191,27 @@ export const exportToCanvas = async ( const { imageCache } = await updateImageCache({ imageCache: new Map(), - fileIds: getInitializedImageElements(elements).map( + fileIds: getInitializedImageElements(nextElements).map( (element) => element.fileId, ), files, }); - const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements); - renderStaticScene({ canvas, rc: rough.canvas(canvas), - elements, - visibleElements: elements, + elements: nextElements, + visibleElements: nextElements, scale, appState: { ...appState, + frameRendering: getFrameRenderingConfig( + exportingFrame ?? null, + appState.frameRendering ?? null, + ), viewBackgroundColor: exportBackground ? viewBackgroundColor : null, - scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding), - scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding), + scrollX: -minX + exportPadding, + scrollY: -minY + exportPadding, zoom: defaultAppState.zoom, shouldCacheIgnoreZoom: false, theme: appState.exportWithDarkMode ? "dark" : "light", @@ -80,6 +223,8 @@ export const exportToCanvas = async ( }, }); + tempScene.destroy(); + return canvas; }; @@ -92,35 +237,65 @@ export const exportToSvg = async ( viewBackgroundColor: string; exportWithDarkMode?: boolean; exportEmbedScene?: boolean; - renderFrame?: boolean; + frameRendering?: AppState["frameRendering"]; }, files: BinaryFiles | null, opts?: { - serializeAsJSON?: () => string; renderEmbeddables?: boolean; + exportingFrame?: ExcalidrawFrameElement | null; }, ): Promise => { - const { + const tempScene = __createSceneForElementsHack__(elements); + elements = tempScene.getNonDeletedElements(); + + let { exportPadding = DEFAULT_EXPORT_PADDING, viewBackgroundColor, exportScale = 1, exportEmbedScene, } = appState; + + const { exportingFrame = null } = opts || {}; + + let nextElements: ExcalidrawElement[] = []; + + if (exportingFrame) { + exportPadding = 0; + nextElements = elementsOverlappingBBox({ + elements, + bounds: exportingFrame, + type: "overlap", + }); + } else { + nextElements = addFrameLabelsAsTextElements(elements, { + exportWithDarkMode: appState.exportWithDarkMode ?? false, + }); + } + let metadata = ""; + + // we need to serialize the "original" elements before we put them through + // the tempScene hack which duplicates and regenerates ids if (exportEmbedScene) { try { metadata = await ( await import(/* webpackChunkName: "image" */ "../../src/data/image") ).encodeSvgMetadata({ - text: opts?.serializeAsJSON - ? opts?.serializeAsJSON?.() - : serializeAsJSON(elements, appState, files || {}, "local"), + // when embedding scene, we want to embed the origionally supplied + // elements which don't contain the temp frame labels. + // But it also requires that the exportToSvg is being supplied with + // only the elements that we're exporting, and no extra. + text: serializeAsJSON(elements, appState, files || {}, "local"), }); } catch (error: any) { console.error(error); } } - const [minX, minY, width, height] = getCanvasSize(elements, exportPadding); + + const [minX, minY, width, height] = getCanvasSize( + exportingFrame ? [exportingFrame] : getRootElements(nextElements), + exportPadding, + ); // initialize SVG root const svgRoot = document.createElementNS(SVG_NS, "svg"); @@ -148,33 +323,23 @@ export const exportToSvg = async ( assetPath = `${assetPath}/dist/excalidraw-assets/`; } - // do not apply clipping when we're exporting the whole scene - const isExportingWholeCanvas = - Scene.getScene(elements[0])?.getNonDeletedElements()?.length === - elements.length; + const offsetX = -minX + exportPadding; + const offsetY = -minY + exportPadding; - const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements); - - const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding); - const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding); - - const exportingFrame = - isExportingWholeCanvas || !onlyExportingSingleFrame - ? undefined - : elements.find((element) => element.type === "frame"); + const frameElements = getFrameElements(elements); let exportingFrameClipPath = ""; - if (exportingFrame) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame); - const cx = (x2 - x1) / 2 - (exportingFrame.x - x1); - const cy = (y2 - y1) / 2 - (exportingFrame.y - y1); + for (const frame of frameElements) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); + const cx = (x2 - x1) / 2 - (frame.x - x1); + const cy = (y2 - y1) / 2 - (frame.y - y1); - exportingFrameClipPath = ` - + `; @@ -193,6 +358,10 @@ export const exportToSvg = async ( font-family: "Cascadia"; src: url("${assetPath}Cascadia.woff2"); } + @font-face { + font-family: "Assistant"; + src: url("${assetPath}Assistant-Regular.woff2"); + } ${exportingFrameClipPath} @@ -210,14 +379,19 @@ export const exportToSvg = async ( } const rsvg = rough.svg(svgRoot); - renderSceneToSvg(elements, rsvg, svgRoot, files || {}, { + renderSceneToSvg(nextElements, rsvg, svgRoot, files || {}, { offsetX, offsetY, - exportWithDarkMode: appState.exportWithDarkMode, - exportingFrameId: exportingFrame?.id || null, - renderEmbeddables: opts?.renderEmbeddables, + exportWithDarkMode: appState.exportWithDarkMode ?? false, + renderEmbeddables: opts?.renderEmbeddables ?? false, + frameRendering: getFrameRenderingConfig( + exportingFrame ?? null, + appState.frameRendering ?? null, + ), }); + tempScene.destroy(); + return svgRoot; }; @@ -226,36 +400,9 @@ const getCanvasSize = ( elements: readonly NonDeletedExcalidrawElement[], exportPadding: number, ): Bounds => { - // we should decide if we are exporting the whole canvas - // if so, we are not clipping elements in the frame - // and therefore, we should not do anything special - - const isExportingWholeCanvas = - Scene.getScene(elements[0])?.getNonDeletedElements()?.length === - elements.length; - - const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements); - - if (!isExportingWholeCanvas || onlyExportingSingleFrame) { - const frames = elements.filter((element) => element.type === "frame"); - - const exportedFrameIds = frames.reduce((acc, frame) => { - acc[frame.id] = true; - return acc; - }, {} as Record); - - // elements in a frame do not affect the canvas size if we're not exporting - // the whole canvas - elements = elements.filter( - (element) => !exportedFrameIds[element.frameId ?? ""], - ); - } - const [minX, minY, maxX, maxY] = getCommonBounds(elements); - const width = - distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2); - const height = - distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2); + const width = distance(minX, maxX) + exportPadding * 2; + const height = distance(minY, maxY) + exportPadding * 2; return [minX, minY, width, height]; }; diff --git a/src/scene/selection.ts b/src/scene/selection.ts index dce7c9cd..510dd9ef 100644 --- a/src/scene/selection.ts +++ b/src/scene/selection.ts @@ -8,7 +8,7 @@ import { isBoundToContainer } from "../element/typeChecks"; import { elementOverlapsWithFrame, getContainingFrame, - getFrameElements, + getFrameChildren, } from "../frame"; import { isShallowEqual } from "../utils"; import { isElementInViewport } from "../element/sizeHelpers"; @@ -191,7 +191,7 @@ export const getSelectedElements = ( const elementsToInclude: ExcalidrawElement[] = []; selectedElements.forEach((element) => { if (element.type === "frame") { - getFrameElements(elements, element.id).forEach((e) => + getFrameChildren(elements, element.id).forEach((e) => elementsToInclude.push(e), ); } diff --git a/src/tests/__snapshots__/export.test.tsx.snap b/src/tests/__snapshots__/export.test.tsx.snap index 8eb93c74..eff82ae4 100644 --- a/src/tests/__snapshots__/export.test.tsx.snap +++ b/src/tests/__snapshots__/export.test.tsx.snap @@ -14,8 +14,12 @@ exports[`export > exporting svg containing transformed images > svg export outpu font-family: \\"Cascadia\\"; src: url(\\"https://excalidraw.com/Cascadia.woff2\\"); } + @font-face { + font-family: \\"Assistant\\"; + src: url(\\"https://excalidraw.com/Assistant-Regular.woff2\\"); + } - " + " `; diff --git a/src/tests/flip.test.tsx b/src/tests/flip.test.tsx index d384f122..13edec64 100644 --- a/src/tests/flip.test.tsx +++ b/src/tests/flip.test.tsx @@ -27,6 +27,7 @@ import * as blob from "../data/blob"; import { KEYS } from "../keys"; import { getBoundTextElementPosition } from "../element/textElement"; import { createPasteEvent } from "../clipboard"; +import { cloneJSON } from "../utils"; const { h } = window; const mouse = new Pointer("mouse"); @@ -206,16 +207,14 @@ const checkElementsBoundingBox = async ( }; const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => { - const originalElement = JSON.parse(JSON.stringify(h.elements[0])); + const originalElement = cloneJSON(h.elements[0]); h.app.actionManager.executeAction(actionFlipHorizontal); const newElement = h.elements[0]; await checkElementsBoundingBox(originalElement, newElement, toleranceInPx); }; const checkTwoPointsLineHorizontalFlip = async () => { - const originalElement = JSON.parse( - JSON.stringify(h.elements[0]), - ) as ExcalidrawLinearElement; + const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement; h.app.actionManager.executeAction(actionFlipHorizontal); const newElement = h.elements[0] as ExcalidrawLinearElement; await waitFor(() => { @@ -239,9 +238,7 @@ const checkTwoPointsLineHorizontalFlip = async () => { }; const checkTwoPointsLineVerticalFlip = async () => { - const originalElement = JSON.parse( - JSON.stringify(h.elements[0]), - ) as ExcalidrawLinearElement; + const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement; h.app.actionManager.executeAction(actionFlipVertical); const newElement = h.elements[0] as ExcalidrawLinearElement; await waitFor(() => { @@ -268,7 +265,7 @@ const checkRotatedHorizontalFlip = async ( expectedAngle: number, toleranceInPx: number = 0.00001, ) => { - const originalElement = JSON.parse(JSON.stringify(h.elements[0])); + const originalElement = cloneJSON(h.elements[0]); h.app.actionManager.executeAction(actionFlipHorizontal); const newElement = h.elements[0]; await waitFor(() => { @@ -281,7 +278,7 @@ const checkRotatedVerticalFlip = async ( expectedAngle: number, toleranceInPx: number = 0.00001, ) => { - const originalElement = JSON.parse(JSON.stringify(h.elements[0])); + const originalElement = cloneJSON(h.elements[0]); h.app.actionManager.executeAction(actionFlipVertical); const newElement = h.elements[0]; await waitFor(() => { @@ -291,7 +288,7 @@ const checkRotatedVerticalFlip = async ( }; const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => { - const originalElement = JSON.parse(JSON.stringify(h.elements[0])); + const originalElement = cloneJSON(h.elements[0]); h.app.actionManager.executeAction(actionFlipVertical); @@ -300,7 +297,7 @@ const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => { }; const checkVerticalHorizontalFlip = async (toleranceInPx: number = 0.00001) => { - const originalElement = JSON.parse(JSON.stringify(h.elements[0])); + const originalElement = cloneJSON(h.elements[0]); h.app.actionManager.executeAction(actionFlipHorizontal); h.app.actionManager.executeAction(actionFlipVertical); diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index f6282143..cdf95de2 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -6,6 +6,7 @@ import { ExcalidrawFreeDrawElement, ExcalidrawImageElement, FileId, + ExcalidrawFrameElement, } from "../../element/types"; import { newElement, newTextElement, newLinearElement } from "../../element"; import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants"; @@ -136,6 +137,8 @@ export class API { ? ExcalidrawTextElement : T extends "image" ? ExcalidrawImageElement + : T extends "frame" + ? ExcalidrawFrameElement : ExcalidrawGenericElement => { let element: Mutable = null!; diff --git a/src/tests/packages/utils.test.ts b/src/tests/packages/utils.test.ts index 7d880d29..df8c2633 100644 --- a/src/tests/packages/utils.test.ts +++ b/src/tests/packages/utils.test.ts @@ -92,7 +92,10 @@ describe("exportToSvg", () => { expect(passedOptionsWhenDefault).toMatchSnapshot(); }); - it("with deleted elements", async () => { + // FIXME the utils.exportToSvg no longer filters out deleted elements. + // It's already supposed to be passed non-deleted elements by we're not + // type-checking for it correctly. + it.skip("with deleted elements", async () => { await utils.exportToSvg({ ...diagramFactory({ overrides: { appState: void 0 }, diff --git a/src/tests/scene/__snapshots__/export.test.ts.snap b/src/tests/scene/__snapshots__/export.test.ts.snap index 33a1f9ca..d64ddfb3 100644 --- a/src/tests/scene/__snapshots__/export.test.ts.snap +++ b/src/tests/scene/__snapshots__/export.test.ts.snap @@ -29,6 +29,10 @@ exports[`exportToSvg > with default arguments 1`] = ` font-family: "Cascadia"; src: url("https://excalidraw.com/Cascadia.woff2"); } + @font-face { + font-family: "Assistant"; + src: url("https://excalidraw.com/Assistant-Regular.woff2"); + } @@ -38,6 +42,7 @@ exports[`exportToSvg > with default arguments 1`] = ` @@ -55,6 +60,7 @@ exports[`exportToSvg > with default arguments 1`] = ` /> @@ -88,10 +94,14 @@ exports[`exportToSvg > with elements that have a link 1`] = ` font-family: \\"Cascadia\\"; src: url(\\"https://excalidraw.com/Cascadia.woff2\\"); } + @font-face { + font-family: \\"Assistant\\"; + src: url(\\"https://excalidraw.com/Assistant-Regular.woff2\\"); + } - " + " `; exports[`exportToSvg > with exportEmbedScene 1`] = ` @@ -108,8 +118,12 @@ exports[`exportToSvg > with exportEmbedScene 1`] = ` font-family: \\"Cascadia\\"; src: url(\\"https://excalidraw.com/Cascadia.woff2\\"); } + @font-face { + font-family: \\"Assistant\\"; + src: url(\\"https://excalidraw.com/Assistant-Regular.woff2\\"); + } - " + " `; diff --git a/src/tests/scene/export.test.ts b/src/tests/scene/export.test.ts index 4da7a702..297401d2 100644 --- a/src/tests/scene/export.test.ts +++ b/src/tests/scene/export.test.ts @@ -5,6 +5,10 @@ import { ellipseFixture, rectangleWithLinkFixture, } from "../fixtures/elementFixture"; +import { API } from "../helpers/api"; +import { exportToCanvas, exportToSvg } from "../../packages/utils"; +import { FRAME_STYLE } from "../../constants"; +import { prepareElementsForExport } from "../../data"; describe("exportToSvg", () => { window.EXCALIDRAW_ASSET_PATH = "/"; @@ -127,3 +131,280 @@ describe("exportToSvg", () => { expect(svgElement.innerHTML).toMatchSnapshot(); }); }); + +describe("exporting frames", () => { + const getFrameNameHeight = (exportType: "canvas" | "svg") => { + const height = + FRAME_STYLE.nameFontSize * FRAME_STYLE.nameLineHeight + + FRAME_STYLE.nameOffsetY; + // canvas truncates dimensions to integers + if (exportType === "canvas") { + return Math.trunc(height); + } + return height; + }; + + // a few tests with exportToCanvas (where we can't inspect elements) + // --------------------------------------------------------------------------- + + describe("exportToCanvas", () => { + it("exporting canvas with a single frame shouldn't crop if not exporting frame directly", async () => { + const elements = [ + API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }), + API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 100, + y: 0, + }), + ]; + + const canvas = await exportToCanvas({ + elements, + files: null, + exportPadding: 0, + }); + + expect(canvas.width).toEqual(200); + expect(canvas.height).toEqual(100 + getFrameNameHeight("canvas")); + }); + + it("exporting canvas with a single frame should crop when exporting frame directly", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const elements = [ + frame, + API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 100, + y: 0, + }), + ]; + + const canvas = await exportToCanvas({ + elements, + files: null, + exportPadding: 0, + exportingFrame: frame, + }); + + expect(canvas.width).toEqual(frame.width); + expect(canvas.height).toEqual(frame.height); + }); + }); + + // exportToSvg (so we can test for element existence) + // --------------------------------------------------------------------------- + describe("exportToSvg", () => { + it("exporting frame should include overlapping elements, but crop to frame", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const frameChild = API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 0, + y: 50, + frameId: frame.id, + }); + const rectOverlapping = API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 50, + y: 0, + }); + + const svg = await exportToSvg({ + elements: [rectOverlapping, frame, frameChild], + files: null, + exportPadding: 0, + exportingFrame: frame, + }); + + // frame itself isn't exported + expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull(); + // frame child is exported + expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull(); + // overlapping element is exported + expect( + svg.querySelector(`[data-id="${rectOverlapping.id}"]`), + ).not.toBeNull(); + + expect(svg.getAttribute("width")).toBe(frame.width.toString()); + expect(svg.getAttribute("height")).toBe(frame.height.toString()); + }); + + it("should filter non-overlapping elements when exporting a frame", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const frameChild = API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 0, + y: 50, + frameId: frame.id, + }); + const elementOutside = API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 200, + y: 0, + }); + + const svg = await exportToSvg({ + elements: [frameChild, frame, elementOutside], + files: null, + exportPadding: 0, + exportingFrame: frame, + }); + + // frame itself isn't exported + expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull(); + // frame child is exported + expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull(); + // non-overlapping element is not exported + expect(svg.querySelector(`[data-id="${elementOutside.id}"]`)).toBeNull(); + + expect(svg.getAttribute("width")).toBe(frame.width.toString()); + expect(svg.getAttribute("height")).toBe(frame.height.toString()); + }); + + it("should export multiple frames when selected, excluding overlapping elements", async () => { + const frame1 = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const frame2 = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 200, + y: 0, + }); + + const frame1Child = API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 0, + y: 50, + frameId: frame1.id, + }); + const frame2Child = API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 200, + y: 0, + frameId: frame2.id, + }); + const frame2Overlapping = API.createElement({ + type: "rectangle", + width: 100, + height: 100, + x: 350, + y: 0, + }); + + // low-level exportToSvg api expects elements to be pre-filtered, so let's + // use the filter we use in the editor + const { exportedElements, exportingFrame } = prepareElementsForExport( + [frame1Child, frame1, frame2Child, frame2, frame2Overlapping], + { + selectedElementIds: { [frame1.id]: true, [frame2.id]: true }, + }, + true, + ); + + const svg = await exportToSvg({ + elements: exportedElements, + files: null, + exportPadding: 0, + exportingFrame, + }); + + // frames themselves should be exported when multiple frames selected + expect(svg.querySelector(`[data-id="${frame1.id}"]`)).not.toBeNull(); + expect(svg.querySelector(`[data-id="${frame2.id}"]`)).not.toBeNull(); + // children should be epxorted + expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull(); + expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).not.toBeNull(); + // overlapping elements or non-overlapping elements should not be exported + expect( + svg.querySelector(`[data-id="${frame2Overlapping.id}"]`), + ).toBeNull(); + + expect(svg.getAttribute("width")).toBe( + (frame2.x + frame2.width).toString(), + ); + expect(svg.getAttribute("height")).toBe( + (frame2.y + frame2.height + getFrameNameHeight("svg")).toString(), + ); + }); + + it("should render frame alone when not selected", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + + // low-level exportToSvg api expects elements to be pre-filtered, so let's + // use the filter we use in the editor + const { exportedElements, exportingFrame } = prepareElementsForExport( + [frame], + { + selectedElementIds: {}, + }, + false, + ); + + const svg = await exportToSvg({ + elements: exportedElements, + files: null, + exportPadding: 0, + exportingFrame, + }); + + // frame itself isn't exported + expect(svg.querySelector(`[data-id="${frame.id}"]`)).not.toBeNull(); + + expect(svg.getAttribute("width")).toBe(frame.width.toString()); + expect(svg.getAttribute("height")).toBe( + (frame.height + getFrameNameHeight("svg")).toString(), + ); + }); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 67481960..d5fb432e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -834,11 +834,18 @@ export const isOnlyExportingSingleFrame = ( ); }; +/** + * supply `null` as message if non-never value is valid, you just need to + * typecheck against it + */ export const assertNever = ( value: never, - message: string, + message: string | null, softAssert?: boolean, ): never => { + if (!message) { + return value; + } if (softAssert) { console.error(message); return value; @@ -931,3 +938,5 @@ export const isMemberOf = ( ? collection.includes(value as T) : collection.hasOwnProperty(value); }; + +export const cloneJSON = (obj: T): T => JSON.parse(JSON.stringify(obj));