feat: initial Laser pointer mvp

This commit is contained in:
are 2023-07-05 19:39:40 +02:00
parent 3ddcc48e4c
commit 0175aa7aa5
10 changed files with 273 additions and 4 deletions

View File

@ -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>
)}

View File

@ -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({

View 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);
}
}

View 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>
);
};

View 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;
}
}
}

View File

@ -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,
);

View File

@ -65,6 +65,7 @@ export const AllowedExcalidrawActiveTools: Record<
custom: true,
frame: true,
hand: true,
laser: false,
};
export type RestoredDataState = {

View File

@ -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"
},

View File

@ -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;
}
| {

View File

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