diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts index 97a005461..47121f21a 100644 --- a/packages/excalidraw/animated-trail.ts +++ b/packages/excalidraw/animated-trail.ts @@ -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 & Partial, ) { @@ -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(); } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 644949e7c..99b457091 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -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 = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f16f88657..acda78406 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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(null!); const AppPropsContext = React.createContext(null!); @@ -635,6 +636,8 @@ class App extends React.Component { : "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 {
{selectedElements.length === 1 && this.state.openDialog?.name !== @@ -4515,6 +4522,14 @@ class App extends React.Component { 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 { 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 { 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); + + 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 { originSnapOffset: null, })); + this.lassoTrail.endPath(); + this.lastPointerMoveCoords = null; SnapCache.setReferenceSnapPoints(null); @@ -8881,6 +8964,7 @@ class App extends React.Component { return; } + if (isImageElement(newElement)) { const imageElement = newElement; try { diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index b8f7e1b83..72e436dfe 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -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", diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 42695b413..4d3b632c4 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -70,6 +70,7 @@ export const AllowedExcalidrawActiveTools: Record< boolean > = { selection: true, + lassoSelection: true, text: true, rectangle: true, diamond: true, diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 19cde12d5..e891abc78 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -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), diff --git a/packages/excalidraw/lasso.ts b/packages/excalidraw/lasso.ts new file mode 100644 index 000000000..ff71ef1f2 --- /dev/null +++ b/packages/excalidraw/lasso.ts @@ -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; + enclosedElements: Set; +}; + +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(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( + lassoPath[index - 1][0], + lassoPath[index - 1][1], + ); + const currentPoint = pointFrom(point[0], point[1]); + acc.push([prevPoint, currentPoint] as LineSegment); + return acc; + }, [] as LineSegment[]); + + 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 = { + type: "polygon", + data: polygonFromPoints(polyline.flat()), + } as { + type: "polygon"; + data: Polygon; + }; + + 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 = new Set(); + private enclosedElements: Set = 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(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(); + } +} diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 3a070e667..aa8fd41c4 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -85,6 +85,7 @@ import { type Radians, } from "../../math"; import { getCornerRadius } from "../shapes"; +import { renderLassoSelection } from "../lasso"; const renderElbowArrowMidPointHighlight = ( context: CanvasRenderingContext2D, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index d1d1824f0..0b76f5b94 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -119,6 +119,7 @@ export type BinaryFiles = Record; 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 = { diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 4670b23ab..83355b2c4 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -239,7 +239,7 @@ export const getCurveShape = ( }; }; -const polylineFromPoints = ( +export const polylineFromPoints = ( points: Point[], ): Polyline => { let previousPoint: Point = points[0];