feat: introduce frames (#6123)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Ryan Di 2023-06-15 00:42:01 +08:00 committed by GitHub
parent 4d7d96eb7b
commit 81ebf82979
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 4563 additions and 480 deletions

View File

@ -12,7 +12,10 @@ export const actionAddToLibrary = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
if (selectedElements.some((element) => element.type === "image")) { if (selectedElements.some((element) => element.type === "image")) {
return { return {

View File

@ -10,6 +10,7 @@ import {
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
@ -17,10 +18,20 @@ import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const alignActionsPredicate = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1; ) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
);
};
const alignSelectedElements = ( const alignSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -36,14 +47,16 @@ const alignSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
return elements.map( return updateFrameMembershipOfSelectedElements(
(element) => updatedElementsMap.get(element.id) || element, elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
); );
}; };
export const actionAlignTop = register({ export const actionAlignTop = register({
name: "alignTop", name: "alignTop",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -58,7 +71,7 @@ export const actionAlignTop = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={AlignTopIcon} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -74,6 +87,7 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({ export const actionAlignBottom = register({
name: "alignBottom", name: "alignBottom",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -88,7 +102,7 @@ export const actionAlignBottom = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={AlignBottomIcon} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -104,6 +118,7 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({ export const actionAlignLeft = register({
name: "alignLeft", name: "alignLeft",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -118,7 +133,7 @@ export const actionAlignLeft = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={AlignLeftIcon} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -134,7 +149,7 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({ export const actionAlignRight = register({
name: "alignRight", name: "alignRight",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -149,7 +164,7 @@ export const actionAlignRight = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={AlignRightIcon} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -165,7 +180,7 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered", name: "alignVerticallyCentered",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -178,7 +193,7 @@ export const actionAlignVerticallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -192,6 +207,7 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered", name: "alignHorizontallyCentered",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -204,7 +220,7 @@ export const actionAlignHorizontallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}

View File

@ -249,6 +249,7 @@ export const actionWrapTextInContainer = register({
"rectangle", "rectangle",
), ),
groupIds: textElement.groupIds, groupIds: textElement.groupIds,
frameId: textElement.frameId,
}); });
// update bindings // update bindings

View File

@ -20,6 +20,8 @@ import {
isHandToolActive, isHandToolActive,
} from "../appState"; } from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@ -206,7 +208,7 @@ export const actionResetZoom = register({
}); });
const zoomValueToFitBoundsOnViewport = ( const zoomValueToFitBoundsOnViewport = (
bounds: [number, number, number, number], bounds: Bounds,
viewportDimensions: { width: number; height: number }, viewportDimensions: { width: number; height: number },
) => { ) => {
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
@ -234,8 +236,10 @@ export const zoomToFitElements = (
const commonBounds = const commonBounds =
zoomToSelection && selectedElements.length > 0 zoomToSelection && selectedElements.length > 0
? getCommonBounds(selectedElements) ? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
: getCommonBounds(nonDeletedElements); : getCommonBounds(
excludeElementsInFramesFromSelection(nonDeletedElements),
);
const newZoom = { const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, { value: zoomValueToFitBoundsOnViewport(commonBounds, {

View File

@ -16,9 +16,12 @@ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true); const elementsToCopy = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
copyToClipboard(selectedElements, app.files); copyToClipboard(elementsToCopy, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
@ -75,7 +78,10 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
try { try {
await exportCanvas( await exportCanvas(
@ -119,7 +125,10 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
try { try {
await exportCanvas( await exportCanvas(
@ -172,7 +181,9 @@ export const copyText = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
); );
const text = selectedElements const text = selectedElements
@ -191,7 +202,9 @@ export const copyText = register({
predicate: (elements, appState) => { predicate: (elements, appState) => {
return ( return (
probablySupportsClipboardWriteText && probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement) getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).some(isTextElement)
); );
}, },
contextItemLabel: "labels.copyText", contextItemLabel: "labels.copyText",

View File

@ -1,4 +1,4 @@
import { isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
@ -18,11 +18,23 @@ const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => el.type === "frame"),
appState,
).map((el) => el.id),
);
return { return {
elements: elements.map((el) => { elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
return newElementWith(el, { isDeleted: true });
}
if ( if (
isBoundToContainer(el) && isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId] appState.selectedElementIds[el.containerId]

View File

@ -6,6 +6,7 @@ import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute"; import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
@ -16,7 +17,17 @@ import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1; ) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
);
};
const distributeSelectedElements = ( const distributeSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -32,8 +43,9 @@ const distributeSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
return elements.map( return updateFrameMembershipOfSelectedElements(
(element) => updatedElementsMap.get(element.id) || element, elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
); );
}; };

View File

@ -2,7 +2,7 @@ import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element"; import { duplicateElement, getNonDeletedElements } from "../element";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
@ -20,9 +20,17 @@ import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
} from "../element/textElement"; } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements"; import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameElements,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@ -94,8 +102,11 @@ const duplicateElements = (
return newElement; return newElement;
}; };
const selectedElementIds = arrayToMap( const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, true), getSelectedElements(sortedElements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
); );
// Ids of elements that have already been processed so we don't push them // Ids of elements that have already been processed so we don't push them
@ -129,12 +140,25 @@ const duplicateElements = (
} }
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (selectedElementIds.get(element.id)) { const isElementAFrame = isFrameElement(element);
// if a group or a container/bound-text, duplicate atomically
if (element.groupIds.length || boundTextElement) { if (idsOfElementsToDuplicate.get(element.id)) {
// if a group or a container/bound-text or frame, duplicate atomically
if (element.groupIds.length || boundTextElement || isElementAFrame) {
const groupId = getSelectedGroupForElement(appState, element); const groupId = getSelectedGroupForElement(appState, element);
if (groupId) { if (groupId) {
const groupElements = getElementsInGroup(sortedElements, groupId); // TODO:
// remove `.flatMap...`
// if the elements in a frame are grouped when the frame is grouped
const groupElements = getElementsInGroup(
sortedElements,
groupId,
).flatMap((element) =>
isFrameElement(element)
? [...getFrameElements(elements, element.id), element]
: [element],
);
elementsWithClones.push( elementsWithClones.push(
...markAsProcessed([ ...markAsProcessed([
...groupElements, ...groupElements,
@ -156,10 +180,34 @@ const duplicateElements = (
); );
continue; continue;
} }
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([
...elementsInFrame,
element,
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
duplicateAndOffsetElement(element),
]),
);
continue;
}
}
// since elements in frames have a lower z-index than the frame itself,
// they will be looped first and if their frames are selected as well,
// they will have been copied along with the frame atomically in the
// above branch, so we must skip those elements here
//
// now, for elements do not belong any frames or elements whose frames
// are selected (or elements that are left out from the above
// steps for whatever reason) we (should at least) duplicate them here
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
} }
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
} else { } else {
elementsWithClones.push(...markAsProcessed([element])); elementsWithClones.push(...markAsProcessed([element]));
} }
@ -200,6 +248,14 @@ const duplicateElements = (
oldElements, oldElements,
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
bindElementsToFramesAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
return { return {
elements: finalElements, elements: finalElements,
@ -207,7 +263,7 @@ const duplicateElements = (
{ {
...appState, ...appState,
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementIds: newElements.reduce( selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => { (acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) { if (!isBoundToContainer(element)) {
acc[element.id] = true; acc[element.id] = true;

View File

@ -11,8 +11,17 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({ export const actionToggleElementLock = register({
name: "toggleElementLock", name: "toggleElementLock",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
if (!selectedElements.length) { if (!selectedElements.length) {
return false; return false;
@ -38,8 +47,10 @@ export const actionToggleElementLock = register({
}; };
}, },
contextItemLabel: (elements, appState) => { contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, false); const selected = getSelectedElements(elements, appState, {
if (selected.length === 1) { includeBoundTextElement: false,
});
if (selected.length === 1 && selected[0].type !== "frame") {
return selected[0].locked return selected[0].locked
? "labels.elementLock.unlock" ? "labels.elementLock.unlock"
: "labels.elementLock.lock"; : "labels.elementLock.lock";
@ -54,7 +65,9 @@ export const actionToggleElementLock = register({
event.key.toLocaleLowerCase() === KEYS.L && event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
event.shiftKey && event.shiftKey &&
getSelectedElements(elements, appState, false).length > 0 getSelectedElements(elements, appState, {
includeBoundTextElement: false,
}).length > 0
); );
}, },
}); });

View File

@ -12,13 +12,17 @@ import {
isBindingEnabled, isBindingEnabled,
unbindLinearElements, unbindLinearElements,
} from "../element/binding"; } from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "horizontal"), elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"),
appState,
),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
@ -32,7 +36,10 @@ export const actionFlipVertical = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "vertical"), elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"),
appState,
),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
@ -50,6 +57,9 @@ const flipSelectedElements = (
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{
includeElementsInFrames: true,
},
); );
const updatedElements = flipElements( const updatedElements = flipElements(

140
src/actions/actionFrame.ts Normal file
View File

@ -0,0 +1,140 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register";
const isSingleFrameSelected = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
};
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
getNonDeletedElements(elements),
selectedFrame.id,
).filter((element) => !(element.type === "text" && element.containerId));
return {
elements,
appState: {
...appState,
selectedElementIds: elementsInFrame.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
commitToHistory: false,
};
}
return {
elements,
appState,
commitToHistory: false,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") {
return {
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
appState: {
...appState,
selectedElementIds: {
[selectedFrame.id]: true,
},
},
commitToHistory: true,
};
}
return {
elements,
appState,
commitToHistory: false,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionToggleFrameRendering = register({
name: "toggleFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
return {
elements,
appState: {
...appState,
shouldRenderFrames: !appState.shouldRenderFrames,
},
commitToHistory: false,
};
},
contextItemLabel: "labels.toggleFrameRendering",
checked: (appState: AppState) => appState.shouldRenderFrames,
});
export const actionSetFrameAsActiveTool = register({
name: "setFrameAsActiveTool",
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setCursorForShape(app.canvas, {
...appState,
activeTool: nextActiveTool,
});
return {
elements,
appState: {
...appState,
activeTool: updateActiveTool(appState, {
type: "frame",
}),
},
commitToHistory: false,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
!event.altKey &&
event.key.toLocaleLowerCase() === KEYS.F,
});

View File

@ -17,9 +17,19 @@ import {
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { randomId } from "../random"; import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@ -45,7 +55,9 @@ const enableActionGroup = (
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
); );
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@ -55,11 +67,13 @@ const enableActionGroup = (
export const actionGroup = register({ export const actionGroup = register({
name: "group", name: "group",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
); );
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
@ -86,9 +100,31 @@ export const actionGroup = register({
return { appState, elements, commitToHistory: false }; return { appState, elements, commitToHistory: false };
} }
} }
let nextElements = [...elements];
// this includes the case where we are grouping elements inside a frame
// and elements outside that frame
const groupingElementsFromDifferentFrames =
new Set(selectedElements.map((element) => element.frameId)).size > 1;
// when it happens, we want to remove elements that are in the frame
// and are going to be grouped from the frame (mouthful, I know)
if (groupingElementsFromDifferentFrames) {
const frameElementsMap = groupByFrames(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame(
nextElements,
elementsInFrame,
appState,
);
});
}
const newGroupId = randomId(); const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements); const selectElementIds = arrayToMap(selectedElements);
const updatedElements = elements.map((element) => {
nextElements = nextElements.map((element) => {
if (!selectElementIds.get(element.id)) { if (!selectElementIds.get(element.id)) {
return element; return element;
} }
@ -102,17 +138,16 @@ export const actionGroup = register({
}); });
// keep the z order within the group the same, but move them // keep the z order within the group the same, but move them
// to the z order of the highest element in the layer stack // to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId); const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
updatedElements.lastIndexOf(lastElementInGroup); const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1); const elementsBeforeGroup = nextElements
const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex) .slice(0, lastGroupElementIndex)
.filter( .filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId), (updatedElement) => !isElementInGroup(updatedElement, newGroupId),
); );
const updatedElementsInOrder = [ nextElements = [
...elementsBeforeGroup, ...elementsBeforeGroup,
...elementsInGroup, ...elementsInGroup,
...elementsAfterGroup, ...elementsAfterGroup,
@ -122,9 +157,9 @@ export const actionGroup = register({
appState: selectGroup( appState: selectGroup(
newGroupId, newGroupId,
{ ...appState, selectedGroupIds: {} }, { ...appState, selectedGroupIds: {} },
getNonDeletedElements(updatedElementsInOrder), getNonDeletedElements(nextElements),
), ),
elements: updatedElementsInOrder, elements: nextElements,
commitToHistory: true, commitToHistory: true,
}; };
}, },
@ -148,14 +183,23 @@ export const actionGroup = register({
export const actionUngroup = register({ export const actionUngroup = register({
name: "ungroup", name: "ungroup",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState); const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) { if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false }; return { appState, elements, commitToHistory: false };
} }
let nextElements = [...elements];
const selectedElements = getSelectedElements(nextElements, appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
app.scene.getElement(element.frameId!),
) as ExcalidrawFrameElement[];
const boundTextElementIds: ExcalidrawTextElement["id"][] = []; const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
const nextElements = elements.map((element) => { nextElements = nextElements.map((element) => {
if (isBoundToContainer(element)) { if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id); boundTextElementIds.push(element.id);
} }
@ -176,13 +220,23 @@ export const actionUngroup = register({
getNonDeletedElements(nextElements), getNonDeletedElements(nextElements),
); );
frames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
getElementsInResizingFrame(nextElements, frame, appState),
frame,
appState,
);
}
});
// remove binded text elements from selection // remove binded text elements from selection
boundTextElementIds.forEach( boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false), (id) => (updateAppState.selectedElementIds[id] = false),
); );
return { return {
appState: updateAppState, appState: updateAppState,
elements: nextElements, elements: nextElements,
commitToHistory: true, commitToHistory: true,
}; };

View File

@ -21,7 +21,9 @@ export const actionToggleLinearEditor = register({
const selectedElement = getSelectedElements( const selectedElement = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement; )[0] as ExcalidrawLinearElement;
const editingLinearElement = const editingLinearElement =
@ -40,7 +42,9 @@ export const actionToggleLinearEditor = register({
const selectedElement = getSelectedElements( const selectedElement = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement; )[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit" ? "labels.lineEditor.exit"

View File

@ -67,7 +67,6 @@ export const actionFullScreen = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
}); });
export const actionShortcuts = register({ export const actionShortcuts = register({

View File

@ -102,8 +102,11 @@ const changeProperty = (
includeBoundText = false, includeBoundText = false,
) => { ) => {
const selectedElementIds = arrayToMap( const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, includeBoundText), getSelectedElements(elements, appState, {
includeBoundTextElement: includeBoundText,
}),
); );
return elements.map((element) => { return elements.map((element) => {
if ( if (
selectedElementIds.get(element.id) || selectedElementIds.get(element.id) ||

View File

@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
@ -13,19 +14,18 @@ export const actionSelectAll = register({
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
return false; return false;
} }
const selectedElementIds = elements.reduce(
(map: Record<ExcalidrawElement["id"], true>, element) => { const selectedElementIds = excludeElementsInFramesFromSelection(
if ( elements.filter(
(element) =>
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) && !(isTextElement(element) && element.containerId) &&
!element.locked !element.locked,
) { ),
map[element.id] = true; ).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
} map[element.id] = true;
return map; return map;
}, }, {});
{},
);
return { return {
appState: selectGroupsForSelectedElements( appState: selectGroupsForSelectedElements(

View File

@ -20,6 +20,7 @@ import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement, getDefaultRoundnessTypeForElement,
isFrameElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
@ -64,7 +65,9 @@ export const actionPasteStyles = register({
return { elements, commitToHistory: false }; return { elements, commitToHistory: false };
} }
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
const selectedElementIds = selectedElements.map((element) => element.id); const selectedElementIds = selectedElements.map((element) => element.id);
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
@ -127,6 +130,13 @@ export const actionPasteStyles = register({
}); });
} }
if (isFrameElement(element)) {
newElement = newElementWith(newElement, {
roundness: null,
backgroundColor: "transparent",
});
}
return newElement; return newElement;
} }
return element; return element;

View File

@ -116,6 +116,11 @@ export type ActionName =
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool" | "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "toggleFrameRendering"
| "setFrameAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer"; | "wrapTextInContainer";
export type PanelComponentProps = { export type PanelComponentProps = {

View File

@ -1,6 +1,6 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { Box, getCommonBoundingBox } from "./element/bounds"; import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
export interface Alignment { export interface Alignment {
@ -33,7 +33,7 @@ export const alignElements = (
const calculateTranslation = ( const calculateTranslation = (
group: ExcalidrawElement[], group: ExcalidrawElement[],
selectionBoundingBox: Box, selectionBoundingBox: BoundingBox,
{ axis, position }: Alignment, { axis, position }: Alignment,
): { x: number; y: number } => { ): { x: number; y: number } => {
const groupBoundingBox = getCommonBoundingBox(group); const groupBoundingBox = getCommonBoundingBox(group);

View File

@ -78,11 +78,16 @@ export const getDefaultAppState = (): Omit<
scrollY: 0, scrollY: 0,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null, selectionElement: null,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showStats: false, showStats: false,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
shouldRenderFrames: true,
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
toast: null, toast: null,
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false, zenModeEnabled: false,
@ -176,11 +181,20 @@ const APP_STATE_STORAGE_CONF = (<
scrollY: { browser: true, export: false, server: false }, scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false }, selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false }, selectedGroupIds: { browser: true, export: false, server: false },
selectedElementsAreBeingDragged: {
browser: false,
export: false,
server: false,
},
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showStats: { browser: true, export: false, server: false }, showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },
shouldRenderFrames: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
elementsToHighlight: { browser: false, export: false, server: false },
toast: { browser: false, export: false, server: false }, toast: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true }, viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false }, width: { browser: false, export: false, server: false },

View File

@ -7,6 +7,9 @@ import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isPromiseLike, isTestEnv } from "./utils"; import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = { type ElementsClipboard = {
@ -57,6 +60,9 @@ export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null, files: BinaryFiles | null,
) => { ) => {
const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"),
);
let foundFile = false; let foundFile = false;
const _files = elements.reduce((acc, element) => { const _files = elements.reduce((acc, element) => {
@ -78,7 +84,20 @@ export const copyToClipboard = async (
// select binded text elements when copying // select binded text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements, elements: elements.map((element) => {
if (
getContainingFrame(element) &&
!framesToCopy.has(getContainingFrame(element)!)
) {
const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, {
frameId: null,
});
return copiedElement;
}
return element;
}),
files: files ? _files : undefined, files: files ? _files : undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useState } from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types"; import { ExcalidrawElement, PointerType } from "../element/types";
@ -35,6 +35,9 @@ import {
} from "../element/textElement"; } from "../element/textElement";
import "./Actions.scss"; import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@ -89,7 +92,8 @@ export const SelectedShapeActions = ({
<div> <div>
{((hasStrokeColor(appState.activeTool.type) && {((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" && appState.activeTool.type !== "image" &&
commonSelectedType !== "image") || commonSelectedType !== "image" &&
commonSelectedType !== "frame") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
</div> </div>
@ -220,28 +224,78 @@ export const ShapesSwitcher = ({
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState; appState: UIAppState;
}) => ( }) => {
<> const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { const device = useDevice();
const label = t(`toolBar.${value}`); return (
const letter = <>
key && capitalizeString(typeof key === "string" ? key : key[0]); {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const shortcut = letter const label = t(`toolBar.${value}`);
? `${letter} ${t("helpDialog.or")} ${numericKey}` const letter =
: `${numericKey}`; key && capitalizeString(typeof key === "string" ? key : key[0]);
return ( const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<ToolButton <ToolButton
className={clsx("Shape", { fillable })} className={clsx("Shape", { fillable: false })}
key={value}
type="radio" type="radio"
icon={icon} icon={frameToolIcon}
checked={activeTool.type === value} checked={activeTool.type === "frame"}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(
keyBindingLabel={numericKey || letter} t("toolBar.frame"),
aria-label={capitalizeString(label)} )} ${KEYS.F.toLocaleUpperCase()}`}
aria-keyshortcuts={shortcut} keyBindingLabel={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-${value}`} aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
setAppState({ setAppState({
@ -251,30 +305,54 @@ export const ShapesSwitcher = ({
} }
}} }}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) { trackEvent("toolbar", "frame", "ui");
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, { const nextActiveTool = updateActiveTool(appState, {
type: value, type: "frame",
}); });
setAppState({ setAppState({
activeTool: nextActiveTool, activeTool: nextActiveTool,
multiElement: null, multiElement: null,
selectedElementIds: {}, selectedElementIds: {},
}); });
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}} }}
/> />
); ) : (
})} <DropdownMenu open={isExtraToolsMenuOpen}>
</> <DropdownMenu.Trigger
); className="App-toolbar__extra-tools-trigger"
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
>
{t("toolBar.frame")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</>
);
};
export const ZoomActions = ({ export const ZoomActions = ({
renderAction, renderAction,

File diff suppressed because it is too large Load Diff

View File

@ -164,6 +164,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.eraser")} label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut <Shortcut
label={t("labels.eyeDropper")} label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]} shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}

View File

@ -84,7 +84,10 @@ const ImageExportModal = ({
const [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected const exportedElements = exportSelected
? getSelectedElements(elements, appState, true) ? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements; : elements;
useEffect(() => { useEffect(() => {

View File

@ -204,12 +204,7 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col gap={6} className={clsx("App-menu_top__left")}>
gap={6}
className={clsx("App-menu_top__left", {
"disable-pointerEvents": appState.zenModeEnabled,
})}
>
{renderCanvasActions()} {renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
@ -254,7 +249,7 @@ const LayerUI = ({
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
<div className="App-toolbar__divider"></div> <div className="App-toolbar__divider" />
<HandButton <HandButton
checked={isHandToolActive(appState)} checked={isHandToolActive(appState)}

View File

@ -148,7 +148,11 @@ const usePendingElementsMemo = (
appState: UIAppState, appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
) => { ) => {
const create = () => getSelectedElements(elements, appState, true); const create = () =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const val = useRef(create()); const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState); const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements); const prevElements = useRef(elements);

View File

@ -1,6 +1,6 @@
import "./ToolIcon.scss"; import "./ToolIcon.scss";
import React, { useEffect, useRef, useState } from "react"; import React, { CSSProperties, useEffect, useRef, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
visible?: boolean; visible?: boolean;
selected?: boolean; selected?: boolean;
className?: string; className?: string;
style?: CSSProperties;
isLoading?: boolean; isLoading?: boolean;
}; };
@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
"ToolIcon--plain": props.type === "icon", "ToolIcon--plain": props.type === "icon",
}, },
)} )}
style={props.style}
data-testid={props["data-testid"]} data-testid={props["data-testid"]}
hidden={props.hidden} hidden={props.hidden}
title={props.title} title={props.title}

View File

@ -15,7 +15,24 @@
height: 1.5rem; height: 1.5rem;
align-self: center; align-self: center;
background-color: var(--default-border-color); background-color: var(--default-border-color);
margin: 0 0.5rem; margin: 0 0.25rem;
} }
} }
.App-toolbar__extra-tools-trigger {
box-shadow: none;
border: 0;
&:active {
background-color: var(--button-hover-bg);
box-shadow: 0 0 0 1px
var(--button-active-border, var(--color-primary-darkest)) inset;
}
}
.App-toolbar__extra-tools-dropdown {
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
}
} }

View File

@ -1,23 +1,23 @@
import clsx from "clsx"; import clsx from "clsx";
import { useUIAppState } from "../../context/ui-appState";
import { useDevice } from "../App"; import { useDevice } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
className = "", className = "",
children, children,
onToggle, onToggle,
title,
...rest
}: { }: {
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
onToggle: () => void; onToggle: () => void;
}) => { title?: string;
const appState = useUIAppState(); } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const device = useDevice(); const device = useDevice();
const classNames = clsx( const classNames = clsx(
`dropdown-menu-button ${className}`, `dropdown-menu-button ${className}`,
"zen-mode-transition", "zen-mode-transition",
{ {
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile, "dropdown-menu-button--mobile": device.isMobile,
}, },
).trim(); ).trim();
@ -28,6 +28,8 @@ const MenuTrigger = ({
onClick={onToggle} onClick={onToggle}
type="button" type="button"
data-testid="dropdown-menu-button" data-testid="dropdown-menu-button"
title={title}
{...rest}
> >
{children} {children}
</button> </button>

View File

@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2); expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
}); });
@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2); expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
}); });
@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2); expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
}); });
@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2); expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
}); });
}); });

View File

@ -1616,3 +1616,24 @@ export const eyeDropperIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const extraToolsIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3l-4 7h8z"></path>
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
</g>,
tablerIconProps,
);
export const frameToolIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 7l16 0"></path>
<path d="M4 17l16 0"></path>
<path d="M7 4l0 16"></path>
<path d="M17 4l0 16"></path>
</g>,
tablerIconProps,
);

View File

@ -42,6 +42,7 @@ const MainMenu = Object.assign(
openMenu: appState.openMenu === "canvas" ? null : "canvas", openMenu: appState.openMenu === "canvas" ? null : "canvas",
}); });
}} }}
data-testid="main-menu-trigger"
> >
{HamburgerMenuIcon} {HamburgerMenuIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>

View File

@ -94,6 +94,17 @@ export const THEME = {
DARK: "dark", DARK: "dark",
}; };
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 1 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,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;

View File

@ -62,6 +62,7 @@ export const AllowedExcalidrawActiveTools: Record<
freedraw: true, freedraw: true,
eraser: false, eraser: false,
custom: true, custom: true,
frame: true,
hand: true, hand: true,
}; };
@ -125,6 +126,7 @@ const restoreElementWithProperties = <
height: element.height || 0, height: element.height || 0,
seed: element.seed ?? 1, seed: element.seed ?? 1,
groupIds: element.groupIds ?? [], groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null,
roundness: element.roundness roundness: element.roundness
? element.roundness ? element.roundness
: element.strokeSharpness === "round" : element.strokeSharpness === "round"
@ -272,6 +274,10 @@ const restoreElement = (
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "diamond": case "diamond":
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,
});
// Don't use default case so as to catch a missing an element type case. // Don't use default case so as to catch a missing an element type case.
// We also don't want to throw, but instead return void so we filter // We also don't want to throw, but instead return void so we filter

View File

@ -344,7 +344,7 @@ export const isPointHittingLinkIcon = (
if ( if (
!isMobile && !isMobile &&
appState.viewModeEnabled && appState.viewModeEnabled &&
isPointHittingElementBoundingBox(element, [x, y], threshold) isPointHittingElementBoundingBox(element, [x, y], threshold, null)
) { ) {
return true; return true;
} }
@ -440,7 +440,9 @@ export const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value; const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box // hitbox to prevent hiding when hovered in element bounding box
if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) { if (
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
) {
return false; return false;
} }
const [x1, y1, x2] = getElementAbsoluteCoords(element); const [x1, y1, x2] = getElementAbsoluteCoords(element);

View File

@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
]; ];
export const shouldEnableBindingForPointerEvent = ( export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
return !event[KEYS.CTRL_OR_CMD]; return !event[KEYS.CTRL_OR_CMD];
}; };

View File

@ -6,7 +6,7 @@ import {
NonDeleted, NonDeleted,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
} from "./types"; } from "./types";
import { distance2d, rotate } from "../math"; import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core"; import { Drawable, Op } from "roughjs/bin/core";
import { Point } from "../types"; import { Point } from "../types";
@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
// x and y position of top left corner, x and y position of bottom right corner export type RectangleBox = {
export type Bounds = readonly [number, number, number, number]; x: number;
y: number;
width: number;
height: number;
angle: number;
};
type MaybeQuadraticSolution = [number | null, number | null] | false; type MaybeQuadraticSolution = [number | null, number | null] | false;
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
export class ElementBounds {
private static boundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement) {
const cachedBounds = ElementBounds.boundsCache.get(element);
if (cachedBounds?.version && cachedBounds.version === element.version) {
return cachedBounds.bounds;
}
const bounds = ElementBounds.calculateBounds(element);
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
return bounds;
}
private static calculateBounds(element: ExcalidrawElement): Bounds {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
return bounds;
}
}
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
//
// If the element is created from right to left, the width is going to be negative // If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points. // This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = ( export const getElementAbsoluteCoords = (
@ -69,6 +160,111 @@ export const getElementAbsoluteCoords = (
]; ];
}; };
/**
* for a given element, `getElementLineSegments` returns line segments
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
*/
export const getElementLineSegments = (
element: ExcalidrawElement,
): [Point, Point][] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
const center: Point = [cx, cy];
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: [Point, Point][] = [];
let i = 0;
while (i < element.points.length - 1) {
segments.push([
rotatePoint(
[
element.points[i][0] + element.x,
element.points[i][1] + element.y,
] as Point,
center,
element.angle,
),
rotatePoint(
[
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
] as Point,
center,
element.angle,
),
]);
i++;
}
return segments;
}
const [nw, ne, sw, se, n, s, w, e] = (
[
[x1, y1],
[x2, y1],
[x1, y2],
[x2, y2],
[cx, y1],
[cx, y2],
[x1, cy],
[x2, cy],
] as Point[]
).map((point) => rotatePoint(point, center, element.angle));
if (element.type === "diamond") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
];
}
if (element.type === "ellipse") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
[n, w],
[n, e],
[s, w],
[s, e],
];
}
return [
[nw, ne],
[sw, se],
[nw, sw],
[ne, se],
[nw, e],
[sw, e],
[ne, w],
[se, w],
];
};
/**
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
*
* Rectangle here means any rectangular frame, not an excalidraw element.
*/
export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
return [
boxSceneCoords.x,
boxSceneCoords.y,
boxSceneCoords.x + boxSceneCoords.width,
boxSceneCoords.y + boxSceneCoords.height,
boxSceneCoords.x + boxSceneCoords.width / 2,
boxSceneCoords.y + boxSceneCoords.height / 2,
];
};
export const pointRelativeTo = ( export const pointRelativeTo = (
element: ExcalidrawElement, element: ExcalidrawElement,
absoluteCoords: Point, absoluteCoords: Point,
@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
return coords; return coords;
}; };
// We could cache this stuff export const getElementBounds = (element: ExcalidrawElement): Bounds => {
export const getElementBounds = ( return ElementBounds.getBounds(element);
element: ExcalidrawElement,
): [number, number, number, number] => {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
return bounds;
}; };
export const getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): [number, number, number, number] => { ): Bounds => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }
@ -608,7 +752,7 @@ export const getElementPointsCoords = (
export const getClosestElementBounds = ( export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
from: { x: number; y: number }, from: { x: number; y: number },
): [number, number, number, number] => { ): Bounds => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }
@ -629,7 +773,7 @@ export const getClosestElementBounds = (
return getElementBounds(closestElement); return getElementBounds(closestElement);
}; };
export interface Box { export interface BoundingBox {
minX: number; minX: number;
minY: number; minY: number;
maxX: number; maxX: number;
@ -642,7 +786,7 @@ export interface Box {
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[], elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): Box => { ): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {
minX, minX,

View File

@ -26,10 +26,16 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
StrokeRoundness, StrokeRoundness,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds"; import {
import { Point } from "../types"; getElementAbsoluteCoords,
getCurvePathOps,
getRectangleBoxAbsoluteCoords,
RectangleBox,
} from "./bounds";
import { FrameNameBoundsCache, Point } from "../types";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement"; import { getShapeForElement } from "../renderer/renderElement";
@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
export const hitTest = ( export const hitTest = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number, x: number,
y: number, y: number,
): boolean => { ): boolean => {
@ -72,22 +79,39 @@ export const hitTest = (
isElementSelected(appState, element) && isElementSelected(appState, element) &&
shouldShowBoundingBox([element], appState) shouldShowBoundingBox([element], appState)
) { ) {
return isPointHittingElementBoundingBox(element, point, threshold); return isPointHittingElementBoundingBox(
element,
point,
threshold,
frameNameBoundsCache,
);
} }
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y); const isHittingBoundTextElement = hitTest(
boundTextElement,
appState,
frameNameBoundsCache,
x,
y,
);
if (isHittingBoundTextElement) { if (isHittingBoundTextElement) {
return true; return true;
} }
} }
return isHittingElementNotConsideringBoundingBox(element, appState, point); return isHittingElementNotConsideringBoundingBox(
element,
appState,
frameNameBoundsCache,
point,
);
}; };
export const isHittingElementBoundingBoxWithoutHittingElement = ( export const isHittingElementBoundingBoxWithoutHittingElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number, x: number,
y: number, y: number,
): boolean => { ): boolean => {
@ -96,19 +120,33 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
// eg for linear elements text can be outside the element bounding box // eg for linear elements text can be outside the element bounding box
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) { if (
boundTextElement &&
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
) {
return false; return false;
} }
return ( return (
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) && !isHittingElementNotConsideringBoundingBox(
isPointHittingElementBoundingBox(element, [x, y], threshold) element,
appState,
frameNameBoundsCache,
[x, y],
) &&
isPointHittingElementBoundingBox(
element,
[x, y],
threshold,
frameNameBoundsCache,
)
); );
}; };
export const isHittingElementNotConsideringBoundingBox = ( export const isHittingElementNotConsideringBoundingBox = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache | null,
point: Point, point: Point,
): boolean => { ): boolean => {
const threshold = 10 / appState.zoom.value; const threshold = 10 / appState.zoom.value;
@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element) : isElementDraggableFromInside(element)
? isInsideCheck ? isInsideCheck
: isNearCheck; : isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check }); return hitTestPointAgainstElement({
element,
point,
threshold,
check,
frameNameBoundsCache,
});
}; };
const isElementSelected = ( const isElementSelected = (
@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
[x, y]: Point, [x, y]: Point,
threshold: number, threshold: number,
frameNameBoundsCache: FrameNameBoundsCache | null,
) => { ) => {
// frames needs be checked differently so as to be able to drag it
// by its frame, whether it has been selected or not
// this logic here is not ideal
// TODO: refactor it later...
if (element.type === "frame") {
return hitTestPointAgainstElement({
element,
point: [x, y],
threshold,
check: isInsideCheck,
frameNameBoundsCache,
});
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCenterX = (x1 + x2) / 2; const elementCenterX = (x1 + x2) / 2;
const elementCenterY = (y1 + y2) / 2; const elementCenterY = (y1 + y2) / 2;
@ -157,7 +216,13 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const check = isOutsideCheck; const check = isOutsideCheck;
const point: Point = [x, y]; const point: Point = [x, y];
return hitTestPointAgainstElement({ element, point, threshold, check }); return hitTestPointAgainstElement({
element,
point,
threshold,
check,
frameNameBoundsCache: null,
});
}; };
export const maxBindingGap = ( export const maxBindingGap = (
@ -177,6 +242,7 @@ type HitTestArgs = {
point: Point; point: Point;
threshold: number; threshold: number;
check: (distance: number, threshold: number) => boolean; check: (distance: number, threshold: number) => boolean;
frameNameBoundsCache: FrameNameBoundsCache | null;
}; };
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
@ -208,6 +274,27 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
"This should not happen, we need to investigate why it does.", "This should not happen, we need to investigate why it does.",
); );
return false; return false;
case "frame": {
// check distance to frame element first
if (
args.check(
distanceToBindableElement(args.element, args.point),
args.threshold,
)
) {
return true;
}
const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
if (frameNameBounds) {
return args.check(
distanceToRectangleBox(frameNameBounds, args.point),
args.threshold,
);
}
return false;
}
} }
}; };
@ -219,6 +306,7 @@ export const distanceToBindableElement = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return distanceToRectangle(element, point); return distanceToRectangle(element, point);
case "diamond": case "diamond":
return distanceToDiamond(element, point); return distanceToDiamond(element, point);
@ -248,7 +336,8 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement, | ExcalidrawImageElement
| ExcalidrawFrameElement,
point: Point, point: Point,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -258,6 +347,14 @@ const distanceToRectangle = (
); );
}; };
const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
return Math.max(
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
);
};
const distanceToDiamond = ( const distanceToDiamond = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
point: Point, point: Point,
@ -457,8 +554,7 @@ const pointRelativeToElement = (
): [GA.Point, GA.Point, number, number] => { ): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple); const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCoords = getElementAbsoluteCoords(element); const center = coordsCenter(x1, y1, x2, y2);
const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate` // GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle); const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point); const pointRotated = GATransform.apply(rotate, point);
@ -466,9 +562,26 @@ const pointRelativeToElement = (
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(element.x, element.y); const elementPos = GA.offset(element.x, element.y);
const pointRelToPos = GA.sub(pointRotated, elementPos); const pointRelToPos = GA.sub(pointRotated, elementPos);
const [ax, ay, bx, by] = elementCoords; const halfWidth = (x2 - x1) / 2;
const halfWidth = (bx - ax) / 2; const halfHeight = (y2 - y1) / 2;
const halfHeight = (by - ay) / 2; return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
};
const pointRelativeToDivElement = (
pointTuple: Point,
rectangle: RectangleBox,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
const center = coordsCenter(x1, y1, x2, y2);
const rotate = GATransform.rotation(center, rectangle.angle);
const pointRotated = GATransform.apply(rotate, point);
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(rectangle.x, rectangle.y);
const pointRelToPos = GA.sub(pointRotated, elementPos);
const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
}; };
@ -490,7 +603,7 @@ const relativizationToElementCenter = (
element: ExcalidrawElement, element: ExcalidrawElement,
): GA.Transform => { ): GA.Transform => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]); const center = coordsCenter(x1, y1, x2, y2);
// GA has angle orientation opposite to `rotate` // GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle); const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse( const translate = GA.reverse(
@ -499,8 +612,13 @@ const relativizationToElementCenter = (
return GATransform.compose(rotate, translate); return GATransform.compose(rotate, translate);
}; };
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => { const coordsCenter = (
return GA.point((ax + bx) / 2, (ay + by) / 2); x1: number,
y1: number,
x2: number,
y2: number,
): GA.Point => {
return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
}; };
// The focus distance is the oriented ratio between the size of // The focus distance is the oriented ratio between the size of
@ -531,6 +649,7 @@ export const determineFocusDistance = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return c / (hwidth * (nabs + q * mabs)); return c / (hwidth * (nabs + q * mabs));
case "diamond": case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@ -548,7 +667,7 @@ export const determineFocusPoint = (
): Point => { ): Point => {
if (focus === 0) { if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]); const center = coordsCenter(x1, y1, x2, y2);
return GAPoint.toTuple(center); return GAPoint.toTuple(center);
} }
const relateToCenter = relativizationToElementCenter(element); const relateToCenter = relativizationToElementCenter(element);
@ -563,6 +682,7 @@ export const determineFocusPoint = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "frame":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel); point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break; break;
case "ellipse": case "ellipse":
@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "frame":
const corners = getCorners(element); const corners = getCorners(element);
intersections = corners intersections = corners
.flatMap((point, i) => { .flatMap((point, i) => {
@ -646,7 +767,8 @@ const getCorners = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement, | ExcalidrawTextElement
| ExcalidrawFrameElement,
scale: number = 1, scale: number = 1,
): GA.Point[] => { ): GA.Point[] => {
const hx = (scale * element.width) / 2; const hx = (scale * element.width) / 2;
@ -655,6 +777,7 @@ const getCorners = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return [ return [
GA.point(hx, hy), GA.point(hx, hy),
GA.point(hx, -hy), GA.point(hx, -hy),
@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement, | ExcalidrawTextElement
| ExcalidrawFrameElement,
// Between -1 and 1 for how far away should the focus point be relative // Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation. // to the size of the element. Sign determines orientation.
relativeDistance: number, relativeDistance: number,

View File

@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types"; import { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups"; import { isSelectedViaGroup } from "../groups";
import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@ -16,10 +18,31 @@ export const dragSelectedElements = (
distanceX: number = 0, distanceX: number = 0,
distanceY: number = 0, distanceY: number = 0,
appState: AppState, appState: AppState,
scene: Scene,
) => { ) => {
const [x1, y1] = getCommonBounds(selectedElements); const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 }; const offset = { x: pointerX - x1, y: pointerY - y1 };
selectedElements.forEach((element) => {
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
selectedElements,
);
const frames = selectedElements
.filter((e) => isFrameElement(e))
.map((f) => f.id);
if (frames.length > 0) {
const elementsInFrames = scene
.getNonDeletedElements()
.filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!));
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
}
elementsToUpdate.forEach((element) => {
updateElementCoords( updateElementCoords(
lockDirection, lockDirection,
distanceX, distanceX,
@ -38,7 +61,13 @@ export const dragSelectedElements = (
(appState.editingGroupId && !isSelectedViaGroup(appState, element)) (appState.editingGroupId && !isSelectedViaGroup(appState, element))
) { ) {
const textElement = getBoundTextElement(element); const textElement = getBoundTextElement(element);
if (textElement) { if (
textElement &&
// when container is added to a frame, so will its bound text
// so the text is already in `elementsToUpdate` and we should avoid
// updating its coords again
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
updateElementCoords( updateElementCoords(
lockDirection, lockDirection,
distanceX, distanceX,
@ -50,7 +79,7 @@ export const dragSelectedElements = (
} }
} }
updateBoundElements(element, { updateBoundElements(element, {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: Array.from(elementsToUpdate),
}); });
}); });
}; };

View File

@ -2,6 +2,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@ -49,7 +50,11 @@ export {
getDragOffsetXY, getDragOffsetXY,
dragNewElement, dragNewElement,
} from "./dragElements"; } from "./dragElements";
export { isTextElement, isExcalidrawElement } from "./typeChecks"; export {
isTextElement,
isExcalidrawElement,
isFrameElement,
} from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg"; export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";
export { export {
@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
(element) => !element.isDeleted, (element) => !element.isDeleted,
) as readonly NonDeletedExcalidrawElement[]; ) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedFrames = (
frames: readonly ExcalidrawFrameElement[],
) =>
frames.filter(
(frame) => !frame.isDeleted,
) as readonly NonDeleted<ExcalidrawFrameElement>[];
export const isNonDeletedElement = <T extends ExcalidrawElement>( export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T, element: T,
): element is NonDeleted<T> => !element.isDeleted; ): element is NonDeleted<T> => !element.isDeleted;

View File

@ -594,7 +594,7 @@ export class LinearElementEditor {
} }
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLElement>,
appState: AppState, appState: AppState,
history: History, history: History,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },

View File

@ -12,6 +12,7 @@ import {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
FontFamilyValues, FontFamilyValues,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement,
} from "../element/types"; } from "../element/types";
import { import {
arrayToMap, arrayToMap,
@ -50,6 +51,7 @@ type ElementConstructorOpts = MarkOptional<
| "height" | "height"
| "angle" | "angle"
| "groupIds" | "groupIds"
| "frameId"
| "boundElements" | "boundElements"
| "seed" | "seed"
| "version" | "version"
@ -82,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0, height = 0,
angle = 0, angle = 0,
groupIds = [], groupIds = [],
frameId = null,
roundness = null, roundness = null,
boundElements = null, boundElements = null,
link = null, link = null,
@ -106,6 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roughness, roughness,
opacity, opacity,
groupIds, groupIds,
frameId,
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
@ -126,6 +130,21 @@ export const newElement = (
): NonDeleted<ExcalidrawGenericElement> => ): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts); _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
export const newFrameElement = (
opts: ElementConstructorOpts,
): NonDeleted<ExcalidrawFrameElement> => {
const frameElement = newElementWith(
{
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
type: "frame",
name: null,
},
{},
);
return frameElement;
};
/** computes element x/y offset based on textAlign/verticalAlign */ /** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = ( const getTextElementPositionOffsets = (
opts: { opts: {
@ -158,6 +177,7 @@ export const newTextElement = (
containerId?: ExcalidrawTextContainer["id"]; containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"]; strokeWidth?: ExcalidrawTextElement["strokeWidth"];
isFrameName?: boolean;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
@ -192,6 +212,7 @@ export const newTextElement = (
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
lineHeight, lineHeight,
isFrameName: opts.isFrameName || false,
}, },
{}, {},
); );
@ -612,6 +633,10 @@ export const duplicateElements = (
: null; : null;
} }
if (clonedElement.frameId) {
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
}
clonedElements.push(clonedElement); clonedElements.push(clonedElement);
} }

View File

@ -27,6 +27,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement,
isFreeDrawElement, isFreeDrawElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@ -160,12 +161,17 @@ const rotateSingleElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx); let angle: number;
if (shouldRotateWithDiscreteAngle) { if (isFrameElement(element)) {
angle += SHIFT_LOCKING_ANGLE / 2; angle = 0;
angle -= angle % SHIFT_LOCKING_ANGLE; } else {
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
if (shouldRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
}
angle = normalizeAngle(angle);
} }
angle = normalizeAngle(angle);
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle }); mutateElement(element, { angle });
@ -877,45 +883,47 @@ const rotateMultipleElements = (
centerAngle += SHIFT_LOCKING_ANGLE / 2; centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
} }
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const origAngle =
pointerDownState.originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = rotate(
cx,
cy,
centerX,
centerY,
centerAngle + origAngle - element.angle,
);
mutateElement( elements
element, .filter((element) => element.type !== "frame")
{ .forEach((element) => {
x: element.x + (rotatedCX - cx), const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
y: element.y + (rotatedCY - cy), const cx = (x1 + x2) / 2;
angle: normalizeAngle(centerAngle + origAngle), const cy = (y1 + y2) / 2;
}, const origAngle =
false, pointerDownState.originalElements.get(element.id)?.angle ??
); element.angle;
const [rotatedCX, rotatedCY] = rotate(
updateBoundElements(element, { simultaneouslyUpdated: elements }); cx,
cy,
const boundText = getBoundTextElement(element); centerX,
if (boundText && !isArrowElement(element)) { centerY,
centerAngle + origAngle - element.angle,
);
mutateElement( mutateElement(
boundText, element,
{ {
x: boundText.x + (rotatedCX - cx), x: element.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy), y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle), angle: normalizeAngle(centerAngle + origAngle),
}, },
false, false,
); );
} updateBoundElements(element, { simultaneouslyUpdated: elements });
});
const boundText = getBoundTextElement(element);
if (boundText && !isArrowElement(element)) {
mutateElement(
boundText,
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
},
false,
);
}
});
Scene.getScene(elements[0])?.informMutation(); Scene.getScene(elements[0])?.informMutation();
}; };

View File

@ -840,10 +840,12 @@ export const getTextBindableContainerAtPosition = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
if ( if (
isArrowElement(elements[index]) && isArrowElement(elements[index]) &&
isHittingElementNotConsideringBoundingBox(elements[index], appState, [ isHittingElementNotConsideringBoundingBox(
x, elements[index],
y, appState,
]) null,
[x, y],
)
) { ) {
hitElement = elements[index]; hitElement = elements[index];
break; break;

View File

@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { AppState, Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isLinearElement } from "./typeChecks"; import { isFrameElement, isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene"; import { DEFAULT_SPACING } from "../renderer/renderScene";
export type TransformHandleDirection = export type TransformHandleDirection =
@ -44,6 +44,14 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
w: true, w: true,
}; };
export const OMIT_SIDES_FOR_FRAME = {
e: true,
s: true,
n: true,
w: true,
rotation: true,
};
const OMIT_SIDES_FOR_TEXT_ELEMENT = { const OMIT_SIDES_FOR_TEXT_ELEMENT = {
e: true, e: true,
s: true, s: true,
@ -249,6 +257,10 @@ export const getTransformHandles = (
} }
} else if (isTextElement(element)) { } else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameElement(element)) {
omitSides = {
rotation: true,
};
} }
const dashedLineMargin = isLinearElement(element) const dashedLineMargin = isLinearElement(element)
? DEFAULT_SPACING + 8 ? DEFAULT_SPACING + 8

View File

@ -12,6 +12,7 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement,
RoundnessType, RoundnessType,
} from "./types"; } from "./types";
@ -45,6 +46,12 @@ export const isTextElement = (
return element != null && element.type === "text"; return element != null && element.type === "text";
}; };
export const isFrameElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawFrameElement => {
return element != null && element.type === "frame";
};
export const isFreeDrawElement = ( export const isFreeDrawElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => { ): element is ExcalidrawFreeDrawElement => {

View File

@ -53,6 +53,7 @@ type _ExcalidrawElementBase = Readonly<{
/** List of groups the element belongs to. /** List of groups the element belongs to.
Ordered from deepest to shallowest. */ Ordered from deepest to shallowest. */
groupIds: readonly GroupId[]; groupIds: readonly GroupId[];
frameId: string | null;
/** other elements that are bound to this element */ /** other elements that are bound to this element */
boundElements: boundElements:
| readonly Readonly<{ | readonly Readonly<{
@ -98,6 +99,11 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
"fileId" "fileId"
>; >;
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
type: "frame";
name: string | null;
};
/** /**
* These are elements that don't have any additional properties. * These are elements that don't have any additional properties.
*/ */
@ -117,7 +123,8 @@ export type ExcalidrawElement =
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement; | ExcalidrawImageElement
| ExcalidrawFrameElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean; isDeleted: boolean;
@ -148,7 +155,8 @@ export type ExcalidrawBindableElement =
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawEllipseElement | ExcalidrawEllipseElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawImageElement; | ExcalidrawImageElement
| ExcalidrawFrameElement;
export type ExcalidrawTextContainer = export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement | ExcalidrawRectangleElement

705
src/frame.ts Normal file
View File

@ -0,0 +1,705 @@
import {
getCommonBounds,
getElementAbsoluteCoords,
isTextElement,
} from "./element";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
import { isPointWithinBounds } from "./math";
import {
getBoundTextElement,
getContainerElement,
} from "./element/textElement";
import { arrayToMap, findIndex } from "./utils";
import { mutateElement } from "./element/mutateElement";
import { AppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds";
// --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = (
nextElements: ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => {
const nextElementMap = arrayToMap(nextElements) as Map<
ExcalidrawElement["id"],
ExcalidrawElement
>;
for (const element of oldElements) {
if (element.frameId) {
// use its frameId to get the new frameId
const nextElementId = oldIdToDuplicatedId.get(element.id);
const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
if (nextElementId) {
const nextElement = nextElementMap.get(nextElementId);
if (nextElement) {
mutateElement(
nextElement,
{
frameId: nextFrameId ?? element.frameId,
},
false,
);
}
}
}
}
};
// --------------------------- Frame Geometry ---------------------------------
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
class LineSegment {
first: Point;
second: Point;
constructor(pointA: Point, pointB: Point) {
this.first = pointA;
this.second = pointB;
}
public getBoundingBox(): [Point, Point] {
return [
new Point(
Math.min(this.first.x, this.second.x),
Math.min(this.first.y, this.second.y),
),
new Point(
Math.max(this.first.x, this.second.x),
Math.max(this.first.y, this.second.y),
),
];
}
}
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
class FrameGeometry {
private static EPSILON = 0.000001;
private static crossProduct(a: Point, b: Point) {
return a.x * b.y - b.x * a.y;
}
private static doBoundingBoxesIntersect(
a: [Point, Point],
b: [Point, Point],
) {
return (
a[0].x <= b[1].x &&
a[1].x >= b[0].x &&
a[0].y <= b[1].y &&
a[1].y >= b[0].y
);
}
private static isPointOnLine(a: LineSegment, b: Point) {
const aTmp = new LineSegment(
new Point(0, 0),
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
);
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
const r = this.crossProduct(aTmp.second, bTmp);
return Math.abs(r) < this.EPSILON;
}
private static isPointRightOfLine(a: LineSegment, b: Point) {
const aTmp = new LineSegment(
new Point(0, 0),
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
);
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
return this.crossProduct(aTmp.second, bTmp) < 0;
}
private static lineSegmentTouchesOrCrossesLine(
a: LineSegment,
b: LineSegment,
) {
return (
this.isPointOnLine(a, b.first) ||
this.isPointOnLine(a, b.second) ||
(this.isPointRightOfLine(a, b.first)
? !this.isPointRightOfLine(a, b.second)
: this.isPointRightOfLine(a, b.second))
);
}
private static doLineSegmentsIntersect(
a: [readonly [number, number], readonly [number, number]],
b: [readonly [number, number], readonly [number, number]],
) {
const aSegment = new LineSegment(
new Point(a[0][0], a[0][1]),
new Point(a[1][0], a[1][1]),
);
const bSegment = new LineSegment(
new Point(b[0][0], b[0][1]),
new Point(b[1][0], b[1][1]),
);
const box1 = aSegment.getBoundingBox();
const box2 = bSegment.getBoundingBox();
return (
this.doBoundingBoxesIntersect(box1, box2) &&
this.lineSegmentTouchesOrCrossesLine(aSegment, bSegment) &&
this.lineSegmentTouchesOrCrossesLine(bSegment, aSegment)
);
}
public static isElementIntersectingFrame(
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) {
const frameLineSegments = getElementLineSegments(frame);
const elementLineSegments = getElementLineSegments(element);
const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) =>
this.doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
),
);
return intersecting;
}
}
export const getElementsCompletelyInFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
) =>
omitGroupsContainingFrames(
getElementsWithinSelection(elements, frame, false),
).filter(
(element) =>
(element.type !== "frame" && !element.frameId) ||
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 = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
) =>
elements.filter((element) =>
FrameGeometry.isElementIntersectingFrame(element, frame),
);
export const elementsAreInFrameBounds = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(frame);
const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(elements);
return (
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
};
export const elementOverlapsWithFrame = (
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) => {
return (
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame) ||
isElementContainingFrame([frame], element, frame)
);
};
export const isCursorInFrame = (
cursorCoords: {
x: number;
y: number;
},
frame: NonDeleted<ExcalidrawFrameElement>,
) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
return isPointWithinBounds(
[fx1, fy1],
[cursorCoords.x, cursorCoords.y],
[fx2, fy2],
);
};
export const groupsAreAtLeastIntersectingTheFrame = (
elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[],
frame: ExcalidrawFrameElement,
) => {
const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId),
);
if (elementsInGroup.length === 0) {
return true;
}
return !!elementsInGroup.find(
(element) =>
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame),
);
};
export const groupsAreCompletelyOutOfFrame = (
elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[],
frame: ExcalidrawFrameElement,
) => {
const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId),
);
if (elementsInGroup.length === 0) {
return true;
}
return (
elementsInGroup.find(
(element) =>
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame),
) === undefined
);
};
// --------------------------- Frame Utils ------------------------------------
/**
* Returns a map of frameId to frame elements. Includes empty frames.
*/
export const groupByFrames = (elements: ExcalidrawElementsIncludingDeleted) => {
const frameElementsMap = new Map<
ExcalidrawElement["id"],
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));
}
}
return frameElementsMap;
};
export const getFrameElements = (
allElements: ExcalidrawElementsIncludingDeleted,
frameId: string,
) => allElements.filter((element) => element.frameId === frameId);
export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
appState: AppState,
): ExcalidrawElement[] => {
const prevElementsInFrame = getFrameElements(allElements, frame.id);
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
const elementsCompletelyInFrame = new Set([
...getElementsCompletelyInFrame(allElements, frame),
...prevElementsInFrame.filter((element) =>
isElementContainingFrame(allElements, element, frame),
),
]);
const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
(element) => !elementsCompletelyInFrame.has(element),
);
// for elements that are completely in the frame
// if they are part of some groups, then those groups are still
// considered to belong to the frame
const groupsToKeep = new Set<string>(
Array.from(elementsCompletelyInFrame).flatMap(
(element) => element.groupIds,
),
);
for (const element of elementsNotCompletelyInFrame) {
if (!FrameGeometry.isElementIntersectingFrame(element, frame)) {
if (element.groupIds.length === 0) {
nextElementsInFrame.delete(element);
}
} else if (element.groupIds.length > 0) {
// group element intersects with the frame, we should keep the groups
// that this element is part of
for (const id of element.groupIds) {
groupsToKeep.add(id);
}
}
}
for (const element of elementsNotCompletelyInFrame) {
if (element.groupIds.length > 0) {
let shouldRemoveElement = true;
for (const id of element.groupIds) {
if (groupsToKeep.has(id)) {
shouldRemoveElement = false;
}
}
if (shouldRemoveElement) {
nextElementsInFrame.delete(element);
}
}
}
const individualElementsCompletelyInFrame = Array.from(
elementsCompletelyInFrame,
).filter((element) => element.groupIds.length === 0);
for (const element of individualElementsCompletelyInFrame) {
nextElementsInFrame.add(element);
}
const newGroupElementsCompletelyInFrame = Array.from(
elementsCompletelyInFrame,
).filter((element) => element.groupIds.length > 0);
const groupIds = selectGroupsFromGivenElements(
newGroupElementsCompletelyInFrame,
appState,
);
// new group elements
for (const [id, isSelected] of Object.entries(groupIds)) {
if (isSelected) {
const elementsInGroup = getElementsInGroup(allElements, id);
if (elementsAreInFrameBounds(elementsInGroup, frame)) {
for (const element of elementsInGroup) {
nextElementsInFrame.add(element);
}
}
}
}
return [...nextElementsInFrame].filter((element) => {
return !(isTextElement(element) && element.containerId);
});
};
export const getElementsInNewFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
) => {
return omitGroupsContainingFrames(
allElements,
getElementsCompletelyInFrame(allElements, frame),
);
};
export const getContainingFrame = (
element: ExcalidrawElement,
/**
* Optionally an elements map, in case the elements aren't in the Scene yet.
* Takes precedence over Scene elements, even if the element exists
* in Scene elements and not the supplied elements map.
*/
elementsMap?: Map<string, ExcalidrawElement>,
) => {
if (element.frameId) {
if (elementsMap) {
return (elementsMap.get(element.frameId) ||
null) as null | ExcalidrawFrameElement;
}
return (
(Scene.getScene(element)?.getElement(
element.frameId,
) as ExcalidrawFrameElement) || null
);
}
return null;
};
// --------------------------- Frame Operations -------------------------------
export const addElementsToFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement,
) => {
const _elementsToAdd: ExcalidrawElement[] = [];
for (const element of elementsToAdd) {
_elementsToAdd.push(element);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
_elementsToAdd.push(boundTextElement);
}
}
let nextElements = allElements.slice();
const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
for (const element of omitGroupsContainingFrames(
allElements,
_elementsToAdd,
)) {
if (element.frameId !== frame.id && !isFrameElement(element)) {
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
if (elementIndex < frameBoundary) {
nextElements = [
...nextElements.slice(0, elementIndex),
...nextElements.slice(elementIndex + 1, frameBoundary),
element,
...nextElements.slice(frameBoundary),
];
} else if (elementIndex > frameIndex) {
nextElements = [
...nextElements.slice(0, frameIndex),
element,
...nextElements.slice(frameIndex, elementIndex),
...nextElements.slice(elementIndex + 1),
];
}
}
}
return nextElements;
};
export const removeElementsFromFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
elementsToRemove: NonDeletedExcalidrawElement[],
appState: AppState,
) => {
const _elementsToRemove: ExcalidrawElement[] = [];
for (const element of elementsToRemove) {
if (element.frameId) {
_elementsToRemove.push(element);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
_elementsToRemove.push(boundTextElement);
}
}
}
for (const element of _elementsToRemove) {
mutateElement(
element,
{
frameId: null,
},
false,
);
}
const nextElements = moveOneRight(
allElements,
appState,
Array.from(_elementsToRemove),
);
return nextElements;
};
export const removeAllElementsFromFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
appState: AppState,
) => {
const elementsInFrame = getFrameElements(allElements, frame.id);
return removeElementsFromFrame(allElements, elementsInFrame, appState);
};
export const replaceAllElementsInFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameElement,
appState: AppState,
) => {
return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame, appState),
nextElementsInFrame,
frame,
);
};
/** does not mutate elements, but return new ones */
export const updateFrameMembershipOfSelectedElements = (
allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState,
) => {
const selectedElements = getSelectedElements(allElements, appState);
const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
if (appState.editingGroupId) {
for (const element of selectedElements) {
if (element.groupIds.length === 0) {
elementsToFilter.add(element);
} else {
element.groupIds
.flatMap((gid) => getElementsInGroup(allElements, gid))
.forEach((element) => elementsToFilter.add(element));
}
}
}
const elementsToRemove = new Set<ExcalidrawElement>();
elementsToFilter.forEach((element) => {
if (
!isFrameElement(element) &&
!isElementInFrame(element, allElements, appState)
) {
elementsToRemove.add(element);
}
});
return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
};
/**
* filters out elements that are inside groups that contain a frame element
* anywhere in the group tree
*/
export const omitGroupsContainingFrames = (
allElements: ExcalidrawElementsIncludingDeleted,
/** subset of elements you want to filter. Optional perf optimization so we
* don't have to filter all elements unnecessarily
*/
selectedElements?: readonly ExcalidrawElement[],
) => {
const uniqueGroupIds = new Set<string>();
for (const el of selectedElements || allElements) {
const topMostGroupId = el.groupIds[el.groupIds.length - 1];
if (topMostGroupId) {
uniqueGroupIds.add(topMostGroupId);
}
}
const rejectedGroupIds = new Set<string>();
for (const groupId of uniqueGroupIds) {
if (
getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el))
) {
rejectedGroupIds.add(groupId);
}
}
return (selectedElements || allElements).filter(
(el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]),
);
};
/**
* depending on the appState, return target frame, which is the frame the given element
* is going to be added to or remove from
*/
export const getTargetFrame = (
element: ExcalidrawElement,
appState: AppState,
) => {
const _element = isTextElement(element)
? getContainerElement(element) || element
: element;
return appState.selectedElementIds[_element.id] &&
appState.selectedElementsAreBeingDragged
? appState.frameToHighlight
: getContainingFrame(_element);
};
// given an element, return if the element is in some frame
export const isElementInFrame = (
element: ExcalidrawElement,
allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState,
) => {
const frame = getTargetFrame(element, appState);
const _element = isTextElement(element)
? getContainerElement(element) || element
: element;
if (frame) {
if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame);
}
const allElementsInGroup = new Set(
_element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
const selectedElements = new Set(
getSelectedElements(allElements, appState),
);
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
if (editingGroupOverlapsFrame) {
return true;
}
selectedElements.forEach((selectedElement) => {
allElementsInGroup.delete(selectedElement);
});
}
for (const elementInGroup of allElementsInGroup) {
if (isFrameElement(elementInGroup)) {
return false;
}
}
for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame)) {
return true;
}
}
}
return false;
};

View File

@ -94,6 +94,31 @@ export const selectGroupsForSelectedElements = (
return nextAppState; return nextAppState;
}; };
// given a list of elements, return the the actual group ids that should be selected
// or used to update the elements
export const selectGroupsFromGivenElements = (
elements: readonly NonDeleted<ExcalidrawElement>[],
appState: AppState,
) => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
for (const element of elements) {
let groupIds = element.groupIds;
if (appState.editingGroupId) {
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
if (indexOfEditingGroup > -1) {
groupIds = groupIds.slice(0, indexOfEditingGroup);
}
}
if (groupIds.length > 0) {
const groupId = groupIds[groupIds.length - 1];
nextAppState = selectGroup(groupId, nextAppState, elements);
}
}
return nextAppState.selectedGroupIds;
};
export const editGroupForSelectedElement = ( export const editGroupForSelectedElement = (
appState: AppState, appState: AppState,
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
@ -186,3 +211,18 @@ export const getMaximumGroups = (
return Array.from(groups.values()); return Array.from(groups.values());
}; };
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
const allGroups = elements.flatMap((element) => element.groupIds);
const groupCount = new Map<string, number>();
let maxGroup = 0;
for (const group of allGroups) {
groupCount.set(group, (groupCount.get(group) ?? 0) + 1);
if (groupCount.get(group)! > maxGroup) {
maxGroup = groupCount.get(group)!;
}
}
return maxGroup === elements.length;
};

View File

@ -42,6 +42,7 @@ export const KEYS = {
CHEVRON_RIGHT: ">", CHEVRON_RIGHT: ">",
PERIOD: ".", PERIOD: ".",
COMMA: ",", COMMA: ",",
SUBTRACT: "-",
A: "a", A: "a",
C: "c", C: "c",

View File

@ -124,6 +124,8 @@
}, },
"statusPublished": "Published", "statusPublished": "Published",
"sidebarLock": "Keep sidebar open", "sidebarLock": "Keep sidebar open",
"selectAllElementsInFrame": "Select all elements in frame",
"removeAllElementsFromFrame": "Remove all elements from frame",
"eyeDropper": "Pick color from canvas" "eyeDropper": "Pick color from canvas"
}, },
"library": { "library": {
@ -221,7 +223,9 @@
"penMode": "Pen mode - prevent touch", "penMode": "Pen mode - prevent touch",
"link": "Add/ Update link for a selected shape", "link": "Add/ Update link for a selected shape",
"eraser": "Eraser", "eraser": "Eraser",
"hand": "Hand (panning tool)" "frame": "Frame tool",
"hand": "Hand (panning tool)",
"extraTools": "More tools"
}, },
"headings": { "headings": {
"canvasActions": "Canvas actions", "canvasActions": "Canvas actions",

View File

@ -206,7 +206,7 @@ export const isPointInPolygon = (
// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`. // Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
// This is an approximation to "does `q` lie on a segment `pr`" check. // This is an approximation to "does `q` lie on a segment `pr`" check.
const isPointWithinBounds = (p: Point, q: Point, r: Point) => { export const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
return ( return (
q[0] <= Math.max(p[0], r[0]) && q[0] <= Math.max(p[0], r[0]) &&
q[0] >= Math.min(p[0], r[0]) && q[0] >= Math.min(p[0], r[0]) &&

View File

@ -205,6 +205,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
height: 141.9765625, height: 141.9765625,
seed: 1968410350, seed: 1968410350,
groupIds: [], groupIds: [],
frameId: null,
boundElements: null, boundElements: null,
locked: false, locked: false,
link: null, link: null,

View File

@ -20,6 +20,7 @@ export default {
height: 141.9765625, height: 141.9765625,
seed: 1968410350, seed: 1968410350,
groupIds: [], groupIds: [],
frameId: null,
}, },
{ {
id: "-xMIs_0jIFqvpx-R9UnaG", id: "-xMIs_0jIFqvpx-R9UnaG",
@ -37,6 +38,7 @@ export default {
roughness: 1, roughness: 1,
opacity: 100, opacity: 100,
groupIds: [], groupIds: [],
frameId: null,
seed: 957947807, seed: 957947807,
version: 47, version: 47,
versionNonce: 1128618623, versionNonce: 1128618623,
@ -58,6 +60,7 @@ export default {
roughness: 1, roughness: 1,
opacity: 100, opacity: 100,
groupIds: [], groupIds: [],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
seed: 707269846, seed: 707269846,
version: 143, version: 143,
@ -94,6 +97,7 @@ export default {
height: 103.65107323746608, height: 103.65107323746608,
seed: 1445523839, seed: 1445523839,
groupIds: [], groupIds: [],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -133,6 +137,7 @@ export default {
height: 113.8575037534261, height: 113.8575037534261,
seed: 1513238033, seed: 1513238033,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -182,6 +187,7 @@ export default {
height: 9.797916664247975, height: 9.797916664247975,
seed: 683951089, seed: 683951089,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -220,6 +226,7 @@ export default {
height: 9.797916664247975, height: 9.797916664247975,
seed: 1817746897, seed: 1817746897,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -258,6 +265,7 @@ export default {
height: 17.72670397681366, height: 17.72670397681366,
seed: 1409727409, seed: 1409727409,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"], boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
}, },
@ -281,6 +289,7 @@ export default {
height: 13.941904362416096, height: 13.941904362416096,
seed: 1073094033, seed: 1073094033,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -304,6 +313,7 @@ export default {
height: 13.941904362416096, height: 13.941904362416096,
seed: 526271345, seed: 526271345,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -327,6 +337,7 @@ export default {
height: 13.941904362416096, height: 13.941904362416096,
seed: 243707217, seed: 243707217,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -352,6 +363,7 @@ export default {
height: 36.77344700318558, height: 36.77344700318558,
seed: 511870335, seed: 511870335,
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"], groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -375,6 +387,7 @@ export default {
height: 36.77344700318558, height: 36.77344700318558,
seed: 1283079231, seed: 1283079231,
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"], groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -398,6 +411,7 @@ export default {
height: 36.77344700318558, height: 36.77344700318558,
seed: 996251633, seed: 996251633,
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"], groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -421,6 +435,7 @@ export default {
height: 36.77344700318558, height: 36.77344700318558,
seed: 1764842481, seed: 1764842481,
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"], groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -446,6 +461,7 @@ export default {
height: 154.56722543646003, height: 154.56722543646003,
seed: 1424381745, seed: 1424381745,
groupIds: ["HSrtfEf-CssQTf160Fb6R"], groupIds: ["HSrtfEf-CssQTf160Fb6R"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -495,6 +511,7 @@ export default {
height: 12.698053371678215, height: 12.698053371678215,
seed: 726657713, seed: 726657713,
groupIds: ["HSrtfEf-CssQTf160Fb6R"], groupIds: ["HSrtfEf-CssQTf160Fb6R"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -533,6 +550,7 @@ export default {
height: 10.178760037658167, height: 10.178760037658167,
seed: 1977326481, seed: 1977326481,
groupIds: ["HSrtfEf-CssQTf160Fb6R"], groupIds: ["HSrtfEf-CssQTf160Fb6R"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -571,6 +589,7 @@ export default {
height: 22.797152568995934, height: 22.797152568995934,
seed: 1774660383, seed: 1774660383,
groupIds: ["HSrtfEf-CssQTf160Fb6R"], groupIds: ["HSrtfEf-CssQTf160Fb6R"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"], boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
}, },
@ -596,6 +615,7 @@ export default {
height: 107.25081879410921, height: 107.25081879410921,
seed: 371096063, seed: 371096063,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [ boundElementIds: [
"CFu0B4Mw_1wC1Hbgx8Fs0", "CFu0B4Mw_1wC1Hbgx8Fs0",
@ -623,6 +643,7 @@ export default {
height: 107.25081879410921, height: 107.25081879410921,
seed: 685932433, seed: 685932433,
groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"], groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [ boundElementIds: [
"CFu0B4Mw_1wC1Hbgx8Fs0", "CFu0B4Mw_1wC1Hbgx8Fs0",
@ -650,6 +671,7 @@ export default {
height: 107.25081879410921, height: 107.25081879410921,
seed: 58634943, seed: 58634943,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [ boundElementIds: [
"CFu0B4Mw_1wC1Hbgx8Fs0", "CFu0B4Mw_1wC1Hbgx8Fs0",
@ -677,6 +699,7 @@ export default {
height: 3.249953844290203, height: 3.249953844290203,
seed: 1673003743, seed: 1673003743,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@ -708,6 +731,7 @@ export default {
height: 2.8032978840147194, height: 2.8032978840147194,
seed: 1821527807, seed: 1821527807,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@ -739,6 +763,7 @@ export default {
height: 4.280657518731036, height: 4.280657518731036,
seed: 1485707039, seed: 1485707039,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@ -771,6 +796,7 @@ export default {
height: 2.9096445412231735, height: 2.9096445412231735,
seed: 1042012991, seed: 1042012991,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@ -804,6 +830,7 @@ export default {
height: 2.4757501798128, height: 2.4757501798128,
seed: 295443295, seed: 295443295,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@ -835,6 +862,7 @@ export default {
height: 2.4757501798128, height: 2.4757501798128,
seed: 1734301567, seed: 1734301567,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@ -869,6 +897,7 @@ export default {
height: 76.53703389977764, height: 76.53703389977764,
seed: 106569279, seed: 106569279,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -892,6 +921,7 @@ export default {
height: 0, height: 0,
seed: 73916127, seed: 73916127,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -924,6 +954,7 @@ export default {
height: 5.001953125, height: 5.001953125,
seed: 387857791, seed: 387857791,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -947,6 +978,7 @@ export default {
height: 5.001953125, height: 5.001953125,
seed: 1486370207, seed: 1486370207,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -970,6 +1002,7 @@ export default {
height: 5.001953125, height: 5.001953125,
seed: 610150847, seed: 610150847,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -993,6 +1026,7 @@ export default {
height: 42.72020253937572, height: 42.72020253937572,
seed: 144280593, seed: 144280593,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -1016,6 +1050,7 @@ export default {
height: 24.44112284281997, height: 24.44112284281997,
seed: 29167967, seed: 29167967,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -1068,6 +1103,7 @@ export default {
height: 0, height: 0,
seed: 1443027377, seed: 1443027377,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -1100,6 +1136,7 @@ export default {
height: 5.711199931375845, height: 5.711199931375845,
seed: 244310513, seed: 244310513,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -1138,6 +1175,7 @@ export default {
height: 44.82230388130942, height: 44.82230388130942,
seed: 683572113, seed: 683572113,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -1161,6 +1199,7 @@ export default {
height: 5.896061363392446, height: 5.896061363392446,
seed: 318798801, seed: 318798801,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@ -1200,6 +1239,7 @@ export default {
height: 108.30428902193904, height: 108.30428902193904,
seed: 1914896753, seed: 1914896753,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -1223,6 +1263,7 @@ export default {
height: 82.83278895375764, height: 82.83278895375764,
seed: 1306468145, seed: 1306468145,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -1246,6 +1287,7 @@ export default {
height: 11.427824006438863, height: 11.427824006438863,
seed: 93422161, seed: 93422161,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -1269,6 +1311,7 @@ export default {
height: 19.889460471185775, height: 19.889460471185775,
seed: 11646495, seed: 11646495,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@ -1292,6 +1335,7 @@ export default {
height: 19.889460471185775, height: 19.889460471185775,
seed: 291717649, seed: 291717649,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },

View File

@ -34,6 +34,7 @@ import { AppState, BinaryFiles, Zoom } from "../types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES, MIME_TYPES,
SVG_NS, SVG_NS,
@ -48,6 +49,7 @@ import {
getBoundTextMaxWidth, getBoundTextMaxWidth,
} from "../element/textElement"; } from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame";
// using a stronger invert (100% vs our regular 93%) and saturate // using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original // as a temp hack to make images in dark theme look closer to original
@ -92,6 +94,7 @@ export interface ExcalidrawElementWithCanvas {
canvasOffsetX: number; canvasOffsetX: number;
canvasOffsetY: number; canvasOffsetY: number;
boundTextElementVersion: number | null; boundTextElementVersion: number | null;
containingFrameOpacity: number;
} }
const cappedElementCanvasSize = ( const cappedElementCanvasSize = (
@ -207,6 +210,7 @@ const generateElementCanvas = (
canvasOffsetX, canvasOffsetX,
canvasOffsetY, canvasOffsetY,
boundTextElementVersion: getBoundTextElement(element)?.version || null, boundTextElementVersion: getBoundTextElement(element)?.version || null,
containingFrameOpacity: getContainingFrame(element)?.opacity || 100,
}; };
}; };
@ -253,7 +257,8 @@ const drawElementOnCanvas = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: RenderConfig, renderConfig: RenderConfig,
) => { ) => {
context.globalAlpha = element.opacity / 100; context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "diamond": case "diamond":
@ -469,7 +474,7 @@ const generateElementShape = (
elementWithCanvasCache.delete(element); elementWithCanvasCache.delete(element);
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle": {
if (element.roundness) { if (element.roundness) {
const w = element.width; const w = element.width;
const h = element.height; const h = element.height;
@ -494,6 +499,7 @@ const generateElementShape = (
setShapeForElement(element, shape); setShapeForElement(element, shape);
break; break;
}
case "diamond": { case "diamond": {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element); getDiamondPoints(element);
@ -717,12 +723,14 @@ const generateElementWithCanvas = (
prevElementWithCanvas.zoomValue !== zoom.value && prevElementWithCanvas.zoomValue !== zoom.value &&
!renderConfig?.shouldCacheIgnoreZoom; !renderConfig?.shouldCacheIgnoreZoom;
const boundTextElementVersion = getBoundTextElement(element)?.version || null; const boundTextElementVersion = getBoundTextElement(element)?.version || null;
const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
if ( if (
!prevElementWithCanvas || !prevElementWithCanvas ||
shouldRegenerateBecauseZoom || shouldRegenerateBecauseZoom ||
prevElementWithCanvas.theme !== renderConfig.theme || prevElementWithCanvas.theme !== renderConfig.theme ||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
) { ) {
const elementWithCanvas = generateElementCanvas( const elementWithCanvas = generateElementCanvas(
element, element,
@ -897,25 +905,59 @@ export const renderElement = (
const generator = rc.generator; const generator = rc.generator;
switch (element.type) { switch (element.type) {
case "selection": { case "selection": {
context.save(); // do not render selection when exporting
context.translate( if (!renderConfig.isExporting) {
element.x + renderConfig.scrollX, context.save();
element.y + renderConfig.scrollY, context.translate(
); element.x + renderConfig.scrollX,
context.fillStyle = "rgba(0, 0, 200, 0.04)"; element.y + renderConfig.scrollY,
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
// render from 0.5px offset to get 1px wide line // render from 0.5px offset to get 1px wide line
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540 // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
// TODO can be be improved by offseting to the negative when user selects // TODO can be be improved by offseting to the negative when user selects
// from right to left // from right to left
const offset = 0.5 / renderConfig.zoom.value; const offset = 0.5 / renderConfig.zoom.value;
context.fillRect(offset, offset, element.width, element.height); context.fillRect(offset, offset, element.width, element.height);
context.lineWidth = 1 / renderConfig.zoom.value; context.lineWidth = 1 / renderConfig.zoom.value;
context.strokeStyle = "rgb(105, 101, 219)"; context.strokeStyle = " rgb(105, 101, 219)";
context.strokeRect(offset, offset, element.width, element.height); context.strokeRect(offset, offset, element.width, element.height);
context.restore(); context.restore();
}
break;
}
case "frame": {
if (!renderConfig.isExporting && appState.shouldRenderFrames) {
context.save();
context.translate(
element.x + renderConfig.scrollX,
element.y + renderConfig.scrollY,
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = 2 / renderConfig.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor;
if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
element.width,
element.height,
FRAME_STYLE.radius / renderConfig.zoom.value,
);
context.stroke();
context.closePath();
} else {
context.strokeRect(0, 0, element.width, element.height);
}
context.restore();
}
break; break;
} }
case "freedraw": { case "freedraw": {
@ -1107,6 +1149,23 @@ const roughSVGDrawWithPrecision = (
return rsvg.draw(pshape); return rsvg.draw(pshape);
}; };
const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement,
root: SVGElement,
nodes: SVGElement[],
exportedFrameId?: string | null,
) => {
const frame = getContainingFrame(element);
if (frame && frame.id === exportedFrameId) {
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
nodes.forEach((node) => g.appendChild(node));
return g;
}
return null;
};
export const renderElementToSvg = ( export const renderElementToSvg = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
rsvg: RoughSVG, rsvg: RoughSVG,
@ -1115,6 +1174,7 @@ export const renderElementToSvg = (
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
exportWithDarkMode?: boolean, exportWithDarkMode?: boolean,
exportingFrameId?: string | null,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
let cx = (x2 - x1) / 2 - (element.x - x1); let cx = (x2 - x1) / 2 - (element.x - x1);
@ -1148,6 +1208,9 @@ export const renderElementToSvg = (
root = anchorTag; root = anchorTag;
} }
const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) { switch (element.type) {
case "selection": { case "selection": {
// Since this is used only during editing experience, which is canvas based, // Since this is used only during editing experience, which is canvas based,
@ -1163,7 +1226,6 @@ export const renderElementToSvg = (
getShapeForElement(element)!, getShapeForElement(element)!,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
); );
const opacity = element.opacity / 100;
if (opacity !== 1) { if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`); node.setAttribute("fill-opacity", `${opacity}`);
@ -1175,7 +1237,15 @@ export const renderElementToSvg = (
offsetY || 0 offsetY || 0
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
root.appendChild(node);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
exportingFrameId,
);
g ? root.appendChild(g) : root.appendChild(node);
break; break;
} }
case "line": case "line":
@ -1228,7 +1298,6 @@ export const renderElementToSvg = (
if (boundText) { if (boundText) {
group.setAttribute("mask", `url(#mask-${element.id})`); group.setAttribute("mask", `url(#mask-${element.id})`);
} }
const opacity = element.opacity / 100;
group.setAttribute("stroke-linecap", "round"); group.setAttribute("stroke-linecap", "round");
getShapeForElement(element)!.forEach((shape) => { getShapeForElement(element)!.forEach((shape) => {
@ -1256,14 +1325,24 @@ export const renderElementToSvg = (
} }
group.appendChild(node); group.appendChild(node);
}); });
root.appendChild(group);
root.append(maskPath); const g = maybeWrapNodesInFrameClipPath(
element,
root,
[group, maskPath],
exportingFrameId,
);
if (g) {
root.appendChild(g);
} else {
root.appendChild(group);
root.append(maskPath);
}
break; break;
} }
case "freedraw": { case "freedraw": {
generateElementShape(element, generator); generateElementShape(element, generator);
generateFreeDrawShape(element); generateFreeDrawShape(element);
const opacity = element.opacity / 100;
const shape = getShapeForElement(element); const shape = getShapeForElement(element);
const node = shape const node = shape
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT) ? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
@ -1283,7 +1362,15 @@ export const renderElementToSvg = (
path.setAttribute("fill", element.strokeColor); path.setAttribute("fill", element.strokeColor);
path.setAttribute("d", getFreeDrawSvgPath(element)); path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path); node.appendChild(path);
root.appendChild(node);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
exportingFrameId,
);
g ? root.appendChild(g) : root.appendChild(node);
break; break;
} }
case "image": { case "image": {
@ -1319,6 +1406,7 @@ export const renderElementToSvg = (
use.setAttribute("width", `${width}`); use.setAttribute("width", `${width}`);
use.setAttribute("height", `${height}`); use.setAttribute("height", `${height}`);
use.setAttribute("opacity", `${opacity}`);
// We first apply `scale` transforms (horizontal/vertical mirroring) // We first apply `scale` transforms (horizontal/vertical mirroring)
// on the <use> element, then apply translation and rotation // on the <use> element, then apply translation and rotation
@ -1344,13 +1432,22 @@ export const renderElementToSvg = (
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
root.appendChild(g); const clipG = maybeWrapNodesInFrameClipPath(
element,
root,
[g],
exportingFrameId,
);
clipG ? root.appendChild(clipG) : root.appendChild(g);
} }
break; break;
} }
// frames are not rendered and only acts as a container
case "frame": {
break;
}
default: { default: {
if (isTextElement(element)) { if (isTextElement(element)) {
const opacity = element.opacity / 100;
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) { if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("stroke-opacity", `${opacity}`);
@ -1395,7 +1492,15 @@ export const renderElementToSvg = (
text.setAttribute("dominant-baseline", "text-before-edge"); text.setAttribute("dominant-baseline", "text-before-edge");
node.appendChild(text); node.appendChild(text);
} }
root.appendChild(node);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
exportingFrameId,
);
g ? root.appendChild(g) : root.appendChild(node);
} else { } else {
// @ts-ignore // @ts-ignore
throw new Error(`Unimplemented type ${element.type}`); throw new Error(`Unimplemented type ${element.type}`);

View File

@ -10,6 +10,7 @@ import {
NonDeleted, NonDeleted,
GroupId, GroupId,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawFrameElement,
} from "../element/types"; } from "../element/types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@ -36,6 +37,7 @@ import {
isSelectedViaGroup, isSelectedViaGroup,
getSelectedGroupIds, getSelectedGroupIds,
getElementsInGroup, getElementsInGroup,
selectGroupsFromGivenElements,
} from "../groups"; } from "../groups";
import { maxBindingGap } from "../element/collision"; import { maxBindingGap } from "../element/collision";
import { import {
@ -44,18 +46,28 @@ import {
isBindingEnabled, isBindingEnabled,
} from "../element/binding"; } from "../element/binding";
import { import {
OMIT_SIDES_FOR_FRAME,
shouldShowBoundingBox, shouldShowBoundingBox,
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { viewportCoordsToSceneCoords, throttleRAF } from "../utils"; import {
viewportCoordsToSceneCoords,
throttleRAF,
isOnlyExportingSingleFrame,
} from "../utils";
import { UserIdleState } from "../types"; import { UserIdleState } from "../types";
import { THEME_FILTER } from "../constants"; import { FRAME_STYLE, THEME_FILTER } from "../constants";
import { import {
EXTERNAL_LINK_IMG, EXTERNAL_LINK_IMG,
getLinkHandleFromCoords, getLinkHandleFromCoords,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { isLinearElement } from "../element/typeChecks"; import { isFrameElement, isLinearElement } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
getTargetFrame,
isElementInFrame,
} from "../frame";
import "canvas-roundrect-polyfill"; import "canvas-roundrect-polyfill";
export const DEFAULT_SPACING = 2; export const DEFAULT_SPACING = 2;
@ -70,6 +82,8 @@ const strokeRectWithRotation = (
cy: number, cy: number,
angle: number, angle: number,
fill: boolean = false, fill: boolean = false,
/** should account for zoom */
radius: number = 0,
) => { ) => {
context.save(); context.save();
context.translate(cx, cy); context.translate(cx, cy);
@ -77,7 +91,14 @@ const strokeRectWithRotation = (
if (fill) { if (fill) {
context.fillRect(x - cx, y - cy, width, height); context.fillRect(x - cx, y - cy, width, height);
} }
context.strokeRect(x - cx, y - cy, width, height); if (radius && context.roundRect) {
context.beginPath();
context.roundRect(x - cx, y - cy, width, height, radius);
context.stroke();
context.closePath();
} else {
context.strokeRect(x - cx, y - cy, width, height);
}
context.restore(); context.restore();
}; };
@ -299,6 +320,34 @@ const renderLinearElementPointHighlight = (
context.restore(); context.restore();
}; };
const frameClip = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
) => {
context.translate(
frame.x + renderConfig.scrollX,
frame.y + renderConfig.scrollY,
);
context.beginPath();
if (context.roundRect && !renderConfig.isExporting) {
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / renderConfig.zoom.value,
);
} else {
context.rect(0, 0, frame.width, frame.height);
}
context.clip();
context.translate(
-(frame.x + renderConfig.scrollX),
-(frame.y + renderConfig.scrollY),
);
};
export const _renderScene = ({ export const _renderScene = ({
elements, elements,
appState, appState,
@ -390,11 +439,51 @@ export const _renderScene = ({
}), }),
); );
const groupsToBeAddedToFrame = new Set<string>();
visibleElements.forEach((element) => {
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),
);
}
});
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined = let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
undefined; undefined;
visibleElements.forEach((element) => { visibleElements.forEach((element) => {
try { try {
renderElement(element, rc, context, renderConfig, appState); // - 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.shouldRenderFrames))
) {
context.save();
const frame = getTargetFrame(element, appState);
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig);
}
renderElement(element, rc, context, renderConfig, appState);
context.restore();
} else {
renderElement(element, rc, context, renderConfig, appState);
}
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
// ShapeCache returns empty hence making sure that we get the // ShapeCache returns empty hence making sure that we get the
// correct element from visible elements // correct element from visible elements
@ -443,7 +532,24 @@ export const _renderScene = ({
renderBindingHighlight(context, renderConfig, suggestedBinding!); renderBindingHighlight(context, renderConfig, suggestedBinding!);
}); });
} }
if (appState.frameToHighlight) {
renderFrameHighlight(context, renderConfig, appState.frameToHighlight);
}
if (appState.elementsToHighlight) {
renderElementsBoxHighlight(
context,
renderConfig,
appState.elementsToHighlight,
appState,
);
}
const locallySelectedElements = getSelectedElements(elements, appState); const locallySelectedElements = getSelectedElements(elements, appState);
const isFrameSelected = locallySelectedElements.some((element) =>
isFrameElement(element),
);
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
// ShapeCache returns empty hence making sure that we get the // ShapeCache returns empty hence making sure that we get the
@ -613,7 +719,9 @@ export const _renderScene = ({
0, 0,
renderConfig.zoom, renderConfig.zoom,
"mouse", "mouse",
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, isFrameSelected
? OMIT_SIDES_FOR_FRAME
: OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
); );
if (locallySelectedElements.some((element) => !element.locked)) { if (locallySelectedElements.some((element) => !element.locked)) {
renderTransformHandles(context, renderConfig, transformHandles, 0); renderTransformHandles(context, renderConfig, transformHandles, 0);
@ -974,6 +1082,7 @@ const renderBindingHighlightForBindableElement = (
case "rectangle": case "rectangle":
case "text": case "text":
case "image": case "image":
case "frame":
strokeRectWithRotation( strokeRectWithRotation(
context, context,
x1 - padding, x1 - padding,
@ -1011,6 +1120,82 @@ const renderBindingHighlightForBindableElement = (
} }
}; };
const renderFrameHighlight = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
frame: NonDeleted<ExcalidrawFrameElement>,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
const width = x2 - x1;
const height = y2 - y1;
context.strokeStyle = "rgb(0,118,255)";
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / renderConfig.zoom.value;
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
strokeRectWithRotation(
context,
x1,
y1,
width,
height,
x1 + width / 2,
y1 + height / 2,
frame.angle,
false,
FRAME_STYLE.radius / renderConfig.zoom.value,
);
context.restore();
};
const renderElementsBoxHighlight = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
elements: NonDeleted<ExcalidrawElement>[],
appState: AppState,
) => {
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] =
getCommonBounds(elements);
return {
angle: 0,
elementX1,
elementX2,
elementY1,
elementY2,
selectionColors: ["rgb(0,118,255)"],
dashed: false,
cx: elementX1 + (elementX2 - elementX1) / 2,
cy: elementY1 + (elementY2 - elementY1) / 2,
};
};
const getSelectionForGroupId = (groupId: GroupId) => {
const groupElements = getElementsInGroup(elements, groupId);
return getSelectionFromElements(groupElements);
};
Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
.filter(([id, isSelected]) => isSelected)
.map(([id, isSelected]) => id)
.map((groupId) => getSelectionForGroupId(groupId))
.concat(
individualElements.map((element) => getSelectionFromElements([element])),
)
.forEach((selection) =>
renderSelectionBorder(context, renderConfig, selection),
);
};
const renderBindingHighlightForSuggestedPointBinding = ( const renderBindingHighlightForSuggestedPointBinding = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
suggestedBinding: SuggestedPointBinding, suggestedBinding: SuggestedPointBinding,
@ -1092,7 +1277,7 @@ const renderLinkIcon = (
} }
}; };
const isVisibleElement = ( export const isVisibleElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
canvasWidth: number, canvasWidth: number,
canvasHeight: number, canvasHeight: number,
@ -1138,15 +1323,18 @@ export const renderSceneToSvg = (
offsetX = 0, offsetX = 0,
offsetY = 0, offsetY = 0,
exportWithDarkMode = false, exportWithDarkMode = false,
exportingFrameId = null,
}: { }: {
offsetX?: number; offsetX?: number;
offsetY?: number; offsetY?: number;
exportWithDarkMode?: boolean; exportWithDarkMode?: boolean;
exportingFrameId?: string | null;
} = {}, } = {},
) => { ) => {
if (!svgRoot) { if (!svgRoot) {
return; return;
} }
// render elements // render elements
elements.forEach((element) => { elements.forEach((element) => {
if (!element.isDeleted) { if (!element.isDeleted) {
@ -1159,6 +1347,7 @@ export const renderSceneToSvg = (
element.x + offsetX, element.x + offsetX,
element.y + offsetY, element.y + offsetY,
exportWithDarkMode, exportWithDarkMode,
exportingFrameId,
); );
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);

View File

@ -2,9 +2,15 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement,
} from "../element/types"; } from "../element/types";
import { getNonDeletedElements, isNonDeletedElement } from "../element"; import {
getNonDeletedElements,
getNonDeletedFrames,
isNonDeletedElement,
} from "../element";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameElement } from "../element/typeChecks";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey; type ElementKey = ExcalidrawElement | ElementIdKey;
@ -12,6 +18,10 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
type SceneStateCallback = () => void; type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void; type SceneStateCallbackRemover = () => void;
// ideally this would be a branded type but it'd be insanely hard to work with
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => { const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
if (typeof elementKey === "string") { if (typeof elementKey === "string") {
return true; return true;
@ -55,6 +65,8 @@ class Scene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = []; private elements: readonly ExcalidrawElement[] = [];
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
private frames: readonly ExcalidrawFrameElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>(); private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
getElementsIncludingDeleted() { getElementsIncludingDeleted() {
@ -65,6 +77,14 @@ class Scene {
return this.nonDeletedElements; return this.nonDeletedElements;
} }
getFramesIncludingDeleted() {
return this.frames;
}
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
return this.nonDeletedFrames;
}
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null { getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
return (this.elementsMap.get(id) as T | undefined) || null; return (this.elementsMap.get(id) as T | undefined) || null;
} }
@ -110,12 +130,19 @@ class Scene {
replaceAllElements(nextElements: readonly ExcalidrawElement[]) { replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
this.elements = nextElements; this.elements = nextElements;
const nextFrames: ExcalidrawFrameElement[] = [];
this.elementsMap.clear(); this.elementsMap.clear();
nextElements.forEach((element) => { nextElements.forEach((element) => {
if (isFrameElement(element)) {
nextFrames.push(element);
}
this.elementsMap.set(element.id, element); this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this); Scene.mapElementToScene(element, this);
}); });
this.nonDeletedElements = getNonDeletedElements(this.elements); this.nonDeletedElements = getNonDeletedElements(this.elements);
this.frames = nextFrames;
this.nonDeletedFrames = getNonDeletedFrames(this.frames);
this.informMutation(); this.informMutation();
} }
@ -165,6 +192,29 @@ class Scene {
this.replaceAllElements(nextElements); this.replaceAllElements(nextElements);
} }
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
...elements,
...this.elements.slice(index),
];
this.replaceAllElements(nextElements);
}
addNewElement = (element: ExcalidrawElement) => {
if (element.frameId) {
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
} else {
this.replaceAllElements([...this.elements, element]);
}
};
getElementIndex(elementId: string) { getElementIndex(elementId: string) {
return this.elements.findIndex((element) => element.id === elementId); return this.elements.findIndex((element) => element.id === elementId);
} }

View File

@ -7,7 +7,8 @@ export const hasBackground = (type: string) =>
type === "line" || type === "line" ||
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: string) => type !== "image"; export const hasStrokeColor = (type: string) =>
type !== "image" && type !== "frame";
export const hasStrokeWidth = (type: string) => export const hasStrokeWidth = (type: string) =>
type === "rectangle" || type === "rectangle" ||

View File

@ -1,8 +1,8 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
import { distance } from "../utils"; import { distance, isOnlyExportingSingleFrame } from "../utils";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants"; import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
@ -11,6 +11,7 @@ import {
getInitializedImageElements, getInitializedImageElements,
updateImageCache, updateImageCache,
} from "../element/image"; } from "../element/image";
import Scene from "./Scene";
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -51,6 +52,8 @@ export const exportToCanvas = async (
files, files,
}); });
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderScene({ renderScene({
elements, elements,
appState, appState,
@ -59,8 +62,8 @@ export const exportToCanvas = async (
canvas, canvas,
renderConfig: { renderConfig: {
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + exportPadding, scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollY: -minY + exportPadding, scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
zoom: defaultAppState.zoom, zoom: defaultAppState.zoom,
remotePointerViewportCoords: {}, remotePointerViewportCoords: {},
remoteSelectedElementIds: {}, remoteSelectedElementIds: {},
@ -88,6 +91,7 @@ export const exportToSvg = async (
viewBackgroundColor: string; viewBackgroundColor: string;
exportWithDarkMode?: boolean; exportWithDarkMode?: boolean;
exportEmbedScene?: boolean; exportEmbedScene?: boolean;
renderFrame?: boolean;
}, },
files: BinaryFiles | null, files: BinaryFiles | null,
opts?: { opts?: {
@ -140,6 +144,39 @@ export const exportToSvg = async (
} }
assetPath = `${assetPath}/dist/excalidraw-assets/`; 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 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");
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);
exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
<rect transform="translate(${exportingFrame.x + offsetX} ${
exportingFrame.y + offsetY
}) rotate(${exportingFrame.angle} ${cx} ${cy})"
width="${exportingFrame.width}"
height="${exportingFrame.height}"
>
</rect>
</clipPath>`;
}
svgRoot.innerHTML = ` svgRoot.innerHTML = `
${SVG_EXPORT_TAG} ${SVG_EXPORT_TAG}
${metadata} ${metadata}
@ -154,8 +191,10 @@ export const exportToSvg = async (
src: url("${assetPath}Cascadia.woff2"); src: url("${assetPath}Cascadia.woff2");
} }
</style> </style>
${exportingFrameClipPath}
</defs> </defs>
`; `;
// render background rect // render background rect
if (appState.exportBackground && viewBackgroundColor) { if (appState.exportBackground && viewBackgroundColor) {
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect"); const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
@ -169,9 +208,10 @@ export const exportToSvg = async (
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, { renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
offsetX: -minX + exportPadding, offsetX,
offsetY: -minY + exportPadding, offsetY,
exportWithDarkMode: appState.exportWithDarkMode, exportWithDarkMode: appState.exportWithDarkMode,
exportingFrameId: exportingFrame?.id || null,
}); });
return svgRoot; return svgRoot;
@ -182,9 +222,36 @@ const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
): [number, number, number, number] => { ): [number, number, number, number] => {
// 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<string, true>);
// 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 [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = distance(minX, maxX) + exportPadding * 2; const width =
const height = distance(minY, maxY) + exportPadding + exportPadding; distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
const height =
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };

View File

@ -5,17 +5,61 @@ import {
import { getElementAbsoluteCoords, getElementBounds } from "../element"; import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState } from "../types"; import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameElements,
} from "../frame";
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
* we only keep the frames.
* @param selectedElements
*/
export const excludeElementsInFramesFromSelection = <
T extends ExcalidrawElement,
>(
selectedElements: readonly T[],
) => {
const framesInSelection = new Set<T["id"]>();
selectedElements.forEach((element) => {
if (element.type === "frame") {
framesInSelection.add(element.id);
}
});
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
export const getElementsWithinSelection = ( export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
excludeElementsInFrames: boolean = true,
) => { ) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] = const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(selection); getElementAbsoluteCoords(selection);
return elements.filter((element) => {
const [elementX1, elementY1, elementX2, elementY2] = let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] =
getElementBounds(element); getElementBounds(element);
const containingFrame = getContainingFrame(element);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
}
return ( return (
element.locked === false && element.locked === false &&
element.type !== "selection" && element.type !== "selection" &&
@ -26,6 +70,22 @@ export const getElementsWithinSelection = (
selectionY2 >= elementY2 selectionY2 >= elementY2
); );
}); });
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame);
}
return true;
});
return elementsInSelection;
}; };
export const isSomeElementSelected = ( export const isSomeElementSelected = (
@ -56,14 +116,17 @@ export const getCommonAttributeOfSelectedElements = <T>(
export const getSelectedElements = ( export const getSelectedElements = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">, appState: Pick<AppState, "selectedElementIds">,
includeBoundTextElement: boolean = false, opts?: {
) => includeBoundTextElement?: boolean;
elements.filter((element) => { includeElementsInFrames?: boolean;
},
) => {
const selectedElements = elements.filter((element) => {
if (appState.selectedElementIds[element.id]) { if (appState.selectedElementIds[element.id]) {
return element; return element;
} }
if ( if (
includeBoundTextElement && opts?.includeBoundTextElement &&
isBoundToContainer(element) && isBoundToContainer(element) &&
appState.selectedElementIds[element?.containerId] appState.selectedElementIds[element?.containerId]
) { ) {
@ -72,10 +135,29 @@ export const getSelectedElements = (
return null; return null;
}); });
if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => {
if (element.type === "frame") {
getFrameElements(elements, element.id).forEach((e) =>
elementsToInclude.push(e),
);
}
elementsToInclude.push(element);
});
return elementsToInclude;
}
return selectedElements;
};
export const getTargetElements = ( export const getTargetElements = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds" | "editingElement">, appState: Pick<AppState, "selectedElementIds" | "editingElement">,
) => ) =>
appState.editingElement appState.editingElement
? [appState.editingElement] ? [appState.editingElement]
: getSelectedElements(elements, appState, true); : getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});

View File

@ -83,6 +83,14 @@ export const SHAPES = [
numericKey: KEYS["0"], numericKey: KEYS["0"],
fillable: false, fillable: false,
}, },
// TODO: frame, create icon and set up numeric key
// {
// icon: RectangleIcon,
// value: "frame",
// key: KEYS.F,
// numericKey: KEYS.SUBTRACT,
// fillable: false,
// },
] as const; ] as const;
export const findShapeByKey = (key: string) => { export const findShapeByKey = (key: string) => {

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ Object {
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -56,6 +57,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -89,6 +91,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -122,6 +125,7 @@ Object {
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -168,6 +172,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",

View File

@ -15,6 +15,7 @@ exports[`export exporting svg containing transformed images: svg export output 1
src: url(\\"https://excalidraw.com/Cascadia.woff2\\"); src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
} }
</style> </style>
</defs> </defs>
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>" <g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
`; `;

View File

@ -6,6 +6,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0_copy", "id": "id0_copy",
@ -37,6 +38,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -68,6 +70,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -104,6 +107,7 @@ Object {
}, },
], ],
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 100, "height": 100,
"id": "id0", "id": "id0",
@ -140,6 +144,7 @@ Object {
}, },
], ],
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 300, "height": 300,
"id": "id1", "id": "id1",
@ -177,6 +182,7 @@ Object {
"gap": 10, "gap": 10,
}, },
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 81.48231043525051, "height": 81.48231043525051,
"id": "id2", "id": "id2",

View File

@ -8,6 +8,7 @@ Object {
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 110, "height": 110,
"id": "id0", "id": "id0",
@ -61,6 +62,7 @@ Object {
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 110, "height": 110,
"id": "id0", "id": "id0",

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ Object {
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -54,6 +55,7 @@ Object {
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -98,6 +100,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -129,6 +132,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@ -160,6 +164,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",

View File

@ -8,6 +8,7 @@ Object {
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 100, "height": 100,
"id": "id-arrow01", "id": "id-arrow01",
@ -52,6 +53,7 @@ Object {
"backgroundColor": "blue", "backgroundColor": "blue",
"boundElements": Array [], "boundElements": Array [],
"fillStyle": "cross-hatch", "fillStyle": "cross-hatch",
"frameId": null,
"groupIds": Array [ "groupIds": Array [
"1", "1",
"2", "2",
@ -87,6 +89,7 @@ Object {
"backgroundColor": "blue", "backgroundColor": "blue",
"boundElements": Array [], "boundElements": Array [],
"fillStyle": "cross-hatch", "fillStyle": "cross-hatch",
"frameId": null,
"groupIds": Array [ "groupIds": Array [
"1", "1",
"2", "2",
@ -122,6 +125,7 @@ Object {
"backgroundColor": "blue", "backgroundColor": "blue",
"boundElements": Array [], "boundElements": Array [],
"fillStyle": "cross-hatch", "fillStyle": "cross-hatch",
"frameId": null,
"groupIds": Array [ "groupIds": Array [
"1", "1",
"2", "2",
@ -157,6 +161,7 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": Array [], "boundElements": Array [],
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 0, "height": 0,
"id": "id-freedraw01", "id": "id-freedraw01",
@ -194,6 +199,7 @@ Object {
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 100, "height": 100,
"id": "id-line01", "id": "id-line01",
@ -240,6 +246,7 @@ Object {
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 100, "height": 100,
"id": "id-draw01", "id": "id-draw01",
@ -288,6 +295,7 @@ Object {
"fillStyle": "hachure", "fillStyle": "hachure",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 14, "fontSize": 14,
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",
@ -328,6 +336,7 @@ Object {
"fillStyle": "hachure", "fillStyle": "hachure",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 10, "fontSize": 10,
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",

View File

@ -15,6 +15,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
roughness: 1, roughness: 1,
opacity: 100, opacity: 100,
groupIds: [], groupIds: [],
frameId: null,
roundness: null, roundness: null,
seed: 1041657908, seed: 1041657908,
version: 120, version: 120,

View File

@ -37,8 +37,12 @@ export class API {
static getSelectedElements = ( static getSelectedElements = (
includeBoundTextElement: boolean = false, includeBoundTextElement: boolean = false,
includeElementsInFrames: boolean = false,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
return getSelectedElements(h.elements, h.state, includeBoundTextElement); return getSelectedElements(h.elements, h.state, {
includeBoundTextElement,
includeElementsInFrames,
});
}; };
static getSelectedElement = (): ExcalidrawElement => { static getSelectedElement = (): ExcalidrawElement => {
@ -141,6 +145,7 @@ export class API {
| "versionNonce" | "versionNonce"
| "isDeleted" | "isDeleted"
| "groupIds" | "groupIds"
| "frameId"
| "link" | "link"
| "updated" | "updated"
> = { > = {

View File

@ -28,8 +28,10 @@ Object {
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"draggingElement": null, "draggingElement": null,
"editingElement": null, "editingElement": null,
"editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
"exportEmbedScene": false, "exportEmbedScene": false,
@ -37,6 +39,7 @@ Object {
"exportScale": 1, "exportScale": 1,
"exportWithDarkMode": false, "exportWithDarkMode": false,
"fileHandle": null, "fileHandle": null,
"frameToHighlight": null,
"gridSize": null, "gridSize": null,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@ -62,10 +65,12 @@ Object {
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"selectedElementIds": Object {}, "selectedElementIds": Object {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": Object {}, "selectedGroupIds": Object {},
"selectedLinearElement": null, "selectedLinearElement": null,
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"shouldRenderFrames": true,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,

View File

@ -11,6 +11,7 @@ const toolMap = {
freedraw: "freedraw", freedraw: "freedraw",
text: "text", text: "text",
eraser: "eraser", eraser: "eraser",
frame: "frame",
}; };
export type ToolName = keyof typeof toolMap; export type ToolName = keyof typeof toolMap;

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,7 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
Theme, Theme,
StrokeRoundness, StrokeRoundness,
ExcalidrawFrameElement,
} from "./element/types"; } from "./element/types";
import { SHAPES } from "./shapes"; import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry"; import { Point as RoughPoint } from "roughjs/bin/geometry";
@ -85,7 +86,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type LastActiveTool = export type LastActiveTool =
| { | {
type: typeof SHAPES[number]["value"] | "eraser" | "hand"; type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
customType: null; customType: null;
} }
| { | {
@ -113,6 +114,10 @@ export type AppState = {
isBindingEnabled: boolean; isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null; startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBindings: SuggestedBinding[]; suggestedBindings: SuggestedBinding[];
frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null;
shouldRenderFrames: boolean;
editingFrame: string | null;
elementsToHighlight: NonDeleted<ExcalidrawElement>[] | null;
// element being edited, but not necessarily added to elements array yet // element being edited, but not necessarily added to elements array yet
// (e.g. text element when typing into the input) // (e.g. text element when typing into the input)
editingElement: NonDeletedExcalidrawElement | null; editingElement: NonDeletedExcalidrawElement | null;
@ -126,7 +131,7 @@ export type AppState = {
locked: boolean; locked: boolean;
} & ( } & (
| { | {
type: typeof SHAPES[number]["value"] | "eraser" | "hand"; type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
customType: null; customType: null;
} }
| { | {
@ -178,6 +183,7 @@ export type AppState = {
lastPointerDownWith: PointerType; lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean }; selectedElementIds: { [id: string]: boolean };
previousSelectedElementIds: { [id: string]: boolean }; previousSelectedElementIds: { [id: string]: boolean };
selectedElementsAreBeingDragged: boolean;
shouldCacheIgnoreZoom: boolean; shouldCacheIgnoreZoom: boolean;
toast: { message: string; closable?: boolean; duration?: number } | null; toast: { message: string; closable?: boolean; duration?: number } | null;
zenModeEnabled: boolean; zenModeEnabled: boolean;
@ -532,6 +538,12 @@ export type ExcalidrawImperativeAPI = {
setCursor: InstanceType<typeof App>["setCursor"]; setCursor: InstanceType<typeof App>["setCursor"];
resetCursor: InstanceType<typeof App>["resetCursor"]; resetCursor: InstanceType<typeof App>["resetCursor"];
toggleSidebar: InstanceType<typeof App>["toggleSidebar"]; toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
/**
* Disables rendering of frames (including element clipping), but currently
* the frames are still interactive in edit mode. As such, this API should be
* used in conjunction with view mode (props.viewModeEnabled).
*/
toggleFrameRendering: InstanceType<typeof App>["toggleFrameRendering"];
}; };
export type Device = Readonly<{ export type Device = Readonly<{
@ -541,3 +553,22 @@ export type Device = Readonly<{
canDeviceFitSidebar: boolean; canDeviceFitSidebar: boolean;
isLandscape: boolean; isLandscape: boolean;
}>; }>;
type FrameNameBounds = {
x: number;
y: number;
width: number;
height: number;
angle: number;
};
export type FrameNameBoundsCache = {
get: (frameElement: ExcalidrawFrameElement) => FrameNameBounds | null;
_cache: Map<
string,
FrameNameBounds & {
zoom: AppState["zoom"]["value"];
versionNonce: ExcalidrawFrameElement["versionNonce"];
}
>;
};

View File

@ -10,7 +10,11 @@ import {
THEME, THEME,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
import { FontFamilyValues, FontString } from "./element/types"; import {
FontFamilyValues,
FontString,
NonDeletedExcalidrawElement,
} from "./element/types";
import { AppState, DataURL, LastActiveTool, Zoom } from "./types"; import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
import { unstable_batchedUpdates } from "react-dom"; import { unstable_batchedUpdates } from "react-dom";
import { SHAPES } from "./shapes"; import { SHAPES } from "./shapes";
@ -299,7 +303,7 @@ export const distance = (x: number, y: number) => Math.abs(x - y);
export const updateActiveTool = ( export const updateActiveTool = (
appState: Pick<AppState, "activeTool">, appState: Pick<AppState, "activeTool">,
data: ( data: (
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" } | { type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame" }
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { lastActiveToolBeforeEraser?: LastActiveTool }, ) & { lastActiveToolBeforeEraser?: LastActiveTool },
): AppState["activeTool"] => { ): AppState["activeTool"] => {
@ -824,3 +828,16 @@ export const composeEventHandlers = <E>(
} }
}; };
}; };
export const isOnlyExportingSingleFrame = (
elements: readonly NonDeletedExcalidrawElement[],
) => {
const frames = elements.filter((element) => element.type === "frame");
return (
frames.length === 1 &&
elements.every(
(element) => element.type === "frame" || element.frameId === frames[0].id,
)
);
};

View File

@ -1,36 +1,52 @@
import { bumpVersion } from "./element/mutateElement"; import { bumpVersion } from "./element/mutateElement";
import { isFrameElement } from "./element/typeChecks";
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { groupByFrames } from "./frame";
import { getElementsInGroup } from "./groups"; import { getElementsInGroup } from "./groups";
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import Scene from "./scene/Scene"; import Scene from "./scene/Scene";
import { AppState } from "./types"; import { AppState } from "./types";
import { arrayToMap, findIndex, findLastIndex } from "./utils"; import { arrayToMap, findIndex, findLastIndex } from "./utils";
// elements that do not belong to a frame are considered a root element
const isRootElement = (element: ExcalidrawElement) => {
return !element.frameId;
};
/** /**
* Returns indices of elements to move based on selected elements. * Returns indices of elements to move based on selected elements.
* Includes contiguous deleted elements that are between two selected elements, * Includes contiguous deleted elements that are between two selected elements,
* e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)] * e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)]
*
* Specified elements (elementsToBeMoved) take precedence over
* appState.selectedElementsIds
*/ */
const getIndicesToMove = ( const getIndicesToMove = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
let selectedIndices: number[] = []; let selectedIndices: number[] = [];
let deletedIndices: number[] = []; let deletedIndices: number[] = [];
let includeDeletedIndex = null; let includeDeletedIndex = null;
let index = -1; let index = -1;
const selectedElementIds = arrayToMap( const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, true), elementsToBeMoved
? elementsToBeMoved
: getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}),
); );
while (++index < elements.length) { while (++index < elements.length) {
if (selectedElementIds.get(elements[index].id)) { const element = elements[index];
if (selectedElementIds.get(element.id)) {
if (deletedIndices.length) { if (deletedIndices.length) {
selectedIndices = selectedIndices.concat(deletedIndices); selectedIndices = selectedIndices.concat(deletedIndices);
deletedIndices = []; deletedIndices = [];
} }
selectedIndices.push(index); selectedIndices.push(index);
includeDeletedIndex = index + 1; includeDeletedIndex = index + 1;
} else if (elements[index].isDeleted && includeDeletedIndex === index) { } else if (element.isDeleted && includeDeletedIndex === index) {
includeDeletedIndex = index + 1; includeDeletedIndex = index + 1;
deletedIndices.push(index); deletedIndices.push(index);
} else { } else {
@ -168,8 +184,8 @@ const getTargetIndex = (
return candidateIndex; return candidateIndex;
}; };
const getTargetElementsMap = ( const getTargetElementsMap = <T extends ExcalidrawElement>(
elements: readonly ExcalidrawElement[], elements: readonly T[],
indices: number[], indices: number[],
) => { ) => {
return indices.reduce((acc, index) => { return indices.reduce((acc, index) => {
@ -179,12 +195,13 @@ const getTargetElementsMap = (
}, {} as Record<string, ExcalidrawElement>); }, {} as Record<string, ExcalidrawElement>);
}; };
const shiftElements = ( const _shiftElements = (
appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right", direction: "left" | "right",
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
const indicesToMove = getIndicesToMove(elements, appState); const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove); const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
let groupedIndices = toContiguousGroups(indicesToMove); let groupedIndices = toContiguousGroups(indicesToMove);
@ -246,7 +263,22 @@ const shiftElements = (
}); });
}; };
const shiftElementsToEnd = ( const shiftElements = (
appState: AppState,
elements: readonly ExcalidrawElement[],
direction: "left" | "right",
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shift(
elements,
appState,
direction,
_shiftElements,
elementsToBeMoved,
);
};
const _shiftElementsToEnd = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
direction: "left" | "right", direction: "left" | "right",
@ -317,33 +349,108 @@ const shiftElementsToEnd = (
]; ];
}; };
const shiftElementsToEnd = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shift(
elements,
appState,
direction,
_shiftElementsToEnd,
elementsToBeMoved,
);
};
function shift(
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
shiftFunction: (
elements: ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
elementsToBeMoved?: readonly ExcalidrawElement[],
) => ExcalidrawElement[] | readonly ExcalidrawElement[],
elementsToBeMoved?: readonly ExcalidrawElement[],
) {
let rootElements = elements.filter((element) => isRootElement(element));
const frameElementsMap = groupByFrames(elements);
// shift the root elements first
rootElements = shiftFunction(
rootElements,
appState,
direction,
elementsToBeMoved,
) as ExcalidrawElement[];
// shift the elements in frames if needed
frameElementsMap.forEach((frameElements, frameId) => {
if (!appState.selectedElementIds[frameId]) {
frameElementsMap.set(
frameId,
shiftFunction(
frameElements,
appState,
direction,
elementsToBeMoved,
) as ExcalidrawElement[],
);
}
});
// return the final elements
let finalElements: ExcalidrawElement[] = [];
rootElements.forEach((element) => {
if (isFrameElement(element)) {
finalElements = [
...finalElements,
...(frameElementsMap.get(element.id) ?? []),
element,
];
} else {
finalElements = [...finalElements, element];
}
});
return finalElements;
}
// public API // public API
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const moveOneLeft = ( export const moveOneLeft = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shiftElements(appState, elements, "left"); return shiftElements(appState, elements, "left", elementsToBeMoved);
}; };
export const moveOneRight = ( export const moveOneRight = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shiftElements(appState, elements, "right"); return shiftElements(appState, elements, "right", elementsToBeMoved);
}; };
export const moveAllLeft = ( export const moveAllLeft = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shiftElementsToEnd(elements, appState, "left"); return shiftElementsToEnd(elements, appState, "left", elementsToBeMoved);
}; };
export const moveAllRight = ( export const moveAllRight = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shiftElementsToEnd(elements, appState, "right"); return shiftElementsToEnd(elements, appState, "right", elementsToBeMoved);
}; };