Compare commits

...

5 Commits

Author SHA1 Message Date
dwelle cb2bf44997 fix setting stale cursor on tool change 2023-10-10 16:01:44 +02:00
dwelle c199686c05 feat: support props.activeTool 2023-10-05 18:17:46 +02:00
Are 2e61926a6b 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>
2023-10-05 17:05:16 +02:00
DanielJGeiger e921bfb1ae feat: Export iconFillColor() (#6996) 2023-10-04 18:17:22 -05:00
David Luzar e6f74350ac refactor: DRY out tool typing (#7086) 2023-10-04 23:39:00 +02:00
21 changed files with 609 additions and 73 deletions
@@ -30,6 +30,7 @@ All `props` are *optional*.
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
| [`activeTool`](#activeTool) | `object` | - | Set the active editor tool (forced) |
### Storing custom data on Excalidraw elements
@@ -236,4 +237,19 @@ validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) =>
This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.
### activeTool
```ts
activeTool?:
| {
type: ToolType;
}
| {
type: "custom";
customType: string;
};
```
Tool to be force-set as active. As long as the prop is set, the editor active tool will be set to this. Useful only in specific circumstances such as when UI is disabled and the editor should be limited to just one tool (such as a laser pointer).
+1 -1
View File
@@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
type: "MOUSE_LOCATION";
payload: {
socketId: string;
pointer: { x: number; y: number };
pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"];
username: string;
+1
View File
@@ -20,6 +20,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.2.0",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
+33 -4
View File
@@ -31,7 +31,12 @@ import {
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
laserPointerToolIcon,
} from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
@@ -222,6 +227,11 @@ export const ShapesSwitcher = ({
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@@ -313,7 +323,15 @@ export const ShapesSwitcher = ({
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<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)}
title={t("toolBar.extraTools")}
>
@@ -331,7 +349,7 @@ export const ShapesSwitcher = ({
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={activeTool.type === "frame"}
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
@@ -341,10 +359,21 @@ export const ShapesSwitcher = ({
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={activeTool.type === "embeddable"}
selected={embeddableToolSelected}
>
{t("toolBar.embeddable")}
</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>
)}
+72 -25
View File
@@ -211,7 +211,7 @@ import {
import Scene from "../scene/Scene";
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, SHAPES } from "../shapes";
import { findShapeByKey } from "../shapes";
import {
AppClassProperties,
AppProps,
@@ -230,6 +230,8 @@ import {
SidebarName,
SidebarTabName,
KeyboardModifiersObject,
CollaboratorPointer,
ToolType,
} from "../types";
import {
debounce,
@@ -367,6 +369,8 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache";
import { LaserToolOverlay } from "./LaserTool/LaserTool";
import { LaserPathManager } from "./LaserTool/LaserPathManager";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -496,6 +500,8 @@ class App extends React.Component<AppProps, AppState> {
null;
lastViewportPosition = { x: 0, y: 0 };
laserPathManager: LaserPathManager = new LaserPathManager(this);
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
@@ -1204,12 +1210,14 @@ class App extends React.Component<AppProps, AppState> {
!this.scene.getElementsIncludingDeleted().length
}
app={this}
isCollaborating={this.props.isCollaborating}
>
{this.props.children}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<LaserToolOverlay manager={this.laserPathManager} />
{selectedElements.length === 1 &&
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
@@ -1737,6 +1745,7 @@ class App extends React.Component<AppProps, AppState> {
this.removeEventListeners();
this.scene.destroy();
this.library.destroy();
this.laserPathManager.destroy();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@@ -1894,6 +1903,13 @@ class App extends React.Component<AppProps, AppState> {
this.refreshDeviceState(this.excalidrawContainerRef.current);
}
if (
this.props.activeTool &&
this.props.activeTool.type !== this.state.activeTool.type
) {
this.setActiveTool(this.props.activeTool);
}
if (
prevState.scrollX !== this.state.scrollX ||
prevState.scrollY !== this.state.scrollY
@@ -3051,6 +3067,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 (
event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@@ -3113,21 +3138,12 @@ class App extends React.Component<AppProps, AppState> {
setActiveTool = (
tool:
| {
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
type: ToolType;
}
| { type: "custom"; customType: string },
) => {
const nextActiveTool = updateActiveTool(this.state, tool);
if (nextActiveTool.type === "hand") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (!isHoldingSpace) {
setCursorForShape(this.interactiveCanvas, this.state);
}
if (isToolIcon(document.activeElement)) {
this.focusContainer();
}
@@ -3144,8 +3160,10 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null,
activeEmbeddable: null,
} as const;
let nextState: AppState;
if (nextActiveTool.type !== "selection") {
return {
nextState = {
...prevState,
activeTool: nextActiveTool,
selectedElementIds: makeNextSelectedElementIds({}, prevState),
@@ -3154,12 +3172,21 @@ class App extends React.Component<AppProps, AppState> {
multiElement: null,
...commonResets,
};
} else {
nextState = {
...prevState,
activeTool: nextActiveTool,
...commonResets,
};
}
return {
...prevState,
activeTool: nextActiveTool,
...commonResets,
};
if (nextActiveTool.type === "hand") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (!isHoldingSpace) {
setCursorForShape(this.interactiveCanvas, nextState);
}
return nextState;
});
};
@@ -4466,6 +4493,10 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return;
}
this.lastPointerDownEvent = event;
this.setState({
@@ -4474,10 +4505,6 @@ class App extends React.Component<AppProps, AppState> {
});
this.savePointer(event.clientX, event.clientY, "down");
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return;
}
// only handle left mouse button or touch
if (
event.button !== POINTER_BUTTON.MAIN &&
@@ -4568,6 +4595,11 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, 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"
@@ -4591,7 +4623,7 @@ class App extends React.Component<AppProps, AppState> {
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_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
@@ -5787,6 +5819,10 @@ 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,
@@ -7033,6 +7069,11 @@ class App extends React.Component<AppProps, AppState> {
: unbindLinearElements)(this.scene.getSelectedElements(this.state));
}
if (activeTool.type === "laser") {
this.laserPathManager.endPath();
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw") {
resetCursor(this.interactiveCanvas);
this.setState({
@@ -8277,15 +8318,21 @@ class App extends React.Component<AppProps, AppState> {
if (!x || !y) {
return;
}
const pointer = viewportCoordsToSceneCoords(
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX: x, clientY: y },
this.state,
);
if (isNaN(pointer.x) || isNaN(pointer.y)) {
if (isNaN(sceneX) || isNaN(sceneY)) {
// sometimes the pointer goes off screen
}
const pointer: CollaboratorPointer = {
x: sceneX,
y: sceneY,
tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
};
this.props.onPointerUpdate?.({
pointer,
button,
+1
View File
@@ -165,6 +165,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[KEYS.E, KEYS["0"]]}
/>
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
@@ -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");
}
}
@@ -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
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>
);
};
@@ -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;
}
}
}
+21
View File
@@ -55,6 +55,7 @@ import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
interface LayerUIProps {
actionManager: ActionManager;
@@ -77,6 +78,7 @@ interface LayerUIProps {
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
}
const DefaultMainMenu: React.FC<{
@@ -134,6 +136,7 @@ const LayerUI = ({
renderWelcomeScreen,
children,
app,
isCollaborating,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -288,6 +291,24 @@ const LayerUI = ({
/>
</Stack.Row>
</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.Col>
</div>
+5
View File
@@ -170,5 +170,10 @@
height: var(--lg-icon-size);
}
}
.ToolIcon__LaserPointer .ToolIcon__icon {
width: var(--default-button-size);
height: var(--default-button-size);
}
}
}
+6
View File
@@ -28,6 +28,12 @@
box-shadow: 0 0 0 1px
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 {
+20 -1
View File
@@ -13,7 +13,7 @@ import clsx from "clsx";
import { Theme } from "../element/types";
import { THEME } from "../constants";
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
export const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
@@ -1653,3 +1653,22 @@ export const frameToolIcon = createIcon(
</g>,
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,
);
+1
View File
@@ -67,6 +67,7 @@ export const AllowedExcalidrawActiveTools: Record<
frame: true,
embeddable: true,
hand: true,
laser: false,
};
export type RestoredDataState = {
+2 -1
View File
@@ -12,6 +12,7 @@ export const showSelectedShapeActions = (
(appState.editingElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand"))) ||
appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser"))) ||
getSelectedElements(elements, appState).length),
);
+1
View File
@@ -236,6 +236,7 @@
"eraser": "Eraser",
"frame": "Frame tool",
"embeddable": "Web Embed",
"laser": "Laser pointer",
"hand": "Hand (panning tool)",
"extraTools": "More tools"
},
+2
View File
@@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
children,
validateEmbeddable,
renderEmbeddable,
activeTool,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@@ -119,6 +120,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onScrollChange={onScrollChange}
validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable}
activeTool={activeTool}
>
{children}
</App>
+37 -31
View File
@@ -18,7 +18,6 @@ import {
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
} from "./element/types";
import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry";
import { LinearElementEditor } from "./element/linearElementEditor";
import { SuggestedBinding } from "./element/binding";
@@ -40,10 +39,7 @@ import { Merge, ForwardRef, ValueOf } from "./utility-types";
export type Point = Readonly<RoughPoint>;
export type Collaborator = {
pointer?: {
x: number;
y: number;
};
pointer?: CollaboratorPointer;
button?: "up" | "down";
selectedElementIds?: AppState["selectedElementIds"];
username?: string | null;
@@ -59,6 +55,12 @@ export type Collaborator = {
id?: string;
};
export type CollaboratorPointer = {
x: number;
y: number;
tool: "pointer" | "laser";
};
export type DataURL = string & { _brand: "DataURL" };
export type BinaryFileData = {
@@ -86,21 +88,31 @@ export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type LastActiveTool =
export type ToolType =
| "selection"
| "rectangle"
| "diamond"
| "ellipse"
| "arrow"
| "line"
| "freedraw"
| "text"
| "image"
| "eraser"
| "hand"
| "frame"
| "embeddable"
| "laser";
export type ActiveTool =
| {
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
type: ToolType;
customType: null;
}
| {
type: "custom";
customType: string;
}
| null;
};
export type SidebarName = string;
export type SidebarTabName = string;
@@ -195,23 +207,9 @@ export type AppState = {
* indicates a previous tool we should revert back to if we deselect the
* currently active tool. At the moment applies to `eraser` and `hand` tool.
*/
lastActiveTool: LastActiveTool;
lastActiveTool: ActiveTool | null;
locked: boolean;
} & (
| {
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
customType: null;
}
| {
type: "custom";
customType: string;
}
);
} & ActiveTool;
penMode: boolean;
penDetected: boolean;
exportBackground: boolean;
@@ -395,7 +393,7 @@ export interface ExcalidrawProps {
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
isCollaborating?: boolean;
onPointerUpdate?: (payload: {
pointer: { x: number; y: number };
pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => void;
@@ -447,6 +445,14 @@ export interface ExcalidrawProps {
element: NonDeleted<ExcalidrawEmbeddableElement>,
appState: AppState,
) => JSX.Element | null;
activeTool?:
| {
type: ToolType;
}
| {
type: "custom";
customType: string;
};
}
export type SceneData = {
+3 -9
View File
@@ -15,9 +15,8 @@ import {
FontString,
NonDeletedExcalidrawElement,
} from "./element/types";
import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
import { ActiveTool, AppState, DataURL, ToolType, Zoom } from "./types";
import { unstable_batchedUpdates } from "react-dom";
import { SHAPES } from "./shapes";
import { isEraserActive, isHandToolActive } from "./appState";
import { ResolutionType } from "./utility-types";
import React from "react";
@@ -371,15 +370,10 @@ export const updateActiveTool = (
appState: Pick<AppState, "activeTool">,
data: (
| {
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
type: ToolType;
}
| { type: "custom"; customType: string }
) & { lastActiveToolBeforeEraser?: LastActiveTool },
) & { lastActiveToolBeforeEraser?: ActiveTool | null },
): AppState["activeTool"] => {
if (data.type === "custom") {
return {
+5
View File
@@ -1522,6 +1522,11 @@
resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
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":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"