Compare commits

..

12 Commits

Author SHA1 Message Date
Ryan Di
3c34b3f48a add clippings bounds tolerance, improve group related checks 2023-11-17 17:23:30 +08:00
Ryan Di
683b80ad2b alternative clipping improvement 2023-11-14 18:31:21 +08:00
Ryan Di
d636abff79 Merge branch 'master' into frame-group-perf 2023-11-13 18:21:21 +08:00
Ryan Di
47d8fa542c remove redundant code 2023-11-13 18:18:36 +08:00
Ryan Di
34cf71b0f4 keep dynamic clipping with batch clipping 2023-11-13 18:17:39 +08:00
zsviczian
ceb255e8ee fix: exportToSvg to honor frameRendering also for name not only for frame itself (#7270)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-12 22:34:05 +00:00
David Luzar
ae5b9a4ffd fix: not cloning elements on export polluting Scene mapping (#7276) 2023-11-12 23:32:12 +01:00
zsviczian
3d4ff59f40 fix: Can't toggle penMode off due to missing typecheck in togglePenMode (#7273)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-12 13:24:13 +01:00
Ryan Di
a30e46b756 batch clipping 2023-11-10 19:24:32 +08:00
Ryan Di
71ba0a3f26 Merge branch 'master' into frame-group-perf 2023-11-10 15:29:46 +08:00
Ryan Di
9bcd0b69dc remove redundant code 2023-11-10 15:16:11 +08:00
Ryan Di
afed893419 keep only unique frames when ungrouping 2023-11-08 22:50:54 +08:00
7 changed files with 259 additions and 222 deletions

View File

@@ -2716,7 +2716,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
}; };
togglePenMode = (force?: boolean) => { togglePenMode = (force: boolean | null) => {
this.setState((prevState) => { this.setState((prevState) => {
return { return {
penMode: force ?? !prevState.penMode, penMode: force ?? !prevState.penMode,
@@ -5954,11 +5954,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
) { ) {
return withBatchedUpdatesThrottled((event: PointerEvent) => { return withBatchedUpdatesThrottled((event: PointerEvent) => {
//To avoid pointerMove canceling the selection of locked elements on mobile
if (Boolean(this.state.contextMenu)) {
return;
}
// We need to initialize dragOffsetXY only after we've updated // We need to initialize dragOffsetXY only after we've updated
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
// event handler should hopefully ensure we're already working with // event handler should hopefully ensure we're already working with
@@ -6873,10 +6868,13 @@ class App extends React.Component<AppProps, AppState> {
topLayerFrame && topLayerFrame &&
!this.state.selectedElementIds[topLayerFrame.id] !this.state.selectedElementIds[topLayerFrame.id]
) { ) {
const processedGroupIds = new Map<string, boolean>();
const elementsToAdd = selectedElements.filter( const elementsToAdd = selectedElements.filter(
(element) => (element) =>
element.frameId !== topLayerFrame.id && element.frameId !== topLayerFrame.id &&
isElementInFrame(element, nextElements, this.state), isElementInFrame(element, nextElements, this.state, {
processedGroupIds,
}),
); );
if (this.state.editingGroupId) { if (this.state.editingGroupId) {

View File

@@ -66,7 +66,7 @@ interface LayerUIProps {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void; onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: AppClassProperties["togglePenMode"];
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
langCode: Language["code"]; langCode: Language["code"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
@@ -258,7 +258,7 @@ const LayerUI = ({
<PenModeButton <PenModeButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode} checked={appState.penMode}
onChange={onPenModeToggle} onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")} title={t("toolBar.penMode")}
penDetected={appState.penDetected} penDetected={appState.penDetected}
/> />

View File

@@ -35,7 +35,7 @@ type MobileMenuProps = {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void; onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: AppClassProperties["togglePenMode"];
renderTopRightUI?: ( renderTopRightUI?: (
isMobile: boolean, isMobile: boolean,
@@ -94,7 +94,7 @@ export const MobileMenu = ({
)} )}
<PenModeButton <PenModeButton
checked={appState.penMode} checked={appState.penMode}
onChange={onPenModeToggle} onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")} title={t("toolBar.penMode")}
isMobile isMobile
penDetected={appState.penDetected} penDetected={appState.penDetected}

View File

@@ -1,8 +1,4 @@
import { import { getCommonBounds, getElementBounds, isTextElement } from "./element";
getCommonBounds,
getElementAbsoluteCoords,
isTextElement,
} from "./element";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameElement,
@@ -56,6 +52,7 @@ export const bindElementsToFramesAfterDuplication = (
} }
}; };
// --------------------------- Frame Geometry ---------------------------------
export function isElementIntersectingFrame( export function isElementIntersectingFrame(
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
@@ -85,36 +82,27 @@ export const getElementsCompletelyInFrame = (
element.frameId === frame.id, element.frameId === frame.id,
); );
export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[],
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) => {
return getElementsWithinSelection(elements, element).some(
(e) => e.id === frame.id,
);
};
export const getElementsIntersectingFrame = ( export const getElementsIntersectingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => elements.filter((element) => isElementIntersectingFrame(element, frame)); ) => elements.filter((element) => isElementIntersectingFrame(element, frame));
export const elementsAreInFrameBounds = ( export const elementsAreInBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, element: ExcalidrawElement,
tolerance = 0,
) => { ) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(frame);
const [elementX1, elementY1, elementX2, elementY2] = const [elementX1, elementY1, elementX2, elementY2] =
getElementBounds(element);
const [elementsX1, elementsY1, elementsX2, elementsY2] =
getCommonBounds(elements); getCommonBounds(elements);
return ( return (
selectionX1 <= elementX1 && elementX1 <= elementsX1 - tolerance &&
selectionY1 <= elementY1 && elementY1 <= elementsY1 - tolerance &&
selectionX2 >= elementX2 && elementX2 >= elementsX2 + tolerance &&
selectionY2 >= elementY2 elementY2 >= elementsY2 + tolerance
); );
}; };
@@ -123,9 +111,12 @@ export const elementOverlapsWithFrame = (
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => { ) => {
return ( return (
elementsAreInFrameBounds([element], frame) || // frame contains element
isElementIntersectingFrame(element, frame) || elementsAreInBounds([element], frame) ||
isElementContainingFrame([frame], element, frame) // element contains frame
(elementsAreInBounds([frame], element) && element.frameId === frame.id) ||
// element intersects with frame
isElementIntersectingFrame(element, frame)
); );
}; };
@@ -136,7 +127,7 @@ export const isCursorInFrame = (
}, },
frame: NonDeleted<ExcalidrawFrameElement>, frame: NonDeleted<ExcalidrawFrameElement>,
) => { ) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame); const [fx1, fy1, fx2, fy2] = getElementBounds(frame);
return isPointWithinBounds( return isPointWithinBounds(
[fx1, fy1], [fx1, fy1],
@@ -160,7 +151,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
return !!elementsInGroup.find( return !!elementsInGroup.find(
(element) => (element) =>
elementsAreInFrameBounds([element], frame) || elementsAreInBounds([element], frame) ||
isElementIntersectingFrame(element, frame), isElementIntersectingFrame(element, frame),
); );
}; };
@@ -181,7 +172,7 @@ export const groupsAreCompletelyOutOfFrame = (
return ( return (
elementsInGroup.find( elementsInGroup.find(
(element) => (element) =>
elementsAreInFrameBounds([element], frame) || elementsAreInBounds([element], frame) ||
isElementIntersectingFrame(element, frame), isElementIntersectingFrame(element, frame),
) === undefined ) === undefined
); );
@@ -249,12 +240,18 @@ export const getElementsInResizingFrame = (
const prevElementsInFrame = getFrameChildren(allElements, frame.id); const prevElementsInFrame = getFrameChildren(allElements, frame.id);
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame); const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
const elementsCompletelyInFrame = new Set([ const elementsCompletelyInFrame = new Set<ExcalidrawElement>(
...getElementsCompletelyInFrame(allElements, frame), getElementsCompletelyInFrame(allElements, frame),
...prevElementsInFrame.filter((element) => );
isElementContainingFrame(allElements, element, frame),
), for (const element of prevElementsInFrame) {
]); if (!elementsCompletelyInFrame.has(element)) {
// element contains the frame
if (elementsAreInBounds([frame], element)) {
elementsCompletelyInFrame.add(element);
}
}
}
const elementsNotCompletelyInFrame = prevElementsInFrame.filter( const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
(element) => !elementsCompletelyInFrame.has(element), (element) => !elementsCompletelyInFrame.has(element),
@@ -321,7 +318,7 @@ export const getElementsInResizingFrame = (
if (isSelected) { if (isSelected) {
const elementsInGroup = getElementsInGroup(allElements, id); const elementsInGroup = getElementsInGroup(allElements, id);
if (elementsAreInFrameBounds(elementsInGroup, frame)) { if (elementsAreInBounds(elementsInGroup, frame)) {
for (const element of elementsInGroup) { for (const element of elementsInGroup) {
nextElementsInFrame.add(element); nextElementsInFrame.add(element);
} }
@@ -370,7 +367,7 @@ export const getContainingFrame = (
// --------------------------- Frame Operations ------------------------------- // --------------------------- Frame Operations -------------------------------
/** /**
* Retains (or repairs for target frame) the ordering invriant where children * Retains (or repairs for target frame) the ordering invariant where children
* elements come right before the parent frame: * elements come right before the parent frame:
* [el, el, child, child, frame, el] * [el, el, child, child, frame, el]
*/ */
@@ -437,25 +434,14 @@ export const removeElementsFromFrame = (
ExcalidrawElement ExcalidrawElement
>(); >();
const toRemoveElementsByFrame = new Map<
ExcalidrawFrameElement["id"],
ExcalidrawElement[]
>();
for (const element of elementsToRemove) { for (const element of elementsToRemove) {
if (element.frameId) { if (element.frameId) {
_elementsToRemove.set(element.id, element); _elementsToRemove.set(element.id, element);
const arr = toRemoveElementsByFrame.get(element.frameId) || [];
arr.push(element);
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
_elementsToRemove.set(boundTextElement.id, boundTextElement); _elementsToRemove.set(boundTextElement.id, boundTextElement);
arr.push(boundTextElement);
} }
toRemoveElementsByFrame.set(element.frameId, arr);
} }
} }
@@ -520,12 +506,15 @@ export const updateFrameMembershipOfSelectedElements = (
} }
const elementsToRemove = new Set<ExcalidrawElement>(); const elementsToRemove = new Set<ExcalidrawElement>();
const processedGroupIds = new Map<string, boolean>();
elementsToFilter.forEach((element) => { elementsToFilter.forEach((element) => {
if ( if (
element.frameId && element.frameId &&
!isFrameElement(element) && !isFrameElement(element) &&
!isElementInFrame(element, allElements, appState) !isElementInFrame(element, allElements, appState, {
processedGroupIds,
})
) { ) {
elementsToRemove.add(element); elementsToRemove.add(element);
} }
@@ -587,26 +576,36 @@ export const getTargetFrame = (
: getContainingFrame(_element); : getContainingFrame(_element);
}; };
// TODO: this a huge bottleneck for large scenes, optimise
// given an element, return if the element is in some frame // given an element, return if the element is in some frame
export const isElementInFrame = ( export const isElementInFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
opts?: {
targetFrame?: ExcalidrawFrameElement;
processedGroupIds?: Map<string, boolean>;
},
) => { ) => {
const frame = getTargetFrame(element, appState); const frame = opts?.targetFrame ?? getTargetFrame(element, appState);
const _element = isTextElement(element) const _element = isTextElement(element)
? getContainerElement(element) || element ? getContainerElement(element) || element
: element; : element;
const groupsInFrame = (yes: boolean) => {
if (opts?.processedGroupIds) {
_element.groupIds.forEach((gid) => {
opts.processedGroupIds?.set(gid, yes);
});
}
};
if (frame) { if (frame) {
// Perf improvement: // Perf improvement:
// For an element that's already in a frame, if it's not being dragged // For an element that's already in a frame, if it's not being selected
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame. // and its frame is not being selected, it has to be in its containing frame.
// It has to be in its containing frame.
if ( if (
!appState.selectedElementIds[element.id] || !appState.selectedElementIds[element.id] &&
!appState.selectedElementsAreBeingDragged !appState.selectedElementIds[frame.id]
) { ) {
return true; return true;
} }
@@ -615,8 +614,21 @@ export const isElementInFrame = (
return elementOverlapsWithFrame(_element, frame); return elementOverlapsWithFrame(_element, frame);
} }
for (const gid of _element.groupIds) {
if (opts?.processedGroupIds?.has(gid)) {
return opts.processedGroupIds.get(gid);
}
}
const allElementsInGroup = new Set( const allElementsInGroup = new Set(
_element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)), _element.groupIds
.filter((gid) => {
if (opts?.processedGroupIds) {
return !opts.processedGroupIds.has(gid);
}
return true;
})
.flatMap((gid) => getElementsInGroup(allElements, gid)),
); );
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) { if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
@@ -637,16 +649,22 @@ export const isElementInFrame = (
for (const elementInGroup of allElementsInGroup) { for (const elementInGroup of allElementsInGroup) {
if (isFrameElement(elementInGroup)) { if (isFrameElement(elementInGroup)) {
groupsInFrame(false);
return false; return false;
} }
} }
for (const elementInGroup of allElementsInGroup) { for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame)) { if (elementOverlapsWithFrame(elementInGroup, frame)) {
groupsInFrame(true);
return true; return true;
} }
} }
} }
if (_element.groupIds.length > 0) {
groupsInFrame(false);
}
return false; return false;
}; };

View File

@@ -232,6 +232,8 @@ export const selectGroupsFromGivenElements = (
selectedGroupIds: {}, selectedGroupIds: {},
}; };
const processedGroupIds = new Set<string>();
for (const element of elements) { for (const element of elements) {
let groupIds = element.groupIds; let groupIds = element.groupIds;
if (appState.editingGroupId) { if (appState.editingGroupId) {
@@ -242,10 +244,13 @@ export const selectGroupsFromGivenElements = (
} }
if (groupIds.length > 0) { if (groupIds.length > 0) {
const groupId = groupIds[groupIds.length - 1]; const groupId = groupIds[groupIds.length - 1];
if (!processedGroupIds.has(groupId)) {
nextAppState = { nextAppState = {
...nextAppState, ...nextAppState,
...selectGroup(groupId, nextAppState, elements), ...selectGroup(groupId, nextAppState, elements),
}; };
processedGroupIds.add(groupId);
}
} }
} }

View File

@@ -71,6 +71,7 @@ import { renderSnaps } from "./renderSnaps";
import { import {
isEmbeddableElement, isEmbeddableElement,
isFrameElement, isFrameElement,
isFreeDrawElement,
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
@@ -78,7 +79,7 @@ import {
createPlaceholderEmbeddableLabel, createPlaceholderEmbeddableLabel,
} from "../element/embeddable"; } from "../element/embeddable";
import { import {
elementOverlapsWithFrame, elementsAreInBounds,
getTargetFrame, getTargetFrame,
isElementInFrame, isElementInFrame,
} from "../frame"; } from "../frame";
@@ -945,61 +946,22 @@ const _renderStaticScene = ({
); );
} }
const groupsToBeAddedToFrame = new Set<string>(); // Paint visible elements with embeddables on top
const visibleNonEmbeddableOrLabelElements = visibleElements.filter(
visibleElements.forEach((element) => { (el) => !isEmbeddableOrLabel(el),
if (
element.groupIds.length > 0 &&
appState.frameToHighlight &&
appState.selectedElementIds[element.id] &&
(elementOverlapsWithFrame(element, appState.frameToHighlight) ||
element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId)))
) {
element.groupIds.forEach((groupId) =>
groupsToBeAddedToFrame.add(groupId),
); );
}
});
// Paint visible elements const visibleEmbeddableOrLabelElements = visibleElements.filter((el) =>
visibleElements isEmbeddableOrLabel(el),
.filter((el) => !isEmbeddableOrLabel(el)) );
.forEach((element) => {
const visibleElementsToRender = [
...visibleNonEmbeddableOrLabelElements,
...visibleEmbeddableOrLabelElements,
];
const _renderElement = (element: ExcalidrawElement) => {
try { try {
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
context.save();
const frame = getTargetFrame(element, appState);
// TODO do we need to check isElementInFrame here?
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig, appState);
}
renderElement(element, rc, context, renderConfig, appState);
context.restore();
} else {
renderElement(element, rc, context, renderConfig, appState);
}
if (!isExporting) {
renderLinkIcon(element, context, appState);
}
} catch (error: any) {
console.error(error);
}
});
// render embeddables on top
visibleElements
.filter((el) => isEmbeddableOrLabel(el))
.forEach((element) => {
try {
const render = () => {
renderElement(element, rc, context, renderConfig, appState); renderElement(element, rc, context, renderConfig, appState);
if ( if (
@@ -1011,36 +973,52 @@ const _renderStaticScene = ({
const label = createPlaceholderEmbeddableLabel(element); const label = createPlaceholderEmbeddableLabel(element);
renderElement(label, rc, context, renderConfig, appState); renderElement(label, rc, context, renderConfig, appState);
} }
if (!isExporting) { if (!isExporting) {
renderLinkIcon(element, context, appState); renderLinkIcon(element, context, appState);
} }
} catch (error: any) {
console.error(error);
}
}; };
// - when exporting the whole canvas, we DO NOT apply clipping
// - when we are exporting a particular frame, apply clipping const processedGroupIds = new Map<string, boolean>();
// if the containing frame is not selected, apply clipping for (const element of visibleElementsToRender) {
const frameId = element.frameId || appState.frameToHighlight?.id; const frameId = element.frameId || appState.frameToHighlight?.id;
if ( if (
frameId && frameId &&
appState.frameRendering.enabled && appState.frameRendering.enabled &&
appState.frameRendering.clip appState.frameRendering.clip
) {
const targetFrame = getTargetFrame(element, appState);
// for perf:
// only clip elements that are not completely in the target frame
if (
targetFrame &&
!elementsAreInBounds(
[element],
targetFrame,
isFreeDrawElement(element)
? element.strokeWidth * 8
: element.roughness * (isLinearElement(element) ? 8 : 4),
) &&
isElementInFrame(element, elements, appState, {
targetFrame,
processedGroupIds,
})
) { ) {
context.save(); context.save();
frameClip(targetFrame, context, renderConfig, appState);
const frame = getTargetFrame(element, appState); _renderElement(element);
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig, appState);
}
render();
context.restore(); context.restore();
} else { } else {
render(); _renderElement(element);
}
} else {
_renderElement(element);
} }
} catch (error: any) {
console.error(error);
} }
});
}; };
/** throttled to animation framerate */ /** throttled to animation framerate */
@@ -1145,7 +1123,7 @@ const renderTransformHandles = (
const renderSelectionBorder = ( const renderSelectionBorder = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState | StaticCanvasAppState,
elementProperties: { elementProperties: {
angle: number; angle: number;
elementX1: number; elementX1: number;
@@ -1310,20 +1288,7 @@ const renderFrameHighlight = (
context.restore(); context.restore();
}; };
const renderElementsBoxHighlight = ( const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
elements: NonDeleted<ExcalidrawElement>[],
) => {
const individualElements = elements.filter(
(element) => element.groupIds.length === 0,
);
const elementsInGroups = elements.filter(
(element) => element.groupIds.length > 0,
);
const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
const [elementX1, elementY1, elementX2, elementY2] = const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(elements); getCommonBounds(elements);
return { return {
@@ -1338,22 +1303,43 @@ const renderElementsBoxHighlight = (
cy: elementY1 + (elementY2 - elementY1) / 2, cy: elementY1 + (elementY2 - elementY1) / 2,
activeEmbeddable: false, activeEmbeddable: false,
}; };
}; };
const renderElementsBoxHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
elements: NonDeleted<ExcalidrawElement>[],
) => {
const individualElements = elements.filter(
(element) => element.groupIds.length === 0,
);
const elementsInGroups = elements.filter(
(element) => element.groupIds.length > 0,
);
const processedGroupIds = new Set<string>();
const getSelectionForGroupId = (groupId: GroupId) => { const getSelectionForGroupId = (groupId: GroupId) => {
if (!processedGroupIds.has(groupId)) {
const groupElements = getElementsInGroup(elements, groupId); const groupElements = getElementsInGroup(elements, groupId);
processedGroupIds.add(groupId);
return getSelectionFromElements(groupElements); return getSelectionFromElements(groupElements);
}
return null;
}; };
Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
.filter(([id, isSelected]) => isSelected) .filter(([id, isSelected]) => isSelected)
.map(([id, isSelected]) => id) .map(([id, isSelected]) => id)
.map((groupId) => getSelectionForGroupId(groupId)) .map((groupId) => getSelectionForGroupId(groupId))
.filter((selection) => selection)
.concat( .concat(
individualElements.map((element) => getSelectionFromElements([element])), individualElements.map((element) => getSelectionFromElements([element])),
) )
.forEach((selection) => .forEach((selection) =>
renderSelectionBorder(context, appState, selection), renderSelectionBorder(context, appState, selection!),
); );
}; };

View File

@@ -11,7 +11,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "../element/bounds"; } from "../element/bounds";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { distance, getFontString } from "../utils"; import { cloneJSON, distance, getFontString } from "../utils";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { import {
DEFAULT_EXPORT_PADDING, DEFAULT_EXPORT_PADDING,
@@ -52,8 +52,9 @@ const __createSceneForElementsHack__ = (
// we can't duplicate elements to regenerate ids because we need the // 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 // 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 // ids to Scene instances so that we don't override the editor elements
// mapping // mapping.
scene.replaceAllElements(elements, false); // We still need to clone the objects themselves to regen references.
scene.replaceAllElements(cloneJSON(elements), false);
return scene; return scene;
}; };
@@ -140,6 +141,36 @@ const getFrameRenderingConfig = (
}; };
}; };
const prepareElementsForRender = ({
elements,
exportingFrame,
frameRendering,
exportWithDarkMode,
}: {
elements: readonly ExcalidrawElement[];
exportingFrame: ExcalidrawFrameElement | null | undefined;
frameRendering: AppState["frameRendering"];
exportWithDarkMode: AppState["exportWithDarkMode"];
}) => {
let nextElements: readonly ExcalidrawElement[];
if (exportingFrame) {
nextElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else if (frameRendering.enabled && frameRendering.name) {
nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode,
});
} else {
nextElements = elements;
}
return nextElements;
};
export const exportToCanvas = async ( export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
@@ -168,21 +199,24 @@ export const exportToCanvas = async (
const tempScene = __createSceneForElementsHack__(elements); const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements(); elements = tempScene.getNonDeletedElements();
let nextElements: ExcalidrawElement[]; const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
);
const elementsForRender = prepareElementsForRender({
elements,
exportingFrame,
exportWithDarkMode: appState.exportWithDarkMode,
frameRendering,
});
if (exportingFrame) { if (exportingFrame) {
exportPadding = 0; exportPadding = 0;
nextElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else {
nextElements = addFrameLabelsAsTextElements(elements, appState);
} }
const [minX, minY, width, height] = getCanvasSize( const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(nextElements), exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding, exportPadding,
); );
@@ -192,7 +226,7 @@ export const exportToCanvas = async (
const { imageCache } = await updateImageCache({ const { imageCache } = await updateImageCache({
imageCache: new Map(), imageCache: new Map(),
fileIds: getInitializedImageElements(nextElements).map( fileIds: getInitializedImageElements(elementsForRender).map(
(element) => element.fileId, (element) => element.fileId,
), ),
files, files,
@@ -201,15 +235,12 @@ export const exportToCanvas = async (
renderStaticScene({ renderStaticScene({
canvas, canvas,
rc: rough.canvas(canvas), rc: rough.canvas(canvas),
elements: nextElements, elements: elementsForRender,
visibleElements: nextElements, visibleElements: elementsForRender,
scale, scale,
appState: { appState: {
...appState, ...appState,
frameRendering: getFrameRenderingConfig( frameRendering,
exportingFrame ?? null,
appState.frameRendering ?? null,
),
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + exportPadding, scrollX: -minX + exportPadding,
scrollY: -minY + exportPadding, scrollY: -minY + exportPadding,
@@ -249,8 +280,14 @@ export const exportToSvg = async (
const tempScene = __createSceneForElementsHack__(elements); const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements(); elements = tempScene.getNonDeletedElements();
const frameRendering = getFrameRenderingConfig(
opts?.exportingFrame ?? null,
appState.frameRendering ?? null,
);
let { let {
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = DEFAULT_EXPORT_PADDING,
exportWithDarkMode = false,
viewBackgroundColor, viewBackgroundColor,
exportScale = 1, exportScale = 1,
exportEmbedScene, exportEmbedScene,
@@ -258,19 +295,15 @@ export const exportToSvg = async (
const { exportingFrame = null } = opts || {}; const { exportingFrame = null } = opts || {};
let nextElements: ExcalidrawElement[] = []; const elementsForRender = prepareElementsForRender({
elements,
exportingFrame,
exportWithDarkMode,
frameRendering,
});
if (exportingFrame) { if (exportingFrame) {
exportPadding = 0; exportPadding = 0;
nextElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} else {
nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode: appState.exportWithDarkMode ?? false,
});
} }
let metadata = ""; let metadata = "";
@@ -294,7 +327,7 @@ export const exportToSvg = async (
} }
const [minX, minY, width, height] = getCanvasSize( const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(nextElements), exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding, exportPadding,
); );
@@ -305,7 +338,7 @@ export const exportToSvg = async (
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`); svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
svgRoot.setAttribute("width", `${width * exportScale}`); svgRoot.setAttribute("width", `${width * exportScale}`);
svgRoot.setAttribute("height", `${height * exportScale}`); svgRoot.setAttribute("height", `${height * exportScale}`);
if (appState.exportWithDarkMode) { if (exportWithDarkMode) {
svgRoot.setAttribute("filter", THEME_FILTER); svgRoot.setAttribute("filter", THEME_FILTER);
} }
@@ -380,15 +413,12 @@ export const exportToSvg = async (
} }
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
renderSceneToSvg(nextElements, rsvg, svgRoot, files || {}, { renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, {
offsetX, offsetX,
offsetY, offsetY,
exportWithDarkMode: appState.exportWithDarkMode ?? false, exportWithDarkMode,
renderEmbeddables: opts?.renderEmbeddables ?? false, renderEmbeddables: opts?.renderEmbeddables ?? false,
frameRendering: getFrameRenderingConfig( frameRendering,
exportingFrame ?? null,
appState.frameRendering ?? null,
),
}); });
tempScene.destroy(); tempScene.destroy();