Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb1fa5e14 | ||
|
|
a4e5e46dd1 | ||
|
|
0fa5f5de4c | ||
|
|
41cc746885 | ||
|
|
8ead8559e0 | ||
|
|
5245276409 | ||
|
|
0c24a7042f | ||
|
|
86cfeb714c | ||
|
|
872973f145 | ||
|
|
3ecf72a507 | ||
|
|
1aaa400876 | ||
|
|
65047cc2cb | ||
|
|
8b993d409e | ||
|
|
1cb350b2aa | ||
|
|
43ccc875fb | ||
|
|
4249b7dec8 |
@@ -73,7 +73,7 @@ function App() {
|
|||||||
|
|
||||||
## tools
|
## tools
|
||||||
|
|
||||||
This `prop ` controls the visibility of the tools in the editor.
|
This `prop` controls the visibility of the tools in the editor.
|
||||||
Currently you can control the visibility of `image` tool via this prop.
|
Currently you can control the visibility of `image` tool via this prop.
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
| Prop | Type | Default | Description |
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
preventUnload,
|
preventUnload,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
throttleRAF,
|
throttleRAF,
|
||||||
withBatchedUpdates,
|
|
||||||
} from "../../packages/excalidraw/utils";
|
} from "../../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
CURSOR_SYNC_TIMEOUT,
|
CURSOR_SYNC_TIMEOUT,
|
||||||
@@ -83,6 +82,7 @@ import { atom, useAtom } from "jotai";
|
|||||||
import { appJotaiStore } from "../app-jotai";
|
import { appJotaiStore } from "../app-jotai";
|
||||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const collabDialogShownAtom = atom(false);
|
export const collabDialogShownAtom = atom(false);
|
||||||
@@ -442,6 +442,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fallbackInitializationHandler = () => {
|
const fallbackInitializationHandler = () => {
|
||||||
|
console.log("fallbackInitializationHandler");
|
||||||
this.initializeRoom({
|
this.initializeRoom({
|
||||||
roomLinkData: existingRoomLinkData,
|
roomLinkData: existingRoomLinkData,
|
||||||
fetchScene: true,
|
fetchScene: true,
|
||||||
@@ -516,7 +517,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
case WS_SUBTYPES.INVALID_RESPONSE:
|
case WS_SUBTYPES.INVALID_RESPONSE:
|
||||||
return;
|
return;
|
||||||
case WS_SUBTYPES.INIT: {
|
case WS_SUBTYPES.INIT: {
|
||||||
|
console.log("INIT (1)");
|
||||||
if (!this.portal.socketInitialized) {
|
if (!this.portal.socketInitialized) {
|
||||||
|
console.log("INIT (2)");
|
||||||
this.initializeRoom({ fetchScene: false });
|
this.initializeRoom({ fetchScene: false });
|
||||||
const remoteElements = decryptedData.payload.elements;
|
const remoteElements = decryptedData.payload.elements;
|
||||||
const reconciledElements = this.reconcileElements(remoteElements);
|
const reconciledElements = this.reconcileElements(remoteElements);
|
||||||
@@ -606,6 +609,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.portal.socket.on("first-in-room", async () => {
|
this.portal.socket.on("first-in-room", async () => {
|
||||||
|
console.log("first-in-room");
|
||||||
if (this.portal.socket) {
|
if (this.portal.socket) {
|
||||||
this.portal.socket.off("first-in-room");
|
this.portal.socket.off("first-in-room");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,17 @@ class Portal {
|
|||||||
this.roomId = id;
|
this.roomId = id;
|
||||||
this.roomKey = key;
|
this.roomKey = key;
|
||||||
|
|
||||||
|
this.socket.on("connect", () => {
|
||||||
|
console.log("connect");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("subbing to init-room");
|
||||||
|
|
||||||
// Initialize socket listeners
|
// Initialize socket listeners
|
||||||
this.socket.on("init-room", () => {
|
this.socket.on("init-room", () => {
|
||||||
|
console.log("init-room (1)");
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
|
console.log("init-room (2)");
|
||||||
this.socket.emit("join-room", this.roomId);
|
this.socket.emit("join-room", this.roomId);
|
||||||
trackEvent("share", "room joined");
|
trackEvent("share", "room joined");
|
||||||
}
|
}
|
||||||
@@ -53,6 +61,7 @@ class Portal {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.socket.on("room-user-change", (clients: SocketId[]) => {
|
this.socket.on("room-user-change", (clients: SocketId[]) => {
|
||||||
|
console.log("room-user-change", clients);
|
||||||
this.collab.setCollaborators(clients);
|
this.collab.setCollaborators(clients);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"name": "excalidraw-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"homepage": ".",
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
@@ -22,17 +26,13 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"homepage": ".",
|
|
||||||
"name": "excalidraw-app",
|
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||||
"build:version": "node ../scripts/build-version.js",
|
"build:version": "node ../scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build": "yarn build:app && yarn build:version",
|
||||||
"install:deps": "yarn install --frozen-lockfile && yarn --cwd ../",
|
|
||||||
"start": "yarn && vite",
|
"start": "yarn && vite",
|
||||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||||
"build:preview": "yarn build && vite preview --port 5000"
|
"build:preview": "yarn build && vite preview --port 5000"
|
||||||
|
|||||||
@@ -14,9 +14,44 @@ Please add the latest change on the top under the correct section.
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||||
|
- Remove `ExcalidrawEmbeddableElement.validated` attribute. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
|
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||||
|
|
||||||
|
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
||||||
|
|
||||||
|
#### Bundler
|
||||||
|
|
||||||
|
- CSS needs to be imported so you will need to import the css along with the excalidraw component
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `types` path is updated
|
||||||
|
|
||||||
|
Instead of importing from `@excalidraw/excalidraw/types/`, you will need to import from `@excalidraw/excalidraw/dist/excalidraw` or `@excalidraw/excalidraw/dist/utils` depending on the types you are using.
|
||||||
|
|
||||||
|
However this we will be fixing before stable release, so in case you want to try it out you will need to update the types for now.
|
||||||
|
|
||||||
|
#### Browser
|
||||||
|
|
||||||
|
- Since its `ESM` so now script type `module` can be used to load it and css needs to be loaded as well.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@excalidraw/excalidraw@next/dist/browser/dev/index.css"
|
||||||
|
/>
|
||||||
|
<script type="module">
|
||||||
|
import * as ExcalidrawLib from "https://unpkg.com/@excalidraw/excalidraw@next/dist/browser/dev/index.js";
|
||||||
|
window.ExcalidrawLib = ExcalidrawLib;
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||||
|
|
||||||
## 0.17.1 (2023-11-28)
|
## 0.17.1 (2023-11-28)
|
||||||
@@ -233,7 +268,7 @@ define: {
|
|||||||
|
|
||||||
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
|
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
|
||||||
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
|
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
|
||||||
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateEmbeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
|
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
|
||||||
- Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581).
|
- Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581).
|
||||||
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
|
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
|
||||||
- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
|
- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||||
|
import { AnimationFrameHandler } from "./animation-frame-handler";
|
||||||
|
import { AppState } from "./types";
|
||||||
|
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
|
||||||
|
import type App from "./components/App";
|
||||||
|
import { SVG_NS } from "./constants";
|
||||||
|
|
||||||
|
export interface Trail {
|
||||||
|
start(container: SVGSVGElement): void;
|
||||||
|
stop(): void;
|
||||||
|
|
||||||
|
startPath(x: number, y: number): void;
|
||||||
|
addPointToPath(x: number, y: number): void;
|
||||||
|
endPath(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimatedTrailOptions {
|
||||||
|
fill: (trail: AnimatedTrail) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnimatedTrail implements Trail {
|
||||||
|
private currentTrail?: LaserPointer;
|
||||||
|
private pastTrails: LaserPointer[] = [];
|
||||||
|
|
||||||
|
private container?: SVGSVGElement;
|
||||||
|
private trailElement: SVGPathElement;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private animationFrameHandler: AnimationFrameHandler,
|
||||||
|
private app: App,
|
||||||
|
private options: Partial<LaserPointerOptions> &
|
||||||
|
Partial<AnimatedTrailOptions>,
|
||||||
|
) {
|
||||||
|
this.animationFrameHandler.register(this, this.onFrame.bind(this));
|
||||||
|
|
||||||
|
this.trailElement = document.createElementNS(SVG_NS, "path");
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasCurrentTrail() {
|
||||||
|
return !!this.currentTrail;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLastPoint(x: number, y: number) {
|
||||||
|
if (this.currentTrail) {
|
||||||
|
const len = this.currentTrail.originalPoints.length;
|
||||||
|
return (
|
||||||
|
this.currentTrail.originalPoints[len - 1][0] === x &&
|
||||||
|
this.currentTrail.originalPoints[len - 1][1] === y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(container?: SVGSVGElement) {
|
||||||
|
if (container) {
|
||||||
|
this.container = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.trailElement.parentNode !== this.container && this.container) {
|
||||||
|
this.container.appendChild(this.trailElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationFrameHandler.start(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.animationFrameHandler.stop(this);
|
||||||
|
|
||||||
|
if (this.trailElement.parentNode === this.container) {
|
||||||
|
this.container?.removeChild(this.trailElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startPath(x: number, y: number) {
|
||||||
|
this.currentTrail = new LaserPointer(this.options);
|
||||||
|
|
||||||
|
this.currentTrail.addPoint([x, y, performance.now()]);
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
addPointToPath(x: number, y: number) {
|
||||||
|
if (this.currentTrail) {
|
||||||
|
this.currentTrail.addPoint([x, y, performance.now()]);
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endPath() {
|
||||||
|
if (this.currentTrail) {
|
||||||
|
this.currentTrail.close();
|
||||||
|
this.currentTrail.options.keepHead = false;
|
||||||
|
this.pastTrails.push(this.currentTrail);
|
||||||
|
this.currentTrail = undefined;
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onFrame() {
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
for (const trail of this.pastTrails) {
|
||||||
|
paths.push(this.drawTrail(trail, this.app.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentTrail) {
|
||||||
|
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
|
||||||
|
|
||||||
|
paths.push(currentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pastTrails = this.pastTrails.filter((trail) => {
|
||||||
|
return trail.getStrokeOutline().length !== 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paths.length === 0) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgPaths = paths.join(" ").trim();
|
||||||
|
|
||||||
|
this.trailElement.setAttribute("d", svgPaths);
|
||||||
|
this.trailElement.setAttribute(
|
||||||
|
"fill",
|
||||||
|
(this.options.fill ?? (() => "black"))(this),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawTrail(trail: LaserPointer, state: AppState): string {
|
||||||
|
const stroke = trail
|
||||||
|
.getStrokeOutline(trail.options.size / state.zoom.value)
|
||||||
|
.map(([x, y]) => {
|
||||||
|
const result = sceneCoordsToViewportCoords(
|
||||||
|
{ sceneX: x, sceneY: y },
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [result.x, result.y];
|
||||||
|
});
|
||||||
|
|
||||||
|
return getSvgPathFromStroke(stroke, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export type AnimationCallback = (timestamp: number) => void | boolean;
|
||||||
|
|
||||||
|
export type AnimationTarget = {
|
||||||
|
callback: AnimationCallback;
|
||||||
|
stopped: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AnimationFrameHandler {
|
||||||
|
private targets = new WeakMap<object, AnimationTarget>();
|
||||||
|
private rafIds = new WeakMap<object, number>();
|
||||||
|
|
||||||
|
register(key: object, callback: AnimationCallback) {
|
||||||
|
this.targets.set(key, { callback, stopped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
start(key: object) {
|
||||||
|
const target = this.targets.get(key);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.rafIds.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targets.set(key, { ...target, stopped: false });
|
||||||
|
this.scheduleFrame(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(key: object) {
|
||||||
|
const target = this.targets.get(key);
|
||||||
|
if (target && !target.stopped) {
|
||||||
|
this.targets.set(key, { ...target, stopped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cancelFrame(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructFrame(key: object): FrameRequestCallback {
|
||||||
|
return (timestamp: number) => {
|
||||||
|
const target = this.targets.get(key);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldAbort = this.onFrame(target, timestamp);
|
||||||
|
|
||||||
|
if (!target.stopped && !shouldAbort) {
|
||||||
|
this.scheduleFrame(key);
|
||||||
|
} else {
|
||||||
|
this.cancelFrame(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleFrame(key: object) {
|
||||||
|
const rafId = requestAnimationFrame(this.constructFrame(key));
|
||||||
|
|
||||||
|
this.rafIds.set(key, rafId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelFrame(key: object) {
|
||||||
|
if (this.rafIds.has(key)) {
|
||||||
|
const rafId = this.rafIds.get(key)!;
|
||||||
|
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rafIds.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onFrame(target: AnimationTarget, timestamp: number): boolean {
|
||||||
|
const shouldAbort = target.callback(timestamp);
|
||||||
|
|
||||||
|
return shouldAbort ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,6 @@ import {
|
|||||||
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
|
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
DRAGGING_THRESHOLD,
|
DRAGGING_THRESHOLD,
|
||||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
|
||||||
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
|
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
|
||||||
ELEMENT_TRANSLATE_AMOUNT,
|
ELEMENT_TRANSLATE_AMOUNT,
|
||||||
ENV,
|
ENV,
|
||||||
@@ -182,6 +181,7 @@ import {
|
|||||||
ExcalidrawIframeLikeElement,
|
ExcalidrawIframeLikeElement,
|
||||||
IframeData,
|
IframeData,
|
||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
|
ExcalidrawEmbeddableElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getCenter, getDistance } from "../gesture";
|
import { getCenter, getDistance } from "../gesture";
|
||||||
import {
|
import {
|
||||||
@@ -246,6 +246,8 @@ import {
|
|||||||
ToolType,
|
ToolType,
|
||||||
OnUserFollowedPayload,
|
OnUserFollowedPayload,
|
||||||
UnsubscribeCallback,
|
UnsubscribeCallback,
|
||||||
|
EmbedsValidationStatus,
|
||||||
|
ElementsPendingErasure,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
debounce,
|
debounce,
|
||||||
@@ -258,9 +260,7 @@ import {
|
|||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
withBatchedUpdates,
|
|
||||||
wrapEvent,
|
wrapEvent,
|
||||||
withBatchedUpdatesThrottled,
|
|
||||||
updateObject,
|
updateObject,
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
@@ -271,11 +271,12 @@ import {
|
|||||||
easeOut,
|
easeOut,
|
||||||
updateStable,
|
updateStable,
|
||||||
addEventListener,
|
addEventListener,
|
||||||
|
normalizeEOL,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import {
|
import {
|
||||||
createSrcDoc,
|
createSrcDoc,
|
||||||
embeddableURLValidator,
|
embeddableURLValidator,
|
||||||
extractSrc,
|
maybeParseEmbedSrc,
|
||||||
getEmbedLink,
|
getEmbedLink,
|
||||||
} from "../element/embeddable";
|
} from "../element/embeddable";
|
||||||
import {
|
import {
|
||||||
@@ -384,8 +385,7 @@ 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 { SVGLayer } from "./SVGLayer";
|
||||||
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
|
||||||
import {
|
import {
|
||||||
setEraserCursor,
|
setEraserCursor,
|
||||||
setCursor,
|
setCursor,
|
||||||
@@ -402,6 +402,12 @@ import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
|||||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||||
import FollowMode from "./FollowMode/FollowMode";
|
import FollowMode from "./FollowMode/FollowMode";
|
||||||
|
|
||||||
|
import { AnimationFrameHandler } from "../animation-frame-handler";
|
||||||
|
import { AnimatedTrail } from "../animated-trail";
|
||||||
|
import { LaserTrails } from "../laser-trails";
|
||||||
|
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||||
|
import { getRenderOpacity } from "../renderer/renderElement";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
|
|
||||||
@@ -524,6 +530,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
public files: BinaryFiles = {};
|
public files: BinaryFiles = {};
|
||||||
public imageCache: AppClassProperties["imageCache"] = new Map();
|
public imageCache: AppClassProperties["imageCache"] = new Map();
|
||||||
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
|
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
|
||||||
|
/**
|
||||||
|
* Indicates whether the embeddable's url has been validated for rendering.
|
||||||
|
* If value not set, indicates that the validation is pending.
|
||||||
|
* Initially or on url change the flag is not reset so that we can guarantee
|
||||||
|
* the validation came from a trusted source (the editor).
|
||||||
|
**/
|
||||||
|
private embedsValidationStatus: EmbedsValidationStatus = new Map();
|
||||||
|
/** embeds that have been inserted to DOM (as a perf optim, we don't want to
|
||||||
|
* insert to DOM before user initially scrolls to them) */
|
||||||
|
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
|
||||||
|
|
||||||
|
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
||||||
|
|
||||||
hitLinkElement?: NonDeletedExcalidrawElement;
|
hitLinkElement?: NonDeletedExcalidrawElement;
|
||||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||||
@@ -532,7 +550,29 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
lastPointerMoveEvent: PointerEvent | null = null;
|
lastPointerMoveEvent: PointerEvent | null = null;
|
||||||
lastViewportPosition = { x: 0, y: 0 };
|
lastViewportPosition = { x: 0, y: 0 };
|
||||||
|
|
||||||
laserPathManager: LaserPathManager = new LaserPathManager(this);
|
animationFrameHandler = new AnimationFrameHandler();
|
||||||
|
|
||||||
|
laserTrails = new LaserTrails(this.animationFrameHandler, this);
|
||||||
|
eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, {
|
||||||
|
streamline: 0.2,
|
||||||
|
size: 5,
|
||||||
|
keepHead: true,
|
||||||
|
sizeMapping: (c) => {
|
||||||
|
const DECAY_TIME = 200;
|
||||||
|
const DECAY_LENGTH = 10;
|
||||||
|
const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME);
|
||||||
|
const l =
|
||||||
|
(DECAY_LENGTH -
|
||||||
|
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
|
||||||
|
DECAY_LENGTH;
|
||||||
|
|
||||||
|
return Math.min(easeOut(l), easeOut(t));
|
||||||
|
},
|
||||||
|
fill: () =>
|
||||||
|
this.state.theme === THEME.LIGHT
|
||||||
|
? "rgba(0, 0, 0, 0.2)"
|
||||||
|
: "rgba(255, 255, 255, 0.2)",
|
||||||
|
});
|
||||||
|
|
||||||
onChangeEmitter = new Emitter<
|
onChangeEmitter = new Emitter<
|
||||||
[
|
[
|
||||||
@@ -839,6 +879,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateEmbedValidationStatus = (
|
||||||
|
element: ExcalidrawEmbeddableElement,
|
||||||
|
status: boolean,
|
||||||
|
) => {
|
||||||
|
this.embedsValidationStatus.set(element.id, status);
|
||||||
|
ShapeCache.delete(element);
|
||||||
|
};
|
||||||
|
|
||||||
private updateEmbeddables = () => {
|
private updateEmbeddables = () => {
|
||||||
const iframeLikes = new Set<ExcalidrawIframeLikeElement["id"]>();
|
const iframeLikes = new Set<ExcalidrawIframeLikeElement["id"]>();
|
||||||
|
|
||||||
@@ -846,7 +894,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElements().filter((element) => {
|
this.scene.getNonDeletedElements().filter((element) => {
|
||||||
if (isEmbeddableElement(element)) {
|
if (isEmbeddableElement(element)) {
|
||||||
iframeLikes.add(element.id);
|
iframeLikes.add(element.id);
|
||||||
if (element.validated == null) {
|
if (!this.embedsValidationStatus.has(element.id)) {
|
||||||
updated = true;
|
updated = true;
|
||||||
|
|
||||||
const validated = embeddableURLValidator(
|
const validated = embeddableURLValidator(
|
||||||
@@ -854,8 +902,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.props.validateEmbeddable,
|
this.props.validateEmbeddable,
|
||||||
);
|
);
|
||||||
|
|
||||||
mutateElement(element, { validated }, false);
|
this.updateEmbedValidationStatus(element, validated);
|
||||||
ShapeCache.delete(element);
|
|
||||||
}
|
}
|
||||||
} else if (isIframeElement(element)) {
|
} else if (isIframeElement(element)) {
|
||||||
iframeLikes.add(element.id);
|
iframeLikes.add(element.id);
|
||||||
@@ -884,7 +931,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
.getNonDeletedElements()
|
.getNonDeletedElements()
|
||||||
.filter(
|
.filter(
|
||||||
(el): el is NonDeleted<ExcalidrawIframeLikeElement> =>
|
(el): el is NonDeleted<ExcalidrawIframeLikeElement> =>
|
||||||
(isEmbeddableElement(el) && !!el.validated) || isIframeElement(el),
|
(isEmbeddableElement(el) &&
|
||||||
|
this.embedsValidationStatus.get(el.id) === true) ||
|
||||||
|
isIframeElement(el),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -895,6 +944,23 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isVisible = isElementInViewport(
|
||||||
|
el,
|
||||||
|
normalizedWidth,
|
||||||
|
normalizedHeight,
|
||||||
|
this.state,
|
||||||
|
);
|
||||||
|
const hasBeenInitialized = this.initializedEmbeds.has(el.id);
|
||||||
|
|
||||||
|
if (isVisible && !hasBeenInitialized) {
|
||||||
|
this.initializedEmbeds.add(el.id);
|
||||||
|
}
|
||||||
|
const shouldRender = isVisible || hasBeenInitialized;
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let src: IframeData | null;
|
let src: IframeData | null;
|
||||||
|
|
||||||
if (isIframeElement(el)) {
|
if (isIframeElement(el)) {
|
||||||
@@ -1036,14 +1102,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
src = getEmbedLink(toValidURL(el.link || ""));
|
src = getEmbedLink(toValidURL(el.link || ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log({ src });
|
|
||||||
|
|
||||||
const isVisible = isElementInViewport(
|
|
||||||
el,
|
|
||||||
normalizedWidth,
|
|
||||||
normalizedHeight,
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
const isActive =
|
const isActive =
|
||||||
this.state.activeEmbeddable?.element === el &&
|
this.state.activeEmbeddable?.element === el &&
|
||||||
this.state.activeEmbeddable?.state === "active";
|
this.state.activeEmbeddable?.state === "active";
|
||||||
@@ -1064,7 +1122,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}px) scale(${scale})`
|
}px) scale(${scale})`
|
||||||
: "none",
|
: "none",
|
||||||
display: isVisible ? "block" : "none",
|
display: isVisible ? "block" : "none",
|
||||||
opacity: el.opacity / 100,
|
opacity: getRenderOpacity(
|
||||||
|
el,
|
||||||
|
getContainingFrame(el),
|
||||||
|
this.elementsPendingErasure,
|
||||||
|
),
|
||||||
["--embeddable-radius" as string]: `${getCornerRadius(
|
["--embeddable-radius" as string]: `${getCornerRadius(
|
||||||
Math.min(el.width, el.height),
|
Math.min(el.width, el.height),
|
||||||
el,
|
el,
|
||||||
@@ -1453,7 +1515,9 @@ 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} />
|
<SVGLayer
|
||||||
|
trails={[this.laserTrails, this.eraserTrail]}
|
||||||
|
/>
|
||||||
{selectedElements.length === 1 &&
|
{selectedElements.length === 1 &&
|
||||||
this.state.showHyperlinkPopup && (
|
this.state.showHyperlinkPopup && (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
@@ -1462,6 +1526,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
setAppState={this.setAppState}
|
setAppState={this.setAppState}
|
||||||
onLinkOpen={this.props.onLinkOpen}
|
onLinkOpen={this.props.onLinkOpen}
|
||||||
setToast={this.setToast}
|
setToast={this.setToast}
|
||||||
|
updateEmbedValidationStatus={
|
||||||
|
this.updateEmbedValidationStatus
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{this.props.aiEnabled !== false &&
|
{this.props.aiEnabled !== false &&
|
||||||
@@ -1572,6 +1639,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
renderGrid: true,
|
renderGrid: true,
|
||||||
canvasBackgroundColor:
|
canvasBackgroundColor:
|
||||||
this.state.viewBackgroundColor,
|
this.state.viewBackgroundColor,
|
||||||
|
embedsValidationStatus: this.embedsValidationStatus,
|
||||||
|
elementsPendingErasure: this.elementsPendingErasure,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<InteractiveCanvas
|
<InteractiveCanvas
|
||||||
@@ -2375,7 +2444,8 @@ 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();
|
this.laserTrails.stop();
|
||||||
|
this.eraserTrail.stop();
|
||||||
this.onChangeEmitter.clear();
|
this.onChangeEmitter.clear();
|
||||||
ShapeCache.destroy();
|
ShapeCache.destroy();
|
||||||
SnapCache.destroy();
|
SnapCache.destroy();
|
||||||
@@ -2600,6 +2670,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.updateLanguage();
|
this.updateLanguage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEraserActive(prevState) && !isEraserActive(this.state)) {
|
||||||
|
this.eraserTrail.endPath();
|
||||||
|
}
|
||||||
|
|
||||||
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
||||||
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
|
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
|
||||||
}
|
}
|
||||||
@@ -2924,21 +2998,49 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
retainSeed: isPlainPaste,
|
retainSeed: isPlainPaste,
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
const maybeUrl = extractSrc(data.text);
|
const nonEmptyLines = normalizeEOL(data.text)
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const embbeddableUrls = nonEmptyLines
|
||||||
|
.map((str) => maybeParseEmbedSrc(str))
|
||||||
|
.filter((string) => {
|
||||||
|
return (
|
||||||
|
embeddableURLValidator(string, this.props.validateEmbeddable) &&
|
||||||
|
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
|
||||||
|
getEmbedLink(string)?.type === "video")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isPlainPaste &&
|
!IS_PLAIN_PASTE &&
|
||||||
embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) &&
|
embbeddableUrls.length > 0 &&
|
||||||
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(maybeUrl) ||
|
// if there were non-embeddable text (lines) mixed in with embeddable
|
||||||
getEmbedLink(maybeUrl)?.type === "video")
|
// urls, ignore and paste as text
|
||||||
|
embbeddableUrls.length === nonEmptyLines.length
|
||||||
) {
|
) {
|
||||||
const embeddable = this.insertEmbeddableElement({
|
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
|
||||||
sceneX,
|
for (const url of embbeddableUrls) {
|
||||||
sceneY,
|
const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
|
||||||
link: normalizeLink(maybeUrl),
|
embeddables[embeddables.length - 1];
|
||||||
});
|
const embeddable = this.insertEmbeddableElement({
|
||||||
if (embeddable) {
|
sceneX: prevEmbeddable
|
||||||
this.setState({ selectedElementIds: { [embeddable.id]: true } });
|
? prevEmbeddable.x + prevEmbeddable.width + 20
|
||||||
|
: sceneX,
|
||||||
|
sceneY,
|
||||||
|
link: normalizeLink(url),
|
||||||
|
});
|
||||||
|
if (embeddable) {
|
||||||
|
embeddables.push(embeddable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (embeddables.length) {
|
||||||
|
this.setState({
|
||||||
|
selectedElementIds: Object.fromEntries(
|
||||||
|
embeddables.map((embeddable) => [embeddable.id, true]),
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5023,30 +5125,48 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
const updateElementIds = (elements: ExcalidrawElement[]) => {
|
this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y);
|
||||||
elements.forEach((element) => {
|
|
||||||
|
let didChange = false;
|
||||||
|
|
||||||
|
const processedGroups = new Set<ExcalidrawElement["id"]>();
|
||||||
|
const nonDeletedElements = this.scene.getNonDeletedElements();
|
||||||
|
|
||||||
|
const processElements = (elements: ExcalidrawElement[]) => {
|
||||||
|
for (const element of elements) {
|
||||||
if (element.locked) {
|
if (element.locked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
idsToUpdate.push(element.id);
|
|
||||||
if (event.altKey) {
|
if (event.altKey) {
|
||||||
if (
|
if (this.elementsPendingErasure.delete(element.id)) {
|
||||||
pointerDownState.elementIdsToErase[element.id] &&
|
didChange = true;
|
||||||
pointerDownState.elementIdsToErase[element.id].erase
|
|
||||||
) {
|
|
||||||
pointerDownState.elementIdsToErase[element.id].erase = false;
|
|
||||||
}
|
}
|
||||||
} else if (!pointerDownState.elementIdsToErase[element.id]) {
|
} else if (!this.elementsPendingErasure.has(element.id)) {
|
||||||
pointerDownState.elementIdsToErase[element.id] = {
|
didChange = true;
|
||||||
erase: true,
|
this.elementsPendingErasure.add(element.id);
|
||||||
opacity: element.opacity,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const idsToUpdate: Array<string> = [];
|
// (un)erase groups atomically
|
||||||
|
if (didChange && element.groupIds?.length) {
|
||||||
|
const shallowestGroupId = element.groupIds.at(-1)!;
|
||||||
|
if (!processedGroups.has(shallowestGroupId)) {
|
||||||
|
processedGroups.add(shallowestGroupId);
|
||||||
|
const elems = getElementsInGroup(
|
||||||
|
nonDeletedElements,
|
||||||
|
shallowestGroupId,
|
||||||
|
);
|
||||||
|
for (const elem of elems) {
|
||||||
|
if (event.altKey) {
|
||||||
|
this.elementsPendingErasure.delete(elem.id);
|
||||||
|
} else {
|
||||||
|
this.elementsPendingErasure.add(elem.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const distance = distance2d(
|
const distance = distance2d(
|
||||||
pointerDownState.lastCoords.x,
|
pointerDownState.lastCoords.x,
|
||||||
@@ -5059,7 +5179,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
let samplingInterval = 0;
|
let samplingInterval = 0;
|
||||||
while (samplingInterval <= distance) {
|
while (samplingInterval <= distance) {
|
||||||
const hitElements = this.getElementsAtPosition(point.x, point.y);
|
const hitElements = this.getElementsAtPosition(point.x, point.y);
|
||||||
updateElementIds(hitElements);
|
processElements(hitElements);
|
||||||
|
|
||||||
// Exit since we reached current point
|
// Exit since we reached current point
|
||||||
if (samplingInterval === distance) {
|
if (samplingInterval === distance) {
|
||||||
@@ -5078,35 +5198,31 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
point.y = nextY;
|
point.y = nextY;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
|
|
||||||
const id =
|
|
||||||
isBoundToContainer(ele) && idsToUpdate.includes(ele.containerId)
|
|
||||||
? ele.containerId
|
|
||||||
: ele.id;
|
|
||||||
if (idsToUpdate.includes(id)) {
|
|
||||||
if (event.altKey) {
|
|
||||||
if (
|
|
||||||
pointerDownState.elementIdsToErase[id] &&
|
|
||||||
pointerDownState.elementIdsToErase[id].erase === false
|
|
||||||
) {
|
|
||||||
return newElementWith(ele, {
|
|
||||||
opacity: pointerDownState.elementIdsToErase[id].opacity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return newElementWith(ele, {
|
|
||||||
opacity: ELEMENT_READY_TO_ERASE_OPACITY,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ele;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.replaceAllElements(elements);
|
|
||||||
|
|
||||||
pointerDownState.lastCoords.x = scenePointer.x;
|
pointerDownState.lastCoords.x = scenePointer.x;
|
||||||
pointerDownState.lastCoords.y = scenePointer.y;
|
pointerDownState.lastCoords.y = scenePointer.y;
|
||||||
|
|
||||||
|
if (didChange) {
|
||||||
|
for (const element of this.scene.getNonDeletedElements()) {
|
||||||
|
if (
|
||||||
|
isBoundToContainer(element) &&
|
||||||
|
(this.elementsPendingErasure.has(element.id) ||
|
||||||
|
this.elementsPendingErasure.has(element.containerId))
|
||||||
|
) {
|
||||||
|
if (event.altKey) {
|
||||||
|
this.elementsPendingErasure.delete(element.id);
|
||||||
|
this.elementsPendingErasure.delete(element.containerId);
|
||||||
|
} else {
|
||||||
|
this.elementsPendingErasure.add(element.id);
|
||||||
|
this.elementsPendingErasure.add(element.containerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elementsPendingErasure = new Set(this.elementsPendingErasure);
|
||||||
|
this.onSceneUpdated();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// set touch moving for mobile context menu
|
// set touch moving for mobile context menu
|
||||||
private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
|
private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
invalidateContextMenu = true;
|
invalidateContextMenu = true;
|
||||||
@@ -5463,7 +5579,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.activeTool.type,
|
this.state.activeTool.type,
|
||||||
);
|
);
|
||||||
} else if (this.state.activeTool.type === "laser") {
|
} else if (this.state.activeTool.type === "laser") {
|
||||||
this.laserPathManager.startPath(
|
this.laserTrails.startPath(
|
||||||
pointerDownState.lastCoords.x,
|
pointerDownState.lastCoords.x,
|
||||||
pointerDownState.lastCoords.y,
|
pointerDownState.lastCoords.y,
|
||||||
);
|
);
|
||||||
@@ -5484,6 +5600,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event,
|
event,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.state.activeTool.type === "eraser") {
|
||||||
|
this.eraserTrail.startPath(
|
||||||
|
pointerDownState.lastCoords.x,
|
||||||
|
pointerDownState.lastCoords.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const onPointerMove =
|
const onPointerMove =
|
||||||
this.onPointerMoveFromPointerDownHandler(pointerDownState);
|
this.onPointerMoveFromPointerDownHandler(pointerDownState);
|
||||||
|
|
||||||
@@ -5792,7 +5915,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
boxSelection: {
|
boxSelection: {
|
||||||
hasOccurred: false,
|
hasOccurred: false,
|
||||||
},
|
},
|
||||||
elementIdsToErase: {},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6348,7 +6470,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
width: embedLink.intrinsicSize.w,
|
width: embedLink.intrinsicSize.w,
|
||||||
height: embedLink.intrinsicSize.h,
|
height: embedLink.intrinsicSize.h,
|
||||||
link,
|
link,
|
||||||
validated: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scene.replaceAllElements([
|
this.scene.replaceAllElements([
|
||||||
@@ -6582,7 +6703,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (elementType === "embeddable") {
|
if (elementType === "embeddable") {
|
||||||
element = newEmbeddableElement({
|
element = newEmbeddableElement({
|
||||||
type: "embeddable",
|
type: "embeddable",
|
||||||
validated: null,
|
|
||||||
...baseElementAttributes,
|
...baseElementAttributes,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -6748,7 +6868,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.activeTool.type === "laser") {
|
if (this.state.activeTool.type === "laser") {
|
||||||
this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
|
this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [gridX, gridY] = getGridPoint(
|
const [gridX, gridY] = getGridPoint(
|
||||||
@@ -7757,6 +7877,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
|
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
|
||||||
|
|
||||||
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
|
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
|
||||||
|
this.eraserTrail.endPath();
|
||||||
|
|
||||||
const draggedDistance = distance2d(
|
const draggedDistance = distance2d(
|
||||||
pointerStart.clientX,
|
pointerStart.clientX,
|
||||||
pointerStart.clientY,
|
pointerStart.clientY,
|
||||||
@@ -7776,18 +7898,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
);
|
);
|
||||||
hitElements.forEach(
|
hitElements.forEach((hitElement) =>
|
||||||
(hitElement) =>
|
this.elementsPendingErasure.add(hitElement.id),
|
||||||
(pointerDownState.elementIdsToErase[hitElement.id] = {
|
|
||||||
erase: true,
|
|
||||||
opacity: hitElement.opacity,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.eraseElements(pointerDownState);
|
this.eraseElements();
|
||||||
return;
|
return;
|
||||||
} else if (Object.keys(pointerDownState.elementIdsToErase).length) {
|
} else if (this.elementsPendingErasure.size) {
|
||||||
this.restoreReadyToEraseElements(pointerDownState);
|
this.restoreReadyToEraseElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -8009,7 +8127,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "laser") {
|
if (activeTool.type === "laser") {
|
||||||
this.laserPathManager.endPath();
|
this.laserTrails.endPath();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8048,65 +8166,32 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreReadyToEraseElements = (
|
private restoreReadyToEraseElements = () => {
|
||||||
pointerDownState: PointerDownState,
|
this.elementsPendingErasure = new Set();
|
||||||
) => {
|
this.onSceneUpdated();
|
||||||
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
|
|
||||||
if (
|
|
||||||
pointerDownState.elementIdsToErase[ele.id] &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.id].erase
|
|
||||||
) {
|
|
||||||
return newElementWith(ele, {
|
|
||||||
opacity: pointerDownState.elementIdsToErase[ele.id].opacity,
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
isBoundToContainer(ele) &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.containerId] &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.containerId].erase
|
|
||||||
) {
|
|
||||||
return newElementWith(ele, {
|
|
||||||
opacity: pointerDownState.elementIdsToErase[ele.containerId].opacity,
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
ele.frameId &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.frameId] &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.frameId].erase
|
|
||||||
) {
|
|
||||||
return newElementWith(ele, {
|
|
||||||
opacity: pointerDownState.elementIdsToErase[ele.frameId].opacity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ele;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.replaceAllElements(elements);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private eraseElements = (pointerDownState: PointerDownState) => {
|
private eraseElements = () => {
|
||||||
|
let didChange = false;
|
||||||
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
|
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
|
||||||
if (
|
if (
|
||||||
pointerDownState.elementIdsToErase[ele.id] &&
|
this.elementsPendingErasure.has(ele.id) ||
|
||||||
pointerDownState.elementIdsToErase[ele.id].erase
|
(ele.frameId && this.elementsPendingErasure.has(ele.frameId)) ||
|
||||||
) {
|
(isBoundToContainer(ele) &&
|
||||||
return newElementWith(ele, { isDeleted: true });
|
this.elementsPendingErasure.has(ele.containerId))
|
||||||
} else if (
|
|
||||||
isBoundToContainer(ele) &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.containerId] &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.containerId].erase
|
|
||||||
) {
|
|
||||||
return newElementWith(ele, { isDeleted: true });
|
|
||||||
} else if (
|
|
||||||
ele.frameId &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.frameId] &&
|
|
||||||
pointerDownState.elementIdsToErase[ele.frameId].erase
|
|
||||||
) {
|
) {
|
||||||
|
didChange = true;
|
||||||
return newElementWith(ele, { isDeleted: true });
|
return newElementWith(ele, { isDeleted: true });
|
||||||
}
|
}
|
||||||
return ele;
|
return ele;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.history.resumeRecording();
|
this.elementsPendingErasure = new Set();
|
||||||
this.scene.replaceAllElements(elements);
|
|
||||||
|
if (didChange) {
|
||||||
|
this.history.resumeRecording();
|
||||||
|
this.scene.replaceAllElements(elements);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private initializeImage = async ({
|
private initializeImage = async ({
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
import "../ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButtonSize } from "../ToolButton";
|
import { ToolButtonSize } from "./ToolButton";
|
||||||
import { laserPointerToolIcon } from "../icons";
|
import { laserPointerToolIcon } from "./icons";
|
||||||
|
|
||||||
type LaserPointerIconProps = {
|
type LaserPointerIconProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
|
||||||
|
|
||||||
import { sceneCoordsToViewportCoords } from "../../utils";
|
|
||||||
import App from "../App";
|
|
||||||
import { getClientColor } from "../../clients";
|
|
||||||
import { SocketId } from "../../types";
|
|
||||||
|
|
||||||
// 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<SocketId, CollabolatorState> = new Map();
|
|
||||||
|
|
||||||
private rafId: number | undefined;
|
|
||||||
private isDrawing = false;
|
|
||||||
private container: SVGSVGElement | undefined;
|
|
||||||
|
|
||||||
constructor(private app: App) {
|
|
||||||
this.ownState = instantiateCollabolatorState();
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.stop();
|
|
||||||
this.isDrawing = false;
|
|
||||||
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.isDrawing = true;
|
|
||||||
|
|
||||||
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 (this.isDrawing) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let somePathsExist = false;
|
|
||||||
|
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paths.trim()) {
|
|
||||||
somePathsExist = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
paths = paths.trim();
|
|
||||||
|
|
||||||
if (paths) {
|
|
||||||
somePathsExist = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ownState.svg.setAttribute("d", paths);
|
|
||||||
this.ownState.svg.setAttribute("fill", "red");
|
|
||||||
|
|
||||||
if (!somePathsExist) {
|
|
||||||
this.isDrawing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -60,7 +60,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";
|
import { LaserPointerButton } from "./LaserPointerButton";
|
||||||
import { MagicSettings } from "./MagicSettings";
|
import { MagicSettings } from "./MagicSettings";
|
||||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,5 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.LaserToolOverlay {
|
.SVGLayer {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -9,10 +9,12 @@
|
|||||||
|
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
||||||
.LaserToolOverlayCanvas {
|
& svg {
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Trail } from "../animated-trail";
|
||||||
|
|
||||||
|
import "./SVGLayer.scss";
|
||||||
|
|
||||||
|
type SVGLayerProps = {
|
||||||
|
trails: Trail[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SVGLayer = ({ trails }: SVGLayerProps) => {
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (svgRef.current) {
|
||||||
|
for (const trail of trails) {
|
||||||
|
trail.start(svgRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const trail of trails) {
|
||||||
|
trail.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, trails);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="SVGLayer">
|
||||||
|
<svg ref={svgRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { renderInteractiveScene } from "../../renderer/renderScene";
|
import { renderInteractiveScene } from "../../renderer/renderScene";
|
||||||
import {
|
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
|
||||||
isRenderThrottlingEnabled,
|
|
||||||
isShallowEqual,
|
|
||||||
sceneCoordsToViewportCoords,
|
|
||||||
} from "../../utils";
|
|
||||||
import { CURSOR_TYPE } from "../../constants";
|
import { CURSOR_TYPE } from "../../constants";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import type { DOMAttributes } from "react";
|
import type { DOMAttributes } from "react";
|
||||||
@@ -14,6 +10,7 @@ import type {
|
|||||||
RenderInteractiveSceneCallback,
|
RenderInteractiveSceneCallback,
|
||||||
} from "../../scene/types";
|
} from "../../scene/types";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
|
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||||
|
|
||||||
type InteractiveCanvasProps = {
|
type InteractiveCanvasProps = {
|
||||||
containerRef: React.RefObject<HTMLDivElement>;
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import { renderStaticScene } from "../../renderer/renderScene";
|
import { renderStaticScene } from "../../renderer/renderScene";
|
||||||
import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils";
|
import { isShallowEqual } from "../../utils";
|
||||||
import type { AppState, StaticCanvasAppState } from "../../types";
|
import type { AppState, StaticCanvasAppState } from "../../types";
|
||||||
import type { StaticCanvasRenderConfig } from "../../scene/types";
|
import type { StaticCanvasRenderConfig } from "../../scene/types";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
|
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||||
|
|
||||||
type StaticCanvasProps = {
|
type StaticCanvasProps = {
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
|
|||||||
@@ -486,10 +486,11 @@ export const DiscordIcon = createIcon(
|
|||||||
modifiedTablerIconProps,
|
modifiedTablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TwitterIcon = createIcon(
|
export const XBrandIcon = createIcon(
|
||||||
<g strokeWidth="1.25">
|
<g strokeWidth="1.25">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
<path d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"></path>
|
<path d="M4 4l11.733 16h4.267l-11.733 -16z" />
|
||||||
|
<path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772" />
|
||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
usersIcon,
|
usersIcon,
|
||||||
} from "../icons";
|
} from "../icons";
|
||||||
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
|
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
|
||||||
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
||||||
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
||||||
import {
|
import {
|
||||||
@@ -241,31 +241,35 @@ export const Export = () => {
|
|||||||
};
|
};
|
||||||
Export.displayName = "Export";
|
Export.displayName = "Export";
|
||||||
|
|
||||||
export const Socials = () => (
|
export const Socials = () => {
|
||||||
<>
|
const { t } = useI18n();
|
||||||
<DropdownMenuItemLink
|
|
||||||
icon={GithubIcon}
|
return (
|
||||||
href="https://github.com/excalidraw/excalidraw"
|
<>
|
||||||
aria-label="GitHub"
|
<DropdownMenuItemLink
|
||||||
>
|
icon={GithubIcon}
|
||||||
GitHub
|
href="https://github.com/excalidraw/excalidraw"
|
||||||
</DropdownMenuItemLink>
|
aria-label="GitHub"
|
||||||
<DropdownMenuItemLink
|
>
|
||||||
icon={DiscordIcon}
|
GitHub
|
||||||
href="https://discord.gg/UexuTaE"
|
</DropdownMenuItemLink>
|
||||||
aria-label="Discord"
|
<DropdownMenuItemLink
|
||||||
>
|
icon={XBrandIcon}
|
||||||
Discord
|
href="https://x.com/excalidraw"
|
||||||
</DropdownMenuItemLink>
|
aria-label="X"
|
||||||
<DropdownMenuItemLink
|
>
|
||||||
icon={TwitterIcon}
|
{t("labels.followUs")}
|
||||||
href="https://twitter.com/excalidraw"
|
</DropdownMenuItemLink>
|
||||||
aria-label="Twitter"
|
<DropdownMenuItemLink
|
||||||
>
|
icon={DiscordIcon}
|
||||||
Twitter
|
href="https://discord.gg/UexuTaE"
|
||||||
</DropdownMenuItemLink>
|
aria-label="Discord"
|
||||||
</>
|
>
|
||||||
);
|
{t("labels.discordChat")}
|
||||||
|
</DropdownMenuItemLink>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Socials.displayName = "Socials";
|
Socials.displayName = "Socials";
|
||||||
|
|
||||||
export const LiveCollaborationTrigger = ({
|
export const LiveCollaborationTrigger = ({
|
||||||
|
|||||||
@@ -295,11 +295,8 @@ const restoreElement = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
case "iframe":
|
case "iframe":
|
||||||
return restoreElementWithProperties(element, {});
|
|
||||||
case "embeddable":
|
case "embeddable":
|
||||||
return restoreElementWithProperties(element, {
|
return restoreElementWithProperties(element, {});
|
||||||
validated: null,
|
|
||||||
});
|
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
case "frame":
|
case "frame":
|
||||||
return restoreElementWithProperties(element, {
|
return restoreElementWithProperties(element, {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import "./Hyperlink.scss";
|
|||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
||||||
import { isEmbeddableElement } from "./typeChecks";
|
import { isEmbeddableElement } from "./typeChecks";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
|
||||||
|
|
||||||
const CONTAINER_WIDTH = 320;
|
const CONTAINER_WIDTH = 320;
|
||||||
const SPACE_BOTTOM = 85;
|
const SPACE_BOTTOM = 85;
|
||||||
@@ -64,6 +63,7 @@ export const Hyperlink = ({
|
|||||||
setAppState,
|
setAppState,
|
||||||
onLinkOpen,
|
onLinkOpen,
|
||||||
setToast,
|
setToast,
|
||||||
|
updateEmbedValidationStatus,
|
||||||
}: {
|
}: {
|
||||||
element: NonDeletedExcalidrawElement;
|
element: NonDeletedExcalidrawElement;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
@@ -71,6 +71,10 @@ export const Hyperlink = ({
|
|||||||
setToast: (
|
setToast: (
|
||||||
toast: { message: string; closable?: boolean; duration?: number } | null,
|
toast: { message: string; closable?: boolean; duration?: number } | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
updateEmbedValidationStatus: (
|
||||||
|
element: ExcalidrawEmbeddableElement,
|
||||||
|
status: boolean,
|
||||||
|
) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const appState = useExcalidrawAppState();
|
const appState = useExcalidrawAppState();
|
||||||
const appProps = useAppProps();
|
const appProps = useAppProps();
|
||||||
@@ -98,9 +102,9 @@ export const Hyperlink = ({
|
|||||||
}
|
}
|
||||||
if (!link) {
|
if (!link) {
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
validated: false,
|
|
||||||
link: null,
|
link: null,
|
||||||
});
|
});
|
||||||
|
updateEmbedValidationStatus(element, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +114,9 @@ export const Hyperlink = ({
|
|||||||
}
|
}
|
||||||
element.link && embeddableLinkCache.set(element.id, element.link);
|
element.link && embeddableLinkCache.set(element.id, element.link);
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
validated: false,
|
|
||||||
link,
|
link,
|
||||||
});
|
});
|
||||||
ShapeCache.delete(element);
|
updateEmbedValidationStatus(element, false);
|
||||||
} else {
|
} else {
|
||||||
const { width, height } = element;
|
const { width, height } = element;
|
||||||
const embedLink = getEmbedLink(link);
|
const embedLink = getEmbedLink(link);
|
||||||
@@ -142,10 +145,9 @@ export const Hyperlink = ({
|
|||||||
: height,
|
: height,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
validated: true,
|
|
||||||
link,
|
link,
|
||||||
});
|
});
|
||||||
ShapeCache.delete(element);
|
updateEmbedValidationStatus(element, true);
|
||||||
if (embeddableLinkCache.has(element.id)) {
|
if (embeddableLinkCache.has(element.id)) {
|
||||||
embeddableLinkCache.delete(element.id);
|
embeddableLinkCache.delete(element.id);
|
||||||
}
|
}
|
||||||
@@ -159,6 +161,7 @@ export const Hyperlink = ({
|
|||||||
appProps.validateEmbeddable,
|
appProps.validateEmbeddable,
|
||||||
appState.activeEmbeddable,
|
appState.activeEmbeddable,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
updateEmbedValidationStatus,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|||||||
@@ -5,14 +5,9 @@ import { getPerfectElementSize } from "./sizeHelpers";
|
|||||||
import { NonDeletedExcalidrawElement } from "./types";
|
import { NonDeletedExcalidrawElement } from "./types";
|
||||||
import { AppState, PointerDownState } from "../types";
|
import { AppState, PointerDownState } from "../types";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import { isSelectedViaGroup } from "../groups";
|
|
||||||
import { getGridPoint } from "../math";
|
import { getGridPoint } from "../math";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import {
|
import { isArrowElement, isFrameLikeElement } from "./typeChecks";
|
||||||
isArrowElement,
|
|
||||||
isBoundToContainer,
|
|
||||||
isFrameLikeElement,
|
|
||||||
} from "./typeChecks";
|
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
@@ -37,13 +32,11 @@ export const dragSelectedElements = (
|
|||||||
.map((f) => f.id);
|
.map((f) => f.id);
|
||||||
|
|
||||||
if (frames.length > 0) {
|
if (frames.length > 0) {
|
||||||
const elementsInFrames = scene
|
for (const element of scene.getNonDeletedElements()) {
|
||||||
.getNonDeletedElements()
|
if (element.frameId !== null && frames.includes(element.frameId)) {
|
||||||
.filter((e) => !isBoundToContainer(e))
|
elementsToUpdate.add(element);
|
||||||
.filter((e) => e.frameId !== null)
|
}
|
||||||
.filter((e) => frames.includes(e.frameId!));
|
}
|
||||||
|
|
||||||
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonBounds = getCommonBounds(
|
const commonBounds = getCommonBounds(
|
||||||
@@ -60,16 +53,9 @@ export const dragSelectedElements = (
|
|||||||
|
|
||||||
elementsToUpdate.forEach((element) => {
|
elementsToUpdate.forEach((element) => {
|
||||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||||
// update coords of bound text only if we're dragging the container directly
|
|
||||||
// (we don't drag the group that it's part of)
|
|
||||||
if (
|
if (
|
||||||
// Don't update coords of arrow label since we calculate its position during render
|
// skip arrow labels since we calculate its position during render
|
||||||
!isArrowElement(element) &&
|
!isArrowElement(element)
|
||||||
// container isn't part of any group
|
|
||||||
// (perf optim so we don't check `isSelectedViaGroup()` in every case)
|
|
||||||
(!element.groupIds.length ||
|
|
||||||
// container is part of a group, but we're dragging the container directly
|
|
||||||
(appState.editingGroupId && !isSelectedViaGroup(appState, element)))
|
|
||||||
) {
|
) {
|
||||||
const textElement = getBoundTextElement(element);
|
const textElement = getBoundTextElement(element);
|
||||||
if (textElement) {
|
if (textElement) {
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ const RE_GH_GIST_EMBED =
|
|||||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
||||||
|
|
||||||
// not anchored to start to allow <blockquote> twitter embeds
|
// not anchored to start to allow <blockquote> twitter embeds
|
||||||
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
|
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:twitter|x).com/;
|
||||||
const RE_TWITTER_EMBED =
|
const RE_TWITTER_EMBED =
|
||||||
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
|
/^<blockquote[\s\S]*?\shref=["'](https:\/\/(?:twitter|x).com\/[^"']*)/i;
|
||||||
|
|
||||||
const RE_VALTOWN =
|
const RE_VALTOWN =
|
||||||
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||||
@@ -54,6 +54,7 @@ const ALLOWED_DOMAINS = new Set([
|
|||||||
"link.excalidraw.com",
|
"link.excalidraw.com",
|
||||||
"gist.github.com",
|
"gist.github.com",
|
||||||
"twitter.com",
|
"twitter.com",
|
||||||
|
"x.com",
|
||||||
"*.simplepdf.eu",
|
"*.simplepdf.eu",
|
||||||
"stackblitz.com",
|
"stackblitz.com",
|
||||||
"val.town",
|
"val.town",
|
||||||
@@ -155,6 +156,9 @@ export const getEmbedLink = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (RE_TWITTER.test(link)) {
|
if (RE_TWITTER.test(link)) {
|
||||||
|
// the embed srcdoc still supports twitter.com domain only
|
||||||
|
link = link.replace(/\bx.com\b/, "twitter.com");
|
||||||
|
|
||||||
let ret: IframeData;
|
let ret: IframeData;
|
||||||
// assume embed code
|
// assume embed code
|
||||||
if (/<blockquote/.test(link)) {
|
if (/<blockquote/.test(link)) {
|
||||||
@@ -321,26 +325,26 @@ const validateHostname = (
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractSrc = (htmlString: string): string => {
|
export const maybeParseEmbedSrc = (str: string): string => {
|
||||||
const twitterMatch = htmlString.match(RE_TWITTER_EMBED);
|
const twitterMatch = str.match(RE_TWITTER_EMBED);
|
||||||
if (twitterMatch && twitterMatch.length === 2) {
|
if (twitterMatch && twitterMatch.length === 2) {
|
||||||
return twitterMatch[1];
|
return twitterMatch[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
|
const gistMatch = str.match(RE_GH_GIST_EMBED);
|
||||||
if (gistMatch && gistMatch.length === 2) {
|
if (gistMatch && gistMatch.length === 2) {
|
||||||
return gistMatch[1];
|
return gistMatch[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RE_GIPHY.test(htmlString)) {
|
if (RE_GIPHY.test(str)) {
|
||||||
return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`;
|
return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = htmlString.match(RE_GENERIC_EMBED);
|
const match = str.match(RE_GENERIC_EMBED);
|
||||||
if (match && match.length === 2) {
|
if (match && match.length === 2) {
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
return htmlString;
|
return str;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const embeddableURLValidator = (
|
export const embeddableURLValidator = (
|
||||||
|
|||||||
@@ -136,13 +136,9 @@ export const newElement = (
|
|||||||
export const newEmbeddableElement = (
|
export const newEmbeddableElement = (
|
||||||
opts: {
|
opts: {
|
||||||
type: "embeddable";
|
type: "embeddable";
|
||||||
validated: ExcalidrawEmbeddableElement["validated"];
|
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||||
return {
|
return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts);
|
||||||
..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
|
|
||||||
validated: opts.validated,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newIframeElement = (
|
export const newIframeElement = (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
@@ -39,15 +39,13 @@ import { ExtractSetType } from "../utility-types";
|
|||||||
|
|
||||||
export const normalizeText = (text: string) => {
|
export const normalizeText = (text: string) => {
|
||||||
return (
|
return (
|
||||||
text
|
normalizeEOL(text)
|
||||||
// replace tabs with spaces so they render and measure correctly
|
// replace tabs with spaces so they render and measure correctly
|
||||||
.replace(/\t/g, " ")
|
.replace(/\t/g, " ")
|
||||||
// normalize newlines
|
|
||||||
.replace(/\r?\n|\r/g, "\n")
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const splitIntoLines = (text: string) => {
|
const splitIntoLines = (text: string) => {
|
||||||
return normalizeText(text).split("\n");
|
return normalizeText(text).split("\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -88,14 +88,6 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
|||||||
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
type: "embeddable";
|
type: "embeddable";
|
||||||
/**
|
|
||||||
* indicates whether the embeddable src (url) has been validated for rendering.
|
|
||||||
* null value indicates that the validation is pending. We reset the
|
|
||||||
* value on each restore (or url change) so that we can guarantee
|
|
||||||
* the validation came from a trusted source (the editor). Also because we
|
|
||||||
* may not have access to host-app supplied url validator during restore.
|
|
||||||
*/
|
|
||||||
validated: boolean | null;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ import type * as TExcalidraw from "../index";
|
|||||||
import "./App.scss";
|
import "./App.scss";
|
||||||
import initialData from "./initialData";
|
import initialData from "./initialData";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import {
|
import { resolvablePromise, ResolvablePromise } from "../utils";
|
||||||
resolvablePromise,
|
|
||||||
ResolvablePromise,
|
|
||||||
withBatchedUpdates,
|
|
||||||
withBatchedUpdatesThrottled,
|
|
||||||
} from "../utils";
|
|
||||||
import { EVENT, ROUNDNESS } from "../constants";
|
import { EVENT, ROUNDNESS } from "../constants";
|
||||||
import { distance2d } from "../math";
|
import { distance2d } from "../math";
|
||||||
import { fileOpen } from "../data/filesystem";
|
import { fileOpen } from "../data/filesystem";
|
||||||
@@ -29,6 +24,7 @@ import { ImportedLibraryData } from "../data/types";
|
|||||||
import CustomFooter from "./CustomFooter";
|
import CustomFooter from "./CustomFooter";
|
||||||
import MobileFooter from "./MobileFooter";
|
import MobileFooter from "./MobileFooter";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||||
|
import { AnimatedTrail, Trail } from "./animated-trail";
|
||||||
|
import { AnimationFrameHandler } from "./animation-frame-handler";
|
||||||
|
import type App from "./components/App";
|
||||||
|
import { SocketId } from "./types";
|
||||||
|
import { easeOut } from "./utils";
|
||||||
|
import { getClientColor } from "./clients";
|
||||||
|
|
||||||
|
export class LaserTrails implements Trail {
|
||||||
|
public localTrail: AnimatedTrail;
|
||||||
|
private collabTrails = new Map<SocketId, AnimatedTrail>();
|
||||||
|
|
||||||
|
private container?: SVGSVGElement;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private animationFrameHandler: AnimationFrameHandler,
|
||||||
|
private app: App,
|
||||||
|
) {
|
||||||
|
this.animationFrameHandler.register(this, this.onFrame.bind(this));
|
||||||
|
|
||||||
|
this.localTrail = new AnimatedTrail(animationFrameHandler, app, {
|
||||||
|
...this.getTrailOptions(),
|
||||||
|
fill: () => "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTrailOptions() {
|
||||||
|
return {
|
||||||
|
simplify: 0,
|
||||||
|
streamline: 0.4,
|
||||||
|
sizeMapping: (c) => {
|
||||||
|
const DECAY_TIME = 1000;
|
||||||
|
const DECAY_LENGTH = 50;
|
||||||
|
const t = Math.max(
|
||||||
|
0,
|
||||||
|
1 - (performance.now() - c.pressure) / DECAY_TIME,
|
||||||
|
);
|
||||||
|
const l =
|
||||||
|
(DECAY_LENGTH -
|
||||||
|
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
|
||||||
|
DECAY_LENGTH;
|
||||||
|
|
||||||
|
return Math.min(easeOut(l), easeOut(t));
|
||||||
|
},
|
||||||
|
} as Partial<LaserPointerOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
startPath(x: number, y: number): void {
|
||||||
|
this.localTrail.startPath(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPointToPath(x: number, y: number): void {
|
||||||
|
this.localTrail.addPointToPath(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
endPath(): void {
|
||||||
|
this.localTrail.endPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
start(container: SVGSVGElement) {
|
||||||
|
this.container = container;
|
||||||
|
|
||||||
|
this.animationFrameHandler.start(this);
|
||||||
|
this.localTrail.start(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.animationFrameHandler.stop(this);
|
||||||
|
this.localTrail.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFrame() {
|
||||||
|
this.updateCollabTrails();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCollabTrails() {
|
||||||
|
if (!this.container || this.app.state.collaborators.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
|
||||||
|
let trail!: AnimatedTrail;
|
||||||
|
|
||||||
|
if (!this.collabTrails.has(key)) {
|
||||||
|
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
|
||||||
|
...this.getTrailOptions(),
|
||||||
|
fill: () => getClientColor(key),
|
||||||
|
});
|
||||||
|
trail.start(this.container);
|
||||||
|
|
||||||
|
this.collabTrails.set(key, trail);
|
||||||
|
} else {
|
||||||
|
trail = this.collabTrails.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
|
||||||
|
if (collabolator.button === "down" && !trail.hasCurrentTrail) {
|
||||||
|
trail.startPath(collabolator.pointer.x, collabolator.pointer.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
collabolator.button === "down" &&
|
||||||
|
trail.hasCurrentTrail &&
|
||||||
|
!trail.hasLastPoint(collabolator.pointer.x, collabolator.pointer.y)
|
||||||
|
) {
|
||||||
|
trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collabolator.button === "up" && trail.hasCurrentTrail) {
|
||||||
|
trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y);
|
||||||
|
trail.endPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of this.collabTrails.keys()) {
|
||||||
|
if (!this.app.state.collaborators.has(key)) {
|
||||||
|
const trail = this.collabTrails.get(key)!;
|
||||||
|
trail.stop();
|
||||||
|
this.collabTrails.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,7 +138,9 @@
|
|||||||
"removeAllElementsFromFrame": "Remove all elements from frame",
|
"removeAllElementsFromFrame": "Remove all elements from frame",
|
||||||
"eyeDropper": "Pick color from canvas",
|
"eyeDropper": "Pick color from canvas",
|
||||||
"textToDiagram": "Text to diagram",
|
"textToDiagram": "Text to diagram",
|
||||||
"prompt": "Prompt"
|
"prompt": "Prompt",
|
||||||
|
"followUs": "Follow us",
|
||||||
|
"discordChat": "Discord chat"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"noItems": "No items added yet...",
|
"noItems": "No items added yet...",
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"development": "./dist/dev/index.js",
|
"development": "./dist/dev/index.js",
|
||||||
"default": "./dist/prod/index.js",
|
"types": "./dist/excalidraw/index.d.ts",
|
||||||
"types": "./dist/excalidraw/index.d.ts"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./index.css": {
|
"./index.css": {
|
||||||
"development": "./dist/dev/index.css",
|
"development": "./dist/dev/index.css",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.2",
|
||||||
"@excalidraw/laser-pointer": "1.2.0",
|
"@excalidraw/laser-pointer": "1.3.1",
|
||||||
"@excalidraw/mermaid-to-excalidraw": "0.2.0",
|
"@excalidraw/mermaid-to-excalidraw": "0.2.0",
|
||||||
"@excalidraw/random-username": "1.1.0",
|
"@excalidraw/random-username": "1.1.0",
|
||||||
"@radix-ui/react-popover": "1.0.3",
|
"@radix-ui/react-popover": "1.0.3",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* @param func handler taking at most single parameter (event).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
|
import { version as ReactVersion } from "react";
|
||||||
|
import { throttleRAF } from "./utils";
|
||||||
|
|
||||||
|
export const withBatchedUpdates = <
|
||||||
|
TFunction extends ((event: any) => void) | (() => void),
|
||||||
|
>(
|
||||||
|
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
||||||
|
) =>
|
||||||
|
((event) => {
|
||||||
|
unstable_batchedUpdates(func as TFunction, event);
|
||||||
|
}) as TFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* barches React state updates and throttles the calls to a single call per
|
||||||
|
* animation frame
|
||||||
|
*/
|
||||||
|
export const withBatchedUpdatesThrottled = <
|
||||||
|
TFunction extends ((event: any) => void) | (() => void),
|
||||||
|
>(
|
||||||
|
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
||||||
|
) => {
|
||||||
|
// @ts-ignore
|
||||||
|
return throttleRAF<Parameters<TFunction>>(((event) => {
|
||||||
|
unstable_batchedUpdates(func, event);
|
||||||
|
}) as TFunction);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isRenderThrottlingEnabled = (() => {
|
||||||
|
// we don't want to throttle in react < 18 because of #5439 and it was
|
||||||
|
// getting more complex to maintain the fix
|
||||||
|
let IS_REACT_18_AND_UP: boolean;
|
||||||
|
try {
|
||||||
|
const version = ReactVersion.split(".");
|
||||||
|
IS_REACT_18_AND_UP = Number(version[0]) > 17;
|
||||||
|
} catch {
|
||||||
|
IS_REACT_18_AND_UP = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasWarned = false;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (window.EXCALIDRAW_THROTTLE_RENDER === true) {
|
||||||
|
if (!IS_REACT_18_AND_UP) {
|
||||||
|
if (!hasWarned) {
|
||||||
|
hasWarned = true;
|
||||||
|
console.warn(
|
||||||
|
"Excalidraw: render throttling is disabled on React versions < 18.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
|
ExcalidrawFrameLikeElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -36,10 +37,12 @@ import {
|
|||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
Zoom,
|
Zoom,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
|
ElementsPendingErasure,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
|
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
@@ -94,6 +97,27 @@ const shouldResetImageFilter = (
|
|||||||
const getCanvasPadding = (element: ExcalidrawElement) =>
|
const getCanvasPadding = (element: ExcalidrawElement) =>
|
||||||
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
|
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
|
||||||
|
|
||||||
|
export const getRenderOpacity = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
containingFrame: ExcalidrawFrameLikeElement | null,
|
||||||
|
elementsPendingErasure: ElementsPendingErasure,
|
||||||
|
) => {
|
||||||
|
// multiplying frame opacity with element opacity to combine them
|
||||||
|
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
|
||||||
|
let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000;
|
||||||
|
|
||||||
|
// if pending erasure, multiply again to combine further
|
||||||
|
// (so that erasing always results in lower opacity than original)
|
||||||
|
if (
|
||||||
|
elementsPendingErasure.has(element.id) ||
|
||||||
|
(containingFrame && elementsPendingErasure.has(containingFrame.id))
|
||||||
|
) {
|
||||||
|
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return opacity;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExcalidrawElementWithCanvas {
|
export interface ExcalidrawElementWithCanvas {
|
||||||
element: ExcalidrawElement | ExcalidrawTextElement;
|
element: ExcalidrawElement | ExcalidrawTextElement;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
@@ -269,8 +293,6 @@ const drawElementOnCanvas = (
|
|||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
context.globalAlpha =
|
|
||||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "iframe":
|
case "iframe":
|
||||||
@@ -372,7 +394,6 @@ const drawElementOnCanvas = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.globalAlpha = 1;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const elementWithCanvasCache = new WeakMap<
|
export const elementWithCanvasCache = new WeakMap<
|
||||||
@@ -595,6 +616,12 @@ export const renderElement = (
|
|||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
|
context.globalAlpha = getRenderOpacity(
|
||||||
|
element,
|
||||||
|
getContainingFrame(element),
|
||||||
|
renderConfig.elementsPendingErasure,
|
||||||
|
);
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
case "frame": {
|
case "frame": {
|
||||||
@@ -831,6 +858,8 @@ export const renderElement = (
|
|||||||
throw new Error(`Unimplemented type ${element.type}`);
|
throw new Error(`Unimplemented type ${element.type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const roughSVGDrawWithPrecision = (
|
const roughSVGDrawWithPrecision = (
|
||||||
|
|||||||
@@ -1007,7 +1007,9 @@ const _renderStaticScene = ({
|
|||||||
if (
|
if (
|
||||||
isIframeLikeElement(element) &&
|
isIframeLikeElement(element) &&
|
||||||
(isExporting ||
|
(isExporting ||
|
||||||
(isEmbeddableElement(element) && !element.validated)) &&
|
(isEmbeddableElement(element) &&
|
||||||
|
renderConfig.embedsValidationStatus.get(element.id) !==
|
||||||
|
true)) &&
|
||||||
element.width &&
|
element.width &&
|
||||||
element.height
|
element.height
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { canChangeRoundness } from "./comparisons";
|
import { canChangeRoundness } from "./comparisons";
|
||||||
|
import { EmbedsValidationStatus } from "../types";
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||||
|
|
||||||
@@ -118,10 +119,13 @@ export const generateRoughOptions = (
|
|||||||
const modifyIframeLikeForRoughOptions = (
|
const modifyIframeLikeForRoughOptions = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
isExporting: boolean,
|
isExporting: boolean,
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus | null,
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
isIframeLikeElement(element) &&
|
isIframeLikeElement(element) &&
|
||||||
(isExporting || (isEmbeddableElement(element) && !element.validated)) &&
|
(isExporting ||
|
||||||
|
(isEmbeddableElement(element) &&
|
||||||
|
embedsValidationStatus?.get(element.id) !== true)) &&
|
||||||
isTransparent(element.backgroundColor) &&
|
isTransparent(element.backgroundColor) &&
|
||||||
isTransparent(element.strokeColor)
|
isTransparent(element.strokeColor)
|
||||||
) {
|
) {
|
||||||
@@ -278,7 +282,12 @@ export const _generateElementShape = (
|
|||||||
{
|
{
|
||||||
isExporting,
|
isExporting,
|
||||||
canvasBackgroundColor,
|
canvasBackgroundColor,
|
||||||
}: { isExporting: boolean; canvasBackgroundColor: string },
|
embedsValidationStatus,
|
||||||
|
}: {
|
||||||
|
isExporting: boolean;
|
||||||
|
canvasBackgroundColor: string;
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||||
|
},
|
||||||
): Drawable | Drawable[] | null => {
|
): Drawable | Drawable[] | null => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
@@ -299,7 +308,11 @@ export const _generateElementShape = (
|
|||||||
h - r
|
h - r
|
||||||
} L 0 ${r} Q 0 0, ${r} 0`,
|
} L 0 ${r} Q 0 0, ${r} 0`,
|
||||||
generateRoughOptions(
|
generateRoughOptions(
|
||||||
modifyIframeLikeForRoughOptions(element, isExporting),
|
modifyIframeLikeForRoughOptions(
|
||||||
|
element,
|
||||||
|
isExporting,
|
||||||
|
embedsValidationStatus,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -310,7 +323,11 @@ export const _generateElementShape = (
|
|||||||
element.width,
|
element.width,
|
||||||
element.height,
|
element.height,
|
||||||
generateRoughOptions(
|
generateRoughOptions(
|
||||||
modifyIframeLikeForRoughOptions(element, isExporting),
|
modifyIframeLikeForRoughOptions(
|
||||||
|
element,
|
||||||
|
isExporting,
|
||||||
|
embedsValidationStatus,
|
||||||
|
),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { elementWithCanvasCache } from "../renderer/renderElement";
|
|||||||
import { _generateElementShape } from "./Shape";
|
import { _generateElementShape } from "./Shape";
|
||||||
import { ElementShape, ElementShapes } from "./types";
|
import { ElementShape, ElementShapes } from "./types";
|
||||||
import { COLOR_PALETTE } from "../colors";
|
import { COLOR_PALETTE } from "../colors";
|
||||||
import { AppState } from "../types";
|
import { AppState, EmbedsValidationStatus } from "../types";
|
||||||
|
|
||||||
export class ShapeCache {
|
export class ShapeCache {
|
||||||
private static rg = new RoughGenerator();
|
private static rg = new RoughGenerator();
|
||||||
@@ -51,6 +51,7 @@ export class ShapeCache {
|
|||||||
renderConfig: {
|
renderConfig: {
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
} | null,
|
} | null,
|
||||||
) => {
|
) => {
|
||||||
// when exporting, always regenerated to guarantee the latest shape
|
// when exporting, always regenerated to guarantee the latest shape
|
||||||
@@ -72,6 +73,7 @@ export class ShapeCache {
|
|||||||
renderConfig || {
|
renderConfig || {
|
||||||
isExporting: false,
|
isExporting: false,
|
||||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||||
|
embedsValidationStatus: null,
|
||||||
},
|
},
|
||||||
) as T["type"] extends keyof ElementShapes
|
) as T["type"] extends keyof ElementShapes
|
||||||
? ElementShapes[T["type"]]
|
? ElementShapes[T["type"]]
|
||||||
|
|||||||
@@ -266,6 +266,9 @@ export const exportToCanvas = async (
|
|||||||
imageCache,
|
imageCache,
|
||||||
renderGrid: false,
|
renderGrid: false,
|
||||||
isExporting: true,
|
isExporting: true,
|
||||||
|
// empty disables embeddable rendering
|
||||||
|
embedsValidationStatus: new Map(),
|
||||||
|
elementsPendingErasure: new Set(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,6 +290,9 @@ export const exportToSvg = async (
|
|||||||
},
|
},
|
||||||
files: BinaryFiles | null,
|
files: BinaryFiles | null,
|
||||||
opts?: {
|
opts?: {
|
||||||
|
/**
|
||||||
|
* if true, all embeddables passed in will be rendered when possible.
|
||||||
|
*/
|
||||||
renderEmbeddables?: boolean;
|
renderEmbeddables?: boolean;
|
||||||
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
||||||
},
|
},
|
||||||
@@ -427,14 +433,24 @@ export const exportToSvg = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rsvg = rough.svg(svgRoot);
|
const rsvg = rough.svg(svgRoot);
|
||||||
|
|
||||||
|
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
||||||
|
|
||||||
renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, {
|
renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, {
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
isExporting: true,
|
isExporting: true,
|
||||||
exportWithDarkMode,
|
exportWithDarkMode,
|
||||||
renderEmbeddables: opts?.renderEmbeddables ?? false,
|
renderEmbeddables,
|
||||||
frameRendering,
|
frameRendering,
|
||||||
canvasBackgroundColor: viewBackgroundColor,
|
canvasBackgroundColor: viewBackgroundColor,
|
||||||
|
embedsValidationStatus: renderEmbeddables
|
||||||
|
? new Map(
|
||||||
|
elementsForRender
|
||||||
|
.filter((element) => isFrameLikeElement(element))
|
||||||
|
.map((element) => [element.id, true]),
|
||||||
|
)
|
||||||
|
: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
tempScene.destroy();
|
tempScene.destroy();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppState,
|
AppState,
|
||||||
|
EmbedsValidationStatus,
|
||||||
|
ElementsPendingErasure,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
StaticCanvasAppState,
|
StaticCanvasAppState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
@@ -20,6 +22,8 @@ export type StaticCanvasRenderConfig = {
|
|||||||
/** when exporting the behavior is slightly different (e.g. we can't use
|
/** when exporting the behavior is slightly different (e.g. we can't use
|
||||||
CSS filters), and we disable render optimizations for best output */
|
CSS filters), and we disable render optimizations for best output */
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
|
elementsPendingErasure: ElementsPendingErasure;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SVGRenderConfig = {
|
export type SVGRenderConfig = {
|
||||||
@@ -30,6 +34,7 @@ export type SVGRenderConfig = {
|
|||||||
renderEmbeddables: boolean;
|
renderEmbeddables: boolean;
|
||||||
frameRendering: AppState["frameRendering"];
|
frameRendering: AppState["frameRendering"];
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InteractiveCanvasRenderConfig = {
|
export type InteractiveCanvasRenderConfig = {
|
||||||
|
|||||||
@@ -386,6 +386,52 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
GitHub
|
GitHub
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
aria-label="X"
|
||||||
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
|
href="https://x.com/excalidraw"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
title="X"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="dropdown-menu-item__icon"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class=""
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
stroke-width="1.25"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0 0h24v24H0z"
|
||||||
|
fill="none"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 4l11.733 16h4.267l-11.733 -16z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="dropdown-menu-item__text"
|
||||||
|
>
|
||||||
|
Follow us
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
aria-label="Discord"
|
aria-label="Discord"
|
||||||
class="dropdown-menu-item dropdown-menu-item-base"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
@@ -423,50 +469,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
Discord
|
Discord chat
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
aria-label="Twitter"
|
|
||||||
class="dropdown-menu-item dropdown-menu-item-base"
|
|
||||||
href="https://twitter.com/excalidraw"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
title="Twitter"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="dropdown-menu-item__icon"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class=""
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<g
|
|
||||||
stroke-width="1.25"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0 0h24v24H0z"
|
|
||||||
fill="none"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="dropdown-menu-item__text"
|
|
||||||
>
|
|
||||||
Twitter
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export const rectangleFixture: ExcalidrawElement = {
|
|||||||
export const embeddableFixture: ExcalidrawElement = {
|
export const embeddableFixture: ExcalidrawElement = {
|
||||||
...elementBase,
|
...elementBase,
|
||||||
type: "embeddable",
|
type: "embeddable",
|
||||||
validated: null,
|
|
||||||
};
|
};
|
||||||
export const ellipseFixture: ExcalidrawElement = {
|
export const ellipseFixture: ExcalidrawElement = {
|
||||||
...elementBase,
|
...elementBase,
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ export class API {
|
|||||||
element = newEmbeddableElement({
|
element = newEmbeddableElement({
|
||||||
type: "embeddable",
|
type: "embeddable",
|
||||||
...base,
|
...base,
|
||||||
validated: null,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "iframe":
|
case "iframe":
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ExcalidrawMagicFrameElement,
|
ExcalidrawMagicFrameElement,
|
||||||
ExcalidrawFrameLikeElement,
|
ExcalidrawFrameLikeElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
|
ExcalidrawIframeLikeElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { Action } from "./actions/types";
|
import { Action } from "./actions/types";
|
||||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
@@ -633,12 +634,6 @@ export type PointerDownState = Readonly<{
|
|||||||
boxSelection: {
|
boxSelection: {
|
||||||
hasOccurred: boolean;
|
hasOccurred: boolean;
|
||||||
};
|
};
|
||||||
elementIdsToErase: {
|
|
||||||
[key: ExcalidrawElement["id"]]: {
|
|
||||||
opacity: ExcalidrawElement["opacity"];
|
|
||||||
erase: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type UnsubscribeCallback = () => void;
|
export type UnsubscribeCallback = () => void;
|
||||||
@@ -751,3 +746,10 @@ export type Primitive =
|
|||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
export type JSONValue = string | number | boolean | null | object;
|
export type JSONValue = string | number | boolean | null | object;
|
||||||
|
|
||||||
|
export type EmbedsValidationStatus = Map<
|
||||||
|
ExcalidrawIframeLikeElement["id"],
|
||||||
|
boolean
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import {
|
|||||||
UnsubscribeCallback,
|
UnsubscribeCallback,
|
||||||
Zoom,
|
Zoom,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { unstable_batchedUpdates } from "react-dom";
|
|
||||||
import { ResolutionType } from "./utility-types";
|
import { ResolutionType } from "./utility-types";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
let mockDateTime: string | null = null;
|
let mockDateTime: string | null = null;
|
||||||
|
|
||||||
@@ -555,33 +553,6 @@ export const resolvablePromise = <T>() => {
|
|||||||
return promise as ResolvablePromise<T>;
|
return promise as ResolvablePromise<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @param func handler taking at most single parameter (event).
|
|
||||||
*/
|
|
||||||
export const withBatchedUpdates = <
|
|
||||||
TFunction extends ((event: any) => void) | (() => void),
|
|
||||||
>(
|
|
||||||
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
|
||||||
) =>
|
|
||||||
((event) => {
|
|
||||||
unstable_batchedUpdates(func as TFunction, event);
|
|
||||||
}) as TFunction;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* barches React state updates and throttles the calls to a single call per
|
|
||||||
* animation frame
|
|
||||||
*/
|
|
||||||
export const withBatchedUpdatesThrottled = <
|
|
||||||
TFunction extends ((event: any) => void) | (() => void),
|
|
||||||
>(
|
|
||||||
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
|
||||||
) => {
|
|
||||||
// @ts-ignore
|
|
||||||
return throttleRAF<Parameters<TFunction>>(((event) => {
|
|
||||||
unstable_batchedUpdates(func, event);
|
|
||||||
}) as TFunction);
|
|
||||||
};
|
|
||||||
|
|
||||||
//https://stackoverflow.com/a/9462382/8418
|
//https://stackoverflow.com/a/9462382/8418
|
||||||
export const nFormatter = (num: number, digits: number): string => {
|
export const nFormatter = (num: number, digits: number): string => {
|
||||||
const si = [
|
const si = [
|
||||||
@@ -939,36 +910,6 @@ export const memoize = <T extends Record<string, any>, R extends any>(
|
|||||||
return ret as typeof func & { clear: () => void };
|
return ret as typeof func & { clear: () => void };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isRenderThrottlingEnabled = (() => {
|
|
||||||
// we don't want to throttle in react < 18 because of #5439 and it was
|
|
||||||
// getting more complex to maintain the fix
|
|
||||||
let IS_REACT_18_AND_UP: boolean;
|
|
||||||
try {
|
|
||||||
const version = React.version.split(".");
|
|
||||||
IS_REACT_18_AND_UP = Number(version[0]) > 17;
|
|
||||||
} catch {
|
|
||||||
IS_REACT_18_AND_UP = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasWarned = false;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (window.EXCALIDRAW_THROTTLE_RENDER === true) {
|
|
||||||
if (!IS_REACT_18_AND_UP) {
|
|
||||||
if (!hasWarned) {
|
|
||||||
hasWarned = true;
|
|
||||||
console.warn(
|
|
||||||
"Excalidraw: render throttling is disabled on React versions < 18.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
/** Checks if value is inside given collection. Useful for type-safety. */
|
/** Checks if value is inside given collection. Useful for type-safety. */
|
||||||
export const isMemberOf = <T extends string>(
|
export const isMemberOf = <T extends string>(
|
||||||
/** Set/Map/Array/Object */
|
/** Set/Map/Array/Object */
|
||||||
@@ -1071,3 +1012,41 @@ export function addEventListener(
|
|||||||
target?.removeEventListener?.(type, listener, options);
|
target?.removeEventListener?.(type, listener, options);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const average = (a: number, b: number) => (a + b) / 2;
|
||||||
|
export 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 const normalizeEOL = (str: string) => {
|
||||||
|
return str.replace(/\r?\n|\r/g, "\n");
|
||||||
|
};
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2247,10 +2247,10 @@
|
|||||||
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":
|
"@excalidraw/laser-pointer@1.3.1":
|
||||||
version "1.2.0"
|
version "1.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.2.0.tgz#cd34ea7d24b11743c726488cc1fcb28c161cacba"
|
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz#7c40836598e8e6ad91f01057883ed8b88fb9266c"
|
||||||
integrity sha512-WjFFwLk9ahmKRKku7U0jqYpeM3fe9ZS1K43pfwPREHk4/FYU3iKDKVeS8m4tEAASnRlBt3hhLCBQLBF2uvgOnw==
|
integrity sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==
|
||||||
|
|
||||||
"@excalidraw/markdown-to-text@0.1.2":
|
"@excalidraw/markdown-to-text@0.1.2":
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user