Compare commits

..

19 Commits

Author SHA1 Message Date
dwelle 0fe79f8819 [debug] 2023-07-28 10:40:11 +02:00
dwelle 772a9999b8 Merge branch 'master' into arnost/sockets-reconnecting
# Conflicts:
#	package.json
#	src/locales/en.json
#	yarn.lock
2023-07-28 10:03:21 +02:00
Arnošt Pleskot 28b0095c8a fix: making linter happy 2023-07-14 20:49:25 +02:00
Arnošt Pleskot c4ff0594e3 fix: get socketId from emitted message 2023-07-13 10:24:36 +02:00
Arnošt Pleskot 4608e809b1 feat: filter disconnected users 2023-07-13 10:07:31 +02:00
Arnošt Pleskot db5149ab5d feat: store userId in localStorage 2023-07-12 17:22:46 +02:00
Arnošt Pleskot 2bdf09153c feat: use userId instead of socketId 2023-07-12 16:56:21 +02:00
Arnošt Pleskot 62df03d78d feat: submit scene to firebase on collab pause 2023-07-11 16:55:21 +02:00
Arnošt Pleskot a1d3350131 fix: connect users when resumed from firebase 2023-07-11 16:55:20 +02:00
Arnošt Pleskot a2d371bf1d feat: when no users in room fetch data from firebase immediately 2023-07-11 16:55:19 +02:00
Arnošt Pleskot e340103250 feat: firebase fallback for resume collaboration 2023-07-11 16:55:18 +02:00
Arnošt Pleskot 0567af1bcb fix: properly sync after reconnecting 2023-07-11 16:55:17 +02:00
Arnošt Pleskot 2ffeff442a feat: disable viewMode on update and init message 2023-07-11 16:55:16 +02:00
Arnošt Pleskot ef190ebf30 feat: add optional spinner into toast message 2023-07-11 16:55:15 +02:00
Arnošt Pleskot e1ff9791f2 feat: move logic from index.tsx into Collab.tsx 2023-07-11 16:55:14 +02:00
Arnošt Pleskot aa91af8f7d fix: emit scene init event after reconnecting 2023-07-11 16:55:13 +02:00
Arnošt Pleskot 52254bca7c fix: do not fire pause logic on window focus/blur event 2023-07-11 16:55:12 +02:00
Arnošt Pleskot addf9d71fa feat: pause collab when user switches tabs in the browser 2023-07-11 16:55:11 +02:00
Arnošt Pleskot 1badf14a93 chore: update socket.io-client 2023-07-11 16:55:00 +02:00
36 changed files with 712 additions and 893 deletions
-5
View File
@@ -1,5 +0,0 @@
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
+2 -9
View File
@@ -27,10 +27,7 @@
"start": { "start": {
"name": "Start Excalidraw", "name": "Start Excalidraw",
"command": "yarn start", "command": "yarn start",
"runAtStart": true, "runAtStart": true
"preview": {
"port": 3000
}
}, },
"test": { "test": {
"name": "Run Tests", "name": "Run Tests",
@@ -40,11 +37,7 @@
"install-deps": { "install-deps": {
"name": "Install Dependencies", "name": "Install Dependencies",
"command": "yarn install", "command": "yarn install",
"restartOn": { "restartOn": { "files": ["yarn.lock"] }
"files": ["yarn.lock"],
"branch": false,
"resume": false
}
} }
} }
} }
+2 -2
View File
@@ -1,5 +1,5 @@
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
-26
View File
@@ -1,26 +0,0 @@
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 }}
+1 -1
View File
@@ -1 +1 @@
18 14
+9 -9
View File
@@ -6611,19 +6611,19 @@ semver@7.0.0:
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
semver@^5.4.1: semver@^5.4.1:
version "5.7.2" version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
version "6.3.1" version "6.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7: semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
version "7.5.4" version "7.3.7"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
+5 -7
View File
@@ -54,7 +54,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "2.3.1", "socket.io-client": "4.6.1",
"tunnel-rat": "0.1.2" "tunnel-rat": "0.1.2"
}, },
"devDependencies": { "devDependencies": {
@@ -70,7 +70,6 @@
"@types/resize-observer-browser": "0.1.7", "@types/resize-observer-browser": "0.1.7",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"@vitejs/plugin-react": "3.1.0", "@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.32.2", "@vitest/ui": "0.32.2",
"chai": "4.3.6", "chai": "4.3.6",
"dotenv": "16.0.1", "dotenv": "16.0.1",
@@ -102,8 +101,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", "build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", "build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ./scripts/build-version.js", "build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
@@ -115,15 +114,14 @@
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "vite", "start": "vite",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
"test:app": "vitest --config vitest.config.ts", "test:app": "vitest --config vitest.config.ts",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
"test:other": "yarn prettier --list-different", "test:other": "yarn prettier --list-different",
"test:typecheck": "tsc", "test:typecheck": "tsc",
"test:update": "yarn test:app --update --watch=false", "test:update": "yarn test:app --update --watch=false",
"test": "yarn test:app", "test": "yarn test:app",
"test:coverage": "vitest --coverage", "test:coverage": "vitest --coverage --watchAll",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui", "test:ui": "yarn test --ui",
"autorelease": "node scripts/autorelease.js", "autorelease": "node scripts/autorelease.js",
"prerelease": "node scripts/prerelease.js", "prerelease": "node scripts/prerelease.js",
Binary file not shown.
-20
View File
@@ -1,20 +0,0 @@
// 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));
});
});
+6 -3
View File
@@ -298,6 +298,7 @@ import {
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerDims,
getContainerElement, getContainerElement,
getDefaultLineHeight, getDefaultLineHeight,
getLineHeightInPx, getLineHeightInPx,
@@ -940,7 +941,7 @@ class App extends React.Component<AppProps, AppState> {
title="Excalidraw Embedded Content" title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true} allowFullScreen={true}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation"
/> />
)} )}
</div> </div>
@@ -2747,6 +2748,7 @@ class App extends React.Component<AppProps, AppState> {
toast: { toast: {
message: string; message: string;
closable?: boolean; closable?: boolean;
spinner?: boolean;
duration?: number; duration?: number;
} | null, } | null,
) => { ) => {
@@ -3547,8 +3549,9 @@ class App extends React.Component<AppProps, AppState> {
lineHeight, lineHeight,
); );
const minHeight = getApproxMinLineHeight(fontSize, lineHeight); const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const newHeight = Math.max(container.height, minHeight); const containerDims = getContainerDims(container);
const newWidth = Math.max(container.width, minWidth); const newHeight = Math.max(containerDims.height, minHeight);
const newWidth = Math.max(containerDims.width, minWidth);
mutateElement(container, { height: newHeight, width: newWidth }); mutateElement(container, { height: newHeight, width: newWidth });
sceneX = container.x + newWidth / 2; sceneX = container.x + newWidth / 2;
sceneY = container.y + newHeight / 2; sceneY = container.y + newHeight / 2;
+2 -2
View File
@@ -77,8 +77,8 @@ export const EyeDropper: React.FC<{
colorPreviewDiv.style.left = `${clientX + 20}px`; colorPreviewDiv.style.left = `${clientX + 20}px`;
const pixel = ctx.getImageData( const pixel = ctx.getImageData(
(clientX - appState.offsetLeft) * window.devicePixelRatio, clientX * window.devicePixelRatio - appState.offsetLeft,
(clientY - appState.offsetTop) * window.devicePixelRatio, clientY * window.devicePixelRatio - appState.offsetTop,
1, 1,
1, 1,
).data; ).data;
+11
View File
@@ -25,6 +25,17 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.Toast__message--spinner {
padding: 0 3rem;
}
.Toast__spinner {
position: absolute;
left: 1.5rem;
top: 50%;
margin-top: -8px;
}
.close { .close {
position: absolute; position: absolute;
top: 0; top: 0;
+16 -1
View File
@@ -1,5 +1,7 @@
import clsx from "clsx";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
import Spinner from "./Spinner";
import "./Toast.scss"; import "./Toast.scss";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@@ -9,12 +11,14 @@ export const Toast = ({
message, message,
onClose, onClose,
closable = false, closable = false,
spinner = true,
// To prevent autoclose, pass duration as Infinity // To prevent autoclose, pass duration as Infinity
duration = DEFAULT_TOAST_TIMEOUT, duration = DEFAULT_TOAST_TIMEOUT,
}: { }: {
message: string; message: string;
onClose: () => void; onClose: () => void;
closable?: boolean; closable?: boolean;
spinner?: boolean;
duration?: number; duration?: number;
}) => { }) => {
const timerRef = useRef<number>(0); const timerRef = useRef<number>(0);
@@ -44,7 +48,18 @@ export const Toast = ({
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
<p className="Toast__message">{message}</p> {spinner && (
<div className="Toast__spinner">
<Spinner />
</div>
)}
<p
className={clsx("Toast__message", {
"Toast__message--spinner": spinner,
})}
>
{message}
</p>
{closable && ( {closable && (
<ToolButton <ToolButton
icon={CloseIcon} icon={CloseIcon}
-3
View File
@@ -117,7 +117,6 @@ export const FRAME_STYLE = {
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; 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_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_TEXT_ALIGN = "left";
@@ -240,8 +239,6 @@ export const VERSIONS = {
} as const; } as const;
export const BOUND_TEXT_PADDING = 5; 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 = { export const VERTICAL_ALIGN = {
TOP: "top", TOP: "top",
+1 -21
View File
@@ -40,9 +40,6 @@ const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
const RE_TWITTER_EMBED = const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.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_$]+/;
const RE_GENERIC_EMBED = const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i; /^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
@@ -55,9 +52,7 @@ const ALLOWED_DOMAINS = new Set([
"link.excalidraw.com", "link.excalidraw.com",
"gist.github.com", "gist.github.com",
"twitter.com", "twitter.com",
"*.simplepdf.eu",
"stackblitz.com", "stackblitz.com",
"val.town",
]); ]);
const createSrcDoc = (body: string) => { const createSrcDoc = (body: string) => {
@@ -127,14 +122,6 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
return { link, aspectRatio, type }; 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)) { if (RE_TWITTER.test(link)) {
let ret: EmbeddedLink; let ret: EmbeddedLink;
// assume embed code // assume embed code
@@ -275,16 +262,9 @@ const validateHostname = (
const { hostname } = new URL(url); const { hostname } = new URL(url);
const bareDomain = hostname.replace(/^www\./, ""); const bareDomain = hostname.replace(/^www\./, "");
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
);
if (allowedHostnames instanceof Set) { if (allowedHostnames instanceof Set) {
return ( return ALLOWED_DOMAINS.has(bareDomain);
ALLOWED_DOMAINS.has(bareDomain) ||
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
);
} }
if (bareDomain === allowedHostnames.replace(/^www\./, "")) { if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
+4 -4
View File
@@ -264,11 +264,11 @@ export class LinearElementEditor {
}; };
}), }),
); );
}
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
handleBindTextResize(element, false); handleBindTextResize(element, false);
}
} }
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
+47 -48
View File
@@ -1,4 +1,4 @@
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { import {
@@ -204,6 +204,8 @@ const rescalePointsInElement = (
} }
: {}; : {};
const MIN_FONT_SIZE = 1;
const measureFontSizeFromWidth = ( const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number, nextWidth: number,
@@ -587,42 +589,24 @@ 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 ( if (
resizedElement.width !== 0 && resizedElement.width !== 0 &&
resizedElement.height !== 0 && resizedElement.height !== 0 &&
Number.isFinite(resizedElement.x) && Number.isFinite(resizedElement.x) &&
Number.isFinite(resizedElement.y) Number.isFinite(resizedElement.y)
) { ) {
mutateElement(element, resizedElement);
updateBoundElements(element, { updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height }, newSize: { width: resizedElement.width, height: resizedElement.height },
}); });
mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont != null) { if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
baseline: boundTextFont.baseline, baseline: boundTextFont.baseline,
}); });
} }
handleBindTextResize( handleBindTextResize(element, transformHandleDirection);
element,
transformHandleDirection,
shouldMaintainAspectRatio,
);
} }
}; };
@@ -738,8 +722,12 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"]; fontSize?: ExcalidrawTextElement["fontSize"];
baseline?: ExcalidrawTextElement["baseline"]; baseline?: ExcalidrawTextElement["baseline"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
}; };
boundText: {
element: ExcalidrawTextElementWithContainer;
fontSize: ExcalidrawTextElement["fontSize"];
baseline: ExcalidrawTextElement["baseline"];
} | null;
}[] = []; }[] = [];
for (const { orig, latest } of targetElements) { for (const { orig, latest } of targetElements) {
@@ -810,39 +798,50 @@ export const resizeMultipleElements = (
} }
} }
if (isTextElement(orig)) { let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
const metrics = measureFontSizeFromWidth(orig, width, height);
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 (!metrics) { if (!metrics) {
return; return;
} }
update.fontSize = metrics.size;
update.baseline = metrics.baseline;
}
const boundTextElement = pointerDownState.originalElements.get( if (isTextElement(orig)) {
getBoundTextElementId(orig) ?? "", update.fontSize = metrics.size;
) as ExcalidrawTextElementWithContainer | undefined; update.baseline = metrics.baseline;
}
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale; if (boundTextElement) {
if (newFontSize < MIN_FONT_SIZE) { boundText = {
return; element: boundTextElement,
fontSize: metrics.size,
baseline: metrics.baseline,
};
} }
update.boundTextFontSize = newFontSize;
} }
elementsAndUpdates.push({ elementsAndUpdates.push({ element: latest, update, boundText });
element: latest,
update,
});
} }
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
for (const { for (const { element, update, boundText } of elementsAndUpdates) {
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { width, height, angle } = update; const { width, height, angle } = update;
mutateElement(element, update, false); mutateElement(element, update, false);
@@ -852,17 +851,17 @@ export const resizeMultipleElements = (
newSize: { width, height }, newSize: { width, height },
}); });
const boundTextElement = getBoundTextElement(element); if (boundText) {
if (boundTextElement && boundTextFontSize) { const { element: boundTextElement, ...boundTextUpdates } = boundText;
mutateElement( mutateElement(
boundTextElement, boundTextElement,
{ {
fontSize: boundTextFontSize, ...boundTextUpdates,
angle: isLinearElement(element) ? undefined : angle, angle: isLinearElement(element) ? undefined : angle,
}, },
false, false,
); );
handleBindTextResize(element, transformHandleType, true); handleBindTextResize(element, transformHandleType);
} }
} }
+22 -23
View File
@@ -10,8 +10,6 @@ import {
} from "./types"; } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
ARROW_LABEL_WIDTH_FRACTION,
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
@@ -67,7 +65,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.text = textElement.text; boundTextUpdates.text = textElement.text;
if (container) { if (container) {
maxWidth = getBoundTextMaxWidth(container, textElement); maxWidth = getBoundTextMaxWidth(container);
boundTextUpdates.text = wrapText( boundTextUpdates.text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
@@ -85,12 +83,13 @@ export const redrawTextBoundingBox = (
boundTextUpdates.baseline = metrics.baseline; boundTextUpdates.baseline = metrics.baseline;
if (container) { if (container) {
const containerDims = getContainerDims(container);
const maxContainerHeight = getBoundTextMaxHeight( const maxContainerHeight = getBoundTextMaxHeight(
container, container,
textElement as ExcalidrawTextElementWithContainer, textElement as ExcalidrawTextElementWithContainer,
); );
let nextHeight = container.height; let nextHeight = containerDims.height;
if (metrics.height > maxContainerHeight) { if (metrics.height > maxContainerHeight) {
nextHeight = computeContainerDimensionForBoundText( nextHeight = computeContainerDimensionForBoundText(
metrics.height, metrics.height,
@@ -156,7 +155,6 @@ export const bindTextToShapeAfterDuplication = (
export const handleBindTextResize = ( export const handleBindTextResize = (
container: NonDeletedExcalidrawElement, container: NonDeletedExcalidrawElement,
transformHandleType: MaybeTransformHandleType, transformHandleType: MaybeTransformHandleType,
shouldMaintainAspectRatio = false,
) => { ) => {
const boundTextElementId = getBoundTextElementId(container); const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) { if (!boundTextElementId) {
@@ -177,17 +175,15 @@ export const handleBindTextResize = (
let text = textElement.text; let text = textElement.text;
let nextHeight = textElement.height; let nextHeight = textElement.height;
let nextWidth = textElement.width; let nextWidth = textElement.width;
const containerDims = getContainerDims(container);
const maxWidth = getBoundTextMaxWidth(container); const maxWidth = getBoundTextMaxWidth(container);
const maxHeight = getBoundTextMaxHeight( const maxHeight = getBoundTextMaxHeight(
container, container,
textElement as ExcalidrawTextElementWithContainer, textElement as ExcalidrawTextElementWithContainer,
); );
let containerHeight = container.height; let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline; let nextBaseLine = textElement.baseline;
if ( if (transformHandleType !== "n" && transformHandleType !== "s") {
shouldMaintainAspectRatio ||
(transformHandleType !== "n" && transformHandleType !== "s")
) {
if (text) { if (text) {
text = wrapText( text = wrapText(
textElement.originalText, textElement.originalText,
@@ -211,7 +207,7 @@ export const handleBindTextResize = (
container.type, container.type,
); );
const diff = containerHeight - container.height; const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n // fix the y coord when resizing from ne/nw/n
const updatedY = const updatedY =
!isArrowElement(container) && !isArrowElement(container) &&
@@ -691,6 +687,16 @@ export const getContainerElement = (
return null; 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 = ( export const getContainerCenter = (
container: ExcalidrawElement, container: ExcalidrawElement,
appState: AppState, appState: AppState,
@@ -881,19 +887,12 @@ export const computeContainerDimensionForBoundText = (
return dimension + padding; return dimension + padding;
}; };
export const getBoundTextMaxWidth = ( export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
container: ExcalidrawElement, const width = getContainerDims(container).width;
boundTextElement: ExcalidrawTextElement | null = getBoundTextElement(
container,
),
) => {
const { width } = container;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const minWidth = return width - BOUND_TEXT_PADDING * 8 * 2;
(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") { if (container.type === "ellipse") {
// The width of the largest rectangle inscribed inside an ellipse is // The width of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
@@ -912,7 +911,7 @@ export const getBoundTextMaxHeight = (
container: ExcalidrawElement, container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
) => { ) => {
const { height } = container; const height = getContainerDims(container).height;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) { if (containerHeight <= 0) {
+5 -3
View File
@@ -23,6 +23,7 @@ import { AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
getBoundTextElementId, getBoundTextElementId,
getContainerDims,
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth, getTextWidth,
@@ -176,19 +177,20 @@ export const textWysiwyg = ({
updatedTextElement, updatedTextElement,
editable, editable,
); );
const containerDims = getContainerDims(container);
let originalContainerData; let originalContainerData;
if (propertiesUpdated) { if (propertiesUpdated) {
originalContainerData = updateOriginalContainerCache( originalContainerData = updateOriginalContainerCache(
container.id, container.id,
container.height, containerDims.height,
); );
} else { } else {
originalContainerData = originalContainerCache[container.id]; originalContainerData = originalContainerCache[container.id];
if (!originalContainerData) { if (!originalContainerData) {
originalContainerData = updateOriginalContainerCache( originalContainerData = updateOriginalContainerCache(
container.id, container.id,
container.height, containerDims.height,
); );
} }
} }
@@ -212,7 +214,7 @@ export const textWysiwyg = ({
// autoshrink container height until original container height // autoshrink container height until original container height
// is reached when text is removed // is reached when text is removed
!isArrowElement(container) && !isArrowElement(container) &&
container.height > originalContainerData.height && containerDims.height > originalContainerData.height &&
textElementHeight < maxHeight textElementHeight < maxHeight
) { ) {
const targetContainerHeight = computeContainerDimensionForBoundText( const targetContainerHeight = computeContainerDimensionForBoundText(
+2
View File
@@ -7,6 +7,8 @@ export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
export const SYNC_BROWSER_TABS_TIMEOUT = 50; export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day 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 export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631) // 1 year (https://stackoverflow.com/a/25201898/927631)
+219 -43
View File
@@ -1,6 +1,6 @@
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { PureComponent } from "react"; import { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../types"; import { ExcalidrawImperativeAPI, PauseCollaborationState } from "../../types";
import { ErrorDialog } from "../../components/ErrorDialog"; import { ErrorDialog } from "../../components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants"; import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types"; import { ImportedDataState } from "../../data/types";
@@ -16,6 +16,7 @@ import { Collaborator, Gesture } from "../../types";
import { import {
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
upsertMap,
withBatchedUpdates, withBatchedUpdates,
} from "../../utils"; } from "../../utils";
import { import {
@@ -24,12 +25,15 @@ import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
INITIAL_SCENE_UPDATE_TIMEOUT, INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT, LOAD_IMAGES_TIMEOUT,
PAUSE_COLLABORATION_TIMEOUT,
WS_SCENE_EVENT_TYPES, WS_SCENE_EVENT_TYPES,
SYNC_FULL_SCENE_INTERVAL_MS, SYNC_FULL_SCENE_INTERVAL_MS,
RESUME_FALLBACK_TIMEOUT,
} from "../app_constants"; } from "../app_constants";
import { import {
generateCollaborationLinkData, generateCollaborationLinkData,
getCollaborationLink, getCollaborationLink,
getCollaborationLinkData,
getCollabServer, getCollabServer,
getSyncableElements, getSyncableElements,
SocketUpdateDataSource, SocketUpdateDataSource,
@@ -43,8 +47,8 @@ import {
saveToFirebase, saveToFirebase,
} from "../data/firebase"; } from "../data/firebase";
import { import {
importUsernameFromLocalStorage, importUsernameAndIdFromLocalStorage,
saveUsernameToLocalStorage, saveUsernameAndIdToLocalStorage,
} from "../data/localStorage"; } from "../data/localStorage";
import Portal from "./Portal"; import Portal from "./Portal";
import RoomDialog from "./RoomDialog"; import RoomDialog from "./RoomDialog";
@@ -71,16 +75,19 @@ import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { appJotaiStore } from "../app-jotai"; import { appJotaiStore } from "../app-jotai";
import { nanoid } from "nanoid";
export const collabAPIAtom = atom<CollabAPI | null>(null); export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false); export const collabDialogShownAtom = atom(false);
export const isCollaboratingAtom = atom(false); export const isCollaboratingAtom = atom(false);
export const isOfflineAtom = atom(false); export const isOfflineAtom = atom(false);
export const isCollaborationPausedAtom = atom(false);
interface CollabState { interface CollabState {
errorMessage: string; errorMessage: string;
username: string; username: string;
activeRoomLink: string; activeRoomLink: string;
userId: string;
} }
type CollabInstance = InstanceType<typeof Collab>; type CollabInstance = InstanceType<typeof Collab>;
@@ -94,6 +101,7 @@ export interface CollabAPI {
syncElements: CollabInstance["syncElements"]; syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void; setUsername: (username: string) => void;
isPaused: () => boolean;
} }
interface PublicProps { interface PublicProps {
@@ -108,6 +116,7 @@ class Collab extends PureComponent<Props, CollabState> {
excalidrawAPI: Props["excalidrawAPI"]; excalidrawAPI: Props["excalidrawAPI"];
activeIntervalId: number | null; activeIntervalId: number | null;
idleTimeoutId: number | null; idleTimeoutId: number | null;
pauseTimeoutId: number | null;
private socketInitializationTimer?: number; private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
@@ -115,9 +124,13 @@ class Collab extends PureComponent<Props, CollabState> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
const { username, userId } = importUsernameAndIdFromLocalStorage() || {};
this.state = { this.state = {
errorMessage: "", errorMessage: "",
username: importUsernameFromLocalStorage() || "", username: username || "",
userId: userId || "",
activeRoomLink: "", activeRoomLink: "",
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
@@ -149,6 +162,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI = props.excalidrawAPI; this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null; this.activeIntervalId = null;
this.idleTimeoutId = null; this.idleTimeoutId = null;
this.pauseTimeoutId = null;
} }
componentDidMount() { componentDidMount() {
@@ -167,6 +181,7 @@ class Collab extends PureComponent<Props, CollabState> {
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase, fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
stopCollaboration: this.stopCollaboration, stopCollaboration: this.stopCollaboration,
setUsername: this.setUsername, setUsername: this.setUsername,
isPaused: this.isPaused,
}; };
appJotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
@@ -192,10 +207,13 @@ class Collab extends PureComponent<Props, CollabState> {
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.removeEventListener(EVENT.UNLOAD, this.onUnload); window.removeEventListener(EVENT.UNLOAD, this.onUnload);
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove); window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
window.removeEventListener( // window.removeEventListener(
EVENT.VISIBILITY_CHANGE, // EVENT.VISIBILITY_CHANGE,
this.onVisibilityChange, // this.onVisibilityChange,
); // );
window.removeEventListener(EVENT.BLUR, this.onVisibilityChange);
window.removeEventListener(EVENT.FOCUS, this.onVisibilityChange);
if (this.activeIntervalId) { if (this.activeIntervalId) {
window.clearInterval(this.activeIntervalId); window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null; this.activeIntervalId = null;
@@ -204,6 +222,10 @@ class Collab extends PureComponent<Props, CollabState> {
window.clearTimeout(this.idleTimeoutId); window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null; this.idleTimeoutId = null;
} }
if (this.pauseTimeoutId) {
window.clearTimeout(this.pauseTimeoutId);
this.pauseTimeoutId = null;
}
} }
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
@@ -307,6 +329,128 @@ 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 }) => { private destroySocketClient = (opts?: { isUnload: boolean }) => {
this.lastBroadcastedOrReceivedSceneVersion = -1; this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close(); this.portal.close();
@@ -385,6 +529,11 @@ class Collab extends PureComponent<Props, CollabState> {
}); });
} }
if (!this.state.userId) {
const userId = nanoid();
this.onUserIdChange(userId);
}
if (this.portal.socket) { if (this.portal.socket) {
return null; return null;
} }
@@ -499,6 +648,7 @@ class Collab extends PureComponent<Props, CollabState> {
elements: reconciledElements, elements: reconciledElements,
scrollToContent: true, scrollToContent: true,
}); });
this.onPauseCollaborationChange(PauseCollaborationState.SYNCED);
} }
break; break;
} }
@@ -508,33 +658,45 @@ class Collab extends PureComponent<Props, CollabState> {
); );
break; break;
case "MOUSE_LOCATION": { case "MOUSE_LOCATION": {
const { pointer, button, username, selectedElementIds } = const {
decryptedData.payload; pointer,
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] = button,
decryptedData.payload.socketId || username,
// @ts-ignore legacy, see #2094 (#2097) selectedElementIds,
decryptedData.payload.socketID; userId,
socketId,
const collaborators = new Map(this.collaborators); } = decryptedData.payload;
const user = collaborators.get(socketId) || {}!; const collaborators = upsertMap(
user.pointer = pointer; userId,
user.button = button; {
user.selectedElementIds = selectedElementIds; username,
user.username = username; pointer,
collaborators.set(socketId, user); button,
selectedElementIds,
socketId,
},
this.collaborators,
);
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
collaborators, collaborators: new Map(collaborators),
}); });
break; break;
} }
case "IDLE_STATUS": { case "IDLE_STATUS": {
const { userState, socketId, username } = decryptedData.payload; const { userState, username, userId, socketId } =
const collaborators = new Map(this.collaborators); decryptedData.payload;
const user = collaborators.get(socketId) || {}!; const collaborators = upsertMap(
user.userState = userState; userId,
user.username = username; {
username,
userState,
userId,
socketId,
},
this.collaborators,
);
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
collaborators, collaborators: new Map(collaborators),
}); });
break; break;
} }
@@ -543,6 +705,7 @@ class Collab extends PureComponent<Props, CollabState> {
); );
this.portal.socket.on("first-in-room", async () => { this.portal.socket.on("first-in-room", async () => {
console.log("first in room");
if (this.portal.socket) { if (this.portal.socket) {
this.portal.socket.off("first-in-room"); this.portal.socket.off("first-in-room");
} }
@@ -684,7 +847,9 @@ class Collab extends PureComponent<Props, CollabState> {
}; };
private onVisibilityChange = () => { private onVisibilityChange = () => {
if (document.hidden) { // if (document.hidden) {
console.log("VIS CHANGE");
if (!document.hasFocus()) {
if (this.idleTimeoutId) { if (this.idleTimeoutId) {
window.clearTimeout(this.idleTimeoutId); window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null; this.idleTimeoutId = null;
@@ -693,6 +858,10 @@ class Collab extends PureComponent<Props, CollabState> {
window.clearInterval(this.activeIntervalId); window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null; this.activeIntervalId = null;
} }
this.pauseTimeoutId = window.setTimeout(
() => this.onPauseCollaborationChange(PauseCollaborationState.PAUSED),
PAUSE_COLLABORATION_TIMEOUT,
);
this.onIdleStateChange(UserIdleState.AWAY); this.onIdleStateChange(UserIdleState.AWAY);
} else { } else {
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD); this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
@@ -701,6 +870,11 @@ class Collab extends PureComponent<Props, CollabState> {
ACTIVE_THRESHOLD, ACTIVE_THRESHOLD,
); );
this.onIdleStateChange(UserIdleState.ACTIVE); this.onIdleStateChange(UserIdleState.ACTIVE);
if (this.pauseTimeoutId) {
window.clearTimeout(this.pauseTimeoutId);
this.onPauseCollaborationChange(PauseCollaborationState.RESUMED);
this.pauseTimeoutId = null;
}
} }
}; };
@@ -717,22 +891,19 @@ class Collab extends PureComponent<Props, CollabState> {
}; };
private initializeIdleDetector = () => { private initializeIdleDetector = () => {
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove); // document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange); // document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
window.addEventListener(EVENT.BLUR, this.onVisibilityChange);
window.addEventListener(EVENT.FOCUS, this.onVisibilityChange);
}; };
setCollaborators(sockets: string[]) { setCollaborators(sockets: string[]) {
const collaborators: InstanceType<typeof Collab>["collaborators"] = this.collaborators.forEach((value, key) => {
new Map(); if (value.socketId && !sockets.includes(value.socketId)) {
for (const socketId of sockets) { this.collaborators.delete(key);
if (this.collaborators.has(socketId)) {
collaborators.set(socketId, this.collaborators.get(socketId)!);
} else {
collaborators.set(socketId, {});
} }
} });
this.collaborators = collaborators; this.excalidrawAPI.updateScene({ collaborators: this.collaborators });
this.excalidrawAPI.updateScene({ collaborators });
} }
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
@@ -818,7 +989,12 @@ class Collab extends PureComponent<Props, CollabState> {
onUsernameChange = (username: string) => { onUsernameChange = (username: string) => {
this.setUsername(username); this.setUsername(username);
saveUsernameToLocalStorage(username); saveUsernameAndIdToLocalStorage(username, this.state.userId);
};
onUserIdChange = (userId: string) => {
this.setState({ userId });
saveUsernameAndIdToLocalStorage(this.state.username, userId);
}; };
render() { render() {
+34 -21
View File
@@ -34,26 +34,12 @@ class Portal {
open(socket: SocketIOClient.Socket, id: string, key: string) { open(socket: SocketIOClient.Socket, id: string, key: string) {
this.socket = socket; this.socket = socket;
// @ts-ignore
window.socket = socket;
this.roomId = id; this.roomId = id;
this.roomKey = key; this.roomKey = key;
// Initialize socket listeners this.initializeSocketListeners();
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
trackEvent("share", "room joined");
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
WS_SCENE_EVENT_TYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: string[]) => {
this.collab.setCollaborators(clients);
});
return socket; return socket;
} }
@@ -71,6 +57,31 @@ class Portal {
this.broadcastedElementVersions = new Map(); 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");
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
WS_SCENE_EVENT_TYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: string[]) => {
this.collab.setCollaborators(clients);
});
}
isOpen() { isOpen() {
return !!( return !!(
this.socketInitialized && this.socketInitialized &&
@@ -181,13 +192,14 @@ class Portal {
}; };
broadcastIdleChange = (userState: UserIdleState) => { broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) { if (this.socket) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = { const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: "IDLE_STATUS", type: "IDLE_STATUS",
payload: { payload: {
socketId: this.socket.id,
userState, userState,
username: this.collab.state.username, username: this.collab.state.username,
userId: this.collab.state.userId,
socketId: this.socket.id,
}, },
}; };
return this._broadcastSocketData( return this._broadcastSocketData(
@@ -201,16 +213,17 @@ class Portal {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"]; pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"]; button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
}) => { }) => {
if (this.socket?.id) { if (this.socket) {
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
type: "MOUSE_LOCATION", type: "MOUSE_LOCATION",
payload: { payload: {
socketId: this.socket.id,
pointer: payload.pointer, pointer: payload.pointer,
button: payload.button || "up", button: payload.button || "up",
selectedElementIds: selectedElementIds:
this.collab.excalidrawAPI.getAppState().selectedElementIds, this.collab.excalidrawAPI.getAppState().selectedElementIds,
username: this.collab.state.username, username: this.collab.state.username,
userId: this.collab.state.userId,
socketId: this.socket.id,
}, },
}; };
return this._broadcastSocketData( return this._broadcastSocketData(
+4 -2
View File
@@ -106,19 +106,21 @@ export type SocketUpdateDataSource = {
MOUSE_LOCATION: { MOUSE_LOCATION: {
type: "MOUSE_LOCATION"; type: "MOUSE_LOCATION";
payload: { payload: {
socketId: string;
pointer: { x: number; y: number }; pointer: { x: number; y: number };
button: "down" | "up"; button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"]; selectedElementIds: AppState["selectedElementIds"];
username: string; username: string;
userId: string;
socketId: string;
}; };
}; };
IDLE_STATUS: { IDLE_STATUS: {
type: "IDLE_STATUS"; type: "IDLE_STATUS";
payload: { payload: {
socketId: string;
userState: UserIdleState; userState: UserIdleState;
username: string; username: string;
userId: string;
socketId: string;
}; };
}; };
}; };
+10 -4
View File
@@ -8,11 +8,14 @@ import { clearElementsForLocalStorage } from "../../element";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../data/types"; import { ImportedDataState } from "../../data/types";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameAndIdToLocalStorage = (
username: string,
userId: string,
) => {
try { try {
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_COLLAB, STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
JSON.stringify({ username }), JSON.stringify({ username, userId }),
); );
} catch (error: any) { } catch (error: any) {
// Unable to access window.localStorage // Unable to access window.localStorage
@@ -20,11 +23,14 @@ export const saveUsernameToLocalStorage = (username: string) => {
} }
}; };
export const importUsernameFromLocalStorage = (): string | null => { export const importUsernameAndIdFromLocalStorage = (): {
username: string;
userId: string;
} | null => {
try { try {
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
if (data) { if (data) {
return JSON.parse(data).username; return JSON.parse(data);
} }
} catch (error: any) { } catch (error: any) {
// Unable to access localStorage // Unable to access localStorage
+3 -2
View File
@@ -65,7 +65,7 @@ import {
import { import {
getLibraryItemsFromStorage, getLibraryItemsFromStorage,
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameAndIdFromLocalStorage,
} from "./data/localStorage"; } from "./data/localStorage";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore"; import { restore, restoreAppState, RestoredDataState } from "../data/restore";
@@ -425,7 +425,8 @@ const ExcalidrawWrapper = () => {
// don't sync if local state is newer or identical to browser state // don't sync if local state is newer or identical to browser state
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage(); const username =
importUsernameAndIdFromLocalStorage()?.username ?? "";
let langCode = languageDetector.detect() || defaultLang.code; let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) { if (Array.isArray(langCode)) {
langCode = langCode[0]; langCode = langCode[0];
+3 -2
View File
@@ -6,11 +6,12 @@ const SentryEnvHostnameMap: { [key: string]: string } = {
"vercel.app": "staging", "vercel.app": "staging",
}; };
const SENTRY_DISABLED = import.meta.env.VITE_APP_DISABLE_SENTRY === "true"; const REACT_APP_DISABLE_SENTRY =
import.meta.env.VITE_APP_DISABLE_SENTRY === "true";
// Disable Sentry locally or inside the Docker to avoid noise/respect privacy // Disable Sentry locally or inside the Docker to avoid noise/respect privacy
const onlineEnv = const onlineEnv =
!SENTRY_DISABLED && !REACT_APP_DISABLE_SENTRY &&
Object.keys(SentryEnvHostnameMap).find( Object.keys(SentryEnvHostnameMap).find(
(item) => window.location.hostname.indexOf(item) >= 0, (item) => window.location.hostname.indexOf(item) >= 0,
); );
-3
View File
@@ -1,14 +1,11 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import ExcalidrawApp from "./excalidraw-app"; import ExcalidrawApp from "./excalidraw-app";
import { registerSW } from "virtual:pwa-register";
import "./excalidraw-app/sentry"; import "./excalidraw-app/sentry";
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA; window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
const rootElement = document.getElementById("root")!; const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement); const root = createRoot(rootElement);
registerSW();
root.render( root.render(
<StrictMode> <StrictMode>
<ExcalidrawApp /> <ExcalidrawApp />
+2 -1
View File
@@ -425,7 +425,8 @@
"selection": "selection", "selection": "selection",
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor", "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", "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" "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"
}, },
"colors": { "colors": {
"transparent": "Transparent", "transparent": "Transparent",
+2 -2
View File
@@ -707,8 +707,8 @@ export const _renderScene = ({
if (renderConfig.remoteSelectedElementIds[element.id]) { if (renderConfig.remoteSelectedElementIds[element.id]) {
selectionColors.push( selectionColors.push(
...renderConfig.remoteSelectedElementIds[element.id].map( ...renderConfig.remoteSelectedElementIds[element.id].map(
(socketId) => { (userId) => {
const background = getClientColor(socketId); const background = getClientColor(userId);
return background; return background;
}, },
), ),
+4 -4
View File
@@ -1128,7 +1128,7 @@ describe("Test Linear Elements", () => {
height: 500, height: 500,
}); });
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: -10, x: 210,
y: 250, y: 250,
width: 400, width: 400,
height: 1, height: 1,
@@ -1152,8 +1152,8 @@ describe("Test Linear Elements", () => {
expect( expect(
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
"Online whiteboard "Online whiteboard collaboration
collaboration made easy" made easy"
`); `);
const handleBindTextResizeSpy = vi.spyOn( const handleBindTextResizeSpy = vi.spyOn(
textElementUtils, textElementUtils,
@@ -1165,7 +1165,7 @@ describe("Test Linear Elements", () => {
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBe(200); expect(arrow.width).toBe(170);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
+7
View File
@@ -56,6 +56,7 @@ export type Collaborator = {
avatarUrl?: string; avatarUrl?: string;
// user id. If supplied, we'll filter out duplicates when rendering user avatars. // user id. If supplied, we'll filter out duplicates when rendering user avatars.
id?: string; id?: string;
socketId?: string;
}; };
export type DataURL = string & { _brand: "DataURL" }; export type DataURL = string & { _brand: "DataURL" };
@@ -401,6 +402,12 @@ export enum UserIdleState {
IDLE = "idle", IDLE = "idle",
} }
export enum PauseCollaborationState {
PAUSED = "paused",
RESUMED = "resumed",
SYNCED = "synced",
}
export type ExportOpts = { export type ExportOpts = {
saveFileToDisk?: boolean; saveFileToDisk?: boolean;
onExportToBackend?: ( onExportToBackend?: (
+11
View File
@@ -914,3 +914,14 @@ 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;
};
+2 -1
View File
@@ -1,7 +1,8 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/vanillajs" /> /// <reference types="vite-plugin-pwa/react" />
/// <reference types="vite-plugin-pwa/info" /> /// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-svgr/client" /> /// <reference types="vite-plugin-svgr/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
// The port to run the dev server // The port to run the dev server
VITE_APP_PORT: string; VITE_APP_PORT: string;
+1 -2
View File
@@ -53,7 +53,6 @@ export default defineConfig({
svgrPlugin(), svgrPlugin(),
ViteEjsPlugin(), ViteEjsPlugin(),
VitePWA({ VitePWA({
registerType: "autoUpdate",
devOptions: { devOptions: {
/* set this flag to true to enable in Development mode */ /* set this flag to true to enable in Development mode */
enabled: false, enabled: false,
@@ -61,7 +60,7 @@ export default defineConfig({
workbox: { workbox: {
// Don't push fonts and locales to app precache // Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"], globIgnores: ["fonts.css", "**/locales/**"],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"), urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
-7
View File
@@ -5,12 +5,5 @@ export default defineConfig({
setupFiles: ["./src/setupTests.ts"], setupFiles: ["./src/setupTests.ts"],
globals: true, globals: true,
environment: "jsdom", environment: "jsdom",
coverage: {
reporter: ["text", "json-summary", "json"],
lines: 70,
branches: 70,
functions: 68,
statements: 70,
},
}, },
}); });
+275 -612
View File
File diff suppressed because it is too large Load Diff