feat: initial Laser pointer mvp
This commit is contained in:
parent
3ddcc48e4c
commit
0175aa7aa5
@ -36,7 +36,7 @@ import {
|
|||||||
|
|
||||||
import "./Actions.scss";
|
import "./Actions.scss";
|
||||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||||
import { extraToolsIcon, frameToolIcon } from "./icons";
|
import { extraToolsIcon, frameToolIcon, laserPointerToolIcon } from "./icons";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
@ -347,6 +347,23 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.frame")}
|
{t("toolBar.frame")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
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")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
@ -327,6 +327,8 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
|||||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||||
import { activeEyeDropperAtom } from "./EyeDropper";
|
import { activeEyeDropperAtom } from "./EyeDropper";
|
||||||
|
import { LaserToolOverlay } from "./LaserTool/LaserTool";
|
||||||
|
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@ -448,6 +450,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
|
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
|
||||||
lastViewportPosition = { x: 0, y: 0 };
|
lastViewportPosition = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
laserPathManager: LaserPathManager = new LaserPathManager(this);
|
||||||
|
|
||||||
constructor(props: AppProps) {
|
constructor(props: AppProps) {
|
||||||
super(props);
|
super(props);
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
@ -862,6 +866,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
<div className="excalidraw-textEditorContainer" />
|
<div className="excalidraw-textEditorContainer" />
|
||||||
<div className="excalidraw-contextMenuContainer" />
|
<div className="excalidraw-contextMenuContainer" />
|
||||||
<div className="excalidraw-eye-dropper-container" />
|
<div className="excalidraw-eye-dropper-container" />
|
||||||
|
<LaserToolOverlay manager={this.laserPathManager} />
|
||||||
{selectedElement.length === 1 &&
|
{selectedElement.length === 1 &&
|
||||||
!this.state.contextMenu &&
|
!this.state.contextMenu &&
|
||||||
this.state.showHyperlinkPopup && (
|
this.state.showHyperlinkPopup && (
|
||||||
@ -4142,6 +4147,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||||
} else if (this.state.activeTool.type === "frame") {
|
} else if (this.state.activeTool.type === "frame") {
|
||||||
this.createFrameElementOnPointerDown(pointerDownState);
|
this.createFrameElementOnPointerDown(pointerDownState);
|
||||||
|
} else if (this.state.activeTool.type === "laser") {
|
||||||
|
this.laserPathManager.startPath([
|
||||||
|
pointerDownState.lastCoords.x,
|
||||||
|
pointerDownState.lastCoords.y,
|
||||||
|
]);
|
||||||
} else if (
|
} else if (
|
||||||
this.state.activeTool.type !== "eraser" &&
|
this.state.activeTool.type !== "eraser" &&
|
||||||
this.state.activeTool.type !== "hand"
|
this.state.activeTool.type !== "hand"
|
||||||
@ -5162,6 +5172,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.state.activeTool.type === "laser") {
|
||||||
|
this.laserPathManager.addPointToPath([
|
||||||
|
pointerCoords.x,
|
||||||
|
pointerCoords.y,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const [gridX, gridY] = getGridPoint(
|
const [gridX, gridY] = getGridPoint(
|
||||||
pointerCoords.x,
|
pointerCoords.x,
|
||||||
pointerCoords.y,
|
pointerCoords.y,
|
||||||
@ -6315,6 +6332,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTool.type === "laser") {
|
||||||
|
this.laserPathManager.endPath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeTool.locked && activeTool.type !== "freedraw") {
|
if (!activeTool.locked && activeTool.type !== "freedraw") {
|
||||||
resetCursor(this.canvas);
|
resetCursor(this.canvas);
|
||||||
this.setState({
|
this.setState({
|
||||||
|
153
src/components/LaserTool/LaserPathManager.ts
Normal file
153
src/components/LaserTool/LaserPathManager.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
27
src/components/LaserTool/LaserTool.tsx
Normal file
27
src/components/LaserTool/LaserTool.tsx
Normal file
@ -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<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (svgRef.current) {
|
||||||
|
manager.start(svgRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
manager.stop();
|
||||||
|
};
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="LaserToolOverlay">
|
||||||
|
<svg ref={svgRef} className="LaserToolOverlayCanvas" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
20
src/components/LaserTool/LaserToolOverlay.scss
Normal file
20
src/components/LaserTool/LaserToolOverlay.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1647,3 +1647,14 @@ export const frameToolIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const laserPointerToolIcon = createIcon(
|
||||||
|
<>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M3 12h18"></path>
|
||||||
|
<path d="M12 21v-18"></path>
|
||||||
|
<path d="M7.5 7.5l9 9"></path>
|
||||||
|
<path d="M7.5 16.5l9 -9"></path>
|
||||||
|
</>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
@ -65,6 +65,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||||||
custom: true,
|
custom: true,
|
||||||
frame: true,
|
frame: true,
|
||||||
hand: true,
|
hand: true,
|
||||||
|
laser: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RestoredDataState = {
|
export type RestoredDataState = {
|
||||||
|
@ -224,6 +224,7 @@
|
|||||||
"link": "Add/ Update link for a selected shape",
|
"link": "Add/ Update link for a selected shape",
|
||||||
"eraser": "Eraser",
|
"eraser": "Eraser",
|
||||||
"frame": "Frame tool",
|
"frame": "Frame tool",
|
||||||
|
"laser": "Laser pointer",
|
||||||
"hand": "Hand (panning tool)",
|
"hand": "Hand (panning tool)",
|
||||||
"extraTools": "More tools"
|
"extraTools": "More tools"
|
||||||
},
|
},
|
||||||
|
14
src/types.ts
14
src/types.ts
@ -86,7 +86,12 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
|
|||||||
|
|
||||||
export type LastActiveTool =
|
export type LastActiveTool =
|
||||||
| {
|
| {
|
||||||
type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
|
type:
|
||||||
|
| typeof SHAPES[number]["value"]
|
||||||
|
| "eraser"
|
||||||
|
| "hand"
|
||||||
|
| "frame"
|
||||||
|
| "laser";
|
||||||
customType: null;
|
customType: null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@ -131,7 +136,12 @@ export type AppState = {
|
|||||||
locked: boolean;
|
locked: boolean;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
|
type:
|
||||||
|
| typeof SHAPES[number]["value"]
|
||||||
|
| "eraser"
|
||||||
|
| "hand"
|
||||||
|
| "frame"
|
||||||
|
| "laser";
|
||||||
customType: null;
|
customType: null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
@ -369,7 +369,14 @@ export const distance = (x: number, y: number) => Math.abs(x - y);
|
|||||||
export const updateActiveTool = (
|
export const updateActiveTool = (
|
||||||
appState: Pick<AppState, "activeTool">,
|
appState: Pick<AppState, "activeTool">,
|
||||||
data: (
|
data: (
|
||||||
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame" }
|
| {
|
||||||
|
type:
|
||||||
|
| typeof SHAPES[number]["value"]
|
||||||
|
| "eraser"
|
||||||
|
| "hand"
|
||||||
|
| "frame"
|
||||||
|
| "laser";
|
||||||
|
}
|
||||||
| { type: "custom"; customType: string }
|
| { type: "custom"; customType: string }
|
||||||
) & { lastActiveToolBeforeEraser?: LastActiveTool },
|
) & { lastActiveToolBeforeEraser?: LastActiveTool },
|
||||||
): AppState["activeTool"] => {
|
): AppState["activeTool"] => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user