feat: initial Laser pointer mvp
This commit is contained in:
parent
3ddcc48e4c
commit
0175aa7aa5
@ -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")}
|
||||
</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>
|
||||
)}
|
||||
|
@ -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<AppClassProperties>(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;
|
||||
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<AppProps, AppState> {
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<LaserToolOverlay manager={this.laserPathManager} />
|
||||
{selectedElement.length === 1 &&
|
||||
!this.state.contextMenu &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
@ -4142,6 +4147,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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<AppProps, AppState> {
|
||||
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<AppProps, AppState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTool.type === "laser") {
|
||||
this.laserPathManager.endPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeTool.locked && activeTool.type !== "freedraw") {
|
||||
resetCursor(this.canvas);
|
||||
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>,
|
||||
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,
|
||||
frame: true,
|
||||
hand: true,
|
||||
laser: false,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
|
@ -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"
|
||||
},
|
||||
|
14
src/types.ts
14
src/types.ts
@ -86,7 +86,12 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
|
||||
|
||||
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;
|
||||
}
|
||||
| {
|
||||
|
@ -369,7 +369,14 @@ export const distance = (x: number, y: number) => Math.abs(x - y);
|
||||
export const updateActiveTool = (
|
||||
appState: Pick<AppState, "activeTool">,
|
||||
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"] => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user