Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6f0cee4c0 | |||
| bb985eba3a | |||
| 23c88a38d0 | |||
| b85d5fa12b | |||
| 9391a09e54 | |||
| 50450a7dab | |||
| fc9a9a2571 | |||
| 6126c34dc0 | |||
| 12e37e3dd2 | |||
| 9ca27c62c7 | |||
| 1acc646534 | |||
| 66bac50de3 |
@@ -0,0 +1,5 @@
|
||||
FROM node:18-bullseye
|
||||
|
||||
# Vite wants to open the browser using `open`, so we
|
||||
# need to install those utils.
|
||||
RUN apt update -y && apt install -y xdg-utils
|
||||
@@ -27,7 +27,10 @@
|
||||
"start": {
|
||||
"name": "Start Excalidraw",
|
||||
"command": "yarn start",
|
||||
"runAtStart": true
|
||||
"runAtStart": true,
|
||||
"preview": {
|
||||
"port": 3000
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"name": "Run Tests",
|
||||
@@ -37,7 +40,11 @@
|
||||
"install-deps": {
|
||||
"name": "Install Dependencies",
|
||||
"command": "yarn install",
|
||||
"restartOn": { "files": ["yarn.lock"] }
|
||||
"restartOn": {
|
||||
"files": ["yarn.lock"],
|
||||
"branch": false,
|
||||
"resume": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Test Coverage PR
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: "Install Node"
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "18.x"
|
||||
- name: "Install Deps"
|
||||
run: yarn --frozen-lockfile
|
||||
- name: "Test Coverage"
|
||||
run: yarn test:coverage
|
||||
- name: "Report Coverage"
|
||||
if: always() # Also generate the report if tests are failing
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+9
-9
@@ -6611,19 +6611,19 @@ semver@7.0.0:
|
||||
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
|
||||
|
||||
semver@^5.4.1:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
|
||||
version "5.7.2"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
|
||||
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
|
||||
|
||||
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
||||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
|
||||
version "7.3.7"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
|
||||
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
|
||||
version "7.5.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
|
||||
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
|
||||
+7
-5
@@ -54,7 +54,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "4.6.1",
|
||||
"socket.io-client": "2.3.1",
|
||||
"tunnel-rat": "0.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,6 +70,7 @@
|
||||
"@types/resize-observer-browser": "0.1.7",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
"@vitest/ui": "0.32.2",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "16.0.1",
|
||||
@@ -101,8 +102,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
@@ -114,14 +115,15 @@
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"start": "vite",
|
||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||
"test:app": "vitest --config vitest.config.ts",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
"test:other": "yarn prettier --list-different",
|
||||
"test:typecheck": "tsc",
|
||||
"test:update": "yarn test:app --update --watch=false",
|
||||
"test": "yarn test:app",
|
||||
"test:coverage": "vitest --coverage --watchAll",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:coverage:watch": "vitest --coverage --watch",
|
||||
"test:ui": "yarn test --ui",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease": "node scripts/prerelease.js",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
// Since we migrated to Vite, the service worker strategy changed, in CRA it was a custom service worker named service-worker.js and in Vite its sw.js handled by vite-plugin-pwa
|
||||
// Due to this the existing CRA users were not able to migrate to Vite or any new changes post Vite unless browser is hard refreshed
|
||||
// Hence adding a self destroying worker so all CRA service workers are destroyed and migrated to Vite
|
||||
// We should remove this code after sometime when we are confident that
|
||||
// all users have migrated to Vite
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", () => {
|
||||
self.registration
|
||||
.unregister()
|
||||
.then(() => {
|
||||
return self.clients.matchAll();
|
||||
})
|
||||
.then((clients) => {
|
||||
clients.forEach((client) => client.navigate(client.url));
|
||||
});
|
||||
});
|
||||
@@ -298,7 +298,6 @@ import {
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
@@ -941,7 +940,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
title="Excalidraw Embedded Content"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -2748,7 +2747,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
toast: {
|
||||
message: string;
|
||||
closable?: boolean;
|
||||
spinner?: boolean;
|
||||
duration?: number;
|
||||
} | null,
|
||||
) => {
|
||||
@@ -3549,9 +3547,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
|
||||
const containerDims = getContainerDims(container);
|
||||
const newHeight = Math.max(containerDims.height, minHeight);
|
||||
const newWidth = Math.max(containerDims.width, minWidth);
|
||||
const newHeight = Math.max(container.height, minHeight);
|
||||
const newWidth = Math.max(container.width, minWidth);
|
||||
mutateElement(container, { height: newHeight, width: newWidth });
|
||||
sceneX = container.x + newWidth / 2;
|
||||
sceneY = container.y + newHeight / 2;
|
||||
|
||||
@@ -77,8 +77,8 @@ export const EyeDropper: React.FC<{
|
||||
colorPreviewDiv.style.left = `${clientX + 20}px`;
|
||||
|
||||
const pixel = ctx.getImageData(
|
||||
clientX * window.devicePixelRatio - appState.offsetLeft,
|
||||
clientY * window.devicePixelRatio - appState.offsetTop,
|
||||
(clientX - appState.offsetLeft) * window.devicePixelRatio,
|
||||
(clientY - appState.offsetTop) * window.devicePixelRatio,
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
|
||||
@@ -25,17 +25,6 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.Toast__message--spinner {
|
||||
padding: 0 3rem;
|
||||
}
|
||||
|
||||
.Toast__spinner {
|
||||
position: absolute;
|
||||
left: 1.5rem;
|
||||
top: 50%;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { CloseIcon } from "./icons";
|
||||
import Spinner from "./Spinner";
|
||||
import "./Toast.scss";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
@@ -11,14 +9,12 @@ export const Toast = ({
|
||||
message,
|
||||
onClose,
|
||||
closable = false,
|
||||
spinner = true,
|
||||
// To prevent autoclose, pass duration as Infinity
|
||||
duration = DEFAULT_TOAST_TIMEOUT,
|
||||
}: {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
closable?: boolean;
|
||||
spinner?: boolean;
|
||||
duration?: number;
|
||||
}) => {
|
||||
const timerRef = useRef<number>(0);
|
||||
@@ -48,18 +44,7 @@ export const Toast = ({
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{spinner && (
|
||||
<div className="Toast__spinner">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={clsx("Toast__message", {
|
||||
"Toast__message--spinner": spinner,
|
||||
})}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
<p className="Toast__message">{message}</p>
|
||||
{closable && (
|
||||
<ToolButton
|
||||
icon={CloseIcon}
|
||||
|
||||
@@ -117,6 +117,7 @@ export const FRAME_STYLE = {
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
@@ -239,6 +240,8 @@ export const VERSIONS = {
|
||||
} as const;
|
||||
|
||||
export const BOUND_TEXT_PADDING = 5;
|
||||
export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
|
||||
export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
|
||||
|
||||
export const VERTICAL_ALIGN = {
|
||||
TOP: "top",
|
||||
|
||||
@@ -40,6 +40,9 @@ const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
|
||||
const RE_TWITTER_EMBED =
|
||||
/^<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_$]+/;
|
||||
|
||||
const RE_GENERIC_EMBED =
|
||||
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
||||
|
||||
@@ -52,7 +55,9 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"link.excalidraw.com",
|
||||
"gist.github.com",
|
||||
"twitter.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
]);
|
||||
|
||||
const createSrcDoc = (body: string) => {
|
||||
@@ -122,6 +127,14 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
return { link, aspectRatio, type };
|
||||
}
|
||||
|
||||
const valLink = link.match(RE_VALTOWN);
|
||||
if (valLink) {
|
||||
link =
|
||||
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
}
|
||||
|
||||
if (RE_TWITTER.test(link)) {
|
||||
let ret: EmbeddedLink;
|
||||
// assume embed code
|
||||
@@ -262,9 +275,16 @@ const validateHostname = (
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
const bareDomain = hostname.replace(/^www\./, "");
|
||||
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
||||
/^([^.]+)/,
|
||||
"*",
|
||||
);
|
||||
|
||||
if (allowedHostnames instanceof Set) {
|
||||
return ALLOWED_DOMAINS.has(bareDomain);
|
||||
return (
|
||||
ALLOWED_DOMAINS.has(bareDomain) ||
|
||||
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
|
||||
);
|
||||
}
|
||||
|
||||
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
|
||||
|
||||
@@ -264,11 +264,11 @@ export class LinearElementEditor {
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import {
|
||||
@@ -204,8 +204,6 @@ const rescalePointsInElement = (
|
||||
}
|
||||
: {};
|
||||
|
||||
const MIN_FONT_SIZE = 1;
|
||||
|
||||
const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
nextWidth: number,
|
||||
@@ -589,24 +587,42 @@ export const resizeSingleElement = (
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isArrowElement(element) &&
|
||||
boundTextElement &&
|
||||
shouldMaintainAspectRatio
|
||||
) {
|
||||
const fontSize =
|
||||
(resizedElement.width / element.width) * boundTextElement.fontSize;
|
||||
if (fontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
boundTextFont.fontSize = fontSize;
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
) {
|
||||
mutateElement(element, resizedElement);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
baseline: boundTextFont.baseline,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
handleBindTextResize(
|
||||
element,
|
||||
transformHandleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -722,12 +738,8 @@ export const resizeMultipleElements = (
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
baseline?: ExcalidrawTextElement["baseline"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
};
|
||||
boundText: {
|
||||
element: ExcalidrawTextElementWithContainer;
|
||||
fontSize: ExcalidrawTextElement["fontSize"];
|
||||
baseline: ExcalidrawTextElement["baseline"];
|
||||
} | null;
|
||||
}[] = [];
|
||||
|
||||
for (const { orig, latest } of targetElements) {
|
||||
@@ -798,50 +810,39 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
}
|
||||
|
||||
let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(latest);
|
||||
|
||||
if (boundTextElement || isTextElement(orig)) {
|
||||
const updatedElement = {
|
||||
...latest,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
boundTextElement
|
||||
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||
: updatedElement.height,
|
||||
);
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
const metrics = measureFontSizeFromWidth(orig, width, height);
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundText = {
|
||||
element: boundTextElement,
|
||||
fontSize: metrics.size,
|
||||
baseline: metrics.baseline,
|
||||
};
|
||||
}
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({ element: latest, update, boundText });
|
||||
const boundTextElement = pointerDownState.originalElements.get(
|
||||
getBoundTextElementId(orig) ?? "",
|
||||
) as ExcalidrawTextElementWithContainer | undefined;
|
||||
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
if (newFontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
update.boundTextFontSize = newFontSize;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({
|
||||
element: latest,
|
||||
update,
|
||||
});
|
||||
}
|
||||
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
|
||||
for (const { element, update, boundText } of elementsAndUpdates) {
|
||||
for (const {
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
mutateElement(element, update, false);
|
||||
@@ -851,17 +852,17 @@ export const resizeMultipleElements = (
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
if (boundText) {
|
||||
const { element: boundTextElement, ...boundTextUpdates } = boundText;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
mutateElement(
|
||||
boundTextElement,
|
||||
{
|
||||
...boundTextUpdates,
|
||||
fontSize: boundTextFontSize,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
handleBindTextResize(element, transformHandleType, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-22
@@ -10,6 +10,8 @@ import {
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
@@ -65,7 +67,7 @@ export const redrawTextBoundingBox = (
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxWidth = getBoundTextMaxWidth(container, textElement);
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
@@ -83,13 +85,12 @@ export const redrawTextBoundingBox = (
|
||||
boundTextUpdates.baseline = metrics.baseline;
|
||||
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
let nextHeight = container.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
@@ -155,6 +156,7 @@ export const bindTextToShapeAfterDuplication = (
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
@@ -175,15 +177,17 @@ export const handleBindTextResize = (
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let containerHeight = containerDims.height;
|
||||
let containerHeight = container.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (
|
||||
shouldMaintainAspectRatio ||
|
||||
(transformHandleType !== "n" && transformHandleType !== "s")
|
||||
) {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
@@ -207,7 +211,7 @@ export const handleBindTextResize = (
|
||||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - containerDims.height;
|
||||
const diff = containerHeight - container.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
!isArrowElement(container) &&
|
||||
@@ -687,16 +691,6 @@ export const getContainerElement = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerDims = (element: ExcalidrawElement) => {
|
||||
const MIN_WIDTH = 300;
|
||||
if (isArrowElement(element)) {
|
||||
const width = Math.max(element.width, MIN_WIDTH);
|
||||
const height = element.height;
|
||||
return { width, height };
|
||||
}
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
@@ -887,12 +881,19 @@ export const computeContainerDimensionForBoundText = (
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
export const getBoundTextMaxWidth = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElement | null = getBoundTextElement(
|
||||
container,
|
||||
),
|
||||
) => {
|
||||
const { width } = container;
|
||||
if (isArrowElement(container)) {
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
const minWidth =
|
||||
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
|
||||
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The width of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||
@@ -911,7 +912,7 @@ export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const height = getContainerDims(container).height;
|
||||
const { height } = container;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
@@ -177,20 +176,19 @@ export const textWysiwyg = ({
|
||||
updatedTextElement,
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
containerDims.height,
|
||||
container.height,
|
||||
);
|
||||
} else {
|
||||
originalContainerData = originalContainerCache[container.id];
|
||||
if (!originalContainerData) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
containerDims.height,
|
||||
container.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -214,7 +212,7 @@ export const textWysiwyg = ({
|
||||
// autoshrink container height until original container height
|
||||
// is reached when text is removed
|
||||
!isArrowElement(container) &&
|
||||
containerDims.height > originalContainerData.height &&
|
||||
container.height > originalContainerData.height &&
|
||||
textElementHeight < maxHeight
|
||||
) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
|
||||
@@ -7,8 +7,6 @@ export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||
export const PAUSE_COLLABORATION_TIMEOUT = 2000;
|
||||
export const RESUME_FALLBACK_TIMEOUT = 5000;
|
||||
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import { ExcalidrawImperativeAPI, PauseCollaborationState } from "../../types";
|
||||
import { ExcalidrawImperativeAPI } from "../../types";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
@@ -16,7 +16,6 @@ import { Collaborator, Gesture } from "../../types";
|
||||
import {
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
upsertMap,
|
||||
withBatchedUpdates,
|
||||
} from "../../utils";
|
||||
import {
|
||||
@@ -25,15 +24,12 @@ import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
PAUSE_COLLABORATION_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
RESUME_FALLBACK_TIMEOUT,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getCollaborationLinkData,
|
||||
getCollabServer,
|
||||
getSyncableElements,
|
||||
SocketUpdateDataSource,
|
||||
@@ -47,8 +43,8 @@ import {
|
||||
saveToFirebase,
|
||||
} from "../data/firebase";
|
||||
import {
|
||||
importUsernameAndIdFromLocalStorage,
|
||||
saveUsernameAndIdToLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
saveUsernameToLocalStorage,
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
@@ -75,19 +71,16 @@ import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const collabDialogShownAtom = atom(false);
|
||||
export const isCollaboratingAtom = atom(false);
|
||||
export const isOfflineAtom = atom(false);
|
||||
export const isCollaborationPausedAtom = atom(false);
|
||||
|
||||
interface CollabState {
|
||||
errorMessage: string;
|
||||
username: string;
|
||||
activeRoomLink: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
@@ -101,7 +94,6 @@ export interface CollabAPI {
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
isPaused: () => boolean;
|
||||
}
|
||||
|
||||
interface PublicProps {
|
||||
@@ -116,7 +108,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
pauseTimeoutId: number | null;
|
||||
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
@@ -124,13 +115,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const { username, userId } = importUsernameAndIdFromLocalStorage() || {};
|
||||
|
||||
this.state = {
|
||||
errorMessage: "",
|
||||
username: username || "",
|
||||
userId: userId || "",
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
activeRoomLink: "",
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
@@ -162,7 +149,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.excalidrawAPI = props.excalidrawAPI;
|
||||
this.activeIntervalId = null;
|
||||
this.idleTimeoutId = null;
|
||||
this.pauseTimeoutId = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -181,7 +167,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||
stopCollaboration: this.stopCollaboration,
|
||||
setUsername: this.setUsername,
|
||||
isPaused: this.isPaused,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
@@ -207,13 +192,10 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
// window.removeEventListener(
|
||||
// EVENT.VISIBILITY_CHANGE,
|
||||
// this.onVisibilityChange,
|
||||
// );
|
||||
window.removeEventListener(EVENT.BLUR, this.onVisibilityChange);
|
||||
window.removeEventListener(EVENT.FOCUS, this.onVisibilityChange);
|
||||
|
||||
window.removeEventListener(
|
||||
EVENT.VISIBILITY_CHANGE,
|
||||
this.onVisibilityChange,
|
||||
);
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
@@ -222,10 +204,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
if (this.pauseTimeoutId) {
|
||||
window.clearTimeout(this.pauseTimeoutId);
|
||||
this.pauseTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
|
||||
@@ -329,128 +307,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
};
|
||||
|
||||
fallbackResumeTimeout: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
/**
|
||||
* Handles the pause and resume states of a collaboration session.
|
||||
* This function gets triggered when a change in the collaboration pause state is detected.
|
||||
* Based on the state, the function carries out the following actions:
|
||||
* 1. `PAUSED`: Saves the current scene to Firebase, disconnects the socket, and updates the scene to view mode.
|
||||
* 2. `RESUMED`: Connects the socket, shows a toast message, sets a fallback to fetch data from Firebase, and resets the pause timeout if any.
|
||||
* 3. `SYNCED`: Clears the fallback timeout if any, updates the collaboration pause state, and updates the scene to editing mode.
|
||||
*
|
||||
* @param state - The new state of the collaboration session. It is one of the values of `PauseCollaborationState` enum, which includes `PAUSED`, `RESUMED`, and `SYNCED`.
|
||||
*/
|
||||
onPauseCollaborationChange = (state: PauseCollaborationState) => {
|
||||
switch (state) {
|
||||
case PauseCollaborationState.PAUSED: {
|
||||
if (this.portal.socket) {
|
||||
// Save current scene to firebase
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
|
||||
this.portal.socket.disconnect();
|
||||
this.portal.socketInitialized = false;
|
||||
this.setIsCollaborationPaused(true);
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: { viewModeEnabled: true },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PauseCollaborationState.RESUMED: {
|
||||
if (this.portal.socket && this.isPaused()) {
|
||||
this.portal.socket.connect();
|
||||
this.portal.socket.emit(WS_SCENE_EVENT_TYPES.INIT);
|
||||
|
||||
console.log("setting toast");
|
||||
this.excalidrawAPI.setToast({
|
||||
message: t("toast.reconnectRoomServer"),
|
||||
duration: Infinity,
|
||||
spinner: true,
|
||||
closable: false,
|
||||
});
|
||||
|
||||
// Fallback to fetch data from firebase when reconnecting to scene without collaborators
|
||||
const fallbackResumeHandler = async () => {
|
||||
const roomLinkData = getCollaborationLinkData(
|
||||
this.state.activeRoomLink,
|
||||
);
|
||||
if (!roomLinkData) {
|
||||
return;
|
||||
}
|
||||
const elements = await loadFromFirebase(
|
||||
roomLinkData.roomId,
|
||||
roomLinkData.roomKey,
|
||||
this.portal.socket,
|
||||
);
|
||||
if (elements) {
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(
|
||||
getSceneVersion(elements),
|
||||
);
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
});
|
||||
}
|
||||
this.onPauseCollaborationChange(PauseCollaborationState.SYNCED);
|
||||
};
|
||||
|
||||
// Set timeout to fallback to fetch data from firebase
|
||||
this.fallbackResumeTimeout = setTimeout(
|
||||
fallbackResumeHandler,
|
||||
RESUME_FALLBACK_TIMEOUT,
|
||||
);
|
||||
|
||||
// When no users are in the room, we fallback to fetch data from firebase immediately and clear fallback timeout
|
||||
this.portal.socket.on("first-in-room", () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
// Recall init event to initialize collab with other users (fixes https://github.com/excalidraw/excalidraw/pull/6638#issuecomment-1600799080)
|
||||
this.portal.socket.emit(WS_SCENE_EVENT_TYPES.INIT);
|
||||
}
|
||||
|
||||
fallbackResumeHandler();
|
||||
});
|
||||
}
|
||||
|
||||
// Clear pause timeout if exists
|
||||
if (this.pauseTimeoutId) {
|
||||
clearTimeout(this.pauseTimeoutId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case PauseCollaborationState.SYNCED: {
|
||||
if (this.fallbackResumeTimeout) {
|
||||
clearTimeout(this.fallbackResumeTimeout);
|
||||
this.fallbackResumeTimeout = null;
|
||||
}
|
||||
|
||||
if (this.isPaused()) {
|
||||
this.setIsCollaborationPaused(false);
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: { viewModeEnabled: false },
|
||||
});
|
||||
console.log("resetting toast");
|
||||
this.excalidrawAPI.setToast(null);
|
||||
this.excalidrawAPI.scrollToContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
isPaused = () => appJotaiStore.get(isCollaborationPausedAtom)!;
|
||||
|
||||
setIsCollaborationPaused = (isPaused: boolean) => {
|
||||
appJotaiStore.set(isCollaborationPausedAtom, isPaused);
|
||||
};
|
||||
|
||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
@@ -529,11 +385,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.state.userId) {
|
||||
const userId = nanoid();
|
||||
this.onUserIdChange(userId);
|
||||
}
|
||||
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
@@ -648,7 +499,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
elements: reconciledElements,
|
||||
scrollToContent: true,
|
||||
});
|
||||
this.onPauseCollaborationChange(PauseCollaborationState.SYNCED);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -658,45 +508,33 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
break;
|
||||
case "MOUSE_LOCATION": {
|
||||
const {
|
||||
pointer,
|
||||
button,
|
||||
username,
|
||||
selectedElementIds,
|
||||
userId,
|
||||
socketId,
|
||||
} = decryptedData.payload;
|
||||
const collaborators = upsertMap(
|
||||
userId,
|
||||
{
|
||||
username,
|
||||
pointer,
|
||||
button,
|
||||
selectedElementIds,
|
||||
socketId,
|
||||
},
|
||||
this.collaborators,
|
||||
);
|
||||
const { pointer, button, username, selectedElementIds } =
|
||||
decryptedData.payload;
|
||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||
decryptedData.payload.socketId ||
|
||||
// @ts-ignore legacy, see #2094 (#2097)
|
||||
decryptedData.payload.socketID;
|
||||
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.pointer = pointer;
|
||||
user.button = button;
|
||||
user.selectedElementIds = selectedElementIds;
|
||||
user.username = username;
|
||||
collaborators.set(socketId, user);
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: new Map(collaborators),
|
||||
collaborators,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "IDLE_STATUS": {
|
||||
const { userState, username, userId, socketId } =
|
||||
decryptedData.payload;
|
||||
const collaborators = upsertMap(
|
||||
userId,
|
||||
{
|
||||
username,
|
||||
userState,
|
||||
userId,
|
||||
socketId,
|
||||
},
|
||||
this.collaborators,
|
||||
);
|
||||
const { userState, socketId, username } = decryptedData.payload;
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.userState = userState;
|
||||
user.username = username;
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: new Map(collaborators),
|
||||
collaborators,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -705,7 +543,6 @@ 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");
|
||||
}
|
||||
@@ -847,9 +684,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
private onVisibilityChange = () => {
|
||||
// if (document.hidden) {
|
||||
console.log("VIS CHANGE");
|
||||
if (!document.hasFocus()) {
|
||||
if (document.hidden) {
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
@@ -858,10 +693,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
this.pauseTimeoutId = window.setTimeout(
|
||||
() => this.onPauseCollaborationChange(PauseCollaborationState.PAUSED),
|
||||
PAUSE_COLLABORATION_TIMEOUT,
|
||||
);
|
||||
this.onIdleStateChange(UserIdleState.AWAY);
|
||||
} else {
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
@@ -870,11 +701,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
ACTIVE_THRESHOLD,
|
||||
);
|
||||
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||
if (this.pauseTimeoutId) {
|
||||
window.clearTimeout(this.pauseTimeoutId);
|
||||
this.onPauseCollaborationChange(PauseCollaborationState.RESUMED);
|
||||
this.pauseTimeoutId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -891,19 +717,22 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
private initializeIdleDetector = () => {
|
||||
// document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
// document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
window.addEventListener(EVENT.BLUR, this.onVisibilityChange);
|
||||
window.addEventListener(EVENT.FOCUS, this.onVisibilityChange);
|
||||
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
};
|
||||
|
||||
setCollaborators(sockets: string[]) {
|
||||
this.collaborators.forEach((value, key) => {
|
||||
if (value.socketId && !sockets.includes(value.socketId)) {
|
||||
this.collaborators.delete(key);
|
||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
if (this.collaborators.has(socketId)) {
|
||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||
} else {
|
||||
collaborators.set(socketId, {});
|
||||
}
|
||||
});
|
||||
this.excalidrawAPI.updateScene({ collaborators: this.collaborators });
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
}
|
||||
|
||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||
@@ -989,12 +818,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
onUsernameChange = (username: string) => {
|
||||
this.setUsername(username);
|
||||
saveUsernameAndIdToLocalStorage(username, this.state.userId);
|
||||
};
|
||||
|
||||
onUserIdChange = (userId: string) => {
|
||||
this.setState({ userId });
|
||||
saveUsernameAndIdToLocalStorage(this.state.username, userId);
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -34,37 +34,11 @@ class Portal {
|
||||
|
||||
open(socket: SocketIOClient.Socket, id: string, key: string) {
|
||||
this.socket = socket;
|
||||
// @ts-ignore
|
||||
window.socket = socket;
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
|
||||
this.initializeSocketListeners();
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
this.queueFileUpload.flush();
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.roomId = null;
|
||||
this.roomKey = null;
|
||||
this.socketInitialized = false;
|
||||
this.broadcastedElementVersions = new Map();
|
||||
}
|
||||
|
||||
initializeSocketListeners() {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize socket listeners
|
||||
this.socket.on("init-room", () => {
|
||||
console.log("join room");
|
||||
if (this.socket) {
|
||||
this.socket.emit("join-room", this.roomId);
|
||||
trackEvent("share", "room joined");
|
||||
@@ -80,6 +54,21 @@ class Portal {
|
||||
this.socket.on("room-user-change", (clients: string[]) => {
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
this.queueFileUpload.flush();
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.roomId = null;
|
||||
this.roomKey = null;
|
||||
this.socketInitialized = false;
|
||||
this.broadcastedElementVersions = new Map();
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
@@ -192,14 +181,13 @@ class Portal {
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
if (this.socket) {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||
type: "IDLE_STATUS",
|
||||
payload: {
|
||||
socketId: this.socket.id,
|
||||
userState,
|
||||
username: this.collab.state.username,
|
||||
userId: this.collab.state.userId,
|
||||
socketId: this.socket.id,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
@@ -213,17 +201,16 @@ class Portal {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
}) => {
|
||||
if (this.socket) {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||
type: "MOUSE_LOCATION",
|
||||
payload: {
|
||||
socketId: this.socket.id,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds:
|
||||
this.collab.excalidrawAPI.getAppState().selectedElementIds,
|
||||
username: this.collab.state.username,
|
||||
userId: this.collab.state.userId,
|
||||
socketId: this.socket.id,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
|
||||
@@ -106,21 +106,19 @@ export type SocketUpdateDataSource = {
|
||||
MOUSE_LOCATION: {
|
||||
type: "MOUSE_LOCATION";
|
||||
payload: {
|
||||
socketId: string;
|
||||
pointer: { x: number; y: number };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
||||
userId: string;
|
||||
socketId: string;
|
||||
};
|
||||
};
|
||||
IDLE_STATUS: {
|
||||
type: "IDLE_STATUS";
|
||||
payload: {
|
||||
socketId: string;
|
||||
userState: UserIdleState;
|
||||
username: string;
|
||||
userId: string;
|
||||
socketId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,14 +8,11 @@ import { clearElementsForLocalStorage } from "../../element";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
|
||||
export const saveUsernameAndIdToLocalStorage = (
|
||||
username: string,
|
||||
userId: string,
|
||||
) => {
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
|
||||
JSON.stringify({ username, userId }),
|
||||
JSON.stringify({ username }),
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
@@ -23,14 +20,11 @@ export const saveUsernameAndIdToLocalStorage = (
|
||||
}
|
||||
};
|
||||
|
||||
export const importUsernameAndIdFromLocalStorage = (): {
|
||||
username: string;
|
||||
userId: string;
|
||||
} | null => {
|
||||
export const importUsernameFromLocalStorage = (): string | null => {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
return JSON.parse(data).username;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameAndIdFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||
@@ -425,8 +425,7 @@ const ExcalidrawWrapper = () => {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
const username =
|
||||
importUsernameAndIdFromLocalStorage()?.username ?? "";
|
||||
const username = importUsernameFromLocalStorage();
|
||||
let langCode = languageDetector.detect() || defaultLang.code;
|
||||
if (Array.isArray(langCode)) {
|
||||
langCode = langCode[0];
|
||||
|
||||
@@ -6,12 +6,11 @@ const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||
"vercel.app": "staging",
|
||||
};
|
||||
|
||||
const REACT_APP_DISABLE_SENTRY =
|
||||
import.meta.env.VITE_APP_DISABLE_SENTRY === "true";
|
||||
const SENTRY_DISABLED = import.meta.env.VITE_APP_DISABLE_SENTRY === "true";
|
||||
|
||||
// Disable Sentry locally or inside the Docker to avoid noise/respect privacy
|
||||
const onlineEnv =
|
||||
!REACT_APP_DISABLE_SENTRY &&
|
||||
!SENTRY_DISABLED &&
|
||||
Object.keys(SentryEnvHostnameMap).find(
|
||||
(item) => window.location.hostname.indexOf(item) >= 0,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ExcalidrawApp from "./excalidraw-app";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import "./excalidraw-app/sentry";
|
||||
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
|
||||
const rootElement = document.getElementById("root")!;
|
||||
const root = createRoot(rootElement);
|
||||
registerSW();
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExcalidrawApp />
|
||||
|
||||
+1
-2
@@ -425,8 +425,7 @@
|
||||
"selection": "selection",
|
||||
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor",
|
||||
"unableToEmbed": "Embedding this url is currently not allowed. Raise an issue on GitHub to request the url whitelisted",
|
||||
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site",
|
||||
"reconnectRoomServer": "Reconnecting to server"
|
||||
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site"
|
||||
},
|
||||
"colors": {
|
||||
"transparent": "Transparent",
|
||||
|
||||
@@ -707,8 +707,8 @@ export const _renderScene = ({
|
||||
if (renderConfig.remoteSelectedElementIds[element.id]) {
|
||||
selectionColors.push(
|
||||
...renderConfig.remoteSelectedElementIds[element.id].map(
|
||||
(userId) => {
|
||||
const background = getClientColor(userId);
|
||||
(socketId) => {
|
||||
const background = getClientColor(socketId);
|
||||
return background;
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1128,7 +1128,7 @@ describe("Test Linear Elements", () => {
|
||||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
x: -10,
|
||||
y: 250,
|
||||
width: 400,
|
||||
height: 1,
|
||||
@@ -1152,8 +1152,8 @@ describe("Test Linear Elements", () => {
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard collaboration
|
||||
made easy"
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`);
|
||||
const handleBindTextResizeSpy = vi.spyOn(
|
||||
textElementUtils,
|
||||
@@ -1165,7 +1165,7 @@ describe("Test Linear Elements", () => {
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
|
||||
expect(arrow.width).toBe(170);
|
||||
expect(arrow.width).toBe(200);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -56,7 +56,6 @@ export type Collaborator = {
|
||||
avatarUrl?: string;
|
||||
// user id. If supplied, we'll filter out duplicates when rendering user avatars.
|
||||
id?: string;
|
||||
socketId?: string;
|
||||
};
|
||||
|
||||
export type DataURL = string & { _brand: "DataURL" };
|
||||
@@ -402,12 +401,6 @@ export enum UserIdleState {
|
||||
IDLE = "idle",
|
||||
}
|
||||
|
||||
export enum PauseCollaborationState {
|
||||
PAUSED = "paused",
|
||||
RESUMED = "resumed",
|
||||
SYNCED = "synced",
|
||||
}
|
||||
|
||||
export type ExportOpts = {
|
||||
saveFileToDisk?: boolean;
|
||||
onExportToBackend?: (
|
||||
|
||||
@@ -914,14 +914,3 @@ export const isOnlyExportingSingleFrame = (
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const upsertMap = <T>(key: T, value: object, map: Map<T, object>) => {
|
||||
if (!map.has(key)) {
|
||||
map.set(key, value);
|
||||
} else {
|
||||
const old = map.get(key);
|
||||
map.set(key, { ...old, ...value });
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
Vendored
+1
-2
@@ -1,8 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
// The port to run the dev server
|
||||
VITE_APP_PORT: string;
|
||||
|
||||
+2
-1
@@ -53,6 +53,7 @@ export default defineConfig({
|
||||
svgrPlugin(),
|
||||
ViteEjsPlugin(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
/* set this flag to true to enable in Development mode */
|
||||
enabled: false,
|
||||
@@ -60,7 +61,7 @@ export default defineConfig({
|
||||
|
||||
workbox: {
|
||||
// Don't push fonts and locales to app precache
|
||||
globIgnores: ["fonts.css", "**/locales/**"],
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||
|
||||
@@ -5,5 +5,12 @@ export default defineConfig({
|
||||
setupFiles: ["./src/setupTests.ts"],
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
coverage: {
|
||||
reporter: ["text", "json-summary", "json"],
|
||||
lines: 70,
|
||||
branches: 70,
|
||||
functions: 68,
|
||||
statements: 70,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user