mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-02-18 13:29:36 +01:00
feat: initial Laser Pointer MVP (#6739)
* feat: initial Laser pointer mvp * feat: add laser-pointer package and integrate it with collab * chore: fix yarn.lock * feat: update laser-pointer package, prevent panning from showing * feat: add laser pointer tool button when collaborating, migrate to official package * feat: reduce laser tool button size * update icon * fix icon & rotate * fix: lock zoom level * fix icon * add `selected` state, simplify and reduce api * set up pointer callbacks in viewMode if laser tool active * highlight extra-tools button if one of the nested tools active * add shortcut to laser pointer * feat: don't update paths if nothing changed * ensure we reset flag if no rAF scheduled * move `lastUpdate` to instance to optimize * return early * factor out into constants and add doc * skip iteration instead of exit * fix naming * feat: remove testing variable on window * destroy on editor unmount * fix incorrectly resetting `lastUpdate` in `stop()` --------- Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
e921bfb1ae
commit
2e61926a6b
@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
|
|||||||
type: "MOUSE_LOCATION";
|
type: "MOUSE_LOCATION";
|
||||||
payload: {
|
payload: {
|
||||||
socketId: string;
|
socketId: string;
|
||||||
pointer: { x: number; y: number };
|
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||||
button: "down" | "up";
|
button: "down" | "up";
|
||||||
selectedElementIds: AppState["selectedElementIds"];
|
selectedElementIds: AppState["selectedElementIds"];
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.2",
|
||||||
|
"@excalidraw/laser-pointer": "1.2.0",
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
"@radix-ui/react-popover": "1.0.3",
|
"@radix-ui/react-popover": "1.0.3",
|
||||||
"@radix-ui/react-tabs": "1.0.2",
|
"@radix-ui/react-tabs": "1.0.2",
|
||||||
|
@ -31,7 +31,12 @@ import {
|
|||||||
|
|
||||||
import "./Actions.scss";
|
import "./Actions.scss";
|
||||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||||
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
|
import {
|
||||||
|
EmbedIcon,
|
||||||
|
extraToolsIcon,
|
||||||
|
frameToolIcon,
|
||||||
|
laserPointerToolIcon,
|
||||||
|
} from "./icons";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
@ -222,6 +227,11 @@ export const ShapesSwitcher = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
|
|
||||||
|
const frameToolSelected = activeTool.type === "frame";
|
||||||
|
const laserToolSelected = activeTool.type === "laser";
|
||||||
|
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||||
@ -313,7 +323,15 @@ export const ShapesSwitcher = ({
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
className="App-toolbar__extra-tools-trigger"
|
className={clsx("App-toolbar__extra-tools-trigger", {
|
||||||
|
"App-toolbar__extra-tools-trigger--selected":
|
||||||
|
frameToolSelected ||
|
||||||
|
embeddableToolSelected ||
|
||||||
|
// in collab we're already highlighting the laser button
|
||||||
|
// outside toolbar, so let's not highlight extra-tools button
|
||||||
|
// on top of it
|
||||||
|
(laserToolSelected && !app.props.isCollaborating),
|
||||||
|
})}
|
||||||
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
||||||
title={t("toolBar.extraTools")}
|
title={t("toolBar.extraTools")}
|
||||||
>
|
>
|
||||||
@ -331,7 +349,7 @@ export const ShapesSwitcher = ({
|
|||||||
icon={frameToolIcon}
|
icon={frameToolIcon}
|
||||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||||
data-testid="toolbar-frame"
|
data-testid="toolbar-frame"
|
||||||
selected={activeTool.type === "frame"}
|
selected={frameToolSelected}
|
||||||
>
|
>
|
||||||
{t("toolBar.frame")}
|
{t("toolBar.frame")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@ -341,10 +359,21 @@ export const ShapesSwitcher = ({
|
|||||||
}}
|
}}
|
||||||
icon={EmbedIcon}
|
icon={EmbedIcon}
|
||||||
data-testid="toolbar-embeddable"
|
data-testid="toolbar-embeddable"
|
||||||
selected={activeTool.type === "embeddable"}
|
selected={embeddableToolSelected}
|
||||||
>
|
>
|
||||||
{t("toolBar.embeddable")}
|
{t("toolBar.embeddable")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
app.setActiveTool({ type: "laser" });
|
||||||
|
}}
|
||||||
|
icon={laserPointerToolIcon}
|
||||||
|
data-testid="toolbar-laser"
|
||||||
|
selected={laserToolSelected}
|
||||||
|
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||||
|
>
|
||||||
|
{t("toolBar.laser")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
@ -230,6 +230,7 @@ import {
|
|||||||
SidebarName,
|
SidebarName,
|
||||||
SidebarTabName,
|
SidebarTabName,
|
||||||
KeyboardModifiersObject,
|
KeyboardModifiersObject,
|
||||||
|
CollaboratorPointer,
|
||||||
ToolType,
|
ToolType,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
@ -368,6 +369,8 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
|||||||
import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||||
import { Renderer } from "../scene/Renderer";
|
import { Renderer } from "../scene/Renderer";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
|
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!);
|
||||||
@ -497,6 +500,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
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();
|
||||||
@ -1205,12 +1210,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
!this.scene.getElementsIncludingDeleted().length
|
!this.scene.getElementsIncludingDeleted().length
|
||||||
}
|
}
|
||||||
app={this}
|
app={this}
|
||||||
|
isCollaborating={this.props.isCollaborating}
|
||||||
>
|
>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</LayerUI>
|
</LayerUI>
|
||||||
<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} />
|
||||||
{selectedElements.length === 1 &&
|
{selectedElements.length === 1 &&
|
||||||
!this.state.contextMenu &&
|
!this.state.contextMenu &&
|
||||||
this.state.showHyperlinkPopup && (
|
this.state.showHyperlinkPopup && (
|
||||||
@ -1738,6 +1745,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.removeEventListeners();
|
this.removeEventListeners();
|
||||||
this.scene.destroy();
|
this.scene.destroy();
|
||||||
this.library.destroy();
|
this.library.destroy();
|
||||||
|
this.laserPathManager.destroy();
|
||||||
ShapeCache.destroy();
|
ShapeCache.destroy();
|
||||||
SnapCache.destroy();
|
SnapCache.destroy();
|
||||||
clearTimeout(touchTimeout);
|
clearTimeout(touchTimeout);
|
||||||
@ -3052,6 +3060,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||||
|
if (this.state.activeTool.type === "laser") {
|
||||||
|
this.setActiveTool({ type: "selection" });
|
||||||
|
} else {
|
||||||
|
this.setActiveTool({ type: "laser" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||||
@ -4462,6 +4479,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.lastPointerDownEvent = event;
|
this.lastPointerDownEvent = event;
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -4470,10 +4491,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
this.savePointer(event.clientX, event.clientY, "down");
|
this.savePointer(event.clientX, event.clientY, "down");
|
||||||
|
|
||||||
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// only handle left mouse button or touch
|
// only handle left mouse button or touch
|
||||||
if (
|
if (
|
||||||
event.button !== POINTER_BUTTON.MAIN &&
|
event.button !== POINTER_BUTTON.MAIN &&
|
||||||
@ -4564,6 +4581,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
setCursor(this.interactiveCanvas, 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"
|
||||||
@ -4587,7 +4609,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
lastPointerUp = onPointerUp;
|
lastPointerUp = onPointerUp;
|
||||||
|
|
||||||
if (!this.state.viewModeEnabled) {
|
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
|
||||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
||||||
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
||||||
@ -5783,6 +5805,10 @@ 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,
|
||||||
@ -7029,6 +7055,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
: unbindLinearElements)(this.scene.getSelectedElements(this.state));
|
: unbindLinearElements)(this.scene.getSelectedElements(this.state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTool.type === "laser") {
|
||||||
|
this.laserPathManager.endPath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeTool.locked && activeTool.type !== "freedraw") {
|
if (!activeTool.locked && activeTool.type !== "freedraw") {
|
||||||
resetCursor(this.interactiveCanvas);
|
resetCursor(this.interactiveCanvas);
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -8273,15 +8304,21 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (!x || !y) {
|
if (!x || !y) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pointer = viewportCoordsToSceneCoords(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
{ clientX: x, clientY: y },
|
{ clientX: x, clientY: y },
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isNaN(pointer.x) || isNaN(pointer.y)) {
|
if (isNaN(sceneX) || isNaN(sceneY)) {
|
||||||
// sometimes the pointer goes off screen
|
// sometimes the pointer goes off screen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pointer: CollaboratorPointer = {
|
||||||
|
x: sceneX,
|
||||||
|
y: sceneY,
|
||||||
|
tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
this.props.onPointerUpdate?.({
|
this.props.onPointerUpdate?.({
|
||||||
pointer,
|
pointer,
|
||||||
button,
|
button,
|
||||||
|
@ -165,6 +165,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||||
|
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.eyeDropper")}
|
label={t("labels.eyeDropper")}
|
||||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||||
|
293
src/components/LaserTool/LaserPathManager.ts
Normal file
293
src/components/LaserTool/LaserPathManager.ts
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||||
|
|
||||||
|
import { sceneCoordsToViewportCoords } from "../../utils";
|
||||||
|
import App from "../App";
|
||||||
|
import { getClientColor } from "../../clients";
|
||||||
|
|
||||||
|
// decay time in milliseconds
|
||||||
|
const DECAY_TIME = 1000;
|
||||||
|
// length of line in points before it starts decaying
|
||||||
|
const DECAY_LENGTH = 50;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
LPM: LaserPathManager;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutCubic(t: number) {
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function instantiateCollabolatorState(): CollabolatorState {
|
||||||
|
return {
|
||||||
|
currentPath: undefined,
|
||||||
|
finishedPaths: [],
|
||||||
|
lastPoint: [-10000, -10000],
|
||||||
|
svg: document.createElementNS("http://www.w3.org/2000/svg", "path"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function instantiatePath() {
|
||||||
|
LaserPointer.constants.cornerDetectionMaxAngle = 70;
|
||||||
|
|
||||||
|
return new LaserPointer({
|
||||||
|
simplify: 0,
|
||||||
|
streamline: 0.4,
|
||||||
|
sizeMapping: (c) => {
|
||||||
|
const pt = DECAY_TIME;
|
||||||
|
const pl = DECAY_LENGTH;
|
||||||
|
const t = Math.max(0, 1 - (performance.now() - c.pressure) / pt);
|
||||||
|
const l = (pl - Math.min(pl, c.totalLength - c.currentIndex)) / pl;
|
||||||
|
|
||||||
|
return Math.min(easeOutCubic(l), easeOutCubic(t));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollabolatorState = {
|
||||||
|
currentPath: LaserPointer | undefined;
|
||||||
|
finishedPaths: LaserPointer[];
|
||||||
|
lastPoint: [number, number];
|
||||||
|
svg: SVGPathElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LaserPathManager {
|
||||||
|
private ownState: CollabolatorState;
|
||||||
|
private collaboratorsState: Map<string, CollabolatorState> = new Map();
|
||||||
|
|
||||||
|
private rafId: number | undefined;
|
||||||
|
private lastUpdate = 0;
|
||||||
|
private container: SVGSVGElement | undefined;
|
||||||
|
|
||||||
|
constructor(private app: App) {
|
||||||
|
this.ownState = instantiateCollabolatorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.stop();
|
||||||
|
this.lastUpdate = 0;
|
||||||
|
this.ownState = instantiateCollabolatorState();
|
||||||
|
this.collaboratorsState = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
startPath(x: number, y: number) {
|
||||||
|
this.ownState.currentPath = instantiatePath();
|
||||||
|
this.ownState.currentPath.addPoint([x, y, performance.now()]);
|
||||||
|
this.updatePath(this.ownState);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPointToPath(x: number, y: number) {
|
||||||
|
if (this.ownState.currentPath) {
|
||||||
|
this.ownState.currentPath?.addPoint([x, y, performance.now()]);
|
||||||
|
this.updatePath(this.ownState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endPath() {
|
||||||
|
if (this.ownState.currentPath) {
|
||||||
|
this.ownState.currentPath.close();
|
||||||
|
this.ownState.finishedPaths.push(this.ownState.currentPath);
|
||||||
|
this.updatePath(this.ownState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePath(state: CollabolatorState) {
|
||||||
|
this.lastUpdate = performance.now();
|
||||||
|
|
||||||
|
if (!this.isRunning) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
start(svg?: SVGSVGElement) {
|
||||||
|
if (svg) {
|
||||||
|
this.container = svg;
|
||||||
|
this.container.appendChild(this.ownState.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stop();
|
||||||
|
this.isRunning = true;
|
||||||
|
this.loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.isRunning = false;
|
||||||
|
if (this.rafId) {
|
||||||
|
cancelAnimationFrame(this.rafId);
|
||||||
|
}
|
||||||
|
this.rafId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop() {
|
||||||
|
this.rafId = requestAnimationFrame(this.loop.bind(this));
|
||||||
|
|
||||||
|
this.updateCollabolatorsState();
|
||||||
|
|
||||||
|
if (performance.now() - this.lastUpdate < DECAY_TIME * 2) {
|
||||||
|
this.update();
|
||||||
|
} else {
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(path: LaserPointer) {
|
||||||
|
const stroke = path
|
||||||
|
.getStrokeOutline(path.options.size / this.app.state.zoom.value)
|
||||||
|
.map(([x, y]) => {
|
||||||
|
const result = sceneCoordsToViewportCoords(
|
||||||
|
{ sceneX: x, sceneY: y },
|
||||||
|
this.app.state,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [result.x, result.y];
|
||||||
|
});
|
||||||
|
|
||||||
|
return getSvgPathFromStroke(stroke, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCollabolatorsState() {
|
||||||
|
if (!this.container || !this.app.state.collaborators.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
|
||||||
|
if (!this.collaboratorsState.has(key)) {
|
||||||
|
const state = instantiateCollabolatorState();
|
||||||
|
this.container.appendChild(state.svg);
|
||||||
|
this.collaboratorsState.set(key, state);
|
||||||
|
|
||||||
|
this.updatePath(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.collaboratorsState.get(key)!;
|
||||||
|
|
||||||
|
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
|
||||||
|
if (collabolator.button === "down" && state.currentPath === undefined) {
|
||||||
|
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||||
|
state.currentPath = instantiatePath();
|
||||||
|
state.currentPath.addPoint([
|
||||||
|
collabolator.pointer.x,
|
||||||
|
collabolator.pointer.y,
|
||||||
|
performance.now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.updatePath(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collabolator.button === "down" && state.currentPath !== undefined) {
|
||||||
|
if (
|
||||||
|
collabolator.pointer.x !== state.lastPoint[0] ||
|
||||||
|
collabolator.pointer.y !== state.lastPoint[1]
|
||||||
|
) {
|
||||||
|
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||||
|
state.currentPath.addPoint([
|
||||||
|
collabolator.pointer.x,
|
||||||
|
collabolator.pointer.y,
|
||||||
|
performance.now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.updatePath(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collabolator.button === "up" && state.currentPath !== undefined) {
|
||||||
|
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||||
|
state.currentPath.addPoint([
|
||||||
|
collabolator.pointer.x,
|
||||||
|
collabolator.pointer.y,
|
||||||
|
performance.now(),
|
||||||
|
]);
|
||||||
|
state.currentPath.close();
|
||||||
|
|
||||||
|
state.finishedPaths.push(state.currentPath);
|
||||||
|
state.currentPath = undefined;
|
||||||
|
|
||||||
|
this.updatePath(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, state] of this.collaboratorsState.entries()) {
|
||||||
|
if (!this.app.state.collaborators.has(key)) {
|
||||||
|
state.svg.remove();
|
||||||
|
this.collaboratorsState.delete(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.finishedPaths = state.finishedPaths.filter((path) => {
|
||||||
|
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
|
||||||
|
|
||||||
|
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
|
||||||
|
});
|
||||||
|
|
||||||
|
let paths = state.finishedPaths.map((path) => this.draw(path)).join(" ");
|
||||||
|
|
||||||
|
if (state.currentPath) {
|
||||||
|
paths += ` ${this.draw(state.currentPath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.svg.setAttribute("d", paths);
|
||||||
|
state.svg.setAttribute("fill", getClientColor(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ownState.finishedPaths = this.ownState.finishedPaths.filter((path) => {
|
||||||
|
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
|
||||||
|
|
||||||
|
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
|
||||||
|
});
|
||||||
|
|
||||||
|
let paths = this.ownState.finishedPaths
|
||||||
|
.map((path) => this.draw(path))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
if (this.ownState.currentPath) {
|
||||||
|
paths += ` ${this.draw(this.ownState.currentPath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ownState.svg.setAttribute("d", paths);
|
||||||
|
this.ownState.svg.setAttribute("fill", "red");
|
||||||
|
}
|
||||||
|
}
|
41
src/components/LaserTool/LaserPointerButton.tsx
Normal file
41
src/components/LaserTool/LaserPointerButton.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import "../ToolIcon.scss";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ToolButtonSize } from "../ToolButton";
|
||||||
|
import { laserPointerToolIcon } from "../icons";
|
||||||
|
|
||||||
|
type LaserPointerIconProps = {
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange?(): void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SIZE: ToolButtonSize = "small";
|
||||||
|
|
||||||
|
export const LaserPointerButton = (props: LaserPointerIconProps) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"ToolIcon ToolIcon__LaserPointer",
|
||||||
|
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||||
|
{
|
||||||
|
"is-mobile": props.isMobile,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
title={`${props.title}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="ToolIcon_type_checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
name={props.name}
|
||||||
|
onChange={props.onChange}
|
||||||
|
checked={props.checked}
|
||||||
|
aria-label={props.title}
|
||||||
|
data-testid="toolbar-LaserPointer"
|
||||||
|
/>
|
||||||
|
<div className="ToolIcon__icon">{laserPointerToolIcon}</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -55,6 +55,7 @@ import "./Toolbar.scss";
|
|||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
|
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@ -77,6 +78,7 @@ interface LayerUIProps {
|
|||||||
renderWelcomeScreen: boolean;
|
renderWelcomeScreen: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
|
isCollaborating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMainMenu: React.FC<{
|
const DefaultMainMenu: React.FC<{
|
||||||
@ -134,6 +136,7 @@ const LayerUI = ({
|
|||||||
renderWelcomeScreen,
|
renderWelcomeScreen,
|
||||||
children,
|
children,
|
||||||
app,
|
app,
|
||||||
|
isCollaborating,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
@ -288,6 +291,24 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
|
{isCollaborating && (
|
||||||
|
<Island
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
alignSelf: "center",
|
||||||
|
height: "fit-content",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LaserPointerButton
|
||||||
|
title={t("toolBar.laser")}
|
||||||
|
checked={appState.activeTool.type === "laser"}
|
||||||
|
onChange={() =>
|
||||||
|
app.setActiveTool({ type: "laser" })
|
||||||
|
}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
)}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
|
@ -170,5 +170,10 @@
|
|||||||
height: var(--lg-icon-size);
|
height: var(--lg-icon-size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ToolIcon__LaserPointer .ToolIcon__icon {
|
||||||
|
width: var(--default-button-size);
|
||||||
|
height: var(--default-button-size);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,12 @@
|
|||||||
box-shadow: 0 0 0 1px
|
box-shadow: 0 0 0 1px
|
||||||
var(--button-active-border, var(--color-primary-darkest)) inset;
|
var(--button-active-border, var(--color-primary-darkest)) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--selected,
|
||||||
|
&--selected:hover {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-toolbar__extra-tools-dropdown {
|
.App-toolbar__extra-tools-dropdown {
|
||||||
|
@ -1653,3 +1653,22 @@ export const frameToolIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const laserPointerToolIcon = createIcon(
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
transform="rotate(90 10 10)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="m9.644 13.69 7.774-7.773a2.357 2.357 0 0 0-3.334-3.334l-7.773 7.774L8 12l1.643 1.69Z"
|
||||||
|
/>
|
||||||
|
<path d="m13.25 3.417 3.333 3.333M10 10l2-2M5 15l3-3M2.156 17.894l1-1M5.453 19.029l-.144-1.407M2.377 11.887l.866 1.118M8.354 17.273l-1.194-.758M.953 14.652l1.408.13" />
|
||||||
|
</g>,
|
||||||
|
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
@ -67,6 +67,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||||||
frame: true,
|
frame: true,
|
||||||
embeddable: true,
|
embeddable: true,
|
||||||
hand: true,
|
hand: true,
|
||||||
|
laser: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RestoredDataState = {
|
export type RestoredDataState = {
|
||||||
|
@ -12,6 +12,7 @@ export const showSelectedShapeActions = (
|
|||||||
(appState.editingElement ||
|
(appState.editingElement ||
|
||||||
(appState.activeTool.type !== "selection" &&
|
(appState.activeTool.type !== "selection" &&
|
||||||
appState.activeTool.type !== "eraser" &&
|
appState.activeTool.type !== "eraser" &&
|
||||||
appState.activeTool.type !== "hand"))) ||
|
appState.activeTool.type !== "hand" &&
|
||||||
|
appState.activeTool.type !== "laser"))) ||
|
||||||
getSelectedElements(elements, appState).length),
|
getSelectedElements(elements, appState).length),
|
||||||
);
|
);
|
||||||
|
@ -236,6 +236,7 @@
|
|||||||
"eraser": "Eraser",
|
"eraser": "Eraser",
|
||||||
"frame": "Frame tool",
|
"frame": "Frame tool",
|
||||||
"embeddable": "Web Embed",
|
"embeddable": "Web Embed",
|
||||||
|
"laser": "Laser pointer",
|
||||||
"hand": "Hand (panning tool)",
|
"hand": "Hand (panning tool)",
|
||||||
"extraTools": "More tools"
|
"extraTools": "More tools"
|
||||||
},
|
},
|
||||||
|
16
src/types.ts
16
src/types.ts
@ -39,10 +39,7 @@ import { Merge, ForwardRef, ValueOf } from "./utility-types";
|
|||||||
export type Point = Readonly<RoughPoint>;
|
export type Point = Readonly<RoughPoint>;
|
||||||
|
|
||||||
export type Collaborator = {
|
export type Collaborator = {
|
||||||
pointer?: {
|
pointer?: CollaboratorPointer;
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
button?: "up" | "down";
|
button?: "up" | "down";
|
||||||
selectedElementIds?: AppState["selectedElementIds"];
|
selectedElementIds?: AppState["selectedElementIds"];
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
@ -58,6 +55,12 @@ export type Collaborator = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CollaboratorPointer = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
tool: "pointer" | "laser";
|
||||||
|
};
|
||||||
|
|
||||||
export type DataURL = string & { _brand: "DataURL" };
|
export type DataURL = string & { _brand: "DataURL" };
|
||||||
|
|
||||||
export type BinaryFileData = {
|
export type BinaryFileData = {
|
||||||
@ -98,7 +101,8 @@ export type ToolType =
|
|||||||
| "eraser"
|
| "eraser"
|
||||||
| "hand"
|
| "hand"
|
||||||
| "frame"
|
| "frame"
|
||||||
| "embeddable";
|
| "embeddable"
|
||||||
|
| "laser";
|
||||||
|
|
||||||
export type ActiveTool =
|
export type ActiveTool =
|
||||||
| {
|
| {
|
||||||
@ -389,7 +393,7 @@ export interface ExcalidrawProps {
|
|||||||
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
|
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
|
||||||
isCollaborating?: boolean;
|
isCollaborating?: boolean;
|
||||||
onPointerUpdate?: (payload: {
|
onPointerUpdate?: (payload: {
|
||||||
pointer: { x: number; y: number };
|
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||||
button: "down" | "up";
|
button: "down" | "up";
|
||||||
pointersMap: Gesture["pointers"];
|
pointersMap: Gesture["pointers"];
|
||||||
}) => void;
|
}) => void;
|
||||||
|
@ -1522,6 +1522,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
|
resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
|
||||||
integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ==
|
integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ==
|
||||||
|
|
||||||
|
"@excalidraw/laser-pointer@1.2.0":
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.2.0.tgz#cd34ea7d24b11743c726488cc1fcb28c161cacba"
|
||||||
|
integrity sha512-WjFFwLk9ahmKRKku7U0jqYpeM3fe9ZS1K43pfwPREHk4/FYU3iKDKVeS8m4tEAASnRlBt3hhLCBQLBF2uvgOnw==
|
||||||
|
|
||||||
"@excalidraw/prettier-config@1.0.2":
|
"@excalidraw/prettier-config@1.0.2":
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"
|
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"
|
||||||
|
Loading…
Reference in New Issue
Block a user