mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-01 13:19:59 +02:00
Compare commits
12 Commits
zsviczian-
...
frame-grou
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3c34b3f48a | ||
![]() |
683b80ad2b | ||
![]() |
d636abff79 | ||
![]() |
47d8fa542c | ||
![]() |
34cf71b0f4 | ||
![]() |
ceb255e8ee | ||
![]() |
ae5b9a4ffd | ||
![]() |
3d4ff59f40 | ||
![]() |
a30e46b756 | ||
![]() |
71ba0a3f26 | ||
![]() |
9bcd0b69dc | ||
![]() |
afed893419 |
@@ -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) {
|
||||||
|
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
@@ -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}
|
||||||
|
134
src/frame.ts
134
src/frame.ts
@@ -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;
|
||||||
};
|
};
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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!),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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();
|
||||||
|
Reference in New Issue
Block a user