From 5cba71972e323ef980022646b15e46e1de5f9910 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 21 Feb 2025 17:45:54 +1100 Subject: [PATCH] lasso without 'real' shape detection --- excalidraw-app/vite.config.mts | 2 +- packages/excalidraw/animated-trail.ts | 52 ++- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/Actions.tsx | 23 +- packages/excalidraw/components/App.tsx | 44 +- packages/excalidraw/components/HintViewer.tsx | 4 +- .../components/canvases/InteractiveCanvas.tsx | 1 + packages/excalidraw/components/icons.tsx | 10 + packages/excalidraw/constants.ts | 1 + packages/excalidraw/data/restore.ts | 1 + .../element/showSelectedShapeActions.ts | 1 + packages/excalidraw/lasso/index.ts | 134 ++++++ packages/excalidraw/lasso/worker.ts | 393 ++++++++++++++++++ packages/excalidraw/locales/en.json | 2 + .../excalidraw/renderer/interactiveScene.ts | 14 +- packages/excalidraw/renderer/renderElement.ts | 29 +- packages/excalidraw/scene/comparisons.ts | 5 +- packages/excalidraw/tests/helpers/ui.ts | 5 +- packages/excalidraw/types.ts | 6 + 19 files changed, 706 insertions(+), 23 deletions(-) create mode 100644 packages/excalidraw/lasso/index.ts create mode 100644 packages/excalidraw/lasso/worker.ts diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index 2d18f8c06..35b390af1 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -169,7 +169,7 @@ export default defineConfig(({ mode }) => { }, ], start_url: "/", - id:"excalidraw", + id: "excalidraw", display: "standalone", theme_color: "#121212", background_color: "#ffffff", diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts index 97a005461..80cadaa98 100644 --- a/packages/excalidraw/animated-trail.ts +++ b/packages/excalidraw/animated-trail.ts @@ -17,6 +17,7 @@ export interface Trail { export interface AnimatedTrailOptions { fill: (trail: AnimatedTrail) => string; + animateTrail?: boolean; } export class AnimatedTrail implements Trail { @@ -25,16 +26,28 @@ export class AnimatedTrail implements Trail { private container?: SVGSVGElement; private trailElement: SVGPathElement; + private trailAnimation?: SVGAnimateElement; constructor( private animationFrameHandler: AnimationFrameHandler, - private app: App, + protected app: App, private options: Partial & Partial, ) { this.animationFrameHandler.register(this, this.onFrame.bind(this)); this.trailElement = document.createElementNS(SVG_NS, "path"); + if (this.options.animateTrail) { + this.trailAnimation = document.createElementNS(SVG_NS, "animate"); + // TODO: make this configurable + this.trailAnimation.setAttribute("attributeName", "stroke-dashoffset"); + this.trailElement.setAttribute("stroke-dasharray", "10 10"); + this.trailElement.setAttribute("stroke-dashoffset", "10"); + this.trailAnimation.setAttribute("from", "0"); + this.trailAnimation.setAttribute("to", `-20`); + this.trailAnimation.setAttribute("dur", "0.2s"); + this.trailElement.appendChild(this.trailAnimation); + } } get hasCurrentTrail() { @@ -98,8 +111,23 @@ export class AnimatedTrail implements Trail { } } + getCurrentTrail() { + return this.currentTrail; + } + + clearTrails() { + this.pastTrails = []; + this.currentTrail = undefined; + this.update(); + } + private update() { + this.pastTrails = []; this.start(); + if (this.trailAnimation) { + this.trailAnimation.setAttribute("begin", "indefinite"); + this.trailAnimation.setAttribute("repeatCount", "indefinite"); + } } private onFrame() { @@ -126,14 +154,22 @@ export class AnimatedTrail implements Trail { const svgPaths = paths.join(" ").trim(); this.trailElement.setAttribute("d", svgPaths); - this.trailElement.setAttribute( - "fill", - (this.options.fill ?? (() => "black"))(this), - ); + if (this.trailAnimation) { + this.trailElement.setAttribute("fill", "transparent"); + this.trailElement.setAttribute( + "stroke", + (this.options.fill ?? (() => "black"))(this), + ); + } else { + this.trailElement.setAttribute( + "fill", + (this.options.fill ?? (() => "black"))(this), + ); + } } private drawTrail(trail: LaserPointer, state: AppState): string { - const stroke = trail + const _stroke = trail .getStrokeOutline(trail.options.size / state.zoom.value) .map(([x, y]) => { const result = sceneCoordsToViewportCoords( @@ -144,6 +180,10 @@ export class AnimatedTrail implements Trail { return [result.x, result.y]; }); + const stroke = this.trailAnimation + ? _stroke.slice(0, _stroke.length / 2) + : _stroke; + return getSvgPathFromStroke(stroke, true); } } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 644949e7c..86ea779ad 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -88,6 +88,7 @@ export const getDefaultAppState = (): Omit< selectedGroupIds: {}, selectedElementsAreBeingDragged: false, selectionElement: null, + lassoSelection: null, shouldCacheIgnoreZoom: false, stats: { open: false, @@ -219,6 +220,7 @@ const APP_STATE_STORAGE_CONF = (< server: false, }, selectionElement: { browser: false, export: false, server: false }, + lassoSelection: { browser: false, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, stats: { browser: true, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false }, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 82deee1a8..319aef7ce 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -47,6 +47,7 @@ import { mermaidLogoIcon, laserPointerToolIcon, MagicIcon, + LassoIcon, } from "./icons"; import { KEYS } from "../keys"; import { useTunnels } from "../context/tunnels"; @@ -69,7 +70,6 @@ export const canChangeStrokeColor = ( return ( (hasStrokeColor(appState.activeTool.type) && - appState.activeTool.type !== "image" && commonSelectedType !== "image" && commonSelectedType !== "frame" && commonSelectedType !== "magicframe") || @@ -285,6 +285,8 @@ export const ShapesSwitcher = ({ const { TTDDialogTriggerTunnel } = useTunnels(); + const lasso = appState.activeTool.type === "lasso"; + return ( <> {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { @@ -302,13 +304,18 @@ export const ShapesSwitcher = ({ const shortcut = letter ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; + + const _icon = value === "selection" && lasso ? LassoIcon : icon; + const _fillable = value === "selection" && lasso ? false : fillable; return ( { if (appState.activeTool.type !== value) { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 326203359..c66fbf902 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -467,6 +467,7 @@ import { getApproxMinLineHeight, getMinTextElementWidth, } from "../element/textMeasurements"; +import { LassoTrail } from "../lasso"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -636,6 +637,7 @@ class App extends React.Component { ? "rgba(0, 0, 0, 0.2)" : "rgba(255, 255, 255, 0.2)", }); + lassoTrail = new LassoTrail(this.animationFrameHandler, this); onChangeEmitter = new Emitter< [ @@ -1613,7 +1615,11 @@ class App extends React.Component {
{selectedElements.length === 1 && this.state.openDialog?.name !== @@ -4528,6 +4534,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: "lasso" }); + } else { + this.setActiveTool({ type: "selection" }); + } + } + if ( event[KEYS.CTRL_OR_CMD] && (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) @@ -6516,6 +6530,7 @@ class App extends React.Component { !this.state.penMode || event.pointerType !== "touch" || this.state.activeTool.type === "selection" || + this.state.activeTool.type === "lasso" || this.state.activeTool.type === "text" || this.state.activeTool.type === "image"; @@ -6523,7 +6538,12 @@ class App extends React.Component { return; } - if (this.state.activeTool.type === "text") { + if (this.state.activeTool.type === "lasso") { + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + ); + } else if (this.state.activeTool.type === "text") { this.handleTextOnPointerDown(event, pointerDownState); } else if ( this.state.activeTool.type === "arrow" || @@ -6587,10 +6607,18 @@ class App extends React.Component { this.state.activeTool.type !== "eraser" && this.state.activeTool.type !== "hand" ) { - this.createGenericElementOnPointerDown( - this.state.activeTool.type, - pointerDownState, - ); + if (this.state.activeTool.type === "selection" && event.altKey) { + this.setActiveTool({ type: "lasso" }); + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + ); + } else { + this.createGenericElementOnPointerDown( + this.state.activeTool.type, + pointerDownState, + ); + } } this.props?.onPointerDown?.(this.state.activeTool, pointerDownState); @@ -8495,6 +8523,8 @@ 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 === "lasso") { + this.lassoTrail.addPointToPath(pointerCoords.x, pointerCoords.y); } else { // It is very important to read this.state within each move event, // otherwise we would read a stale one! @@ -8749,6 +8779,8 @@ class App extends React.Component { originSnapOffset: null, })); + // just in case, tool changes mid drag, always clean up + this.lassoTrail.endPath(); this.lastPointerMoveCoords = null; SnapCache.setReferenceSnapPoints(null); diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index f09f65852..aa0e28d09 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -115,7 +115,7 @@ const getHints = ({ !appState.editingTextElement && !appState.editingLinearElement ) { - return t("hints.deepBoxSelect"); + return [t("hints.deepBoxSelect")]; } if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { @@ -123,7 +123,7 @@ const getHints = ({ } if (!selectedElements.length && !isMobile) { - return t("hints.canvasPanning"); + return [t("hints.canvasPanning"), t("hints.lassoSelect")]; } if (selectedElements.length === 1) { diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 7b8003332..eb10a37f7 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -192,6 +192,7 @@ const getRelevantAppStateProps = ( theme: appState.theme, pendingImageElementId: appState.pendingImageElementId, selectionElement: appState.selectionElement, + lassoSelection: appState.lassoSelection, selectedGroupIds: appState.selectedGroupIds, selectedLinearElement: appState.selectedLinearElement, multiElement: appState.multiElement, diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 073d7a9f7..c3b46979b 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -273,6 +273,16 @@ export const SelectionIcon = createIcon( { fill: "none", width: 22, height: 22, strokeWidth: 1.25 }, ); +export const LassoIcon = createIcon( + + + + + , + + { fill: "none", width: 22, height: 22, strokeWidth: 1.25 }, +); + // tabler-icons: square export const RectangleIcon = createIcon( diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index cb32190b2..8d2525467 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", + lasso: "lasso", rectangle: "rectangle", diamond: "diamond", ellipse: "ellipse", diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 550b88071..02fd5b5a6 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -71,6 +71,7 @@ export const AllowedExcalidrawActiveTools: Record< boolean > = { selection: true, + lasso: true, text: true, rectangle: true, diamond: true, diff --git a/packages/excalidraw/element/showSelectedShapeActions.ts b/packages/excalidraw/element/showSelectedShapeActions.ts index bbf313d01..a66f5a03a 100644 --- a/packages/excalidraw/element/showSelectedShapeActions.ts +++ b/packages/excalidraw/element/showSelectedShapeActions.ts @@ -12,6 +12,7 @@ export const showSelectedShapeActions = ( ((appState.activeTool.type !== "custom" && (appState.editingTextElement || (appState.activeTool.type !== "selection" && + appState.activeTool.type !== "lasso" && appState.activeTool.type !== "eraser" && appState.activeTool.type !== "hand" && appState.activeTool.type !== "laser"))) || diff --git a/packages/excalidraw/lasso/index.ts b/packages/excalidraw/lasso/index.ts new file mode 100644 index 000000000..72a55bd1a --- /dev/null +++ b/packages/excalidraw/lasso/index.ts @@ -0,0 +1,134 @@ +import { GlobalPoint, pointFrom } from "../../math"; +import { AnimatedTrail } from "../animated-trail"; +import { AnimationFrameHandler } from "../animation-frame-handler"; +import App from "../components/App"; +import { isFrameLikeElement } from "../element/typeChecks"; +import { ExcalidrawElement } from "../element/types"; +import { getFrameChildren } from "../frame"; +import { selectGroupsForSelectedElements } from "../groups"; +import { easeOut } from "../utils"; +import { LassoWorkerInput, LassoWorkerOutput } from "./worker"; + +export class LassoTrail extends AnimatedTrail { + private intersectedElements: Set = new Set(); + private enclosedElements: Set = new Set(); + private worker: Worker | null = null; + + constructor(animationFrameHandler: AnimationFrameHandler, app: App) { + super(animationFrameHandler, app, { + animateTrail: true, + 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: () => "rgba(0,118,255)", + }); + } + + startPath(x: number, y: number) { + super.startPath(x, y); + this.intersectedElements.clear(); + this.enclosedElements.clear(); + + this.worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + }); + + this.worker.onmessage = (event: MessageEvent) => { + const { selectedElementIds } = event.data; + + this.app.setState((prevState) => { + const nextSelectedElementIds = selectedElementIds.reduce((acc, id) => { + acc[id] = true; + return acc; + }, {} as Record); + + for (const [id] of Object.entries(nextSelectedElementIds)) { + const element = this.app.scene.getNonDeletedElement(id); + if (element && isFrameLikeElement(element)) { + const elementsInFrame = getFrameChildren( + this.app.scene.getNonDeletedElementsMap(), + element.id, + ); + for (const child of elementsInFrame) { + delete nextSelectedElementIds[child.id]; + } + } + } + + const nextSelection = selectGroupsForSelectedElements( + { + editingGroupId: prevState.editingGroupId, + selectedElementIds: nextSelectedElementIds, + }, + this.app.scene.getNonDeletedElements(), + prevState, + this.app, + ); + + return { + selectedElementIds: nextSelection.selectedElementIds, + selectedGroupIds: nextSelection.selectedGroupIds, + }; + }); + }; + + this.worker.onerror = (error) => { + console.error("Worker error:", error); + }; + } + + addPointToPath = (x: number, y: number) => { + super.addPointToPath(x, y); + + this.app.setState({ + lassoSelection: { + points: + (this.getCurrentTrail()?.originalPoints?.map((p) => + pointFrom(p[0], p[1]), + ) as readonly GlobalPoint[]) ?? null, + }, + }); + + this.updateSelection(); + }; + + private updateSelection = () => { + const lassoPath = super + .getCurrentTrail() + ?.originalPoints?.map((p) => pointFrom(p[0], p[1])); + + if (lassoPath) { + const message: LassoWorkerInput = { + lassoPath, + elements: this.app.visibleElements, + intersectedElements: this.intersectedElements, + enclosedElements: this.enclosedElements, + }; + + this.worker?.postMessage(message); + } + }; + + endPath(): void { + super.endPath(); + super.clearTrails(); + this.intersectedElements.clear(); + this.enclosedElements.clear(); + this.app.setState({ + lassoSelection: null, + }); + this.worker?.terminate(); + } +} diff --git a/packages/excalidraw/lasso/worker.ts b/packages/excalidraw/lasso/worker.ts new file mode 100644 index 000000000..0b974e832 --- /dev/null +++ b/packages/excalidraw/lasso/worker.ts @@ -0,0 +1,393 @@ +import { + GlobalPoint, + LineSegment, + LocalPoint, + Radians, +} from "../../math/types"; +import { pointFrom, pointRotateRads } from "../../math/point"; +import { polygonFromPoints } from "../../math/polygon"; +import { ElementsMap, ExcalidrawElement } from "../element/types"; +import { pointsOnBezierCurves, simplify } from "points-on-curve"; +import { lineSegment } from "../../math/segment"; +import throttle from "lodash.throttle"; +import { RoughGenerator } from "roughjs/bin/generator"; +import { Point } from "roughjs/bin/geometry"; +import { Drawable, Op } from "roughjs/bin/core"; + +// variables to track processing state and latest input data +// for "backpressure" purposes +let isProcessing: boolean = false; +let latestInputData: LassoWorkerInput | null = null; + +self.onmessage = (event: MessageEvent) => { + if (!event.data) { + self.postMessage({ + error: "No data received", + selectedElementIds: [], + }); + return; + } + + latestInputData = event.data; + + if (!isProcessing) { + processInputData(); + } +}; + +// function to process the latest data +const processInputData = () => { + // If no data to process, return + if (!latestInputData) return; + + // capture the current data to process and reset latestData + const dataToProcess = latestInputData; + latestInputData = null; // reset to avoid re-processing the same data + isProcessing = true; + + try { + const { lassoPath, elements, intersectedElements, enclosedElements } = + dataToProcess; + + if (!Array.isArray(lassoPath) || !Array.isArray(elements)) { + throw new Error("Invalid input: lassoPath and elements must be arrays"); + } + + if ( + !(intersectedElements instanceof Set) || + !(enclosedElements instanceof Set) + ) { + throw new Error( + "Invalid input: intersectedElements and enclosedElements must be Sets", + ); + } + + const result = updateSelection(dataToProcess); + self.postMessage(result); + } catch (error) { + self.postMessage({ + error: error instanceof Error ? error.message : "Unknown error occurred", + selectedElementIds: [], + }); + } finally { + isProcessing = false; + // if new data arrived during processing, process it + // as we're done with processing the previous data + if (latestInputData) { + processInputData(); + } + } +}; + +export type LassoWorkerInput = { + lassoPath: GlobalPoint[]; + elements: readonly ExcalidrawElement[]; + intersectedElements: Set; + enclosedElements: Set; +}; + +export type LassoWorkerOutput = { + selectedElementIds: string[]; +}; + +export const updateSelection = throttle( + (input: LassoWorkerInput): LassoWorkerOutput => { + const { lassoPath, elements, intersectedElements, enclosedElements } = + input; + + const elementsMap = arrayToMap(elements); + // simplify the path to reduce the number of points + const simplifiedPath = simplify(lassoPath, 0.75) as GlobalPoint[]; + // close the path to form a polygon for enclosure check + const closedPath = polygonFromPoints(simplifiedPath); + // as the path might not enclose a shape anymore, clear before checking + enclosedElements.clear(); + for (const [, element] of elementsMap) { + if ( + !intersectedElements.has(element.id) && + !enclosedElements.has(element.id) + ) { + const enclosed = enclosureTest(closedPath, element, elementsMap); + if (enclosed) { + enclosedElements.add(element.id); + } else { + const intersects = intersectionTest(closedPath, element, elementsMap); + if (intersects) { + intersectedElements.add(element.id); + } + } + } + } + + const results = [...intersectedElements, ...enclosedElements]; + + return { + selectedElementIds: results, + }; + }, + 100, +); + +const enclosureTest = ( + lassoPath: GlobalPoint[], + element: ExcalidrawElement, + elementsMap: ElementsMap, +): boolean => { + const lassoPolygon = polygonFromPoints(lassoPath); + const segments = getElementLineSegments(element, elementsMap); + + return segments.some((segment) => { + return segment.some((point) => isPointInPolygon(point, lassoPolygon)); + }); +}; + +// // Helper function to check if a point is inside a polygon +const isPointInPolygon = ( + point: GlobalPoint, + polygon: GlobalPoint[], +): boolean => { + let isInside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i][0], + yi = polygon[i][1]; + const xj = polygon[j][0], + yj = polygon[j][1]; + + const intersect = + yi > point[1] !== yj > point[1] && + point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi; + if (intersect) isInside = !isInside; + } + return isInside; +}; + +const intersectionTest = ( + lassoPath: GlobalPoint[], + element: ExcalidrawElement, + elementsMap: ElementsMap, +): boolean => { + const elementSegments = getElementLineSegments(element, elementsMap); + + const lassoSegments = lassoPath.reduce((acc, point, index) => { + if (index === 0) return acc; + acc.push([lassoPath[index - 1], point] as [GlobalPoint, GlobalPoint]); + return acc; + }, [] as [GlobalPoint, GlobalPoint][]); + + return lassoSegments.some((lassoSegment) => + elementSegments.some((elementSegment) => + doLineSegmentsIntersect(lassoSegment, elementSegment), + ), + ); +}; + +// Helper function to check if two line segments intersect +const doLineSegmentsIntersect = ( + [p1, p2]: [GlobalPoint, GlobalPoint], + [p3, p4]: [GlobalPoint, GlobalPoint], +): boolean => { + const denominator = + (p4[1] - p3[1]) * (p2[0] - p1[0]) - (p4[0] - p3[0]) * (p2[1] - p1[1]); + + if (denominator === 0) return false; + + const ua = + ((p4[0] - p3[0]) * (p1[1] - p3[1]) - (p4[1] - p3[1]) * (p1[0] - p3[0])) / + denominator; + const ub = + ((p2[0] - p1[0]) * (p1[1] - p3[1]) - (p2[1] - p1[1]) * (p1[0] - p3[0])) / + denominator; + + return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1; +}; + +const getCurvePathOps = (shape: Drawable): Op[] => { + for (const set of shape.sets) { + if (set.type === "path") { + return set.ops; + } + } + return shape.sets[0].ops; +}; + +const getElementLineSegments = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): LineSegment[] => { + const [x1, y1, x2, y2, cx, cy] = [ + element.x, + element.y, + element.x + element.width, + element.y + element.height, + element.x + element.width / 2, + element.y + element.height / 2, + ]; + + const center: GlobalPoint = pointFrom(cx, cy); + + if ( + element.type === "line" || + element.type === "arrow" || + element.type === "freedraw" + ) { + const segments: LineSegment[] = []; + + const getPointsOnCurve = () => { + const generator = new RoughGenerator(); + + const drawable = generator.curve(element.points as unknown as Point[]); + + const ops = getCurvePathOps(drawable); + + const _points: LocalPoint[] = []; + // let odd = false; + // for (const operation of ops) { + // if (operation.op === "move") { + // odd = !odd; + // if (odd) { + // if ( + // Array.isArray(operation.data) && + // operation.data.length >= 2 && + // operation.data.every( + // (d) => d !== undefined && typeof d === "number", + // ) + // ) { + // _points.push(pointFrom(operation.data[0], operation.data[1])); + // } + // } + // } else if (operation.op === "bcurveTo") { + // if (odd) { + // if ( + // Array.isArray(operation.data) && + // operation.data.length === 6 && + // operation.data.every( + // (d) => d !== undefined && typeof d === "number", + // ) + // ) { + // _points.push(pointFrom(operation.data[0], operation.data[1])); + // _points.push(pointFrom(operation.data[2], operation.data[3])); + // _points.push(pointFrom(operation.data[4], operation.data[5])); + // } + // } + // } else if (operation.op === "lineTo") { + // if ( + // Array.isArray(operation.data) && + // operation.data.length >= 2 && + // odd && + // operation.data.every( + // (d) => d !== undefined && typeof d === "number", + // ) + // ) { + // _points.push(pointFrom(operation.data[0], operation.data[1])); + // } + // } + // } + + return pointsOnBezierCurves(_points, 10, 5); + }; + + let i = 0; + + // const points = + // element.roughness !== 0 && element.type !== "freedraw" + // ? getPointsOnCurve() + // : element.points; + + const points = element.points; + + while (i < points.length - 1) { + segments.push( + lineSegment( + pointRotateRads( + pointFrom( + element.points[i][0] + element.x, + element.points[i][1] + element.y, + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.points[i + 1][0] + element.x, + element.points[i + 1][1] + element.y, + ), + center, + element.angle, + ), + ), + ); + i++; + } + + return segments; + } + + const [nw, ne, sw, se, n, s, w, e] = ( + [ + [x1, y1], + [x2, y1], + [x1, y2], + [x2, y2], + [cx, y1], + [cx, y2], + [x1, cy], + [x2, cy], + ] as GlobalPoint[] + ).map((point) => pointRotateRads(point, center, element.angle)); + + if (element.type === "diamond") { + return [ + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), + ]; + } + + if (element.type === "ellipse") { + return [ + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), + ]; + } + + if (element.type === "frame" || element.type === "magicframe") { + return [ + lineSegment(nw, ne), + lineSegment(ne, se), + lineSegment(se, sw), + lineSegment(sw, nw), + ]; + } + + return [ + lineSegment(nw, ne), + lineSegment(sw, se), + lineSegment(nw, sw), + lineSegment(ne, se), + lineSegment(nw, e), + lineSegment(sw, e), + lineSegment(ne, w), + lineSegment(se, w), + ]; +}; + +// This is a copy of arrayToMap from utils.ts +// copy to avoid accessing DOM related things in worker +const arrayToMap = ( + items: readonly T[] | Map, +) => { + if (items instanceof Map) { + return items; + } + return items.reduce((acc: Map, element) => { + acc.set(typeof element === "string" ? element : element.id, element); + return acc; + }, new Map()); +}; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index f14b79705..14c70355c 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -276,6 +276,7 @@ }, "toolBar": { "selection": "Selection", + "lasso": "Lasso", "image": "Insert image", "rectangle": "Rectangle", "diamond": "Diamond", @@ -341,6 +342,7 @@ "bindTextToElement": "Press enter to add text", "createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart", "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging", + "lassoSelect": "Hold Alt, or click on the selection again, to lasso select", "eraserRevert": "Hold Alt to revert the elements marked for deletion", "firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.", "disableSnapping": "Hold CtrlOrCmd to disable snapping", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 3a070e667..77285f462 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -13,7 +13,10 @@ import { SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { renderSelectionElement } from "../renderer/renderElement"; +import { + renderLassoSelection, + renderSelectionElement, +} from "../renderer/renderElement"; import { getClientColor, renderRemoteCursors } from "../clients"; import { isSelectedViaGroup, @@ -826,6 +829,15 @@ const _renderInteractiveScene = ({ } } + if (appState.lassoSelection) { + renderLassoSelection( + appState.lassoSelection, + context, + appState, + renderConfig.selectionColor, + ); + } + if ( appState.editingTextElement && isTextElement(appState.editingTextElement) diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 3e87ebaf5..680da8a1b 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -60,7 +60,7 @@ import { LinearElementEditor } from "../element/linearElementEditor"; import { getContainingFrame } from "../frame"; import { ShapeCache } from "../scene/ShapeCache"; import { getVerticalOffset } from "../fonts"; -import { isRightAngleRads } from "../../math"; +import { GlobalPoint, isRightAngleRads } from "../../math"; import { getCornerRadius } from "../shapes"; import { getUncroppedImageElement } from "../element/cropElement"; import { getLineHeightInPx } from "../element/textMeasurements"; @@ -695,6 +695,33 @@ export const renderSelectionElement = ( context.restore(); }; +export const renderLassoSelection = ( + lassoPath: AppState["lassoSelection"], + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], +) => { + if (!lassoPath || lassoPath.points.length < 2) { + return; + } + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.beginPath(); + + for (const point of lassoPath.points) { + context.lineTo(point[0], point[1]); + } + + context.closePath(); + + context.globalAlpha = 0.05; + context.fillStyle = selectionColor; + context.fill(); + + context.restore(); +}; + export const renderElement = ( element: NonDeletedExcalidrawElement, elementsMap: RenderableElementsMap, diff --git a/packages/excalidraw/scene/comparisons.ts b/packages/excalidraw/scene/comparisons.ts index 9af3e66cb..fb772ffd4 100644 --- a/packages/excalidraw/scene/comparisons.ts +++ b/packages/excalidraw/scene/comparisons.ts @@ -10,7 +10,10 @@ export const hasBackground = (type: ElementOrToolType) => type === "freedraw"; export const hasStrokeColor = (type: ElementOrToolType) => - type !== "image" && type !== "frame" && type !== "magicframe"; + type !== "image" && + type !== "frame" && + type !== "magicframe" && + type !== "lasso"; export const hasStrokeWidth = (type: ElementOrToolType) => type === "rectangle" || diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 05144011c..263673683 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -394,7 +394,10 @@ const proxy = ( }; /** Tools that can be used to draw shapes */ -type DrawingToolName = Exclude; +type DrawingToolName = Exclude< + ToolType, + "lock" | "selection" | "eraser" | "lasso" +>; type Element = T extends "line" | "freedraw" ? ExcalidrawLinearElement diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 4974f4214..0d5d90613 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -41,6 +41,7 @@ import type { ContextMenuItems } from "./components/ContextMenu"; import type { SnapLine } from "./snapping"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { StoreActionType } from "./store"; +import { GlobalPoint } from "../math"; export type SocketId = string & { _brand: "SocketId" }; @@ -119,6 +120,7 @@ export type BinaryFiles = Record; export type ToolType = | "selection" + | "lasso" | "rectangle" | "diamond" | "ellipse" @@ -194,6 +196,7 @@ export type InteractiveCanvasAppState = Readonly< activeEmbeddable: AppState["activeEmbeddable"]; editingLinearElement: AppState["editingLinearElement"]; selectionElement: AppState["selectionElement"]; + lassoSelection: AppState["lassoSelection"]; selectedGroupIds: AppState["selectedGroupIds"]; selectedLinearElement: AppState["selectedLinearElement"]; multiElement: AppState["multiElement"]; @@ -267,6 +270,9 @@ export interface AppState { * - set on pointer down, updated during pointer move */ selectionElement: NonDeletedExcalidrawElement | null; + lassoSelection: { + points: readonly GlobalPoint[]; + } | null; isBindingEnabled: boolean; startBoundElement: NonDeleted | null; suggestedBindings: SuggestedBinding[];