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"] => {