Compare commits
15 Commits
arnost/soc
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
3ea07076ad | ||
|
ded0222e8d | ||
|
e7983bc493 | ||
|
083bcf802c | ||
|
bb985eba3a | ||
|
23c88a38d0 | ||
|
b85d5fa12b | ||
|
9391a09e54 | ||
|
50450a7dab | ||
|
fc9a9a2571 | ||
|
6126c34dc0 | ||
|
12e37e3dd2 | ||
|
9ca27c62c7 | ||
|
1acc646534 | ||
|
66bac50de3 |
5
.codesandbox/Dockerfile
Normal file
5
.codesandbox/Dockerfile
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
26
.github/workflows/test-coverage-pr.yml
vendored
Normal file
26
.github/workflows/test-coverage-pr.yml
vendored
Normal file
@ -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 }}
|
@ -69,6 +69,10 @@ It's also a good idea to consider if your change should include additional tests
|
||||
|
||||
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
|
||||
|
||||
:::note
|
||||
Some checks, such as the `lint` and `test`, require approval from the maintainers to run.
|
||||
They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval.
|
||||
:::
|
||||
|
||||
## Translating
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
12
package.json
12
package.json
@ -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",
|
||||
|
20
public/service-worker.js
Normal file
20
public/service-worker.js
Normal file
@ -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));
|
||||
});
|
||||
});
|
@ -24,6 +24,7 @@ export interface ClipboardData {
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
programmaticAPI?: boolean;
|
||||
}
|
||||
|
||||
let CLIPBOARD = "";
|
||||
@ -48,6 +49,7 @@ const clipboardContainsElements = (
|
||||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
|
||||
].includes(contents?.type) &&
|
||||
Array.isArray(contents.elements)
|
||||
) {
|
||||
@ -191,6 +193,8 @@ export const parseClipboard = async (
|
||||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
const programmaticAPI =
|
||||
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
@ -198,6 +202,7 @@ export const parseClipboard = async (
|
||||
text: isPlainPaste
|
||||
? JSON.stringify(systemClipboardData.elements, null, 2)
|
||||
: undefined,
|
||||
programmaticAPI,
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
@ -298,7 +298,6 @@ import {
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
@ -347,6 +346,10 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
import { activeEyeDropperAtom } from "./EyeDropper";
|
||||
import {
|
||||
ExcalidrawElementSkeleton,
|
||||
convertToExcalidrawElements,
|
||||
} from "../data/transform";
|
||||
import { ValueOf } from "../utility-types";
|
||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
|
||||
@ -941,7 +944,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>
|
||||
@ -2232,7 +2235,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let file = event?.clipboardData?.files[0];
|
||||
|
||||
const data = await parseClipboard(event, isPlainPaste);
|
||||
|
||||
if (!file && data.text && !isPlainPaste) {
|
||||
const string = data.text.trim();
|
||||
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
||||
@ -2287,9 +2289,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
});
|
||||
} else if (data.elements) {
|
||||
const elements = (
|
||||
data.programmaticAPI
|
||||
? convertToExcalidrawElements(
|
||||
data.elements as ExcalidrawElementSkeleton[],
|
||||
)
|
||||
: data.elements
|
||||
) as readonly ExcalidrawElement[];
|
||||
// TODO remove formatting from elements if isPlainPaste
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements: data.elements,
|
||||
elements,
|
||||
files: data.files || null,
|
||||
position: "cursor",
|
||||
retainSeed: isPlainPaste,
|
||||
@ -2748,7 +2757,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
toast: {
|
||||
message: string;
|
||||
closable?: boolean;
|
||||
spinner?: boolean;
|
||||
duration?: number;
|
||||
} | null,
|
||||
) => {
|
||||
@ -3549,9 +3557,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;
|
||||
@ -5398,7 +5405,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
width: embedLink.aspectRatio.w,
|
||||
height: embedLink.aspectRatio.h,
|
||||
link,
|
||||
validated: undefined,
|
||||
validated: null,
|
||||
});
|
||||
|
||||
this.scene.replaceAllElements([
|
||||
@ -5586,7 +5593,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
private createGenericElementOnPointerDown = (
|
||||
elementType: ExcalidrawGenericElement["type"],
|
||||
elementType: ExcalidrawGenericElement["type"] | "embeddable",
|
||||
pointerDownState: PointerDownState,
|
||||
): void => {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
@ -5600,8 +5607,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
y: gridY,
|
||||
});
|
||||
|
||||
const element = newElement({
|
||||
type: elementType,
|
||||
const baseElementAttributes = {
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
@ -5614,8 +5620,21 @@ class App extends React.Component<AppProps, AppState> {
|
||||
roundness: this.getCurrentItemRoundness(elementType),
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
...(elementType === "embeddable" ? { validated: false } : {}),
|
||||
});
|
||||
} as const;
|
||||
|
||||
let element;
|
||||
if (elementType === "embeddable") {
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
...baseElementAttributes,
|
||||
});
|
||||
} else {
|
||||
element = newElement({
|
||||
type: elementType,
|
||||
...baseElementAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "selection") {
|
||||
this.setState({
|
||||
|
@ -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";
|
||||
@ -163,6 +164,7 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidraw: "excalidraw",
|
||||
excalidrawClipboard: "excalidraw/clipboard",
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE =
|
||||
@ -239,6 +241,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",
|
||||
|
2032
src/data/__snapshots__/transform.test.ts.snap
Normal file
2032
src/data/__snapshots__/transform.test.ts.snap
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,6 +29,7 @@ import {
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
DEFAULT_SIDEBAR,
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
@ -41,7 +42,6 @@ import {
|
||||
getDefaultLineHeight,
|
||||
measureBaseline,
|
||||
} from "../element/textElement";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
@ -122,16 +122,18 @@ const restoreElementWithProperties = <
|
||||
versionNonce: element.versionNonce ?? 0,
|
||||
isDeleted: element.isDeleted ?? false,
|
||||
id: element.id || randomId(),
|
||||
fillStyle: element.fillStyle || "hachure",
|
||||
strokeWidth: element.strokeWidth || 1,
|
||||
strokeStyle: element.strokeStyle ?? "solid",
|
||||
roughness: element.roughness ?? 1,
|
||||
opacity: element.opacity == null ? 100 : element.opacity,
|
||||
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
|
||||
opacity:
|
||||
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
|
||||
angle: element.angle || 0,
|
||||
x: extra.x ?? element.x ?? 0,
|
||||
y: extra.y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor || COLOR_PALETTE.black,
|
||||
backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
|
||||
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
backgroundColor:
|
||||
element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
width: element.width || 0,
|
||||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
@ -246,7 +248,6 @@ const restoreElement = (
|
||||
startArrowhead = null,
|
||||
endArrowhead = element.type === "arrow" ? "arrow" : null,
|
||||
} = element;
|
||||
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
@ -286,7 +287,7 @@ const restoreElement = (
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "embeddable":
|
||||
return restoreElementWithProperties(element, {
|
||||
validated: undefined,
|
||||
validated: null,
|
||||
});
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
@ -410,7 +411,6 @@ export const restoreElements = (
|
||||
): ExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
|
||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||
const restoredElements = (elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
@ -429,6 +429,7 @@ export const restoreElements = (
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
}
|
||||
|
706
src/data/transform.test.ts
Normal file
706
src/data/transform.test.ts
Normal file
@ -0,0 +1,706 @@
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
ExcalidrawElementSkeleton,
|
||||
convertToExcalidrawElements,
|
||||
} from "./transform";
|
||||
import { ExcalidrawArrowElement } from "../element/types";
|
||||
|
||||
describe("Test Transform", () => {
|
||||
it("should transform regular shapes", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 100,
|
||||
y: 250,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#c0eb75",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 300,
|
||||
y: 250,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#ffc9c9",
|
||||
strokeStyle: "dotted",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 300,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#a5d8ff",
|
||||
strokeColor: "#1971c2",
|
||||
strokeStyle: "dashed",
|
||||
fillStyle: "cross-hatch",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
];
|
||||
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform text element", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 100,
|
||||
text: "HELLO WORLD!",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 150,
|
||||
text: "STYLED HELLO WORLD!",
|
||||
fontSize: 20,
|
||||
strokeColor: "#5f3dc4",
|
||||
},
|
||||
];
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform linear elements", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "dot",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 100,
|
||||
y: 60,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 450,
|
||||
y: 60,
|
||||
strokeColor: "#2f9e44",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "dotted",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to text containers when label provided", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "RECTANGLE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 200,
|
||||
label: {
|
||||
text: "ELLIPSE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 150,
|
||||
width: 280,
|
||||
label: {
|
||||
text: "DIAMOND\nTEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "STYLED DIAMOND TEXT CONTAINER",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 500,
|
||||
y: 300,
|
||||
width: 200,
|
||||
strokeColor: "#c2255c",
|
||||
label: {
|
||||
text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 500,
|
||||
strokeColor: "#f08c00",
|
||||
backgroundColor: "#ffec99",
|
||||
width: 200,
|
||||
label: {
|
||||
text: "STYLED ELLIPSE TEXT CONTAINER",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(12);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to labelled arrows when label provided for arrows", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "LABELED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 200,
|
||||
label: {
|
||||
text: "STYLED LABELED ARROW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 300,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 400,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
strokeColor: "#099268",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(8);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test arrow bindings", () => {
|
||||
it("should bind arrows to shapes when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "rectangle",
|
||||
},
|
||||
end: {
|
||||
type: "ellipse",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [arrow, text, rectangle, ellipse] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: rectangle.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: ellipse.id,
|
||||
focus: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toMatchObject({
|
||||
x: 340,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(rectangle).toMatchObject({
|
||||
x: 155,
|
||||
y: 189,
|
||||
type: "rectangle",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ellipse).toMatchObject({
|
||||
x: 555,
|
||||
y: 189,
|
||||
type: "ellipse",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to text when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
},
|
||||
end: {
|
||||
type: "text",
|
||||
text: "WHATS UP ?",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
const [arrow, text1, text2, text3] = excaldrawElements;
|
||||
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [{ id: text1.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: text2.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: text3.id,
|
||||
focus: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text1).toMatchObject({
|
||||
x: 340,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(text2).toMatchObject({
|
||||
x: 185,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(text3).toMatchObject({
|
||||
x: 555,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing shapes when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "ellipse-1",
|
||||
strokeColor: "#66a80f",
|
||||
x: 630,
|
||||
y: 316,
|
||||
width: 300,
|
||||
height: 300,
|
||||
backgroundColor: "#d8f5a2",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
id: "diamond-1",
|
||||
strokeColor: "#9c36b5",
|
||||
width: 140,
|
||||
x: 96,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 247,
|
||||
y: 420,
|
||||
width: 395,
|
||||
height: 35,
|
||||
strokeColor: "#1864ab",
|
||||
start: {
|
||||
type: "rectangle",
|
||||
width: 300,
|
||||
height: 300,
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 227,
|
||||
y: 450,
|
||||
width: 400,
|
||||
strokeColor: "#e67700",
|
||||
start: {
|
||||
id: "diamond-1",
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(5);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing text elements when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "text-2",
|
||||
x: 560,
|
||||
y: 239,
|
||||
text: "Whats up ?",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-1",
|
||||
},
|
||||
end: {
|
||||
id: "text-2",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing elements if ids are correct", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-13",
|
||||
},
|
||||
end: {
|
||||
id: "rect-11",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [, , arrow] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [
|
||||
{
|
||||
id: "id46",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"No element for start binding with id text-13 found",
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"No element for end binding with id rect-11 found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should bind when ids referenced before the element data", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
end: {
|
||||
id: "rect-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
expect(excaldrawElements.length).toBe(2);
|
||||
const [arrow, rect] = excaldrawElements;
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
id: "id47",
|
||||
type: "arrow",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow duplicate ids", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 200,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(1);
|
||||
expect(excaldrawElements[0]).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Duplicate id found for rect-1",
|
||||
);
|
||||
});
|
||||
});
|
561
src/data/transform.ts
Normal file
561
src/data/transform.ts
Normal file
@ -0,0 +1,561 @@
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
newElement,
|
||||
newLinearElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { bindLinearElement } from "../element/binding";
|
||||
import {
|
||||
ElementConstructorOpts,
|
||||
newImageElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
measureText,
|
||||
normalizeText,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FileId,
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "../element/types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import { assertNever, getFontString } from "../utils";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
x: number;
|
||||
y: number;
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
end?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
start?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & Partial<ExcalidrawLinearElement>;
|
||||
|
||||
export type ValidContainer =
|
||||
| {
|
||||
type: Exclude<ExcalidrawGenericElement["type"], "selection">;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & ElementConstructorOpts;
|
||||
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawFrameElement
|
||||
>
|
||||
| ({
|
||||
type: Extract<ExcalidrawLinearElement["type"], "line">;
|
||||
x: number;
|
||||
y: number;
|
||||
} & Partial<ExcalidrawLinearElement>)
|
||||
| ValidContainer
|
||||
| ValidLinearElement
|
||||
| ({
|
||||
type: "text";
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
id?: ExcalidrawTextElement["id"];
|
||||
} & Partial<ExcalidrawTextElement>)
|
||||
| ({
|
||||
type: Extract<ExcalidrawImageElement["type"], "image">;
|
||||
x: number;
|
||||
y: number;
|
||||
fileId: FileId;
|
||||
} & Partial<ExcalidrawImageElement>);
|
||||
|
||||
const DEFAULT_LINEAR_ELEMENT_PROPS = {
|
||||
width: 300,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DIMENSION = 100;
|
||||
|
||||
const bindTextToContainer = (
|
||||
container: ExcalidrawElement,
|
||||
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
|
||||
) => {
|
||||
const textElement: ExcalidrawTextElement = newTextElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
...textProps,
|
||||
containerId: container.id,
|
||||
strokeColor: textProps.strokeColor || container.strokeColor,
|
||||
});
|
||||
|
||||
Object.assign(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
return [container, textElement] as const;
|
||||
};
|
||||
|
||||
const bindLinearElementToElement = (
|
||||
linearElement: ExcalidrawArrowElement,
|
||||
start: ValidLinearElement["start"],
|
||||
end: ValidLinearElement["end"],
|
||||
elementStore: ElementStore,
|
||||
): {
|
||||
linearElement: ExcalidrawLinearElement;
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
endBoundElement?: ExcalidrawElement;
|
||||
} => {
|
||||
let startBoundElement;
|
||||
let endBoundElement;
|
||||
|
||||
Object.assign(linearElement, {
|
||||
startBinding: linearElement?.startBinding || null,
|
||||
endBinding: linearElement.endBinding || null,
|
||||
});
|
||||
|
||||
if (start) {
|
||||
const width = start?.width ?? DEFAULT_DIMENSION;
|
||||
const height = start?.height ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (start.id) {
|
||||
existingElement = elementStore.getElement(start.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for start binding with id ${start.id} found`);
|
||||
}
|
||||
}
|
||||
|
||||
const startX = start.x || linearElement.x - width;
|
||||
const startY = start.y || linearElement.y - height / 2;
|
||||
const startType = existingElement ? existingElement.type : start.type;
|
||||
|
||||
if (startType) {
|
||||
if (startType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (start.type === "text") {
|
||||
text = start.text;
|
||||
}
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for start binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
startBoundElement = newTextElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...start,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(startBoundElement, {
|
||||
x: start.x || linearElement.x - startBoundElement.width,
|
||||
y: start.y || linearElement.y - startBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (startType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
startBoundElement = newElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...start,
|
||||
type: startType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element start type "${start.type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
startBoundElement as ExcalidrawBindableElement,
|
||||
"start",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (end) {
|
||||
const height = end?.height ?? DEFAULT_DIMENSION;
|
||||
const width = end?.width ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (end.id) {
|
||||
existingElement = elementStore.getElement(end.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for end binding with id ${end.id} found`);
|
||||
}
|
||||
}
|
||||
const endX = end.x || linearElement.x + linearElement.width;
|
||||
const endY = end.y || linearElement.y - height / 2;
|
||||
const endType = existingElement ? existingElement.type : end.type;
|
||||
|
||||
if (endType) {
|
||||
if (endType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (end.type === "text") {
|
||||
text = end.text;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for end binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
endBoundElement = newTextElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...end,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(endBoundElement, {
|
||||
y: end.y || linearElement.y - endBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (endType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
endBoundElement = newElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...end,
|
||||
type: endType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element end type "${endType}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
endBoundElement as ExcalidrawBindableElement,
|
||||
"end",
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
linearElement,
|
||||
startBoundElement,
|
||||
endBoundElement,
|
||||
};
|
||||
};
|
||||
|
||||
class ElementStore {
|
||||
excalidrawElements = new Map<string, ExcalidrawElement>();
|
||||
|
||||
add = (ele?: ExcalidrawElement) => {
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.excalidrawElements.set(ele.id, ele);
|
||||
};
|
||||
getElements = () => {
|
||||
return Array.from(this.excalidrawElements.values());
|
||||
};
|
||||
|
||||
getElement = (id: string) => {
|
||||
return this.excalidrawElements.get(id);
|
||||
};
|
||||
}
|
||||
|
||||
export const convertToExcalidrawElements = (
|
||||
elements: ExcalidrawElementSkeleton[] | null,
|
||||
) => {
|
||||
if (!elements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const elementStore = new ElementStore();
|
||||
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
||||
|
||||
// Create individual elements
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
const width =
|
||||
element?.label?.text && element.width === undefined
|
||||
? 0
|
||||
: element?.width || DEFAULT_DIMENSION;
|
||||
const height =
|
||||
element?.label?.text && element.height === undefined
|
||||
? 0
|
||||
: element?.height || DEFAULT_DIMENSION;
|
||||
excalidrawElement = newElement({
|
||||
...element,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "line": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "text": {
|
||||
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight =
|
||||
element?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const text = element.text ?? "";
|
||||
const normalizedText = normalizeText(text);
|
||||
const metrics = measureText(
|
||||
normalizedText,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
);
|
||||
|
||||
excalidrawElement = newTextElement({
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
excalidrawElement = newImageElement({
|
||||
width: element?.width || DEFAULT_DIMENSION,
|
||||
height: element?.height || DEFAULT_DIMENSION,
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "frame":
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
excalidrawElement = element;
|
||||
assertNever(
|
||||
element,
|
||||
`Unhandled element type "${(element as any).type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingElement = elementStore.getElement(excalidrawElement.id);
|
||||
if (existingElement) {
|
||||
console.error(`Duplicate id found for ${excalidrawElement.id}`);
|
||||
} else {
|
||||
elementStore.add(excalidrawElement);
|
||||
elementsWithIds.set(excalidrawElement.id, element);
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels and arrow bindings
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
const excalidrawElement = elementStore.getElement(id)!;
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond":
|
||||
case "arrow": {
|
||||
if (element.label?.text) {
|
||||
let [container, text] = bindTextToContainer(
|
||||
excalidrawElement,
|
||||
element?.label,
|
||||
);
|
||||
elementStore.add(container);
|
||||
elementStore.add(text);
|
||||
|
||||
if (container.type === "arrow") {
|
||||
const originalStart =
|
||||
element.type === "arrow" ? element?.start : undefined;
|
||||
const originalEnd =
|
||||
element.type === "arrow" ? element?.end : undefined;
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
container as ExcalidrawArrowElement,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
elementStore,
|
||||
);
|
||||
container = linearElement;
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
}
|
||||
} else {
|
||||
switch (element.type) {
|
||||
case "arrow": {
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
excalidrawElement as ExcalidrawArrowElement,
|
||||
element.start,
|
||||
element.end,
|
||||
elementStore,
|
||||
);
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return elementStore.getElements();
|
||||
};
|
@ -190,7 +190,7 @@ export const maybeBindLinearElement = (
|
||||
}
|
||||
};
|
||||
|
||||
const bindLinearElement = (
|
||||
export const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
|
@ -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
|
||||
|
@ -46,7 +46,7 @@ import {
|
||||
} from "../constants";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
| "width"
|
||||
| "height"
|
||||
@ -134,7 +134,7 @@ export const newElement = (
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
validated: boolean | undefined;
|
||||
validated: ExcalidrawEmbeddableElement["validated"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||
return {
|
||||
@ -187,7 +187,7 @@ export const newTextElement = (
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
} & ElementConstructorOpts,
|
||||
@ -361,8 +361,8 @@ export const newFreeDrawElement = (
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
@ -372,8 +372,8 @@ export const newLinearElement = (
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead,
|
||||
endArrowhead: opts.endArrowhead,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
};
|
||||
};
|
||||
|
||||
@ -477,7 +477,7 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
||||
* for test assertions.
|
||||
*/
|
||||
const regenerateId = (
|
||||
export const regenerateId = (
|
||||
/** supply null if no previous id exists */
|
||||
previousId: string | null,
|
||||
) => {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,21 +85,27 @@ export const redrawTextBoundingBox = (
|
||||
boundTextUpdates.baseline = metrics.baseline;
|
||||
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
const nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
if (metrics.width > maxContainerWidth) {
|
||||
const nextWidth = computeContainerDimensionForBoundText(
|
||||
metrics.width,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { width: nextWidth });
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
@ -155,6 +163,7 @@ export const bindTextToShapeAfterDuplication = (
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
@ -175,15 +184,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 +218,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 +698,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,
|
||||
@ -865,8 +866,9 @@ const VALID_CONTAINER_TYPES = new Set([
|
||||
"arrow",
|
||||
]);
|
||||
|
||||
export const isValidTextContainer = (element: ExcalidrawElement) =>
|
||||
VALID_CONTAINER_TYPES.has(element.type);
|
||||
export const isValidTextContainer = (element: {
|
||||
type: ExcalidrawElement["type"];
|
||||
}) => VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
dimension: number,
|
||||
@ -887,12 +889,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 +920,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(
|
||||
|
@ -86,15 +86,15 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
||||
|
||||
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "embeddable";
|
||||
/**
|
||||
* indicates whether the embeddable src (url) has been validated for rendering.
|
||||
* nullish value indicates that the validation is pending. We reset the
|
||||
* null value indicates that the validation is pending. We reset the
|
||||
* value on each restore (or url change) so that we can guarantee
|
||||
* the validation came from a trusted source (the editor). Also because we
|
||||
* may not have access to host-app supplied url validator during restore.
|
||||
*/
|
||||
validated?: boolean;
|
||||
type: "embeddable";
|
||||
validated: boolean | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
@ -123,7 +123,6 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||
export type ExcalidrawGenericElement =
|
||||
| ExcalidrawSelectionElement
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
@ -138,7 +137,8 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
|
@ -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 />
|
||||
|
@ -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",
|
||||
|
@ -75,6 +75,7 @@ const {
|
||||
WelcomeScreen,
|
||||
MainMenu,
|
||||
LiveCollaborationTrigger,
|
||||
convertToExcalidrawElements,
|
||||
} = window.ExcalidrawLib;
|
||||
|
||||
const COMMENT_ICON_DIMENSION = 32;
|
||||
@ -140,7 +141,10 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
];
|
||||
|
||||
//@ts-ignore
|
||||
initialStatePromiseRef.current.promise.resolve(initialData);
|
||||
initialStatePromiseRef.current.promise.resolve({
|
||||
...initialData,
|
||||
elements: convertToExcalidrawElements(initialData.elements),
|
||||
});
|
||||
excalidrawAPI.addFiles(imagesArray);
|
||||
};
|
||||
};
|
||||
@ -184,38 +188,40 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
const updateScene = () => {
|
||||
const sceneData = {
|
||||
elements: restoreElements(
|
||||
[
|
||||
convertToExcalidrawElements([
|
||||
{
|
||||
type: "rectangle",
|
||||
version: 141,
|
||||
versionNonce: 361174001,
|
||||
isDeleted: false,
|
||||
id: "oDVXy8D6rom3H1-LLH2-f",
|
||||
id: "rect-1",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
angle: 0,
|
||||
x: 100.50390625,
|
||||
y: 93.67578125,
|
||||
strokeColor: "#c92a2a",
|
||||
backgroundColor: "transparent",
|
||||
width: 186.47265625,
|
||||
height: 141.9765625,
|
||||
seed: 1968410350,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
boundElements: null,
|
||||
locked: false,
|
||||
link: null,
|
||||
updated: 1,
|
||||
roundness: {
|
||||
type: ROUNDNESS.ADAPTIVE_RADIUS,
|
||||
value: 32,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
type: "arrow",
|
||||
x: 300,
|
||||
y: 150,
|
||||
start: { id: "rect-1" },
|
||||
end: { type: "ellipse" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
x: 300,
|
||||
y: 100,
|
||||
text: "HELLO WORLD!",
|
||||
},
|
||||
]),
|
||||
null,
|
||||
),
|
||||
appState: {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -253,3 +253,4 @@ export { LiveCollaborationTrigger };
|
||||
export { DefaultSidebar } from "../../components/DefaultSidebar";
|
||||
|
||||
export { normalizeLink } from "../../data/url";
|
||||
export { convertToExcalidrawElements } from "../../data/transform";
|
||||
|
@ -2,8 +2,8 @@ const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
|
||||
const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist";
|
||||
|
||||
module.exports = {
|
||||
mode: "development",
|
||||
devtool: false,
|
||||
@ -17,7 +17,6 @@ module.exports = {
|
||||
filename: "[name].js",
|
||||
chunkFilename: "excalidraw-assets-dev/[name]-[contenthash].js",
|
||||
assetModuleFilename: "excalidraw-assets-dev/[name][ext]",
|
||||
|
||||
publicPath: "",
|
||||
},
|
||||
resolve: {
|
||||
@ -45,7 +44,7 @@ module.exports = {
|
||||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude:
|
||||
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
/node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
use: [
|
||||
{
|
||||
loader: "import-meta-loader",
|
||||
|
@ -1,10 +1,10 @@
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const BundleAnalyzerPlugin =
|
||||
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const webpack = require("webpack");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
@ -47,8 +47,7 @@ module.exports = {
|
||||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude:
|
||||
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
|
||||
/node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
use: [
|
||||
{
|
||||
loader: "import-meta-loader",
|
||||
|
@ -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;
|
||||
},
|
||||
),
|
||||
|
@ -140,9 +140,8 @@ describe("restoreElements", () => {
|
||||
expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("when arrow element has defined endArrowHead", () => {
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
||||
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
|
||||
@ -150,7 +149,7 @@ describe("restoreElements", () => {
|
||||
expect(arrowElement.endArrowhead).toBe(restoredArrow.endArrowhead);
|
||||
});
|
||||
|
||||
it("when arrow element has undefined endArrowHead", () => {
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is undefined', () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
Object.defineProperty(arrowElement, "endArrowhead", {
|
||||
get: vi.fn(() => undefined),
|
||||
|
1
src/tests/fixtures/elementFixture.ts
vendored
1
src/tests/fixtures/elementFixture.ts
vendored
@ -34,6 +34,7 @@ export const rectangleFixture: ExcalidrawElement = {
|
||||
export const embeddableFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
};
|
||||
export const ellipseFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
|
@ -15,7 +15,11 @@ import fs from "fs";
|
||||
import util from "util";
|
||||
import path from "path";
|
||||
import { getMimeType } from "../../data/blob";
|
||||
import { newFreeDrawElement, newImageElement } from "../../element/newElement";
|
||||
import {
|
||||
newEmbeddableElement,
|
||||
newFreeDrawElement,
|
||||
newImageElement,
|
||||
} from "../../element/newElement";
|
||||
import { Point } from "../../types";
|
||||
import { getSelectedElements } from "../../scene/selection";
|
||||
import { isLinearElementType } from "../../element/typeChecks";
|
||||
@ -178,14 +182,20 @@ export class API {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "embeddable":
|
||||
element = newElement({
|
||||
type: type as "rectangle" | "diamond" | "ellipse" | "embeddable",
|
||||
type: type as "rectangle" | "diamond" | "ellipse",
|
||||
width,
|
||||
height,
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "embeddable":
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
...base,
|
||||
validated: null,
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
|
||||
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
|
||||
|
@ -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?: (
|
||||
|
16
src/utils.ts
16
src/utils.ts
@ -915,13 +915,15 @@ 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 });
|
||||
export const assertNever = (
|
||||
value: never,
|
||||
message: string,
|
||||
softAssert?: boolean,
|
||||
): never => {
|
||||
if (softAssert) {
|
||||
console.error(message);
|
||||
return value;
|
||||
}
|
||||
|
||||
return map;
|
||||
throw new Error(message);
|
||||
};
|
||||
|
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
@ -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;
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user