diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx
index 594951ae1..1f3153639 100644
--- a/src/components/Actions.tsx
+++ b/src/components/Actions.tsx
@@ -36,7 +36,7 @@ import {
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
-import { extraToolsIcon, frameToolIcon } from "./icons";
+import { extraToolsIcon, frameToolIcon, laserPointerToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
@@ -347,6 +347,23 @@ export const ShapesSwitcher = ({
>
{t("toolBar.frame")}
+ {
+ const nextActiveTool = updateActiveTool(appState, {
+ type: "laser",
+ });
+ setAppState({
+ activeTool: nextActiveTool,
+ multiElement: null,
+ selectedElementIds: {},
+ });
+ }}
+ icon={laserPointerToolIcon}
+ shortcut={KEYS.F.toLocaleUpperCase()}
+ data-testid="toolbar-laser"
+ >
+ {t("toolBar.laser")}
+
)}
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 3ccfa1246..e4f1ad246 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -327,6 +327,8 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { activeEyeDropperAtom } from "./EyeDropper";
+import { LaserToolOverlay } from "./LaserTool/LaserTool";
+import { LaserPathManager } from "./LaserTool/LaserPathManager";
const AppContext = React.createContext(null!);
const AppPropsContext = React.createContext(null!);
@@ -448,6 +450,8 @@ class App extends React.Component {
lastPointerUp: React.PointerEvent | PointerEvent | null = null;
lastViewportPosition = { x: 0, y: 0 };
+ laserPathManager: LaserPathManager = new LaserPathManager(this);
+
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
@@ -862,6 +866,7 @@ class App extends React.Component {
+
{selectedElement.length === 1 &&
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
@@ -4142,6 +4147,11 @@ class App extends React.Component {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
} else if (this.state.activeTool.type === "frame") {
this.createFrameElementOnPointerDown(pointerDownState);
+ } else if (this.state.activeTool.type === "laser") {
+ this.laserPathManager.startPath([
+ pointerDownState.lastCoords.x,
+ pointerDownState.lastCoords.y,
+ ]);
} else if (
this.state.activeTool.type !== "eraser" &&
this.state.activeTool.type !== "hand"
@@ -5162,6 +5172,13 @@ class App extends React.Component {
return;
}
+ if (this.state.activeTool.type === "laser") {
+ this.laserPathManager.addPointToPath([
+ pointerCoords.x,
+ pointerCoords.y,
+ ]);
+ }
+
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
@@ -6315,6 +6332,11 @@ class App extends React.Component {
);
}
+ if (activeTool.type === "laser") {
+ this.laserPathManager.endPath();
+ return;
+ }
+
if (!activeTool.locked && activeTool.type !== "freedraw") {
resetCursor(this.canvas);
this.setState({
diff --git a/src/components/LaserTool/LaserPathManager.ts b/src/components/LaserTool/LaserPathManager.ts
new file mode 100644
index 000000000..7c2cde74d
--- /dev/null
+++ b/src/components/LaserTool/LaserPathManager.ts
@@ -0,0 +1,153 @@
+import { getStroke } from "perfect-freehand";
+
+import { sceneCoordsToViewportCoords } from "../../utils";
+import App from "../App";
+
+type Point = [number, number];
+
+const average = (a: number, b: number) => (a + b) / 2;
+function getSvgPathFromStroke(points: number[][], closed = true) {
+ const len = points.length;
+
+ if (len < 4) {
+ return ``;
+ }
+
+ let a = points[0];
+ let b = points[1];
+ const c = points[2];
+
+ let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
+ 2,
+ )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
+ b[1],
+ c[1],
+ ).toFixed(2)} T`;
+
+ for (let i = 2, max = len - 1; i < max; i++) {
+ a = points[i];
+ b = points[i + 1];
+ result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
+ 2,
+ )} `;
+ }
+
+ if (closed) {
+ result += "Z";
+ }
+
+ return result;
+}
+
+export type LaserPath = {
+ original: [number, number, number][];
+};
+
+declare global {
+ interface Window {
+ LPM: LaserPathManager;
+ }
+}
+
+export class LaserPathManager {
+ private currentPath: LaserPath | undefined;
+
+ private rafId: number | undefined;
+ private container: SVGSVGElement | undefined;
+
+ constructor(private app: App) {
+ window.LPM = this;
+ }
+
+ startPath(point: Point) {
+ this.currentPath = {
+ original: [[...point, performance.now()]],
+ };
+ }
+
+ addPointToPath(point: Point) {
+ if (this.currentPath) {
+ this.currentPath.original.push([...point, performance.now()]);
+ }
+ }
+
+ endPath() {
+ if (this.currentPath) {
+ }
+ }
+
+ private translatePoint(point: number[]): Point {
+ const result = sceneCoordsToViewportCoords(
+ { sceneX: point[0], sceneY: point[1] },
+ this.app.state,
+ );
+
+ return [result.x, result.y];
+ }
+
+ loop(time: number = 0) {
+ this.rafId = requestAnimationFrame(this.loop.bind(this));
+
+ this.tick(time);
+ }
+
+ ownPath: SVGPathElement | undefined;
+
+ start(svg: SVGSVGElement) {
+ this.container = svg;
+ this.ownPath = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "path",
+ );
+
+ this.container.appendChild(this.ownPath);
+
+ this.stop();
+ this.loop();
+ }
+
+ stop() {
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId);
+ }
+ }
+
+ tick(time: number) {
+ if (!this.container) {
+ return;
+ }
+
+ if (this.currentPath) {
+ this.ownPath?.setAttribute("d", this.draw(this.currentPath, time));
+ this.ownPath?.setAttribute("fill", "red");
+ }
+ }
+
+ draw(path: LaserPath, time: number) {
+ const pointsToDraw: [number, number, number][] = [];
+ const DELAY = 500;
+
+ if (path.original.length <= 3) {
+ return "";
+ }
+
+ path.original = path.original.filter((point, i) => {
+ const age = 1 - Math.min(DELAY, time - point[2]) / 500;
+
+ if (age > 0) {
+ pointsToDraw.push([...this.translatePoint(point), age]);
+ }
+
+ return age > 0;
+ });
+
+ const stroke = getStroke(pointsToDraw, {
+ size: 4,
+ simulatePressure: false,
+ thinning: 1,
+ streamline: 0,
+ });
+
+ return getSvgPathFromStroke(stroke, true);
+ }
+}
diff --git a/src/components/LaserTool/LaserTool.tsx b/src/components/LaserTool/LaserTool.tsx
new file mode 100644
index 000000000..e93d72dfc
--- /dev/null
+++ b/src/components/LaserTool/LaserTool.tsx
@@ -0,0 +1,27 @@
+import { useEffect, useRef } from "react";
+import { LaserPathManager } from "./LaserPathManager";
+import "./LaserToolOverlay.scss";
+
+type LaserToolOverlayProps = {
+ manager: LaserPathManager;
+};
+
+export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => {
+ const svgRef = useRef(null);
+
+ useEffect(() => {
+ if (svgRef.current) {
+ manager.start(svgRef.current);
+ }
+
+ return () => {
+ manager.stop();
+ };
+ }, [manager]);
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/LaserTool/LaserToolOverlay.scss b/src/components/LaserTool/LaserToolOverlay.scss
new file mode 100644
index 000000000..da874b452
--- /dev/null
+++ b/src/components/LaserTool/LaserToolOverlay.scss
@@ -0,0 +1,20 @@
+.excalidraw {
+ .LaserToolOverlay {
+ pointer-events: none;
+ width: 100vw;
+ height: 100vh;
+ position: fixed;
+ top: 0;
+ left: 0;
+
+ z-index: 2;
+
+ .LaserToolOverlayCanvas {
+ image-rendering: auto;
+ overflow: visible;
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+ }
+}
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 1fcaa3c82..87d831c1e 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -1647,3 +1647,14 @@ export const frameToolIcon = createIcon(
,
tablerIconProps,
);
+
+export const laserPointerToolIcon = createIcon(
+ <>
+
+
+
+
+
+ >,
+ tablerIconProps,
+);
diff --git a/src/data/restore.ts b/src/data/restore.ts
index 5f2adc004..fa21b0ec3 100644
--- a/src/data/restore.ts
+++ b/src/data/restore.ts
@@ -65,6 +65,7 @@ export const AllowedExcalidrawActiveTools: Record<
custom: true,
frame: true,
hand: true,
+ laser: false,
};
export type RestoredDataState = {
diff --git a/src/locales/en.json b/src/locales/en.json
index 7092e9be8..e5dda9cfe 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -224,6 +224,7 @@
"link": "Add/ Update link for a selected shape",
"eraser": "Eraser",
"frame": "Frame tool",
+ "laser": "Laser pointer",
"hand": "Hand (panning tool)",
"extraTools": "More tools"
},
diff --git a/src/types.ts b/src/types.ts
index fd00578b8..edf1a7bcb 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -86,7 +86,12 @@ export type BinaryFiles = Record;
export type LastActiveTool =
| {
- type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
+ type:
+ | typeof SHAPES[number]["value"]
+ | "eraser"
+ | "hand"
+ | "frame"
+ | "laser";
customType: null;
}
| {
@@ -131,7 +136,12 @@ export type AppState = {
locked: boolean;
} & (
| {
- type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
+ type:
+ | typeof SHAPES[number]["value"]
+ | "eraser"
+ | "hand"
+ | "frame"
+ | "laser";
customType: null;
}
| {
diff --git a/src/utils.ts b/src/utils.ts
index c644efd81..28ee76974 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -369,7 +369,14 @@ export const distance = (x: number, y: number) => Math.abs(x - y);
export const updateActiveTool = (
appState: Pick,
data: (
- | { type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame" }
+ | {
+ type:
+ | typeof SHAPES[number]["value"]
+ | "eraser"
+ | "hand"
+ | "frame"
+ | "laser";
+ }
| { type: "custom"; customType: string }
) & { lastActiveToolBeforeEraser?: LastActiveTool },
): AppState["activeTool"] => {