mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
16 Commits
zsviczian-
...
dwelle/col
Author | SHA1 | Date | |
---|---|---|---|
2cb1fa5e14 | |||
a4e5e46dd1 | |||
0fa5f5de4c | |||
41cc746885 | |||
8ead8559e0 | |||
5245276409 | |||
0c24a7042f | |||
86cfeb714c | |||
872973f145 | |||
3ecf72a507 | |||
1aaa400876 | |||
65047cc2cb | |||
8b993d409e | |||
1cb350b2aa | |||
43ccc875fb | |||
4249b7dec8 |
@ -73,9 +73,9 @@ function App() {
|
||||
|
||||
## 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.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| image | boolean | true | Decides whether `image` tool should be visible.
|
||||
| image | boolean | true | Decides whether `image` tool should be visible.
|
||||
|
@ -22,7 +22,6 @@ import {
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
throttleRAF,
|
||||
withBatchedUpdates,
|
||||
} from "../../packages/excalidraw/utils";
|
||||
import {
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
@ -83,6 +82,7 @@ import { atom, useAtom } from "jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const collabDialogShownAtom = atom(false);
|
||||
@ -442,6 +442,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
const fallbackInitializationHandler = () => {
|
||||
console.log("fallbackInitializationHandler");
|
||||
this.initializeRoom({
|
||||
roomLinkData: existingRoomLinkData,
|
||||
fetchScene: true,
|
||||
@ -516,7 +517,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
case WS_SUBTYPES.INVALID_RESPONSE:
|
||||
return;
|
||||
case WS_SUBTYPES.INIT: {
|
||||
console.log("INIT (1)");
|
||||
if (!this.portal.socketInitialized) {
|
||||
console.log("INIT (2)");
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements = this.reconcileElements(remoteElements);
|
||||
@ -606,6 +609,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
this.portal.socket.on("first-in-room", async () => {
|
||||
console.log("first-in-room");
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
|
@ -38,9 +38,17 @@ class Portal {
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
console.log("connect");
|
||||
});
|
||||
|
||||
console.log("subbing to init-room");
|
||||
|
||||
// Initialize socket listeners
|
||||
this.socket.on("init-room", () => {
|
||||
console.log("init-room (1)");
|
||||
if (this.socket) {
|
||||
console.log("init-room (2)");
|
||||
this.socket.emit("join-room", this.roomId);
|
||||
trackEvent("share", "room joined");
|
||||
}
|
||||
@ -53,6 +61,7 @@ class Portal {
|
||||
);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: SocketId[]) => {
|
||||
console.log("room-user-change", clients);
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
|
||||
|
@ -1,4 +1,8 @@
|
||||
{
|
||||
"name": "excalidraw-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"homepage": ".",
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
@ -22,17 +26,13 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {},
|
||||
"homepage": ".",
|
||||
"name": "excalidraw-app",
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"install:deps": "yarn install --frozen-lockfile && yarn --cwd ../",
|
||||
"start": "yarn && vite",
|
||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"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
|
||||
|
||||
- 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
|
||||
|
||||
- `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)
|
||||
|
||||
## 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)
|
||||
- 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).
|
||||
- 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)
|
||||
|
148
packages/excalidraw/animated-trail.ts
Normal file
148
packages/excalidraw/animated-trail.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
79
packages/excalidraw/animation-frame-handler.ts
Normal file
79
packages/excalidraw/animation-frame-handler.ts
Normal file
@ -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_VERTICAL_ALIGN,
|
||||
DRAGGING_THRESHOLD,
|
||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
|
||||
ELEMENT_TRANSLATE_AMOUNT,
|
||||
ENV,
|
||||
@ -182,6 +181,7 @@ import {
|
||||
ExcalidrawIframeLikeElement,
|
||||
IframeData,
|
||||
ExcalidrawIframeElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
@ -246,6 +246,8 @@ import {
|
||||
ToolType,
|
||||
OnUserFollowedPayload,
|
||||
UnsubscribeCallback,
|
||||
EmbedsValidationStatus,
|
||||
ElementsPendingErasure,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@ -258,9 +260,7 @@ import {
|
||||
sceneCoordsToViewportCoords,
|
||||
tupleToCoors,
|
||||
viewportCoordsToSceneCoords,
|
||||
withBatchedUpdates,
|
||||
wrapEvent,
|
||||
withBatchedUpdatesThrottled,
|
||||
updateObject,
|
||||
updateActiveTool,
|
||||
getShortcutKey,
|
||||
@ -271,11 +271,12 @@ import {
|
||||
easeOut,
|
||||
updateStable,
|
||||
addEventListener,
|
||||
normalizeEOL,
|
||||
} from "../utils";
|
||||
import {
|
||||
createSrcDoc,
|
||||
embeddableURLValidator,
|
||||
extractSrc,
|
||||
maybeParseEmbedSrc,
|
||||
getEmbedLink,
|
||||
} from "../element/embeddable";
|
||||
import {
|
||||
@ -384,8 +385,7 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||
import { Renderer } from "../scene/Renderer";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { LaserToolOverlay } from "./LaserTool/LaserTool";
|
||||
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
||||
import { SVGLayer } from "./SVGLayer";
|
||||
import {
|
||||
setEraserCursor,
|
||||
setCursor,
|
||||
@ -402,6 +402,12 @@ import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||
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 AppPropsContext = React.createContext<AppProps>(null!);
|
||||
|
||||
@ -524,6 +530,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public files: BinaryFiles = {};
|
||||
public imageCache: AppClassProperties["imageCache"] = new Map();
|
||||
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;
|
||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||
@ -532,7 +550,29 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lastPointerMoveEvent: PointerEvent | null = null;
|
||||
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<
|
||||
[
|
||||
@ -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 = () => {
|
||||
const iframeLikes = new Set<ExcalidrawIframeLikeElement["id"]>();
|
||||
|
||||
@ -846,7 +894,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElements().filter((element) => {
|
||||
if (isEmbeddableElement(element)) {
|
||||
iframeLikes.add(element.id);
|
||||
if (element.validated == null) {
|
||||
if (!this.embedsValidationStatus.has(element.id)) {
|
||||
updated = true;
|
||||
|
||||
const validated = embeddableURLValidator(
|
||||
@ -854,8 +902,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.props.validateEmbeddable,
|
||||
);
|
||||
|
||||
mutateElement(element, { validated }, false);
|
||||
ShapeCache.delete(element);
|
||||
this.updateEmbedValidationStatus(element, validated);
|
||||
}
|
||||
} else if (isIframeElement(element)) {
|
||||
iframeLikes.add(element.id);
|
||||
@ -884,7 +931,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
.getNonDeletedElements()
|
||||
.filter(
|
||||
(el): el is NonDeleted<ExcalidrawIframeLikeElement> =>
|
||||
(isEmbeddableElement(el) && !!el.validated) || isIframeElement(el),
|
||||
(isEmbeddableElement(el) &&
|
||||
this.embedsValidationStatus.get(el.id) === true) ||
|
||||
isIframeElement(el),
|
||||
);
|
||||
|
||||
return (
|
||||
@ -895,6 +944,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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;
|
||||
|
||||
if (isIframeElement(el)) {
|
||||
@ -1036,14 +1102,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
src = getEmbedLink(toValidURL(el.link || ""));
|
||||
}
|
||||
|
||||
// console.log({ src });
|
||||
|
||||
const isVisible = isElementInViewport(
|
||||
el,
|
||||
normalizedWidth,
|
||||
normalizedHeight,
|
||||
this.state,
|
||||
);
|
||||
const isActive =
|
||||
this.state.activeEmbeddable?.element === el &&
|
||||
this.state.activeEmbeddable?.state === "active";
|
||||
@ -1064,7 +1122,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}px) scale(${scale})`
|
||||
: "none",
|
||||
display: isVisible ? "block" : "none",
|
||||
opacity: el.opacity / 100,
|
||||
opacity: getRenderOpacity(
|
||||
el,
|
||||
getContainingFrame(el),
|
||||
this.elementsPendingErasure,
|
||||
),
|
||||
["--embeddable-radius" as string]: `${getCornerRadius(
|
||||
Math.min(el.width, el.height),
|
||||
el,
|
||||
@ -1453,7 +1515,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<LaserToolOverlay manager={this.laserPathManager} />
|
||||
<SVGLayer
|
||||
trails={[this.laserTrails, this.eraserTrail]}
|
||||
/>
|
||||
{selectedElements.length === 1 &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
@ -1462,6 +1526,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
setToast={this.setToast}
|
||||
updateEmbedValidationStatus={
|
||||
this.updateEmbedValidationStatus
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{this.props.aiEnabled !== false &&
|
||||
@ -1572,6 +1639,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
renderGrid: true,
|
||||
canvasBackgroundColor:
|
||||
this.state.viewBackgroundColor,
|
||||
embedsValidationStatus: this.embedsValidationStatus,
|
||||
elementsPendingErasure: this.elementsPendingErasure,
|
||||
}}
|
||||
/>
|
||||
<InteractiveCanvas
|
||||
@ -2375,7 +2444,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.removeEventListeners();
|
||||
this.scene.destroy();
|
||||
this.library.destroy();
|
||||
this.laserPathManager.destroy();
|
||||
this.laserTrails.stop();
|
||||
this.eraserTrail.stop();
|
||||
this.onChangeEmitter.clear();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
@ -2600,6 +2670,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.updateLanguage();
|
||||
}
|
||||
|
||||
if (isEraserActive(prevState) && !isEraserActive(this.state)) {
|
||||
this.eraserTrail.endPath();
|
||||
}
|
||||
|
||||
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
||||
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
|
||||
}
|
||||
@ -2924,21 +2998,49 @@ class App extends React.Component<AppProps, AppState> {
|
||||
retainSeed: isPlainPaste,
|
||||
});
|
||||
} 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 (
|
||||
!isPlainPaste &&
|
||||
embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) &&
|
||||
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(maybeUrl) ||
|
||||
getEmbedLink(maybeUrl)?.type === "video")
|
||||
!IS_PLAIN_PASTE &&
|
||||
embbeddableUrls.length > 0 &&
|
||||
// if there were non-embeddable text (lines) mixed in with embeddable
|
||||
// urls, ignore and paste as text
|
||||
embbeddableUrls.length === nonEmptyLines.length
|
||||
) {
|
||||
const embeddable = this.insertEmbeddableElement({
|
||||
sceneX,
|
||||
sceneY,
|
||||
link: normalizeLink(maybeUrl),
|
||||
});
|
||||
if (embeddable) {
|
||||
this.setState({ selectedElementIds: { [embeddable.id]: true } });
|
||||
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
|
||||
for (const url of embbeddableUrls) {
|
||||
const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
|
||||
embeddables[embeddables.length - 1];
|
||||
const embeddable = this.insertEmbeddableElement({
|
||||
sceneX: prevEmbeddable
|
||||
? 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;
|
||||
}
|
||||
@ -5023,30 +5125,48 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState: PointerDownState,
|
||||
scenePointer: { x: number; y: number },
|
||||
) => {
|
||||
const updateElementIds = (elements: ExcalidrawElement[]) => {
|
||||
elements.forEach((element) => {
|
||||
this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
idsToUpdate.push(element.id);
|
||||
if (event.altKey) {
|
||||
if (
|
||||
pointerDownState.elementIdsToErase[element.id] &&
|
||||
pointerDownState.elementIdsToErase[element.id].erase
|
||||
) {
|
||||
pointerDownState.elementIdsToErase[element.id].erase = false;
|
||||
if (this.elementsPendingErasure.delete(element.id)) {
|
||||
didChange = true;
|
||||
}
|
||||
} else if (!pointerDownState.elementIdsToErase[element.id]) {
|
||||
pointerDownState.elementIdsToErase[element.id] = {
|
||||
erase: true,
|
||||
opacity: element.opacity,
|
||||
};
|
||||
} else if (!this.elementsPendingErasure.has(element.id)) {
|
||||
didChange = true;
|
||||
this.elementsPendingErasure.add(element.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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(
|
||||
pointerDownState.lastCoords.x,
|
||||
@ -5059,7 +5179,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let samplingInterval = 0;
|
||||
while (samplingInterval <= distance) {
|
||||
const hitElements = this.getElementsAtPosition(point.x, point.y);
|
||||
updateElementIds(hitElements);
|
||||
processElements(hitElements);
|
||||
|
||||
// Exit since we reached current point
|
||||
if (samplingInterval === distance) {
|
||||
@ -5078,35 +5198,31 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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.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
|
||||
private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
invalidateContextMenu = true;
|
||||
@ -5463,7 +5579,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type,
|
||||
);
|
||||
} else if (this.state.activeTool.type === "laser") {
|
||||
this.laserPathManager.startPath(
|
||||
this.laserTrails.startPath(
|
||||
pointerDownState.lastCoords.x,
|
||||
pointerDownState.lastCoords.y,
|
||||
);
|
||||
@ -5484,6 +5600,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event,
|
||||
);
|
||||
|
||||
if (this.state.activeTool.type === "eraser") {
|
||||
this.eraserTrail.startPath(
|
||||
pointerDownState.lastCoords.x,
|
||||
pointerDownState.lastCoords.y,
|
||||
);
|
||||
}
|
||||
|
||||
const onPointerMove =
|
||||
this.onPointerMoveFromPointerDownHandler(pointerDownState);
|
||||
|
||||
@ -5792,7 +5915,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
boxSelection: {
|
||||
hasOccurred: false,
|
||||
},
|
||||
elementIdsToErase: {},
|
||||
};
|
||||
}
|
||||
|
||||
@ -6348,7 +6470,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
width: embedLink.intrinsicSize.w,
|
||||
height: embedLink.intrinsicSize.h,
|
||||
link,
|
||||
validated: null,
|
||||
});
|
||||
|
||||
this.scene.replaceAllElements([
|
||||
@ -6582,7 +6703,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (elementType === "embeddable") {
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
...baseElementAttributes,
|
||||
});
|
||||
} else {
|
||||
@ -6748,7 +6868,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (this.state.activeTool.type === "laser") {
|
||||
this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||
this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||
}
|
||||
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
@ -7757,6 +7877,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
|
||||
|
||||
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
|
||||
this.eraserTrail.endPath();
|
||||
|
||||
const draggedDistance = distance2d(
|
||||
pointerStart.clientX,
|
||||
pointerStart.clientY,
|
||||
@ -7776,18 +7898,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
hitElements.forEach(
|
||||
(hitElement) =>
|
||||
(pointerDownState.elementIdsToErase[hitElement.id] = {
|
||||
erase: true,
|
||||
opacity: hitElement.opacity,
|
||||
}),
|
||||
hitElements.forEach((hitElement) =>
|
||||
this.elementsPendingErasure.add(hitElement.id),
|
||||
);
|
||||
}
|
||||
this.eraseElements(pointerDownState);
|
||||
this.eraseElements();
|
||||
return;
|
||||
} else if (Object.keys(pointerDownState.elementIdsToErase).length) {
|
||||
this.restoreReadyToEraseElements(pointerDownState);
|
||||
} else if (this.elementsPendingErasure.size) {
|
||||
this.restoreReadyToEraseElements();
|
||||
}
|
||||
|
||||
if (
|
||||
@ -8009,7 +8127,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (activeTool.type === "laser") {
|
||||
this.laserPathManager.endPath();
|
||||
this.laserTrails.endPath();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -8048,65 +8166,32 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
private restoreReadyToEraseElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
) => {
|
||||
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 restoreReadyToEraseElements = () => {
|
||||
this.elementsPendingErasure = new Set();
|
||||
this.onSceneUpdated();
|
||||
};
|
||||
|
||||
private eraseElements = (pointerDownState: PointerDownState) => {
|
||||
private eraseElements = () => {
|
||||
let didChange = false;
|
||||
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
|
||||
if (
|
||||
pointerDownState.elementIdsToErase[ele.id] &&
|
||||
pointerDownState.elementIdsToErase[ele.id].erase
|
||||
) {
|
||||
return newElementWith(ele, { isDeleted: true });
|
||||
} 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
|
||||
this.elementsPendingErasure.has(ele.id) ||
|
||||
(ele.frameId && this.elementsPendingErasure.has(ele.frameId)) ||
|
||||
(isBoundToContainer(ele) &&
|
||||
this.elementsPendingErasure.has(ele.containerId))
|
||||
) {
|
||||
didChange = true;
|
||||
return newElementWith(ele, { isDeleted: true });
|
||||
}
|
||||
return ele;
|
||||
});
|
||||
|
||||
this.history.resumeRecording();
|
||||
this.scene.replaceAllElements(elements);
|
||||
this.elementsPendingErasure = new Set();
|
||||
|
||||
if (didChange) {
|
||||
this.history.resumeRecording();
|
||||
this.scene.replaceAllElements(elements);
|
||||
}
|
||||
};
|
||||
|
||||
private initializeImage = async ({
|
||||
|
@ -1,8 +1,8 @@
|
||||
import "../ToolIcon.scss";
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "../ToolButton";
|
||||
import { laserPointerToolIcon } from "../icons";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import { laserPointerToolIcon } from "./icons";
|
||||
|
||||
type LaserPointerIconProps = {
|
||||
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 { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
|
||||
import { LaserPointerButton } from "./LaserPointerButton";
|
||||
import { MagicSettings } from "./MagicSettings";
|
||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
.excalidraw {
|
||||
.LaserToolOverlay {
|
||||
.SVGLayer {
|
||||
pointer-events: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@ -9,10 +9,12 @@
|
||||
|
||||
z-index: 2;
|
||||
|
||||
.LaserToolOverlayCanvas {
|
||||
& svg {
|
||||
image-rendering: auto;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
33
packages/excalidraw/components/SVGLayer.tsx
Normal file
33
packages/excalidraw/components/SVGLayer.tsx
Normal file
@ -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 { renderInteractiveScene } from "../../renderer/renderScene";
|
||||
import {
|
||||
isRenderThrottlingEnabled,
|
||||
isShallowEqual,
|
||||
sceneCoordsToViewportCoords,
|
||||
} from "../../utils";
|
||||
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
|
||||
import { CURSOR_TYPE } from "../../constants";
|
||||
import { t } from "../../i18n";
|
||||
import type { DOMAttributes } from "react";
|
||||
@ -14,6 +10,7 @@ import type {
|
||||
RenderInteractiveSceneCallback,
|
||||
} from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||
|
||||
type InteractiveCanvasProps = {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
|
@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { renderStaticScene } from "../../renderer/renderScene";
|
||||
import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils";
|
||||
import { isShallowEqual } from "../../utils";
|
||||
import type { AppState, StaticCanvasAppState } from "../../types";
|
||||
import type { StaticCanvasRenderConfig } from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||
|
||||
type StaticCanvasProps = {
|
||||
canvas: HTMLCanvasElement;
|
||||
|
@ -486,10 +486,11 @@ export const DiscordIcon = createIcon(
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const TwitterIcon = createIcon(
|
||||
export const XBrandIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<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 stroke="none" d="M0 0h24v24H0z" fill="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>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
@ -17,7 +17,7 @@ import {
|
||||
TrashIcon,
|
||||
usersIcon,
|
||||
} from "../icons";
|
||||
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
|
||||
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
|
||||
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
||||
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
||||
import {
|
||||
@ -241,31 +241,35 @@ export const Export = () => {
|
||||
};
|
||||
Export.displayName = "Export";
|
||||
|
||||
export const Socials = () => (
|
||||
<>
|
||||
<DropdownMenuItemLink
|
||||
icon={GithubIcon}
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
GitHub
|
||||
</DropdownMenuItemLink>
|
||||
<DropdownMenuItemLink
|
||||
icon={DiscordIcon}
|
||||
href="https://discord.gg/UexuTaE"
|
||||
aria-label="Discord"
|
||||
>
|
||||
Discord
|
||||
</DropdownMenuItemLink>
|
||||
<DropdownMenuItemLink
|
||||
icon={TwitterIcon}
|
||||
href="https://twitter.com/excalidraw"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
Twitter
|
||||
</DropdownMenuItemLink>
|
||||
</>
|
||||
);
|
||||
export const Socials = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItemLink
|
||||
icon={GithubIcon}
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
GitHub
|
||||
</DropdownMenuItemLink>
|
||||
<DropdownMenuItemLink
|
||||
icon={XBrandIcon}
|
||||
href="https://x.com/excalidraw"
|
||||
aria-label="X"
|
||||
>
|
||||
{t("labels.followUs")}
|
||||
</DropdownMenuItemLink>
|
||||
<DropdownMenuItemLink
|
||||
icon={DiscordIcon}
|
||||
href="https://discord.gg/UexuTaE"
|
||||
aria-label="Discord"
|
||||
>
|
||||
{t("labels.discordChat")}
|
||||
</DropdownMenuItemLink>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Socials.displayName = "Socials";
|
||||
|
||||
export const LiveCollaborationTrigger = ({
|
||||
|
@ -295,11 +295,8 @@ const restoreElement = (
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "iframe":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "embeddable":
|
||||
return restoreElementWithProperties(element, {
|
||||
validated: null,
|
||||
});
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "magicframe":
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
|
@ -39,7 +39,6 @@ import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@ -64,6 +63,7 @@ export const Hyperlink = ({
|
||||
setAppState,
|
||||
onLinkOpen,
|
||||
setToast,
|
||||
updateEmbedValidationStatus,
|
||||
}: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
@ -71,6 +71,10 @@ export const Hyperlink = ({
|
||||
setToast: (
|
||||
toast: { message: string; closable?: boolean; duration?: number } | null,
|
||||
) => void;
|
||||
updateEmbedValidationStatus: (
|
||||
element: ExcalidrawEmbeddableElement,
|
||||
status: boolean,
|
||||
) => void;
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const appProps = useAppProps();
|
||||
@ -98,9 +102,9 @@ export const Hyperlink = ({
|
||||
}
|
||||
if (!link) {
|
||||
mutateElement(element, {
|
||||
validated: false,
|
||||
link: null,
|
||||
});
|
||||
updateEmbedValidationStatus(element, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -110,10 +114,9 @@ export const Hyperlink = ({
|
||||
}
|
||||
element.link && embeddableLinkCache.set(element.id, element.link);
|
||||
mutateElement(element, {
|
||||
validated: false,
|
||||
link,
|
||||
});
|
||||
ShapeCache.delete(element);
|
||||
updateEmbedValidationStatus(element, false);
|
||||
} else {
|
||||
const { width, height } = element;
|
||||
const embedLink = getEmbedLink(link);
|
||||
@ -142,10 +145,9 @@ export const Hyperlink = ({
|
||||
: height,
|
||||
}
|
||||
: {}),
|
||||
validated: true,
|
||||
link,
|
||||
});
|
||||
ShapeCache.delete(element);
|
||||
updateEmbedValidationStatus(element, true);
|
||||
if (embeddableLinkCache.has(element.id)) {
|
||||
embeddableLinkCache.delete(element.id);
|
||||
}
|
||||
@ -159,6 +161,7 @@ export const Hyperlink = ({
|
||||
appProps.validateEmbeddable,
|
||||
appState.activeEmbeddable,
|
||||
setAppState,
|
||||
updateEmbedValidationStatus,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
@ -5,14 +5,9 @@ import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
import { getGridPoint } from "../math";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
} from "./typeChecks";
|
||||
import { isArrowElement, isFrameLikeElement } from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@ -37,13 +32,11 @@ export const dragSelectedElements = (
|
||||
.map((f) => f.id);
|
||||
|
||||
if (frames.length > 0) {
|
||||
const elementsInFrames = scene
|
||||
.getNonDeletedElements()
|
||||
.filter((e) => !isBoundToContainer(e))
|
||||
.filter((e) => e.frameId !== null)
|
||||
.filter((e) => frames.includes(e.frameId!));
|
||||
|
||||
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
|
||||
for (const element of scene.getNonDeletedElements()) {
|
||||
if (element.frameId !== null && frames.includes(element.frameId)) {
|
||||
elementsToUpdate.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commonBounds = getCommonBounds(
|
||||
@ -60,16 +53,9 @@ export const dragSelectedElements = (
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
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 (
|
||||
// Don't update coords of arrow label since we calculate its position during render
|
||||
!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)))
|
||||
// skip arrow labels since we calculate its position during render
|
||||
!isArrowElement(element)
|
||||
) {
|
||||
const textElement = getBoundTextElement(element);
|
||||
if (textElement) {
|
||||
|
@ -32,9 +32,9 @@ const RE_GH_GIST_EMBED =
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
||||
|
||||
// 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 =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
|
||||
/^<blockquote[\s\S]*?\shref=["'](https:\/\/(?:twitter|x).com\/[^"']*)/i;
|
||||
|
||||
const RE_VALTOWN =
|
||||
/^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",
|
||||
"gist.github.com",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
@ -155,6 +156,9 @@ export const getEmbedLink = (
|
||||
}
|
||||
|
||||
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;
|
||||
// assume embed code
|
||||
if (/<blockquote/.test(link)) {
|
||||
@ -321,26 +325,26 @@ const validateHostname = (
|
||||
return false;
|
||||
};
|
||||
|
||||
export const extractSrc = (htmlString: string): string => {
|
||||
const twitterMatch = htmlString.match(RE_TWITTER_EMBED);
|
||||
export const maybeParseEmbedSrc = (str: string): string => {
|
||||
const twitterMatch = str.match(RE_TWITTER_EMBED);
|
||||
if (twitterMatch && twitterMatch.length === 2) {
|
||||
return twitterMatch[1];
|
||||
}
|
||||
|
||||
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
|
||||
const gistMatch = str.match(RE_GH_GIST_EMBED);
|
||||
if (gistMatch && gistMatch.length === 2) {
|
||||
return gistMatch[1];
|
||||
}
|
||||
|
||||
if (RE_GIPHY.test(htmlString)) {
|
||||
return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`;
|
||||
if (RE_GIPHY.test(str)) {
|
||||
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) {
|
||||
return match[1];
|
||||
}
|
||||
return htmlString;
|
||||
return str;
|
||||
};
|
||||
|
||||
export const embeddableURLValidator = (
|
||||
|
@ -136,13 +136,9 @@ export const newElement = (
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
validated: ExcalidrawEmbeddableElement["validated"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
|
||||
validated: opts.validated,
|
||||
};
|
||||
return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts);
|
||||
};
|
||||
|
||||
export const newIframeElement = (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
||||
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
@ -39,15 +39,13 @@ import { ExtractSetType } from "../utility-types";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
text
|
||||
normalizeEOL(text)
|
||||
// replace tabs with spaces so they render and measure correctly
|
||||
.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");
|
||||
};
|
||||
|
||||
|
@ -88,14 +88,6 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
||||
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
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 &
|
||||
|
@ -5,12 +5,7 @@ import type * as TExcalidraw from "../index";
|
||||
import "./App.scss";
|
||||
import initialData from "./initialData";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
resolvablePromise,
|
||||
ResolvablePromise,
|
||||
withBatchedUpdates,
|
||||
withBatchedUpdatesThrottled,
|
||||
} from "../utils";
|
||||
import { resolvablePromise, ResolvablePromise } from "../utils";
|
||||
import { EVENT, ROUNDNESS } from "../constants";
|
||||
import { distance2d } from "../math";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
@ -29,6 +24,7 @@ import { ImportedLibraryData } from "../data/types";
|
||||
import CustomFooter from "./CustomFooter";
|
||||
import MobileFooter from "./MobileFooter";
|
||||
import { KEYS } from "../keys";
|
||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
124
packages/excalidraw/laser-trails.ts
Normal file
124
packages/excalidraw/laser-trails.ts
Normal file
@ -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",
|
||||
"eyeDropper": "Pick color from canvas",
|
||||
"textToDiagram": "Text to diagram",
|
||||
"prompt": "Prompt"
|
||||
"prompt": "Prompt",
|
||||
"followUs": "Follow us",
|
||||
"discordChat": "Discord chat"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "No items added yet...",
|
||||
|
@ -7,8 +7,8 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"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": {
|
||||
"development": "./dist/dev/index.css",
|
||||
@ -57,7 +57,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/random-username": "1.1.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
|
61
packages/excalidraw/reactUtils.ts
Normal file
61
packages/excalidraw/reactUtils.ts
Normal file
@ -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,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawFrameLikeElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
isTextElement,
|
||||
@ -36,10 +37,12 @@ import {
|
||||
BinaryFiles,
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
ElementsPendingErasure,
|
||||
} from "../types";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||
FRAME_STYLE,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
MIME_TYPES,
|
||||
@ -94,6 +97,27 @@ const shouldResetImageFilter = (
|
||||
const getCanvasPadding = (element: ExcalidrawElement) =>
|
||||
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 {
|
||||
element: ExcalidrawElement | ExcalidrawTextElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
@ -269,8 +293,6 @@ const drawElementOnCanvas = (
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
context.globalAlpha =
|
||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
@ -372,7 +394,6 @@ const drawElementOnCanvas = (
|
||||
}
|
||||
}
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
export const elementWithCanvasCache = new WeakMap<
|
||||
@ -595,6 +616,12 @@ export const renderElement = (
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
context.globalAlpha = getRenderOpacity(
|
||||
element,
|
||||
getContainingFrame(element),
|
||||
renderConfig.elementsPendingErasure,
|
||||
);
|
||||
|
||||
switch (element.type) {
|
||||
case "magicframe":
|
||||
case "frame": {
|
||||
@ -831,6 +858,8 @@ export const renderElement = (
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
const roughSVGDrawWithPrecision = (
|
||||
|
@ -1007,7 +1007,9 @@ const _renderStaticScene = ({
|
||||
if (
|
||||
isIframeLikeElement(element) &&
|
||||
(isExporting ||
|
||||
(isEmbeddableElement(element) && !element.validated)) &&
|
||||
(isEmbeddableElement(element) &&
|
||||
renderConfig.embedsValidationStatus.get(element.id) !==
|
||||
true)) &&
|
||||
element.width &&
|
||||
element.height
|
||||
) {
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import { EmbedsValidationStatus } from "../types";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
@ -118,10 +119,13 @@ export const generateRoughOptions = (
|
||||
const modifyIframeLikeForRoughOptions = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
isExporting: boolean,
|
||||
embedsValidationStatus: EmbedsValidationStatus | null,
|
||||
) => {
|
||||
if (
|
||||
isIframeLikeElement(element) &&
|
||||
(isExporting || (isEmbeddableElement(element) && !element.validated)) &&
|
||||
(isExporting ||
|
||||
(isEmbeddableElement(element) &&
|
||||
embedsValidationStatus?.get(element.id) !== true)) &&
|
||||
isTransparent(element.backgroundColor) &&
|
||||
isTransparent(element.strokeColor)
|
||||
) {
|
||||
@ -278,7 +282,12 @@ export const _generateElementShape = (
|
||||
{
|
||||
isExporting,
|
||||
canvasBackgroundColor,
|
||||
}: { isExporting: boolean; canvasBackgroundColor: string },
|
||||
embedsValidationStatus,
|
||||
}: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: string;
|
||||
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||
},
|
||||
): Drawable | Drawable[] | null => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
@ -299,7 +308,11 @@ export const _generateElementShape = (
|
||||
h - r
|
||||
} L 0 ${r} Q 0 0, ${r} 0`,
|
||||
generateRoughOptions(
|
||||
modifyIframeLikeForRoughOptions(element, isExporting),
|
||||
modifyIframeLikeForRoughOptions(
|
||||
element,
|
||||
isExporting,
|
||||
embedsValidationStatus,
|
||||
),
|
||||
true,
|
||||
),
|
||||
);
|
||||
@ -310,7 +323,11 @@ export const _generateElementShape = (
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(
|
||||
modifyIframeLikeForRoughOptions(element, isExporting),
|
||||
modifyIframeLikeForRoughOptions(
|
||||
element,
|
||||
isExporting,
|
||||
embedsValidationStatus,
|
||||
),
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
@ -8,7 +8,7 @@ import { elementWithCanvasCache } from "../renderer/renderElement";
|
||||
import { _generateElementShape } from "./Shape";
|
||||
import { ElementShape, ElementShapes } from "./types";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, EmbedsValidationStatus } from "../types";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
@ -51,6 +51,7 @@ export class ShapeCache {
|
||||
renderConfig: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
@ -72,6 +73,7 @@ export class ShapeCache {
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
|
@ -266,6 +266,9 @@ export const exportToCanvas = async (
|
||||
imageCache,
|
||||
renderGrid: false,
|
||||
isExporting: true,
|
||||
// empty disables embeddable rendering
|
||||
embedsValidationStatus: new Map(),
|
||||
elementsPendingErasure: new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
@ -287,6 +290,9 @@ export const exportToSvg = async (
|
||||
},
|
||||
files: BinaryFiles | null,
|
||||
opts?: {
|
||||
/**
|
||||
* if true, all embeddables passed in will be rendered when possible.
|
||||
*/
|
||||
renderEmbeddables?: boolean;
|
||||
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
||||
},
|
||||
@ -427,14 +433,24 @@ export const exportToSvg = async (
|
||||
}
|
||||
|
||||
const rsvg = rough.svg(svgRoot);
|
||||
|
||||
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
||||
|
||||
renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, {
|
||||
offsetX,
|
||||
offsetY,
|
||||
isExporting: true,
|
||||
exportWithDarkMode,
|
||||
renderEmbeddables: opts?.renderEmbeddables ?? false,
|
||||
renderEmbeddables,
|
||||
frameRendering,
|
||||
canvasBackgroundColor: viewBackgroundColor,
|
||||
embedsValidationStatus: renderEmbeddables
|
||||
? new Map(
|
||||
elementsForRender
|
||||
.filter((element) => isFrameLikeElement(element))
|
||||
.map((element) => [element.id, true]),
|
||||
)
|
||||
: new Map(),
|
||||
});
|
||||
|
||||
tempScene.destroy();
|
||||
|
@ -7,6 +7,8 @@ import {
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
ElementsPendingErasure,
|
||||
InteractiveCanvasAppState,
|
||||
StaticCanvasAppState,
|
||||
} from "../types";
|
||||
@ -20,6 +22,8 @@ export type StaticCanvasRenderConfig = {
|
||||
/** when exporting the behavior is slightly different (e.g. we can't use
|
||||
CSS filters), and we disable render optimizations for best output */
|
||||
isExporting: boolean;
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
elementsPendingErasure: ElementsPendingErasure;
|
||||
};
|
||||
|
||||
export type SVGRenderConfig = {
|
||||
@ -30,6 +34,7 @@ export type SVGRenderConfig = {
|
||||
renderEmbeddables: boolean;
|
||||
frameRendering: AppState["frameRendering"];
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
};
|
||||
|
||||
export type InteractiveCanvasRenderConfig = {
|
||||
|
@ -386,6 +386,52 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
GitHub
|
||||
</div>
|
||||
</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
|
||||
aria-label="Discord"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
@ -423,50 +469,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Discord
|
||||
</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
|
||||
Discord chat
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -34,7 +34,6 @@ export const rectangleFixture: ExcalidrawElement = {
|
||||
export const embeddableFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
};
|
||||
export const ellipseFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
|
@ -205,7 +205,6 @@ export class API {
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
...base,
|
||||
validated: null,
|
||||
});
|
||||
break;
|
||||
case "iframe":
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawIframeLikeElement,
|
||||
} from "./element/types";
|
||||
import { Action } from "./actions/types";
|
||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
@ -633,12 +634,6 @@ export type PointerDownState = Readonly<{
|
||||
boxSelection: {
|
||||
hasOccurred: boolean;
|
||||
};
|
||||
elementIdsToErase: {
|
||||
[key: ExcalidrawElement["id"]]: {
|
||||
opacity: ExcalidrawElement["opacity"];
|
||||
erase: boolean;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export type UnsubscribeCallback = () => void;
|
||||
@ -751,3 +746,10 @@ export type Primitive =
|
||||
| undefined;
|
||||
|
||||
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,
|
||||
Zoom,
|
||||
} from "./types";
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import { ResolutionType } from "./utility-types";
|
||||
import React from "react";
|
||||
|
||||
let mockDateTime: string | null = null;
|
||||
|
||||
@ -555,33 +553,6 @@ export const 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
|
||||
export const nFormatter = (num: number, digits: number): string => {
|
||||
const si = [
|
||||
@ -939,36 +910,6 @@ export const memoize = <T extends Record<string, any>, R extends any>(
|
||||
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. */
|
||||
export const isMemberOf = <T extends string>(
|
||||
/** Set/Map/Array/Object */
|
||||
@ -1071,3 +1012,41 @@ export function addEventListener(
|
||||
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");
|
||||
};
|
||||
|
BIN
public/Assistant-Regular.woff2
Normal file
BIN
public/Assistant-Regular.woff2
Normal file
Binary file not shown.
BIN
public/Cascadia.woff2
Normal file
BIN
public/Cascadia.woff2
Normal file
Binary file not shown.
BIN
public/Virgil.woff2
Normal file
BIN
public/Virgil.woff2
Normal file
Binary file not shown.
@ -2247,10 +2247,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
|
||||
integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ==
|
||||
|
||||
"@excalidraw/laser-pointer@1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.2.0.tgz#cd34ea7d24b11743c726488cc1fcb28c161cacba"
|
||||
integrity sha512-WjFFwLk9ahmKRKku7U0jqYpeM3fe9ZS1K43pfwPREHk4/FYU3iKDKVeS8m4tEAASnRlBt3hhLCBQLBF2uvgOnw==
|
||||
"@excalidraw/laser-pointer@1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz#7c40836598e8e6ad91f01057883ed8b88fb9266c"
|
||||
integrity sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==
|
||||
|
||||
"@excalidraw/markdown-to-text@0.1.2":
|
||||
version "0.1.2"
|
||||
|
Reference in New Issue
Block a user