Compare commits

...
Author SHA1 Message Date
Ryan Di 3b5d62c8d6 fix uppercase typo 2025-02-05 21:05:27 +11:00
Ryan Di 4f74274d04 animated trail for lasso selection 2025-02-05 20:59:51 +11:00
52eaf64591 feat: box select frame & children to allow resizing at the same time (#9031)
* box select frame & children

* avoid selecting children twice to avoid double their moving

* do not show ele stats if frame and children selected together

* do not update frame membership if selected together

* do not group frame and its children

* comment and refactor code

* hide align altogether

* include frame children when selecting all

* simplify

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-28 22:10:16 +01:00
David LuzarandGitHub 7028daa44a fix: remove flushSync to fix flickering (#9057) 2025-01-28 19:23:35 +01:00
Ashwin TemkarandGitHub 65f218b100 fix: excalidraw issue #9045 flowcharts: align attributes of new node (#9047)
* fix: excalidraw#9045 by modifying the stroke style, opacity, and fill style for the new node and next nodes.

* fix: added roughness and opacity to the arrowbindings
2025-01-25 17:05:50 +01:00
AlpluneandGitHub 807b3c59f2 fix: align arrows bound to elements excalidraw#8833 (#8998) 2025-01-25 17:00:39 +01:00
AlpluneandGitHub b8da5065fd fix: update elbow arrow on font size change #8798 (#9002) 2025-01-25 17:00:26 +01:00
Márk TolmácsandGitHub 49f1276ef2 fix: Undo for elbow arrows create incorrect routing (#9046) 2025-01-24 20:18:08 +01:00
Ashwin TemkarandGitHub 8f20b29b73 fix: #8575 , Flowchart clones the current arrowhead (#8581)
* fix: #8575, Flowchart clones the current arrowhead

* fix: #8575, changed stroke color, style and width to startBindingElement
2025-01-24 16:50:07 +01:00
David LuzarandGitHub f87c2cde09 feat: allow installing libs from excal github (#9041) 2025-01-23 16:50:47 +01:00
Ryan DiandGitHub 0bf234fcc9 fix: adding partial group to frame (#9014)
* prevent new frame from including partial groups

* separate wrapped partial group
2025-01-23 07:26:12 +08:00
Ryan DiandGitHub dd1b45a25a perf: reduce unnecessary frame clippings (#8980)
* reduce unnecessary frame clippings

* further optim
2025-01-23 07:25:46 +08:00
David LuzarandGitHub ec06fbc1fc fix: do not refocus element link input on unrelated updates (#9037) 2025-01-22 21:30:15 +01:00
David LuzarandGitHub fa05ae1230 refactor: remove defaultProps (#9035) 2025-01-22 12:43:02 +01:00
32 changed files with 1238 additions and 409 deletions
+20 -15
View File
@@ -21,10 +21,8 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const alignActionsPredicate = ( export const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: UIAppState, appState: UIAppState,
_: unknown,
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
@@ -48,6 +46,7 @@ const alignSelectedElements = (
selectedElements, selectedElements,
elementsMap, elementsMap,
alignment, alignment,
app.scene,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
@@ -64,7 +63,8 @@ export const actionAlignTop = register({
label: "labels.alignTop", label: "labels.alignTop",
icon: AlignTopIcon, icon: AlignTopIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -79,7 +79,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, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignTopIcon} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -97,7 +97,8 @@ export const actionAlignBottom = register({
label: "labels.alignBottom", label: "labels.alignBottom",
icon: AlignBottomIcon, icon: AlignBottomIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -112,7 +113,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, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignBottomIcon} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -130,7 +131,8 @@ export const actionAlignLeft = register({
label: "labels.alignLeft", label: "labels.alignLeft",
icon: AlignLeftIcon, icon: AlignLeftIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -145,7 +147,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, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignLeftIcon} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -163,7 +165,8 @@ export const actionAlignRight = register({
label: "labels.alignRight", label: "labels.alignRight",
icon: AlignRightIcon, icon: AlignRightIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -178,7 +181,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, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignRightIcon} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -196,7 +199,8 @@ export const actionAlignVerticallyCentered = register({
label: "labels.centerVertically", label: "labels.centerVertically",
icon: CenterVerticallyIcon, icon: CenterVerticallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -209,7 +213,7 @@ export const actionAlignVerticallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -225,7 +229,8 @@ export const actionAlignHorizontallyCentered = register({
label: "labels.centerHorizontally", label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon, icon: CenterHorizontallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -238,7 +243,7 @@ export const actionAlignHorizontallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -12,6 +12,8 @@ import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { newFrameElement } from "../element/newElement"; import { newFrameElement } from "../element/newElement";
import { getElementsInGroup } from "../groups";
import { mutateElement } from "../element/mutateElement";
const isSingleFrameSelected = ( const isSingleFrameSelected = (
appState: UIAppState, appState: UIAppState,
@@ -174,10 +176,31 @@ export const actionWrapSelectionInFrame = register({
height: y2 - y1 + PADDING * 2, height: y2 - y1 + PADDING * 2,
}); });
// for a selected partial group, we want to remove it from the remainder of the group
if (appState.editingGroupId) {
const elementsInGroup = getElementsInGroup(
selectedElements,
appState.editingGroupId,
);
for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement(
elementInGroup,
{
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
}
}
const nextElements = addElementsToFrame( const nextElements = addElementsToFrame(
[...app.scene.getElementsIncludingDeleted(), frame], [...app.scene.getElementsIncludingDeleted(), frame],
selectedElements, selectedElements,
frame, frame,
appState,
); );
return { return {
+12 -5
View File
@@ -25,8 +25,10 @@ import type {
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { import {
frameAndChildrenSelectedTogether,
getElementsInResizingFrame, getElementsInResizingFrame,
getFrameLikeElements, getFrameLikeElements,
getRootElements,
groupByFrameLikes, groupByFrameLikes,
removeElementsFromFrame, removeElementsFromFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
@@ -60,8 +62,11 @@ const enableActionGroup = (
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements) &&
!frameAndChildrenSelectedTogether(selectedElements)
); );
}; };
@@ -71,10 +76,12 @@ export const actionGroup = register({
icon: (appState) => <GroupIcon theme={appState.theme} />, icon: (appState) => <GroupIcon theme={appState.theme} />,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({ const selectedElements = getRootElements(
selectedElementIds: appState.selectedElementIds, app.scene.getSelectedElements({
includeBoundTextElement: true, selectedElementIds: appState.selectedElementIds,
}); includeBoundTextElement: true,
}),
);
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
return { appState, elements, storeAction: StoreAction.NONE }; return { appState, elements, storeAction: StoreAction.NONE };
@@ -89,6 +89,7 @@ import type {
FontFamilyValues, FontFamilyValues,
TextAlign, TextAlign,
VerticalAlign, VerticalAlign,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -115,6 +116,7 @@ import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding, getHoveredElementForBinding,
updateBoundElements,
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
@@ -218,33 +220,47 @@ const changeFontSize = (
) => { ) => {
const newFontSizes = new Set<number>(); const newFontSizes = new Set<number>();
const updatedElements = changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
);
// Update arrow elements after text elements have been updated
const updatedElementsMap = arrayToMap(updatedElements);
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).forEach((element) => {
if (isTextElement(element)) {
updateBoundElements(
element,
updatedElementsMap as NonDeletedSceneElementsMap,
);
}
});
return { return {
elements: changeProperty( elements: updatedElements,
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: { appState: {
...appState, ...appState,
// update state only if we've set all select text elements to // update state only if we've set all select text elements to
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { 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";
import { selectAllIcon } from "../components/icons"; import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -20,17 +19,17 @@ export const actionSelectAll = register({
return false; return false;
} }
const selectedElementIds = excludeElementsInFramesFromSelection( const selectedElementIds = elements
elements.filter( .filter(
(element) => (element) =>
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) && !(isTextElement(element) && element.containerId) &&
!element.locked, !element.locked,
), )
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => { .reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true; map[element.id] = true;
return map; return map;
}, {}); }, {});
return { return {
appState: { appState: {
+14 -5
View File
@@ -1,8 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types"; import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import type { BoundingBox } from "./element/bounds"; import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds"; import { getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import { updateBoundElements } from "./element/binding";
import type Scene from "./scene/Scene";
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -13,6 +15,7 @@ export const alignElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: ElementsMap,
alignment: Alignment, alignment: Alignment,
scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getMaximumGroups( const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements, selectedElements,
@@ -26,12 +29,18 @@ export const alignElements = (
selectionBoundingBox, selectionBoundingBox,
alignment, alignment,
); );
return group.map((element) => return group.map((element) => {
newElementWith(element, { // update element
const updatedEle = mutateElement(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}), });
); // update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: group,
});
return updatedEle;
});
}); });
}; };
+12 -2
View File
@@ -20,7 +20,7 @@ export interface AnimatedTrailOptions {
} }
export class AnimatedTrail implements Trail { export class AnimatedTrail implements Trail {
private currentTrail?: LaserPointer; currentTrail?: LaserPointer;
private pastTrails: LaserPointer[] = []; private pastTrails: LaserPointer[] = [];
private container?: SVGSVGElement; private container?: SVGSVGElement;
@@ -28,7 +28,7 @@ export class AnimatedTrail implements Trail {
constructor( constructor(
private animationFrameHandler: AnimationFrameHandler, private animationFrameHandler: AnimationFrameHandler,
private app: App, protected app: App,
private options: Partial<LaserPointerOptions> & private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>, Partial<AnimatedTrailOptions>,
) { ) {
@@ -98,6 +98,16 @@ export class AnimatedTrail implements Trail {
} }
} }
getCurrentTrail() {
return this.currentTrail;
}
clearTrails() {
this.pastTrails = [];
this.currentTrail = undefined;
this.update();
}
private update() { private update() {
this.start(); this.start();
} }
+2
View File
@@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
isCropping: false, isCropping: false,
croppingElementId: null, croppingElementId: null,
searchMatches: [], searchMatches: [],
lassoSelectionEnabled: false,
}; };
}; };
@@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
isCropping: { browser: false, export: false, server: false }, isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false }, croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lassoSelectionEnabled: { browser: true, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+7 -1
View File
@@ -51,6 +51,7 @@ import {
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { alignActionsPredicate } from "../actions/actionAlign";
export const canChangeStrokeColor = ( export const canChangeStrokeColor = (
appState: UIAppState, appState: UIAppState,
@@ -90,10 +91,12 @@ export const SelectedShapeActions = ({
appState, appState,
elementsMap, elementsMap,
renderAction, renderAction,
app,
}: { }: {
appState: UIAppState; appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
app: AppClassProperties;
}) => { }) => {
const targetElements = getTargetElements(elementsMap, appState); const targetElements = getTargetElements(elementsMap, appState);
@@ -133,6 +136,9 @@ export const SelectedShapeActions = ({
targetElements.length === 1 && targetElements.length === 1 &&
isImageElement(targetElements[0]); isImageElement(targetElements[0]);
const showAlignActions =
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> <div>
@@ -200,7 +206,7 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
{targetElements.length > 1 && !isSingleElementBoundContainer && ( {showAlignActions && !isSingleElementBoundContainer && (
<fieldset> <fieldset>
<legend>{t("labels.align")}</legend> <legend>{t("labels.align")}</legend>
<div className="buttonList"> <div className="buttonList">
+105 -9
View File
@@ -465,6 +465,7 @@ import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping"; import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink"; import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink"; import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
import { LassoTrail } from "../lasso";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -635,6 +636,8 @@ class App extends React.Component<AppProps, AppState> {
: "rgba(255, 255, 255, 0.2)", : "rgba(255, 255, 255, 0.2)",
}); });
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
onChangeEmitter = new Emitter< onChangeEmitter = new Emitter<
[ [
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -1607,7 +1610,11 @@ class App extends React.Component<AppProps, AppState> {
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" /> <div className="excalidraw-eye-dropper-container" />
<SVGLayer <SVGLayer
trails={[this.laserTrails, this.eraserTrail]} trails={[
this.laserTrails,
this.eraserTrail,
this.lassoTrail,
]}
/> />
{selectedElements.length === 1 && {selectedElements.length === 1 &&
this.state.openDialog?.name !== this.state.openDialog?.name !==
@@ -3235,7 +3242,12 @@ class App extends React.Component<AppProps, AppState> {
newElements, newElements,
topLayerFrame, topLayerFrame,
); );
addElementsToFrame(nextElements, eligibleElements, topLayerFrame); addElementsToFrame(
nextElements,
eligibleElements,
topLayerFrame,
this.state,
);
} }
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
@@ -4326,10 +4338,14 @@ class App extends React.Component<AppProps, AppState> {
} }
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement(element, { mutateElement(
x: element.x + offsetX, element,
y: element.y + offsetY, {
}); x: element.x + offsetX,
y: element.y + offsetY,
},
false,
);
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
@@ -4346,6 +4362,8 @@ class App extends React.Component<AppProps, AppState> {
), ),
}); });
this.scene.triggerUpdate();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ENTER) { } else if (event.key === KEYS.ENTER) {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
@@ -4504,6 +4522,14 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (event.key === KEYS[1] && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "selection") {
this.setActiveTool({ type: "lassoSelection" });
} else {
this.setActiveTool({ type: "selection" });
}
}
if ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@@ -6534,6 +6560,15 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type, this.state.activeTool.type,
pointerDownState, pointerDownState,
); );
} else if (this.state.activeTool.type === "lassoSelection") {
// Begin a mark capture. This does not have to update state yet.
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
null,
);
this.lassoTrail.startPath(gridX, gridY);
} else if (this.state.activeTool.type === "custom") { } else if (this.state.activeTool.type === "custom") {
setCursorForShape(this.interactiveCanvas, this.state); setCursorForShape(this.interactiveCanvas, this.state);
} else if ( } else if (
@@ -8320,9 +8355,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
flushSync(() => { this.setState({ snapLines });
this.setState({ snapLines });
});
// when we're editing the name of a frame, we want the user to be // when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input // able to select and interact with the text input
@@ -8452,6 +8485,63 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event); this.maybeDragNewGenericElement(pointerDownState, event);
} else if (this.state.activeTool.type === "lassoSelection") {
const { intersectedElementIds, enclosedElementIds } =
this.lassoTrail.addPointToPath(pointerCoords.x, pointerCoords.y);
this.setState((prevState) => {
const elements = [...intersectedElementIds, ...enclosedElementIds];
const nextSelectedElementIds = elements.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>);
const nextSelectedGroupIds = selectGroupsForSelectedElements(
{
selectedElementIds: nextSelectedElementIds,
editingGroupId: prevState.editingGroupId,
},
this.scene.getNonDeletedElements(),
prevState,
this,
);
// TODO: not entirely correct (need to select all elements in group instead)
for (const [id, selected] of Object.entries(nextSelectedElementIds)) {
if (selected) {
const element = this.scene.getNonDeletedElement(id);
if (element && element.groupIds.length > 0) {
delete nextSelectedElementIds[id];
}
}
}
// TODO: make elegant and decide if all children are selected, do we keep?
for (const [id, selected] of Object.entries(nextSelectedElementIds)) {
if (selected) {
const element = this.scene.getNonDeletedElement(id);
if (element && isFrameLikeElement(element)) {
const elementsInFrame = getFrameChildren(
elementsMap,
element.id,
);
for (const child of elementsInFrame) {
delete nextSelectedElementIds[child.id];
}
}
}
}
return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
selectedGroupIds: nextSelectedGroupIds.selectedGroupIds,
};
});
} else { } else {
// It is very important to read this.state within each move event, // It is very important to read this.state within each move event,
// otherwise we would read a stale one! // otherwise we would read a stale one!
@@ -8588,6 +8678,7 @@ class App extends React.Component<AppProps, AppState> {
elements, elements,
this.state.selectionElement, this.state.selectionElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
false,
) )
: []; : [];
@@ -8705,6 +8796,8 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null, originSnapOffset: null,
})); }));
this.lassoTrail.endPath();
this.lastPointerMoveCoords = null; this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null); SnapCache.setReferenceSnapPoints(null);
@@ -8871,6 +8964,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (isImageElement(newElement)) { if (isImageElement(newElement)) {
const imageElement = newElement; const imageElement = newElement;
try { try {
@@ -9016,6 +9110,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementsMapIncludingDeleted(), this.scene.getElementsMapIncludingDeleted(),
elementsInsideFrame, elementsInsideFrame,
newElement, newElement,
this.state,
), ),
); );
} }
@@ -9133,6 +9228,7 @@ class App extends React.Component<AppProps, AppState> {
nextElements, nextElements,
elementsToAdd, elementsToAdd,
topLayerFrame, topLayerFrame,
this.state,
); );
} else if (!topLayerFrame) { } else if (!topLayerFrame) {
if (this.state.editingGroupId) { if (this.state.editingGroupId) {
@@ -219,6 +219,7 @@ const LayerUI = ({
appState={appState} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app}
/> />
</Island> </Island>
</Section> </Section>
@@ -179,6 +179,7 @@ export const MobileMenu = ({
appState={appState} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app}
/> />
</Section> </Section>
) : null} ) : null}
@@ -31,6 +31,7 @@ import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping"; import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement"; import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math"; import { round } from "../../../math";
import { frameAndChildrenSelectedTogether } from "../../frame";
interface StatsProps { interface StatsProps {
app: AppClassProperties; app: AppClassProperties;
@@ -170,6 +171,10 @@ export const StatsInner = memo(
return getAtomicUnits(selectedElements, appState); return getAtomicUnits(selectedElements, appState);
}, [selectedElements, appState]); }, [selectedElements, appState]);
const _frameAndChildrenSelectedTogether = useMemo(() => {
return frameAndChildrenSelectedTogether(selectedElements);
}, [selectedElements]);
return ( return (
<div className="exc-stats"> <div className="exc-stats">
<Island padding={3}> <Island padding={3}>
@@ -226,7 +231,7 @@ export const StatsInner = memo(
{renderCustomStats?.(elements, appState)} {renderCustomStats?.(elements, appState)}
</Collapsible> </Collapsible>
{selectedElements.length > 0 && ( {!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
<div <div
id="elementStats" id="elementStats"
style={{ style={{
+135 -129
View File
@@ -55,146 +55,152 @@ type ToolButtonProps =
onPointerDown?(data: { pointerType: PointerType }): void; onPointerDown?(data: { pointerType: PointerType }): void;
}); });
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { export const ToolButton = React.forwardRef(
const { id: excalId } = useExcalidrawContainer(); (
const innerRef = React.useRef(null); {
React.useImperativeHandle(ref, () => innerRef.current); size = "medium",
const sizeCn = `ToolIcon_size_${props.size}`; visible = true,
className = "",
...props
}: ToolButtonProps,
ref,
) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${size}`;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const onClick = async (event: React.MouseEvent) => { const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event); const ret = "onClick" in props && props.onClick?.(event);
if (isPromiseLike(ret)) { if (isPromiseLike(ret)) {
try { try {
setIsLoading(true); setIsLoading(true);
await ret; await ret;
} catch (error: any) { } catch (error: any) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
throw error; throw error;
} else { } else {
console.warn(error); console.warn(error);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsLoading(false); setIsLoading(false);
}
} }
} }
}
};
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
}; };
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null); useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return (
<button
className={clsx(
"ToolIcon_type_button",
sizeCn,
className,
visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}
aria-label={props["aria-label"]}
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return ( return (
<button <label
className={clsx( className={clsx("ToolIcon", className)}
"ToolIcon_type_button",
sizeCn,
props.className,
props.visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title} title={props.title}
aria-label={props["aria-label"]} onPointerDown={(event) => {
type={type} lastPointerTypeRef.current = event.pointerType || null;
onClick={onClick} props.onPointerDown?.({ pointerType: event.pointerType || null });
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
return (
<label
className={clsx("ToolIcon", props.className)}
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<input
className={`ToolIcon_type_radio ${sizeCn}`}
type="radio"
name={props.name}
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
onChange={() => {
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}} }}
checked={props.checked} onPointerUp={() => {
ref={innerRef} requestAnimationFrame(() => {
/> lastPointerTypeRef.current = null;
<div className="ToolIcon__icon"> });
{props.icon} }}
{props.keyBindingLabel && ( >
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span> <input
)} className={`ToolIcon_type_radio ${sizeCn}`}
</div> type="radio"
</label> name={props.name}
); aria-label={props["aria-label"]}
}); aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
ToolButton.defaultProps = { id={`${excalId}-${props.id}`}
visible: true, onChange={() => {
className: "", props.onChange?.({ pointerType: lastPointerTypeRef.current });
size: "medium", }}
}; checked={props.checked}
ref={innerRef}
/>
<div className="ToolIcon__icon">
{props.icon}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
</div>
</label>
);
},
);
ToolButton.displayName = "ToolButton"; ToolButton.displayName = "ToolButton";
@@ -171,15 +171,17 @@ export const Hyperlink = ({
}, [handleSubmit]); }, [handleSubmit]);
useEffect(() => { useEffect(() => {
let timeoutId: number | null = null;
if ( if (
inputRef && isEditing &&
inputRef.current && inputRef?.current &&
!(device.viewport.isMobile || device.isTouchScreen) !(device.viewport.isMobile || device.isTouchScreen)
) { ) {
inputRef.current.select(); inputRef.current.select();
} }
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
useEffect(() => {
let timeoutId: number | null = null;
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (isEditing) { if (isEditing) {
@@ -207,15 +209,7 @@ export const Hyperlink = ({
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}; };
}, [ }, [appState, element, isEditing, setAppState, elementsMap]);
appState,
element,
isEditing,
setAppState,
elementsMap,
device.viewport.isMobile,
device.isTouchScreen,
]);
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete"); trackEvent("hyperlink", "delete");
+1
View File
@@ -417,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites // use these constants to easily identify reference sites
export const TOOL_TYPE = { export const TOOL_TYPE = {
selection: "selection", selection: "selection",
lassoSelection: "lassoSelection",
rectangle: "rectangle", rectangle: "rectangle",
diamond: "diamond", diamond: "diamond",
ellipse: "ellipse", ellipse: "ellipse",
+105
View File
@@ -0,0 +1,105 @@
import { validateLibraryUrl } from "./library";
describe("validateLibraryUrl", () => {
it("should validate hostname & pathname", () => {
// valid hostnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", [
"library.excalidraw.com",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]),
).toBe(true);
// valid pathnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path/",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/other", [
"excalidraw.com/specific/path",
]),
).toBe(true);
// invalid hostnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// protocol must be https
expect(() =>
validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// invalid pathnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/other/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/paths", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/path-s", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/some/specific/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
});
});
+34 -12
View File
@@ -36,7 +36,18 @@ import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element"; import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url"; import { toValidURL } from "./url";
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"]; /**
* format: hostname or hostname/pathname
*
* Both hostname and pathname are matched partially,
* hostname from the end, pathname from the start, with subdomain/path
* boundaries
**/
const ALLOWED_LIBRARY_URLS = [
"excalidraw.com",
// when installing from github PRs
"raw.githubusercontent.com/excalidraw/excalidraw-libraries",
];
type LibraryUpdate = { type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */ /** deleted library items since last onLibraryChange event */
@@ -469,26 +480,37 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements; return resElements;
}; };
const validateLibraryUrl = ( export const validateLibraryUrl = (
libraryUrl: string, libraryUrl: string,
/** /**
* If supplied, takes precedence over the default whitelist. * @returns `true` if the URL is valid, throws otherwise.
* Return `true` if the URL is valid.
*/ */
validator?: (libraryUrl: string) => boolean, validator:
): boolean => { | ((libraryUrl: string) => boolean)
| string[] = ALLOWED_LIBRARY_URLS,
): true => {
if ( if (
validator typeof validator === "function"
? validator(libraryUrl) ? validator(libraryUrl)
: ALLOWED_LIBRARY_HOSTNAMES.includes( : validator.some((allowedUrlDef) => {
new URL(libraryUrl).hostname.split(".").slice(-2).join("."), const allowedUrl = new URL(
) `https://${allowedUrlDef.replace(/^https?:\/\//, "")}`,
);
const { hostname, pathname } = new URL(libraryUrl);
return (
new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) &&
new RegExp(
`^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`,
).test(pathname)
);
})
) { ) {
return true; return true;
} }
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`); throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
throw new Error("Invalid or disallowed library URL");
}; };
export const parseLibraryTokensFromUrl = () => { export const parseLibraryTokensFromUrl = () => {
+1
View File
@@ -70,6 +70,7 @@ export const AllowedExcalidrawActiveTools: Record<
boolean boolean
> = { > = {
selection: true, selection: true,
lassoSelection: true,
text: true, text: true,
rectangle: true, rectangle: true,
diamond: true, diamond: true,
+10
View File
@@ -15,6 +15,7 @@ import { generateRoughOptions } from "../scene/Shape";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
@@ -324,6 +325,15 @@ export const getElementLineSegments = (
]; ];
} }
if (isFrameLikeElement(element)) {
return [
lineSegment(nw, ne),
lineSegment(ne, se),
lineSegment(se, sw),
lineSegment(sw, nw),
];
}
return [ return [
lineSegment(nw, ne), lineSegment(nw, ne),
lineSegment(sw, se), lineSegment(sw, se),
+14
View File
@@ -909,6 +909,20 @@ export const updateElbowArrowPoints = (
); );
} }
// 0. During all element replacement in the scene, we just need to renormalize
// the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
if (elementsMap.size === 0 && updates.points) {
return normalizeArrowElementUpdate(
updates.points.map((p) =>
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
),
arrow.fixedSegments,
arrow.startIsSpecial,
arrow.endIsSpecial,
);
}
const updatedPoints: readonly LocalPoint[] = updates.points const updatedPoints: readonly LocalPoint[] = updates.points
? updates.points && updates.points.length === 2 ? updates.points && updates.points.length === 2
? arrow.points.map((p, idx) => ? arrow.points.map((p, idx) =>
+12 -4
View File
@@ -254,6 +254,9 @@ const addNewNode = (
backgroundColor: element.backgroundColor, backgroundColor: element.backgroundColor,
strokeColor: element.strokeColor, strokeColor: element.strokeColor,
strokeWidth: element.strokeWidth, strokeWidth: element.strokeWidth,
opacity: element.opacity,
fillStyle: element.fillStyle,
strokeStyle: element.strokeStyle,
}); });
invariant( invariant(
@@ -329,6 +332,9 @@ export const addNewNodes = (
backgroundColor: startNode.backgroundColor, backgroundColor: startNode.backgroundColor,
strokeColor: startNode.strokeColor, strokeColor: startNode.strokeColor,
strokeWidth: startNode.strokeWidth, strokeWidth: startNode.strokeWidth,
opacity: startNode.opacity,
fillStyle: startNode.fillStyle,
strokeStyle: startNode.strokeStyle,
}); });
invariant( invariant(
@@ -416,11 +422,13 @@ const createBindingArrow = (
type: "arrow", type: "arrow",
x: startX, x: startX,
y: startY, y: startY,
startArrowhead: appState.currentItemStartArrowhead, startArrowhead: null,
endArrowhead: appState.currentItemEndArrowhead, endArrowhead: appState.currentItemEndArrowhead,
strokeColor: appState.currentItemStrokeColor, strokeColor: startBindingElement.strokeColor,
strokeStyle: appState.currentItemStrokeStyle, strokeStyle: startBindingElement.strokeStyle,
strokeWidth: appState.currentItemStrokeWidth, strokeWidth: startBindingElement.strokeWidth,
opacity: startBindingElement.opacity,
roughness: startBindingElement.roughness,
points: [pointFrom(0, 0), pointFrom(endX, endY)], points: [pointFrom(0, 0), pointFrom(endX, endY)],
elbowed: true, elbowed: true,
}); });
+214 -44
View File
@@ -95,12 +95,11 @@ export const getElementsCompletelyInFrame = (
); );
export const isElementContainingFrame = ( export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[],
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return getElementsWithinSelection(elements, element, elementsMap).some( return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
); );
}; };
@@ -144,7 +143,7 @@ export const elementOverlapsWithFrame = (
return ( return (
elementsAreInFrameBounds([element], frame, elementsMap) || elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) || isElementIntersectingFrame(element, frame, elementsMap) ||
isElementContainingFrame([frame], element, frame, elementsMap) isElementContainingFrame(element, frame, elementsMap)
); );
}; };
@@ -283,7 +282,7 @@ export const getElementsInResizingFrame = (
const elementsCompletelyInFrame = new Set([ const elementsCompletelyInFrame = new Set([
...getElementsCompletelyInFrame(allElements, frame, elementsMap), ...getElementsCompletelyInFrame(allElements, frame, elementsMap),
...prevElementsInFrame.filter((element) => ...prevElementsInFrame.filter((element) =>
isElementContainingFrame(allElements, element, frame, elementsMap), isElementContainingFrame(element, frame, elementsMap),
), ),
]); ]);
@@ -370,12 +369,57 @@ export const getElementsInNewFrame = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return omitGroupsContainingFrameLikes( return omitPartialGroups(
elements, omitGroupsContainingFrameLikes(
getElementsCompletelyInFrame(elements, frame, elementsMap), elements,
getElementsCompletelyInFrame(elements, frame, elementsMap),
),
frame,
elementsMap,
); );
}; };
export const omitPartialGroups = (
elements: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
allElementsMap: ElementsMap,
) => {
const elementsToReturn = [];
const checkedGroups = new Map<string, boolean>();
for (const element of elements) {
let shouldOmit = false;
if (element.groupIds.length > 0) {
// if some partial group should be omitted, then all elements in that group should be omitted
if (element.groupIds.some((gid) => checkedGroups.get(gid))) {
shouldOmit = true;
} else {
const allElementsInGroup = new Set(
element.groupIds.flatMap((gid) =>
getElementsInGroup(allElementsMap, gid),
),
);
shouldOmit = !elementsAreInFrameBounds(
Array.from(allElementsInGroup),
frame,
allElementsMap,
);
}
element.groupIds.forEach((gid) => {
checkedGroups.set(gid, shouldOmit);
});
}
if (!shouldOmit) {
elementsToReturn.push(element);
}
}
return elementsToReturn;
};
export const getContainingFrame = ( export const getContainingFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
@@ -454,6 +498,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T, allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => { ): T => {
const elementsMap = arrayToMap(allElements); const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>(); const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
@@ -489,6 +534,17 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue; continue;
} }
// if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) { if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element); finalElementsToAdd.push(element);
} }
@@ -577,6 +633,7 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
removeAllElementsFromFrame(allElements, frame), removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame, nextElementsInFrame,
frame, frame,
app.state,
).slice(); ).slice();
}; };
@@ -683,6 +740,16 @@ export const getTargetFrame = (
? getContainerElement(element, elementsMap) || element ? getContainerElement(element, elementsMap) || element
: element; : element;
// if the element and its containing frame are both selected, then
// the containing frame is the target frame
if (
_element.frameId &&
appState.selectedElementIds[_element.id] &&
appState.selectedElementIds[_element.frameId]
) {
return getContainingFrame(_element, elementsMap);
}
return appState.selectedElementIds[_element.id] && return appState.selectedElementIds[_element.id] &&
appState.selectedElementsAreBeingDragged appState.selectedElementsAreBeingDragged
? appState.frameToHighlight ? appState.frameToHighlight
@@ -695,61 +762,151 @@ export const isElementInFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
allElementsMap: ElementsMap, allElementsMap: ElementsMap,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
opts?: {
targetFrame?: ExcalidrawFrameLikeElement;
checkedGroups?: Map<string, boolean>;
},
) => { ) => {
const frame = getTargetFrame(element, allElementsMap, appState); const frame =
opts?.targetFrame ?? getTargetFrame(element, allElementsMap, appState);
if (!frame) {
return false;
}
const _element = isTextElement(element) const _element = isTextElement(element)
? getContainerElement(element, allElementsMap) || element ? getContainerElement(element, allElementsMap) || element
: element; : element;
if (frame) { const setGroupsInFrame = (isInFrame: boolean) => {
// Perf improvement: if (opts?.checkedGroups) {
// For an element that's already in a frame, if it's not being dragged _element.groupIds.forEach((groupId) => {
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame. opts.checkedGroups?.set(groupId, isInFrame);
// It has to be in its containing frame. });
if ( }
!appState.selectedElementIds[element.id] || };
!appState.selectedElementsAreBeingDragged
) { if (
// if the element is not selected, or it is selected but not being dragged,
// frame membership won't update, so return true
!appState.selectedElementIds[_element.id] ||
!appState.selectedElementsAreBeingDragged ||
// if both frame and element are selected, won't update membership, so return true
(appState.selectedElementIds[_element.id] &&
appState.selectedElementIds[frame.id])
) {
return true;
}
if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame, allElementsMap);
}
for (const gid of _element.groupIds) {
if (opts?.checkedGroups?.has(gid)) {
return opts.checkedGroups.get(gid)!!;
}
}
const allElementsInGroup = new Set(
_element.groupIds
.filter((gid) => {
if (opts?.checkedGroups) {
return !opts.checkedGroups.has(gid);
}
return true;
})
.flatMap((gid) => getElementsInGroup(allElementsMap, gid)),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
const selectedElements = new Set(
getSelectedElements(allElementsMap, appState),
);
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
if (editingGroupOverlapsFrame) {
return true; return true;
} }
if (_element.groupIds.length === 0) { selectedElements.forEach((selectedElement) => {
return elementOverlapsWithFrame(_element, frame, allElementsMap); allElementsInGroup.delete(selectedElement);
});
}
for (const elementInGroup of allElementsInGroup) {
if (isFrameLikeElement(elementInGroup)) {
setGroupsInFrame(false);
return false;
}
}
for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
setGroupsInFrame(true);
return true;
}
}
return false;
};
export const shouldApplyFrameClip = (
element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement,
appState: StaticCanvasAppState,
elementsMap: ElementsMap,
checkedGroups?: Map<string, boolean>,
) => {
if (!appState.frameRendering || !appState.frameRendering.clip) {
return false;
}
// for individual elements, only clip when the element is
// a. overlapping with the frame, or
// b. containing the frame, for example when an element is used as a background
// and is therefore bigger than the frame and completely contains the frame
const shouldClipElementItself =
isElementIntersectingFrame(element, frame, elementsMap) ||
isElementContainingFrame(element, frame, elementsMap);
if (shouldClipElementItself) {
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, true);
} }
const allElementsInGroup = new Set( return true;
_element.groupIds.flatMap((gid) => }
getElementsInGroup(allElementsMap, gid),
),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) { // if an element is outside the frame, but is part of a group that has some elements
const selectedElements = new Set( // "in" the frame, we should clip the element
getSelectedElements(allElementsMap, appState), if (
); !shouldClipElementItself &&
element.groupIds.length > 0 &&
!elementsAreInFrameBounds([element], frame, elementsMap)
) {
let shouldClip = false;
const editingGroupOverlapsFrame = appState.frameToHighlight !== null; // if no elements are being dragged, we can skip the geometry check
// because we know if the element is in the given frame or not
if (editingGroupOverlapsFrame) { if (!appState.selectedElementsAreBeingDragged) {
return true; shouldClip = element.frameId === frame.id;
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, shouldClip);
} }
} else {
selectedElements.forEach((selectedElement) => { shouldClip = isElementInFrame(element, elementsMap, appState, {
allElementsInGroup.delete(selectedElement); targetFrame: frame,
checkedGroups,
}); });
} }
for (const elementInGroup of allElementsInGroup) { for (const groupId of element.groupIds) {
if (isFrameLikeElement(elementInGroup)) { checkedGroups?.set(groupId, shouldClip);
return false;
}
} }
for (const elementInGroup of allElementsInGroup) { return shouldClip;
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
return true;
}
}
} }
return false; return false;
@@ -779,3 +936,16 @@ export const getElementsOverlappingFrame = (
.filter((el) => !el.frameId || el.frameId === frame.id) .filter((el) => !el.frameId || el.frameId === frame.id)
); );
}; };
export const frameAndChildrenSelectedTogether = (
selectedElements: readonly ExcalidrawElement[],
) => {
const selectedElementsMap = arrayToMap(selectedElements);
return (
selectedElements.length > 1 &&
selectedElements.some(
(element) => element.frameId && selectedElementsMap.has(element.frameId),
)
);
};
+308
View File
@@ -0,0 +1,308 @@
/**
* all things related to lasso selection
* - lasso selection
* - intersection and enclosure checks
*/
import {
type GlobalPoint,
type LineSegment,
type LocalPoint,
type Polygon,
pointFrom,
pointsEqual,
polygonFromPoints,
segmentsIntersectAt,
} from "../math";
import { isPointInShape } from "../utils/collision";
import {
type GeometricShape,
polylineFromPoints,
} from "../utils/geometry/shape";
import { AnimatedTrail } from "./animated-trail";
import { type AnimationFrameHandler } from "./animation-frame-handler";
import type App from "./components/App";
import { getElementLineSegments } from "./element/bounds";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import type { InteractiveCanvasRenderConfig } from "./scene/types";
import type { InteractiveCanvasAppState } from "./types";
import { easeOut } from "./utils";
export type LassoPath = {
x: number;
y: number;
points: LocalPoint[];
intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
};
export const renderLassoSelection = (
lassoPath: LassoPath,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => {
context.save();
context.translate(
lassoPath.x + appState.scrollX,
lassoPath.y + appState.scrollY,
);
const firstPoint = lassoPath.points[0];
if (firstPoint) {
context.beginPath();
context.moveTo(firstPoint[0], firstPoint[1]);
for (let i = 1; i < lassoPath.points.length; i++) {
context.lineTo(lassoPath.points[i][0], lassoPath.points[i][1]);
}
context.strokeStyle = selectionColor;
context.lineWidth = 3 / appState.zoom.value;
if (
lassoPath.points.length >= 3 &&
pointsEqual(
lassoPath.points[0],
lassoPath.points[lassoPath.points.length - 1],
)
) {
context.closePath();
}
context.stroke();
}
context.restore();
};
// export class LassoSelection {
// static createLassoPath = (x: number, y: number): LassoPath => {
// return {
// x,
// y,
// points: [],
// intersectedElements: new Set(),
// enclosedElements: new Set(),
// };
// };
// static updateLassoPath = (
// lassoPath: LassoPath,
// pointerCoords: { x: number; y: number },
// elementsMap: ElementsMap,
// ): LassoPath => {
// const points = lassoPath.points;
// const dx = pointerCoords.x - lassoPath.x;
// const dy = pointerCoords.y - lassoPath.y;
// const lastPoint = points.length > 0 && points[points.length - 1];
// const discardPoint =
// lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
// if (!discardPoint) {
// const nextLassoPath = {
// ...lassoPath,
// points: [...points, pointFrom<LocalPoint>(dx, dy)],
// };
// // nextLassoPath.enclosedElements.clear();
// // const enclosedLassoPath = LassoSelection.closeLassoPath(
// // nextLassoPath,
// // elementsMap,
// // );
// // for (const [id, element] of elementsMap) {
// // if (!lassoPath.intersectedElements.has(element.id)) {
// // const intersects = intersect(nextLassoPath, element, elementsMap);
// // if (intersects) {
// // lassoPath.intersectedElements.add(element.id);
// // } else {
// // // check if the lasso path encloses the element
// // const enclosed = enclose(enclosedLassoPath, element, elementsMap);
// // if (enclosed) {
// // lassoPath.enclosedElements.add(element.id);
// // }
// // }
// // }
// // }
// return nextLassoPath;
// }
// return lassoPath;
// };
// private static closeLassoPath = (
// lassoPath: LassoPath,
// elementsMap: ElementsMap,
// ) => {
// const finalPoints = [...lassoPath.points, lassoPath.points[0]];
// // TODO: check if the lasso path encloses or intersects with any element
// const finalLassoPath = {
// ...lassoPath,
// points: finalPoints,
// };
// return finalLassoPath;
// };
// static finalizeLassoPath = (
// lassoPath: LassoPath,
// elementsMap: ElementsMap,
// ) => {
// const enclosedLassoPath = LassoSelection.closeLassoPath(
// lassoPath,
// elementsMap,
// );
// enclosedLassoPath.enclosedElements.clear();
// enclosedLassoPath.intersectedElements.clear();
// // for (const [id, element] of elementsMap) {
// // const intersects = intersect(enclosedLassoPath, element, elementsMap);
// // if (intersects) {
// // enclosedLassoPath.intersectedElements.add(element.id);
// // } else {
// // const enclosed = enclose(enclosedLassoPath, element, elementsMap);
// // if (enclosed) {
// // enclosedLassoPath.enclosedElements.add(element.id);
// // }
// // }
// // }
// return enclosedLassoPath;
// };
// }
const intersectionTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const elementLineSegments = getElementLineSegments(element, elementsMap);
const lassoSegments = lassoPath.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
const prevPoint = pointFrom<GlobalPoint>(
lassoPath[index - 1][0],
lassoPath[index - 1][1],
);
const currentPoint = pointFrom<GlobalPoint>(point[0], point[1]);
acc.push([prevPoint, currentPoint] as LineSegment<GlobalPoint>);
return acc;
}, [] as LineSegment<GlobalPoint>[]);
for (const lassoSegment of lassoSegments) {
for (const elementSegment of elementLineSegments) {
if (segmentsIntersectAt(lassoSegment, elementSegment)) {
return true;
}
}
}
return false;
};
const enclosureTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const polyline = polylineFromPoints(lassoPath);
const closedPathShape: GeometricShape<GlobalPoint> = {
type: "polygon",
data: polygonFromPoints(polyline.flat()),
} as {
type: "polygon";
data: Polygon<GlobalPoint>;
};
const elementSegments = getElementLineSegments(element, elementsMap);
for (const segment of elementSegments) {
if (segment.some((point) => isPointInShape(point, closedPathShape))) {
return true;
}
}
return false;
};
export class LassoTrail extends AnimatedTrail {
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, {
simplify: 0,
streamline: 0.4,
sizeMapping: (c) => {
const DECAY_TIME = Infinity;
const DECAY_LENGTH = 5000;
const t = Math.max(
0,
1 - (performance.now() - c.pressure) / DECAY_TIME,
);
const l =
(DECAY_LENGTH -
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
DECAY_LENGTH;
return Math.min(easeOut(l), easeOut(t));
},
fill: () => "rgb(0,118,255)",
});
}
startPath(x: number, y: number) {
super.startPath(x, y);
this.intersectedElements.clear();
this.enclosedElements.clear();
}
addPointToPath(x: number, y: number) {
super.addPointToPath(x, y);
const lassoPath = super
.getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1]));
if (lassoPath) {
// TODO: further OPT: do not check elements that are "far away"
const elementsMap = this.app.scene.getNonDeletedElementsMap();
const closedPath = polygonFromPoints(lassoPath);
// need to clear the enclosed elements as path might change
this.enclosedElements.clear();
for (const [, element] of elementsMap) {
if (!this.intersectedElements.has(element.id)) {
const intersects = intersectionTest(lassoPath, element, elementsMap);
if (intersects) {
this.intersectedElements.add(element.id);
} else {
// TODO: check bounding box is at least in the lasso path area first
// BUT: need to compare bounding box check with enclosure check performance
const enclosed = enclosureTest(closedPath, element, elementsMap);
if (enclosed) {
this.enclosedElements.add(element.id);
}
}
}
}
}
return {
intersectedElementIds: this.intersectedElements,
enclosedElementIds: this.enclosedElements,
};
}
endPath(): void {
super.endPath();
super.clearTrails();
this.intersectedElements.clear();
this.enclosedElements.clear();
}
}
@@ -85,6 +85,7 @@ import {
type Radians, type Radians,
} from "../../math"; } from "../../math";
import { getCornerRadius } from "../shapes"; import { getCornerRadius } from "../shapes";
import { renderLassoSelection } from "../lasso";
const renderElbowArrowMidPointHighlight = ( const renderElbowArrowMidPointHighlight = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
+23 -5
View File
@@ -4,7 +4,7 @@ import { getElementAbsoluteCoords } from "../element";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getTargetFrame, getTargetFrame,
isElementInFrame, shouldApplyFrameClip,
} from "../frame"; } from "../frame";
import { import {
isEmbeddableElement, isEmbeddableElement,
@@ -273,6 +273,8 @@ const _renderStaticScene = ({
} }
}); });
const inFrameGroupsMap = new Map<string, boolean>();
// Paint visible elements // Paint visible elements
visibleElements visibleElements
.filter((el) => !isIframeLikeElement(el)) .filter((el) => !isIframeLikeElement(el))
@@ -297,9 +299,16 @@ const _renderStaticScene = ({
appState.frameRendering.clip appState.frameRendering.clip
) { ) {
const frame = getTargetFrame(element, elementsMap, appState); const frame = getTargetFrame(element, elementsMap, appState);
if (
// TODO do we need to check isElementInFrame here? frame &&
if (frame && isElementInFrame(element, elementsMap, appState)) { shouldApplyFrameClip(
element,
frame,
appState,
elementsMap,
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState); frameClip(frame, context, renderConfig, appState);
} }
renderElement( renderElement(
@@ -400,7 +409,16 @@ const _renderStaticScene = ({
const frame = getTargetFrame(element, elementsMap, appState); const frame = getTargetFrame(element, elementsMap, appState);
if (frame && isElementInFrame(element, elementsMap, appState)) { if (
frame &&
shouldApplyFrameClip(
element,
frame,
appState,
elementsMap,
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState); frameClip(frame, context, renderConfig, appState);
} }
render(); render();
+5 -2
View File
@@ -183,10 +183,12 @@ export const getSelectedElements = (
includeElementsInFrames?: boolean; includeElementsInFrames?: boolean;
}, },
) => { ) => {
const addedElements = new Set<ExcalidrawElement["id"]>();
const selectedElements: ExcalidrawElement[] = []; const selectedElements: ExcalidrawElement[] = [];
for (const element of elements.values()) { for (const element of elements.values()) {
if (appState.selectedElementIds[element.id]) { if (appState.selectedElementIds[element.id]) {
selectedElements.push(element); selectedElements.push(element);
addedElements.add(element.id);
continue; continue;
} }
if ( if (
@@ -195,6 +197,7 @@ export const getSelectedElements = (
appState.selectedElementIds[element?.containerId] appState.selectedElementIds[element?.containerId]
) { ) {
selectedElements.push(element); selectedElements.push(element);
addedElements.add(element.id);
continue; continue;
} }
} }
@@ -203,8 +206,8 @@ export const getSelectedElements = (
const elementsToInclude: ExcalidrawElement[] = []; const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach((e) => getFrameChildren(elements, element.id).forEach(
elementsToInclude.push(e), (e) => !addedElements.has(e.id) && elementsToInclude.push(e),
); );
} }
elementsToInclude.push(element); elementsToInclude.push(element);
@@ -10896,12 +10896,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [],
{
"id": "6Rm4g567UQM4WjLwej2Vc",
"type": "arrow",
},
],
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -10909,7 +10904,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"height": 126, "height": 126,
"id": "KPrBI4g_v9qUB1XxYLgSz", "id": "KPrBI4g_v9qUB1XxYLgSz",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": true,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -10922,7 +10917,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"width": 157, "width": 157,
"x": 600, "x": 600,
"y": 0, "y": 0,
@@ -10933,12 +10928,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [],
{
"id": "6Rm4g567UQM4WjLwej2Vc",
"type": "arrow",
},
],
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -10946,7 +10936,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"height": 129, "height": 129,
"id": "u2JGnnmoJ0VATV4vCNJE5", "id": "u2JGnnmoJ0VATV4vCNJE5",
"index": "a1", "index": "a1",
"isDeleted": false, "isDeleted": true,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -10959,7 +10949,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"width": 124, "width": 124,
"x": 1152, "x": 1152,
"y": 516, "y": 516,
@@ -10983,15 +10973,15 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00161", "focus": "-0.00161",
"gap": "3.53708", "gap": "3.53708",
}, },
"endIsSpecial": null, "endIsSpecial": false,
"fillStyle": "solid", "fillStyle": "solid",
"fixedSegments": null, "fixedSegments": [],
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "448.10100", "height": "236.10000",
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": true,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
@@ -11002,16 +10992,12 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
0, 0,
], ],
[ [
"225.95000", "178.90000",
0, 0,
], ],
[ [
"225.95000", "178.90000",
"448.10100", "236.10000",
],
[
"451.90000",
"448.10100",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -11028,16 +11014,16 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00159", "focus": "-0.00159",
"gap": 5, "gap": 5,
}, },
"startIsSpecial": null, "startIsSpecial": false,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 3,
"width": "451.90000", "width": "178.90000",
"x": 762, "x": 1035,
"y": "62.90000", "y": "274.90000",
} }
`; `;
@@ -11049,8 +11035,7 @@ History {
[Function], [Function],
], ],
}, },
"redoStack": [], "redoStack": [
"undoStack": [
HistoryEntry { HistoryEntry {
"appStateChange": AppStateChange { "appStateChange": AppStateChange {
"delta": Delta { "delta": Delta {
@@ -11059,86 +11044,12 @@ History {
}, },
}, },
"elementsChange": ElementsChange { "elementsChange": ElementsChange {
"added": Map {}, "added": Map {
"removed": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 126,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"width": 157,
"x": 873,
"y": 212,
},
"inserted": {
"isDeleted": true,
},
},
"u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 129,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "diamond",
"width": 124,
"x": 1152,
"y": 516,
},
"inserted": {
"isDeleted": true,
},
},
},
"updated": Map {},
},
},
HistoryEntry {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {},
"inserted": {},
},
},
"elementsChange": ElementsChange {
"added": Map {},
"removed": Map {
"6Rm4g567UQM4WjLwej2Vc" => Delta { "6Rm4g567UQM4WjLwej2Vc" => Delta {
"deleted": { "deleted": {
"isDeleted": true,
},
"inserted": {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
@@ -11203,14 +11114,15 @@ History {
"x": 1035, "x": 1035,
"y": "274.90000", "y": "274.90000",
}, },
"inserted": {
"isDeleted": true,
},
}, },
}, },
"removed": Map {},
"updated": Map { "updated": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta { "KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": { "deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [ "boundElements": [
{ {
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
@@ -11218,12 +11130,12 @@ History {
}, },
], ],
}, },
"inserted": {
"boundElements": [],
},
}, },
"u2JGnnmoJ0VATV4vCNJE5" => Delta { "u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": { "deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [ "boundElements": [
{ {
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
@@ -11231,14 +11143,88 @@ History {
}, },
], ],
}, },
"inserted": {
"boundElements": [],
},
}, },
}, },
}, },
}, },
HistoryEntry {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {},
"inserted": {},
},
},
"elementsChange": ElementsChange {
"added": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": {
"isDeleted": true,
},
"inserted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 126,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"width": 157,
"x": 600,
"y": 0,
},
},
"u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": {
"isDeleted": true,
},
"inserted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 129,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "diamond",
"width": 124,
"x": 1152,
"y": 516,
},
},
},
"removed": Map {},
"updated": Map {},
},
},
], ],
"undoStack": [],
} }
`; `;
@@ -233,7 +233,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"versionNonce": 1996028265, "versionNonce": 1051383431,
"width": 81, "width": 81,
"x": 110, "x": 110,
"y": 50, "y": 50,
+4 -5
View File
@@ -2077,16 +2077,15 @@ describe("history", () => {
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
Keyboard.redo(); Keyboard.undo();
const modifiedArrow = h.elements.filter( const modifiedArrow = h.elements.filter(
(el) => el.type === "arrow", (el) => el.type === "arrow",
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(modifiedArrow.points).toEqual([ expect(modifiedArrow.points).toCloselyEqualPoints([
[0, 0], [0, 0],
[225.95000000000005, 0], [178.9, 0],
[225.95000000000005, 448.10100010002003], [178.9, 236.1],
[451.9000000000001, 448.10100010002003],
]); ]);
}); });
+3
View File
@@ -119,6 +119,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type ToolType = export type ToolType =
| "selection" | "selection"
| "lassoSelection"
| "rectangle" | "rectangle"
| "diamond" | "diamond"
| "ellipse" | "ellipse"
@@ -408,6 +409,8 @@ export interface AppState {
croppingElementId: ExcalidrawElement["id"] | null; croppingElementId: ExcalidrawElement["id"] | null;
searchMatches: readonly SearchMatch[]; searchMatches: readonly SearchMatch[];
lassoSelectionEnabled: boolean;
} }
type SearchMatch = { type SearchMatch = {
+1 -1
View File
@@ -239,7 +239,7 @@ export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
}; };
}; };
const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>( export const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
points: Point[], points: Point[],
): Polyline<Point> => { ): Polyline<Point> => {
let previousPoint: Point = points[0]; let previousPoint: Point = points[0];