Compare commits

..

3 Commits

Author SHA1 Message Date
zsviczian f244b76ec0 Update actionFinalize.tsx 2024-01-06 18:11:26 +01:00
zsviczian 2eab545f27 Update linearElementEditor.ts 2024-01-06 18:10:18 +01:00
zsviczian 2b835ebb78 Do not close loop for freedraw 2024-01-03 16:08:22 +01:00
51 changed files with 744 additions and 1013 deletions
@@ -23,7 +23,7 @@ All `props` are _optional_.
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | `"light"` | `"dark"` | `"light"` | The theme of the Excalidraw component |
| [`name`](#name) | `string` | | Name of the drawing |
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
@@ -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.
+1 -1
View File
@@ -22,6 +22,7 @@ import {
preventUnload,
resolvablePromise,
throttleRAF,
withBatchedUpdates,
} from "../../packages/excalidraw/utils";
import {
CURSOR_SYNC_TIMEOUT,
@@ -82,7 +83,6 @@ 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);
+4 -4
View File
@@ -1,8 +1,4 @@
{
"name": "excalidraw-app",
"version": "1.0.0",
"private": true,
"homepage": ".",
"browserslist": {
"production": [
">0.2%",
@@ -26,13 +22,17 @@
"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"
+1 -36
View File
@@ -14,44 +14,9 @@ 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)
@@ -268,7 +233,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)
+5 -2
View File
@@ -11,6 +11,7 @@ import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
@@ -44,8 +45,10 @@ const alignSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
@@ -7,6 +7,7 @@ import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
@@ -35,8 +36,10 @@ const distributeSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
@@ -104,8 +104,8 @@ const duplicateElements = (
const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, {
includeBoundTextElement: false,
includeElementsInFrames: false,
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
);
@@ -100,10 +100,7 @@ export const actionFinalize = register({
// set the last point to first point.
// This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if (
multiPointElement.type === "line" ||
multiPointElement.type === "freedraw"
) {
if (multiPointElement.type === "line") {
if (isLoop) {
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
+5 -1
View File
@@ -19,7 +19,11 @@ export const actionFlipHorizontal = register({
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
elements: flipSelectedElements(elements, appState, "horizontal"),
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"),
appState,
app,
),
appState,
commitToHistory: true,
};
-148
View File
@@ -1,148 +0,0 @@
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);
}
}
@@ -1,79 +0,0 @@
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;
}
}
+143 -241
View File
@@ -57,6 +57,7 @@ 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,
@@ -181,7 +182,6 @@ import {
ExcalidrawIframeLikeElement,
IframeData,
ExcalidrawIframeElement,
ExcalidrawEmbeddableElement,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
@@ -246,8 +246,6 @@ import {
ToolType,
OnUserFollowedPayload,
UnsubscribeCallback,
EmbedsValidationStatus,
ElementsPendingErasure,
} from "../types";
import {
debounce,
@@ -260,24 +258,24 @@ import {
sceneCoordsToViewportCoords,
tupleToCoors,
viewportCoordsToSceneCoords,
withBatchedUpdates,
wrapEvent,
withBatchedUpdatesThrottled,
updateObject,
updateActiveTool,
getShortcutKey,
isTransparent,
easeToValuesRAF,
muteFSAbortError,
arrayToMap,
isTestEnv,
easeOut,
updateStable,
addEventListener,
normalizeEOL,
} from "../utils";
import {
createSrcDoc,
embeddableURLValidator,
maybeParseEmbedSrc,
extractSrc,
getEmbedLink,
} from "../element/embeddable";
import {
@@ -386,7 +384,8 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache";
import { SVGLayer } from "./SVGLayer";
import { LaserToolOverlay } from "./LaserTool/LaserTool";
import { LaserPathManager } from "./LaserTool/LaserPathManager";
import {
setEraserCursor,
setCursor,
@@ -403,12 +402,6 @@ 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!);
@@ -531,18 +524,6 @@ 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;
@@ -551,29 +532,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerMoveEvent: PointerEvent | null = null;
lastViewportPosition = { x: 0, y: 0 };
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)",
});
laserPathManager: LaserPathManager = new LaserPathManager(this);
onChangeEmitter = new Emitter<
[
@@ -880,14 +839,6 @@ 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"]>();
@@ -895,7 +846,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElements().filter((element) => {
if (isEmbeddableElement(element)) {
iframeLikes.add(element.id);
if (!this.embedsValidationStatus.has(element.id)) {
if (element.validated == null) {
updated = true;
const validated = embeddableURLValidator(
@@ -903,7 +854,8 @@ class App extends React.Component<AppProps, AppState> {
this.props.validateEmbeddable,
);
this.updateEmbedValidationStatus(element, validated);
mutateElement(element, { validated }, false);
ShapeCache.delete(element);
}
} else if (isIframeElement(element)) {
iframeLikes.add(element.id);
@@ -932,9 +884,7 @@ class App extends React.Component<AppProps, AppState> {
.getNonDeletedElements()
.filter(
(el): el is NonDeleted<ExcalidrawIframeLikeElement> =>
(isEmbeddableElement(el) &&
this.embedsValidationStatus.get(el.id) === true) ||
isIframeElement(el),
(isEmbeddableElement(el) && !!el.validated) || isIframeElement(el),
);
return (
@@ -945,23 +895,6 @@ 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)) {
@@ -1103,6 +1036,14 @@ 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";
@@ -1123,11 +1064,7 @@ class App extends React.Component<AppProps, AppState> {
}px) scale(${scale})`
: "none",
display: isVisible ? "block" : "none",
opacity: getRenderOpacity(
el,
getContainingFrame(el),
this.elementsPendingErasure,
),
opacity: el.opacity / 100,
["--embeddable-radius" as string]: `${getCornerRadius(
Math.min(el.width, el.height),
el,
@@ -1516,9 +1453,7 @@ class App extends React.Component<AppProps, AppState> {
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<SVGLayer
trails={[this.laserTrails, this.eraserTrail]}
/>
<LaserToolOverlay manager={this.laserPathManager} />
{selectedElements.length === 1 &&
this.state.showHyperlinkPopup && (
<Hyperlink
@@ -1527,9 +1462,6 @@ class App extends React.Component<AppProps, AppState> {
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast}
updateEmbedValidationStatus={
this.updateEmbedValidationStatus
}
/>
)}
{this.props.aiEnabled !== false &&
@@ -1640,8 +1572,6 @@ class App extends React.Component<AppProps, AppState> {
renderGrid: true,
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus: this.embedsValidationStatus,
elementsPendingErasure: this.elementsPendingErasure,
}}
/>
<InteractiveCanvas
@@ -2178,13 +2108,6 @@ class App extends React.Component<AppProps, AppState> {
},
);
}
// update frame membership if needed
updateFrameMembershipOfSelectedElements(
this.scene.getElementsIncludingDeleted(),
this.state,
this,
);
},
);
@@ -2452,8 +2375,7 @@ class App extends React.Component<AppProps, AppState> {
this.removeEventListeners();
this.scene.destroy();
this.library.destroy();
this.laserTrails.stop();
this.eraserTrail.stop();
this.laserPathManager.destroy();
this.onChangeEmitter.clear();
ShapeCache.destroy();
SnapCache.destroy();
@@ -2678,10 +2600,6 @@ 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 });
}
@@ -3006,49 +2924,21 @@ class App extends React.Component<AppProps, AppState> {
retainSeed: isPlainPaste,
});
} else if (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")
);
});
const maybeUrl = extractSrc(data.text);
if (
!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
!isPlainPaste &&
embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(maybeUrl) ||
getEmbedLink(maybeUrl)?.type === "video")
) {
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]),
),
});
const embeddable = this.insertEmbeddableElement({
sceneX,
sceneY,
link: normalizeLink(maybeUrl),
});
if (embeddable) {
this.setState({ selectedElementIds: { [embeddable.id]: true } });
}
return;
}
@@ -4348,13 +4238,11 @@ class App extends React.Component<AppProps, AppState> {
!(isTextElement(element) && element.containerId)),
);
const elementsMap = arrayToMap(elements);
return getElementsAtPosition(elements, (element) =>
hitTest(element, this.state, this.frameNameBoundsCache, x, y),
).filter((element) => {
// hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element, elementsMap);
const containingFrame = getContainingFrame(element);
return containingFrame &&
this.state.frameRendering.enabled &&
this.state.frameRendering.clip
@@ -5135,49 +5023,31 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState,
scenePointer: { x: number; y: number },
) => {
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) {
const updateElementIds = (elements: ExcalidrawElement[]) => {
elements.forEach((element) => {
if (element.locked) {
return;
}
idsToUpdate.push(element.id);
if (event.altKey) {
if (this.elementsPendingErasure.delete(element.id)) {
didChange = true;
if (
pointerDownState.elementIdsToErase[element.id] &&
pointerDownState.elementIdsToErase[element.id].erase
) {
pointerDownState.elementIdsToErase[element.id].erase = false;
}
} else if (!this.elementsPendingErasure.has(element.id)) {
didChange = true;
this.elementsPendingErasure.add(element.id);
} else if (!pointerDownState.elementIdsToErase[element.id]) {
pointerDownState.elementIdsToErase[element.id] = {
erase: true,
opacity: element.opacity,
};
}
// (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 idsToUpdate: Array<string> = [];
const distance = distance2d(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
@@ -5189,7 +5059,7 @@ class App extends React.Component<AppProps, AppState> {
let samplingInterval = 0;
while (samplingInterval <= distance) {
const hitElements = this.getElementsAtPosition(point.x, point.y);
processElements(hitElements);
updateElementIds(hitElements);
// Exit since we reached current point
if (samplingInterval === distance) {
@@ -5208,31 +5078,35 @@ class App extends React.Component<AppProps, AppState> {
point.y = nextY;
}
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);
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.elementsPendingErasure = new Set(this.elementsPendingErasure);
this.onSceneUpdated();
}
this.scene.replaceAllElements(elements);
pointerDownState.lastCoords.x = scenePointer.x;
pointerDownState.lastCoords.y = scenePointer.y;
};
// set touch moving for mobile context menu
private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
invalidateContextMenu = true;
@@ -5589,7 +5463,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type,
);
} else if (this.state.activeTool.type === "laser") {
this.laserTrails.startPath(
this.laserPathManager.startPath(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
);
@@ -5610,13 +5484,6 @@ 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);
@@ -5925,6 +5792,7 @@ class App extends React.Component<AppProps, AppState> {
boxSelection: {
hasOccurred: false,
},
elementIdsToErase: {},
};
}
@@ -6480,6 +6348,7 @@ class App extends React.Component<AppProps, AppState> {
width: embedLink.intrinsicSize.w,
height: embedLink.intrinsicSize.h,
link,
validated: null,
});
this.scene.replaceAllElements([
@@ -6713,6 +6582,7 @@ class App extends React.Component<AppProps, AppState> {
if (elementType === "embeddable") {
element = newEmbeddableElement({
type: "embeddable",
validated: null,
...baseElementAttributes,
});
} else {
@@ -6878,7 +6748,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (this.state.activeTool.type === "laser") {
this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
}
const [gridX, gridY] = getGridPoint(
@@ -7689,10 +7559,7 @@ class App extends React.Component<AppProps, AppState> {
);
if (linearElement?.frameId) {
const frame = getContainingFrame(
linearElement,
arrayToMap(this.scene.getElementsIncludingDeleted()),
);
const frame = getContainingFrame(linearElement);
if (frame && linearElement) {
if (!elementOverlapsWithFrame(linearElement, frame)) {
@@ -7890,8 +7757,6 @@ 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,
@@ -7911,14 +7776,18 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x,
scenePointer.y,
);
hitElements.forEach((hitElement) =>
this.elementsPendingErasure.add(hitElement.id),
hitElements.forEach(
(hitElement) =>
(pointerDownState.elementIdsToErase[hitElement.id] = {
erase: true,
opacity: hitElement.opacity,
}),
);
}
this.eraseElements();
this.eraseElements(pointerDownState);
return;
} else if (this.elementsPendingErasure.size) {
this.restoreReadyToEraseElements();
} else if (Object.keys(pointerDownState.elementIdsToErase).length) {
this.restoreReadyToEraseElements(pointerDownState);
}
if (
@@ -8140,7 +8009,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (activeTool.type === "laser") {
this.laserTrails.endPath();
this.laserPathManager.endPath();
return;
}
@@ -8179,32 +8048,65 @@ class App extends React.Component<AppProps, AppState> {
});
}
private restoreReadyToEraseElements = () => {
this.elementsPendingErasure = new Set();
this.onSceneUpdated();
};
private eraseElements = () => {
let didChange = false;
private restoreReadyToEraseElements = (
pointerDownState: PointerDownState,
) => {
const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
if (
this.elementsPendingErasure.has(ele.id) ||
(ele.frameId && this.elementsPendingErasure.has(ele.frameId)) ||
(isBoundToContainer(ele) &&
this.elementsPendingErasure.has(ele.containerId))
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) => {
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
) {
didChange = true;
return newElementWith(ele, { isDeleted: true });
}
return ele;
});
this.elementsPendingErasure = new Set();
if (didChange) {
this.history.resumeRecording();
this.scene.replaceAllElements(elements);
}
this.history.resumeRecording();
this.scene.replaceAllElements(elements);
};
private initializeImage = async ({
@@ -0,0 +1,310 @@
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,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;
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import { LaserPathManager } from "./LaserPathManager";
import "./LaserToolOverlay.scss";
type LaserToolOverlayProps = {
manager: LaserPathManager;
};
export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => {
const svgRef = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (svgRef.current) {
manager.start(svgRef.current);
}
return () => {
manager.stop();
};
}, [manager]);
return (
<div className="LaserToolOverlay">
<svg ref={svgRef} className="LaserToolOverlayCanvas" />
</div>
);
};
@@ -1,5 +1,5 @@
.excalidraw {
.SVGLayer {
.LaserToolOverlay {
pointer-events: none;
width: 100vw;
height: 100vh;
@@ -9,12 +9,10 @@
z-index: 2;
& svg {
.LaserToolOverlayCanvas {
image-rendering: auto;
overflow: visible;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
+1 -1
View File
@@ -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 "./LaserPointerButton";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog";
@@ -1,33 +0,0 @@
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,6 +1,10 @@
import React, { useEffect, useRef } from "react";
import { renderInteractiveScene } from "../../renderer/renderScene";
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
import {
isRenderThrottlingEnabled,
isShallowEqual,
sceneCoordsToViewportCoords,
} from "../../utils";
import { CURSOR_TYPE } from "../../constants";
import { t } from "../../i18n";
import type { DOMAttributes } from "react";
@@ -10,7 +14,6 @@ import type {
RenderInteractiveSceneCallback,
} from "../../scene/types";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import { isRenderThrottlingEnabled } from "../../reactUtils";
type InteractiveCanvasProps = {
containerRef: React.RefObject<HTMLDivElement>;
@@ -1,11 +1,10 @@
import React, { useEffect, useRef } from "react";
import { RoughCanvas } from "roughjs/bin/canvas";
import { renderStaticScene } from "../../renderer/renderScene";
import { isShallowEqual } from "../../utils";
import { isRenderThrottlingEnabled, 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;
+3 -4
View File
@@ -486,11 +486,10 @@ export const DiscordIcon = createIcon(
modifiedTablerIconProps,
);
export const XBrandIcon = createIcon(
export const TwitterIcon = createIcon(
<g strokeWidth="1.25">
<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" />
<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>
</g>,
tablerIconProps,
);
@@ -17,7 +17,7 @@ import {
TrashIcon,
usersIcon,
} from "../icons";
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import {
@@ -241,35 +241,31 @@ export const Export = () => {
};
Export.displayName = "Export";
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>
</>
);
};
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>
</>
);
Socials.displayName = "Socials";
export const LiveCollaborationTrigger = ({
+4 -1
View File
@@ -295,8 +295,11 @@ const restoreElement = (
case "rectangle":
case "diamond":
case "iframe":
case "embeddable":
return restoreElementWithProperties(element, {});
case "embeddable":
return restoreElementWithProperties(element, {
validated: null,
});
case "magicframe":
case "frame":
return restoreElementWithProperties(element, {
+6 -9
View File
@@ -39,6 +39,7 @@ 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;
@@ -63,7 +64,6 @@ export const Hyperlink = ({
setAppState,
onLinkOpen,
setToast,
updateEmbedValidationStatus,
}: {
element: NonDeletedExcalidrawElement;
setAppState: React.Component<any, AppState>["setState"];
@@ -71,10 +71,6 @@ 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();
@@ -102,9 +98,9 @@ export const Hyperlink = ({
}
if (!link) {
mutateElement(element, {
validated: false,
link: null,
});
updateEmbedValidationStatus(element, false);
return;
}
@@ -114,9 +110,10 @@ export const Hyperlink = ({
}
element.link && embeddableLinkCache.set(element.id, element.link);
mutateElement(element, {
validated: false,
link,
});
updateEmbedValidationStatus(element, false);
ShapeCache.delete(element);
} else {
const { width, height } = element;
const embedLink = getEmbedLink(link);
@@ -145,9 +142,10 @@ export const Hyperlink = ({
: height,
}
: {}),
validated: true,
link,
});
updateEmbedValidationStatus(element, true);
ShapeCache.delete(element);
if (embeddableLinkCache.has(element.id)) {
embeddableLinkCache.delete(element.id);
}
@@ -161,7 +159,6 @@ export const Hyperlink = ({
appProps.validateEmbeddable,
appState.activeEmbeddable,
setAppState,
updateEmbedValidationStatus,
]);
useLayoutEffect(() => {
+22 -8
View File
@@ -5,9 +5,14 @@ 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, isFrameLikeElement } from "./typeChecks";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
} from "./typeChecks";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@@ -32,11 +37,13 @@ export const dragSelectedElements = (
.map((f) => f.id);
if (frames.length > 0) {
for (const element of scene.getNonDeletedElements()) {
if (element.frameId !== null && frames.includes(element.frameId)) {
elementsToUpdate.add(element);
}
}
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));
}
const commonBounds = getCommonBounds(
@@ -53,9 +60,16 @@ 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 (
// skip arrow labels since we calculate its position during render
!isArrowElement(element)
// 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)))
) {
const textElement = getBoundTextElement(element);
if (textElement) {
+9 -13
View File
@@ -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|x).com/;
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https:\/\/(?:twitter|x).com\/[^"']*)/i;
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.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,7 +54,6 @@ const ALLOWED_DOMAINS = new Set([
"link.excalidraw.com",
"gist.github.com",
"twitter.com",
"x.com",
"*.simplepdf.eu",
"stackblitz.com",
"val.town",
@@ -156,9 +155,6 @@ 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)) {
@@ -325,26 +321,26 @@ const validateHostname = (
return false;
};
export const maybeParseEmbedSrc = (str: string): string => {
const twitterMatch = str.match(RE_TWITTER_EMBED);
export const extractSrc = (htmlString: string): string => {
const twitterMatch = htmlString.match(RE_TWITTER_EMBED);
if (twitterMatch && twitterMatch.length === 2) {
return twitterMatch[1];
}
const gistMatch = str.match(RE_GH_GIST_EMBED);
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 2) {
return gistMatch[1];
}
if (RE_GIPHY.test(str)) {
return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`;
if (RE_GIPHY.test(htmlString)) {
return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`;
}
const match = str.match(RE_GENERIC_EMBED);
const match = htmlString.match(RE_GENERIC_EMBED);
if (match && match.length === 2) {
return match[1];
}
return str;
return htmlString;
};
export const embeddableURLValidator = (
+5 -1
View File
@@ -136,9 +136,13 @@ export const newElement = (
export const newEmbeddableElement = (
opts: {
type: "embeddable";
validated: ExcalidrawEmbeddableElement["validated"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawEmbeddableElement> => {
return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts);
return {
..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
validated: opts.validated,
};
};
export const newIframeElement = (
+5 -3
View File
@@ -1,4 +1,4 @@
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
ExcalidrawElementType,
@@ -39,13 +39,15 @@ import { ExtractSetType } from "../utility-types";
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
// normalize newlines
.replace(/\r?\n|\r/g, "\n")
);
};
const splitIntoLines = (text: string) => {
export const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
+8
View File
@@ -88,6 +88,14 @@ 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 &
+6 -2
View File
@@ -5,7 +5,12 @@ import type * as TExcalidraw from "../index";
import "./App.scss";
import initialData from "./initialData";
import { nanoid } from "nanoid";
import { resolvablePromise, ResolvablePromise } from "../utils";
import {
resolvablePromise,
ResolvablePromise,
withBatchedUpdates,
withBatchedUpdatesThrottled,
} from "../utils";
import { EVENT, ROUNDNESS } from "../constants";
import { distance2d } from "../math";
import { fileOpen } from "../data/filesystem";
@@ -24,7 +29,6 @@ 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
View File
@@ -1,124 +0,0 @@
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);
}
}
}
}
+1 -3
View File
@@ -138,9 +138,7 @@
"removeAllElementsFromFrame": "Remove all elements from frame",
"eyeDropper": "Pick color from canvas",
"textToDiagram": "Text to diagram",
"prompt": "Prompt",
"followUs": "Follow us",
"discordChat": "Discord chat"
"prompt": "Prompt"
},
"library": {
"noItems": "No items added yet...",
+3 -3
View File
@@ -7,8 +7,8 @@
"exports": {
".": {
"development": "./dist/dev/index.js",
"types": "./dist/excalidraw/index.d.ts",
"default": "./dist/prod/index.js"
"default": "./dist/prod/index.js",
"types": "./dist/excalidraw/index.d.ts"
},
"./index.css": {
"development": "./dist/dev/index.css",
@@ -57,7 +57,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/laser-pointer": "1.2.0",
"@excalidraw/mermaid-to-excalidraw": "0.2.0",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.0.3",
-61
View File
@@ -1,61 +0,0 @@
/**
* @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;
};
})();
+3 -32
View File
@@ -5,7 +5,6 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawFrameLikeElement,
} from "../element/types";
import {
isTextElement,
@@ -37,12 +36,10 @@ 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,
@@ -97,27 +94,6 @@ 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;
@@ -293,6 +269,8 @@ const drawElementOnCanvas = (
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) {
case "rectangle":
case "iframe":
@@ -394,6 +372,7 @@ const drawElementOnCanvas = (
}
}
}
context.globalAlpha = 1;
};
export const elementWithCanvasCache = new WeakMap<
@@ -616,12 +595,6 @@ export const renderElement = (
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.globalAlpha = getRenderOpacity(
element,
getContainingFrame(element),
renderConfig.elementsPendingErasure,
);
switch (element.type) {
case "magicframe":
case "frame": {
@@ -858,8 +831,6 @@ export const renderElement = (
throw new Error(`Unimplemented type ${element.type}`);
}
}
context.globalAlpha = 1;
};
const roughSVGDrawWithPrecision = (
+1 -3
View File
@@ -1007,9 +1007,7 @@ const _renderStaticScene = ({
if (
isIframeLikeElement(element) &&
(isExporting ||
(isEmbeddableElement(element) &&
renderConfig.embedsValidationStatus.get(element.id) !==
true)) &&
(isEmbeddableElement(element) && !element.validated)) &&
element.width &&
element.height
) {
+4 -21
View File
@@ -21,7 +21,6 @@ import {
isLinearElement,
} from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons";
import { EmbedsValidationStatus } from "../types";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -119,13 +118,10 @@ export const generateRoughOptions = (
const modifyIframeLikeForRoughOptions = (
element: NonDeletedExcalidrawElement,
isExporting: boolean,
embedsValidationStatus: EmbedsValidationStatus | null,
) => {
if (
isIframeLikeElement(element) &&
(isExporting ||
(isEmbeddableElement(element) &&
embedsValidationStatus?.get(element.id) !== true)) &&
(isExporting || (isEmbeddableElement(element) && !element.validated)) &&
isTransparent(element.backgroundColor) &&
isTransparent(element.strokeColor)
) {
@@ -282,12 +278,7 @@ export const _generateElementShape = (
{
isExporting,
canvasBackgroundColor,
embedsValidationStatus,
}: {
isExporting: boolean;
canvasBackgroundColor: string;
embedsValidationStatus: EmbedsValidationStatus | null;
},
}: { isExporting: boolean; canvasBackgroundColor: string },
): Drawable | Drawable[] | null => {
switch (element.type) {
case "rectangle":
@@ -308,11 +299,7 @@ export const _generateElementShape = (
h - r
} L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions(
modifyIframeLikeForRoughOptions(
element,
isExporting,
embedsValidationStatus,
),
modifyIframeLikeForRoughOptions(element, isExporting),
true,
),
);
@@ -323,11 +310,7 @@ export const _generateElementShape = (
element.width,
element.height,
generateRoughOptions(
modifyIframeLikeForRoughOptions(
element,
isExporting,
embedsValidationStatus,
),
modifyIframeLikeForRoughOptions(element, isExporting),
false,
),
);
+1 -3
View File
@@ -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, EmbedsValidationStatus } from "../types";
import { AppState } from "../types";
export class ShapeCache {
private static rg = new RoughGenerator();
@@ -51,7 +51,6 @@ export class ShapeCache {
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
@@ -73,7 +72,6 @@ export class ShapeCache {
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
+1 -17
View File
@@ -266,9 +266,6 @@ export const exportToCanvas = async (
imageCache,
renderGrid: false,
isExporting: true,
// empty disables embeddable rendering
embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(),
},
});
@@ -290,9 +287,6 @@ export const exportToSvg = async (
},
files: BinaryFiles | null,
opts?: {
/**
* if true, all embeddables passed in will be rendered when possible.
*/
renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameLikeElement | null;
},
@@ -433,24 +427,14 @@ 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,
renderEmbeddables: opts?.renderEmbeddables ?? false,
frameRendering,
canvasBackgroundColor: viewBackgroundColor,
embedsValidationStatus: renderEmbeddables
? new Map(
elementsForRender
.filter((element) => isFrameLikeElement(element))
.map((element) => [element.id, true]),
)
: new Map(),
});
tempScene.destroy();
+2 -5
View File
@@ -11,7 +11,6 @@ import {
getFrameChildren,
} from "../frame";
import { isShallowEqual } from "../utils";
import { arrayToMap } from "../utils";
import { isElementInViewport } from "../element/sizeHelpers";
/**
@@ -49,13 +48,11 @@ export const getElementsWithinSelection = (
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(selection);
const elementsMap = arrayToMap(elements);
let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] =
getElementBounds(element);
const containingFrame = getContainingFrame(element, elementsMap);
const containingFrame = getContainingFrame(element);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame);
@@ -81,7 +78,7 @@ export const getElementsWithinSelection = (
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
const containingFrame = getContainingFrame(element);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame);
-5
View File
@@ -7,8 +7,6 @@ import {
import {
AppClassProperties,
AppState,
EmbedsValidationStatus,
ElementsPendingErasure,
InteractiveCanvasAppState,
StaticCanvasAppState,
} from "../types";
@@ -22,8 +20,6 @@ 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 = {
@@ -34,7 +30,6 @@ export type SVGRenderConfig = {
renderEmbeddables: boolean;
frameRendering: AppState["frameRendering"];
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
};
export type InteractiveCanvasRenderConfig = {
@@ -386,52 +386,6 @@ 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"
@@ -469,7 +423,50 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Discord chat
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
</div>
</a>
</div>
+1
View File
@@ -34,6 +34,7 @@ export const rectangleFixture: ExcalidrawElement = {
export const embeddableFixture: ExcalidrawElement = {
...elementBase,
type: "embeddable",
validated: null,
};
export const ellipseFixture: ExcalidrawElement = {
...elementBase,
+1
View File
@@ -205,6 +205,7 @@ export class API {
element = newEmbeddableElement({
type: "embeddable",
...base,
validated: null,
});
break;
case "iframe":
+6 -8
View File
@@ -19,7 +19,6 @@ import {
ExcalidrawMagicFrameElement,
ExcalidrawFrameLikeElement,
ExcalidrawElementType,
ExcalidrawIframeLikeElement,
} from "./element/types";
import { Action } from "./actions/types";
import { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -634,6 +633,12 @@ export type PointerDownState = Readonly<{
boxSelection: {
hasOccurred: boolean;
};
elementIdsToErase: {
[key: ExcalidrawElement["id"]]: {
opacity: ExcalidrawElement["opacity"];
erase: boolean;
};
};
}>;
export type UnsubscribeCallback = () => void;
@@ -746,10 +751,3 @@ 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"]>;
+59 -38
View File
@@ -14,7 +14,9 @@ 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;
@@ -553,6 +555,33 @@ 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 = [
@@ -910,6 +939,36 @@ 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 */
@@ -1012,41 +1071,3 @@ 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");
};
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -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.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/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/markdown-to-text@0.1.2":
version "0.1.2"