Compare commits

...

2 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
10 changed files with 424 additions and 4 deletions

View File

@ -20,7 +20,7 @@ export interface AnimatedTrailOptions {
}
export class AnimatedTrail implements Trail {
private currentTrail?: LaserPointer;
currentTrail?: LaserPointer;
private pastTrails: LaserPointer[] = [];
private container?: SVGSVGElement;
@ -28,7 +28,7 @@ export class AnimatedTrail implements Trail {
constructor(
private animationFrameHandler: AnimationFrameHandler,
private app: App,
protected app: App,
private options: Partial<LaserPointerOptions> &
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() {
this.start();
}

View File

@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
isCropping: false,
croppingElementId: null,
searchMatches: [],
lassoSelectionEnabled: false,
};
};
@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
lassoSelectionEnabled: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -465,6 +465,7 @@ import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
import { LassoTrail } from "../lasso";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -635,6 +636,8 @@ class App extends React.Component<AppProps, AppState> {
: "rgba(255, 255, 255, 0.2)",
});
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
onChangeEmitter = new Emitter<
[
elements: readonly ExcalidrawElement[],
@ -1607,7 +1610,11 @@ class App extends React.Component<AppProps, AppState> {
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<SVGLayer
trails={[this.laserTrails, this.eraserTrail]}
trails={[
this.laserTrails,
this.eraserTrail,
this.lassoTrail,
]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
@ -4515,6 +4522,14 @@ class App extends React.Component<AppProps, AppState> {
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 (
event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@ -6545,6 +6560,15 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type,
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") {
setCursorForShape(this.interactiveCanvas, this.state);
} else if (
@ -8461,6 +8485,63 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
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 {
// It is very important to read this.state within each move event,
// otherwise we would read a stale one!
@ -8715,6 +8796,8 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null,
}));
this.lassoTrail.endPath();
this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null);
@ -8881,6 +8964,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (isImageElement(newElement)) {
const imageElement = newElement;
try {

View File

@ -417,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
lassoSelection: "lassoSelection",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",

View File

@ -70,6 +70,7 @@ export const AllowedExcalidrawActiveTools: Record<
boolean
> = {
selection: true,
lassoSelection: true,
text: true,
rectangle: true,
diamond: true,

View File

@ -15,6 +15,7 @@ import { generateRoughOptions } from "../scene/Shape";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
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 [
lineSegment(nw, ne),
lineSegment(sw, se),

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();
}
}

View File

@ -85,6 +85,7 @@ import {
type Radians,
} from "../../math";
import { getCornerRadius } from "../shapes";
import { renderLassoSelection } from "../lasso";
const renderElbowArrowMidPointHighlight = (
context: CanvasRenderingContext2D,

View File

@ -119,6 +119,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type ToolType =
| "selection"
| "lassoSelection"
| "rectangle"
| "diamond"
| "ellipse"
@ -408,6 +409,8 @@ export interface AppState {
croppingElementId: ExcalidrawElement["id"] | null;
searchMatches: readonly SearchMatch[];
lassoSelectionEnabled: boolean;
}
type SearchMatch = {

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[],
): Polyline<Point> => {
let previousPoint: Point = points[0];