├── .nvmrc
├── .prettierignore
├── .watchmanconfig
├── .npmrc
├── .gitattributes
├── .github
├── FUNDING.yml
├── assets
│ ├── logo.png
│ ├── vercel.svg
│ └── sentry.svg
├── workflows
│ ├── build-docker.yml
│ ├── semantic-pr-title.yml
│ ├── test.yml
│ ├── cancel.yml
│ ├── lint.yml
│ ├── publish-docker.yml
│ ├── autorelease-excalidraw.yml
│ ├── build-packages.yml
│ ├── sentry-production.yml
│ └── locales-coverage.yml
└── dependabot.yml
├── src
├── pwacompat.d.ts
├── packages
│ ├── excalidraw
│ │ ├── .gitignore
│ │ ├── entry.js
│ │ ├── main.js
│ │ ├── publicPath.js
│ │ ├── webpack.dev.config.js
│ │ └── package.json
│ ├── utils
│ │ ├── index.js
│ │ ├── CHANGELOG.md
│ │ ├── webpack.prod.config.js
│ │ └── package.json
│ ├── tsconfig.prod.json
│ └── tsconfig.dev.json
├── react-app-env.d.ts
├── renderer
│ ├── index.ts
│ └── roundRect.ts
├── tests
│ ├── fixtures
│ │ ├── smiley.png
│ │ ├── test_embedded_v1.png
│ │ ├── smiley_embedded_v2.png
│ │ ├── diagramFixture.ts
│ │ ├── fixture_library.excalidrawlib
│ │ ├── elementFixture.ts
│ │ ├── smiley_embedded_v2.svg
│ │ └── test_embedded_v1.svg
│ ├── charts.test.tsx
│ ├── __snapshots__
│ │ ├── charts.test.tsx.snap
│ │ └── multiPointCreate.test.tsx.snap
│ ├── utils.test.ts
│ ├── queries
│ │ └── toolQueries.ts
│ ├── clients.test.ts
│ ├── scroll.test.tsx
│ ├── appState.test.tsx
│ ├── library.test.tsx
│ ├── viewMode.test.tsx
│ ├── packages
│ │ └── __snapshots__
│ │ │ └── utils.test.ts.snap
│ ├── collab.test.tsx
│ └── geometricAlgebra.test.ts
├── components
│ ├── Popover.scss
│ ├── LoadingMessage.tsx
│ ├── Avatar.scss
│ ├── Island.scss
│ ├── Stack.scss
│ ├── ActiveFile.scss
│ ├── UserList.tsx
│ ├── HelpIcon.tsx
│ ├── Avatar.tsx
│ ├── Card.tsx
│ ├── ProjectName.scss
│ ├── Island.tsx
│ ├── FixedSideContainer.tsx
│ ├── UserList.scss
│ ├── TextInput.scss
│ ├── BackgroundPickerAndDarkModeToggle.tsx
│ ├── Toast.scss
│ ├── CollabButton.scss
│ ├── HintViewer.scss
│ ├── ButtonIconCycle.tsx
│ ├── ButtonSelect.tsx
│ ├── InitializeApp.tsx
│ ├── CheckboxItem.tsx
│ ├── ActiveFile.tsx
│ ├── FixedSideContainer.scss
│ ├── Section.tsx
│ ├── Toast.tsx
│ ├── ButtonIconSelect.tsx
│ ├── Tooltip.scss
│ ├── Stats.scss
│ ├── CollabButton.tsx
│ ├── PasteChartDialog.scss
│ ├── ErrorDialog.tsx
│ ├── Card.scss
│ ├── Stack.tsx
│ ├── HelpDialog.scss
│ ├── LibraryUnit.scss
│ ├── ProjectName.tsx
│ ├── Popover.tsx
│ ├── LibraryButton.tsx
│ ├── CheckboxItem.scss
│ ├── Dialog.scss
│ ├── LockButton.tsx
│ ├── DarkModeToggle.tsx
│ ├── Modal.scss
│ ├── ContextMenu.scss
│ ├── Tooltip.tsx
│ ├── HintViewer.tsx
│ └── Modal.tsx
├── bug-issue-template.js
├── actions
│ ├── register.ts
│ ├── actionToggleStats.tsx
│ ├── actionToggleViewMode.tsx
│ ├── actionToggleZenMode.tsx
│ ├── actionAddToLibrary.ts
│ ├── actionToggleGridMode.tsx
│ ├── actionSelectAll.ts
│ ├── actionNavigate.tsx
│ ├── shortcuts.ts
│ ├── index.ts
│ └── actionStyles.ts
├── css.d.ts
├── css
│ ├── variables.module.scss
│ └── app.scss
├── hooks
│ └── useCallbackRefState.ts
├── index.tsx
├── errors.ts
├── setupTests.ts
├── excalidraw-app
│ ├── app_constants.ts
│ ├── index.scss
│ ├── components
│ │ ├── LanguageList.tsx
│ │ └── GitHubCorner.tsx
│ ├── pwa.ts
│ ├── sentry.ts
│ ├── collab
│ │ └── RoomDialog.scss
│ └── CustomStats.tsx
├── random.ts
├── element
│ ├── textElement.ts
│ ├── showSelectedShapeActions.ts
│ ├── sizeHelpers.test.ts
│ └── typeChecks.ts
├── math.test.ts
├── gesture.ts
├── colors.ts
├── scene
│ ├── index.ts
│ ├── zoom.ts
│ ├── types.ts
│ ├── selection.ts
│ └── scroll.ts
├── gadirections.ts
├── locales
│ ├── README.md
│ └── percentages.json
├── analytics.ts
├── data
│ ├── types.ts
│ └── resave.ts
├── gapoints.ts
├── clients.ts
├── createInverseContext.tsx
├── gatransforms.ts
├── points.ts
├── keys.ts
├── galines.ts
├── index-node.ts
└── service-worker.js
├── .env.production
├── public
├── _headers
├── robots.txt
├── Cascadia.ttf
├── Virgil.woff2
├── favicon.ico
├── og-image.png
├── Cascadia.woff2
├── FG_Virgil.ttf
├── FG_Virgil.woff2
├── og-image-sm.png
├── logo-180x180.png
├── apple-touch-icon.png
├── screenshots
│ ├── export.png
│ ├── shapes.png
│ ├── wireframe.png
│ ├── collaboration.png
│ ├── illustration.png
│ └── virtual-whiteboard.png
├── fonts.css
├── workbox
│ ├── workbox-cacheable-response.prod.js
│ ├── workbox-navigation-preload.prod.js
│ ├── workbox-sw.js
│ ├── workbox-streams.prod.js
│ ├── workbox-range-requests.prod.js
│ ├── workbox-broadcast-update.prod.js
│ └── workbox-offline-ga.prod.js
└── manifest.json
├── firebase-project
├── firestore.indexes.json
├── .firebaserc
├── firebase.json
├── storage.rules
├── firestore.rules
└── .gitignore
├── crowdin.yml
├── .eslintignore
├── .dockerignore
├── .eslintrc.json
├── CHANGELOG.md
├── .editorconfig
├── Dockerfile
├── .gitignore
├── .lintstagedrc.js
├── vercel.json
├── tsconfig-types.json
├── docker-compose.yml
├── tsconfig.json
├── .env
├── scripts
├── build-locales-coverage.js
├── updateReadme.js
├── release.js
├── build-node.js
├── build-version.js
└── autorelease.js
└── LICENSE
/.nvmrc:
--------------------------------------------------------------------------------
1 | 14
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.watchmanconfig:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | save-exact=true
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | open_collective: excalidraw
2 |
--------------------------------------------------------------------------------
/src/pwacompat.d.ts:
--------------------------------------------------------------------------------
1 | declare module "pwacompat";
2 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
2 |
--------------------------------------------------------------------------------
/public/_headers:
--------------------------------------------------------------------------------
1 | /*
2 | Access-Control-Allow-Origin: *
3 |
--------------------------------------------------------------------------------
/src/packages/excalidraw/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | user-agent: *
2 | Allow: /$
3 | Disallow: /
4 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/renderer/index.ts:
--------------------------------------------------------------------------------
1 | export { renderScene } from "./renderScene";
2 |
--------------------------------------------------------------------------------
/public/Cascadia.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/Cascadia.ttf
--------------------------------------------------------------------------------
/public/Virgil.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/Virgil.woff2
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/og-image.png
--------------------------------------------------------------------------------
/public/Cascadia.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/Cascadia.woff2
--------------------------------------------------------------------------------
/public/FG_Virgil.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/FG_Virgil.ttf
--------------------------------------------------------------------------------
/public/FG_Virgil.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/FG_Virgil.woff2
--------------------------------------------------------------------------------
/public/og-image-sm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/og-image-sm.png
--------------------------------------------------------------------------------
/.github/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/.github/assets/logo.png
--------------------------------------------------------------------------------
/firebase-project/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [],
3 | "fieldOverrides": []
4 | }
5 |
--------------------------------------------------------------------------------
/public/logo-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/logo-180x180.png
--------------------------------------------------------------------------------
/crowdin.yml:
--------------------------------------------------------------------------------
1 | files:
2 | - source: /src/locales/en.json
3 | translation: /src/locales/%locale%.json
4 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/packages/utils/index.js:
--------------------------------------------------------------------------------
1 | export { exportToBlob, exportToSvg, exportToCanvas } from "../utils.ts";
2 |
--------------------------------------------------------------------------------
/public/screenshots/export.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/screenshots/export.png
--------------------------------------------------------------------------------
/public/screenshots/shapes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/screenshots/shapes.png
--------------------------------------------------------------------------------
/src/tests/fixtures/smiley.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/src/tests/fixtures/smiley.png
--------------------------------------------------------------------------------
/firebase-project/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "excalidraw-room-persistence"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/public/screenshots/wireframe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/screenshots/wireframe.png
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | build/
3 | package-lock.json
4 | .vscode/
5 | firebase/
6 | dist/
7 | public/workbox
8 |
--------------------------------------------------------------------------------
/public/screenshots/collaboration.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/screenshots/collaboration.png
--------------------------------------------------------------------------------
/public/screenshots/illustration.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/screenshots/illustration.png
--------------------------------------------------------------------------------
/src/components/Popover.scss:
--------------------------------------------------------------------------------
1 | .excalidraw {
2 | .popover {
3 | position: absolute;
4 | z-index: 10;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/tests/fixtures/test_embedded_v1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/src/tests/fixtures/test_embedded_v1.png
--------------------------------------------------------------------------------
/public/screenshots/virtual-whiteboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/public/screenshots/virtual-whiteboard.png
--------------------------------------------------------------------------------
/src/tests/fixtures/smiley_embedded_v2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dai-shi/excalidraw/HEAD/src/tests/fixtures/smiley_embedded_v2.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | *
2 | !.env
3 | !.eslintrc.json
4 | !.npmrc
5 | !.prettierrc
6 | !package.json
7 | !public/
8 | !src/
9 | !tsconfig.json
10 | !yarn.lock
11 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@excalidraw/eslint-config", "react-app"],
3 | "rules": {
4 | "import/no-anonymous-default-export": "off"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/packages/excalidraw/entry.js:
--------------------------------------------------------------------------------
1 | import Excalidraw from "./index";
2 |
3 | import "../../../public/fonts.css";
4 |
5 | export default Excalidraw;
6 | export * from "./index";
7 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 2020-10-13
2 |
3 | - Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219
4 |
--------------------------------------------------------------------------------
/src/bug-issue-template.js:
--------------------------------------------------------------------------------
1 | export default (sentryErrorId) => `
2 | ### Scene content
3 |
4 | \`\`\`
5 | Paste scene content here
6 | \`\`\`
7 |
8 | ### Sentry Error ID
9 |
10 | ${sentryErrorId}
11 | `;
12 |
--------------------------------------------------------------------------------
/firebase-project/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "firestore": {
3 | "rules": "firestore.rules",
4 | "indexes": "firestore.indexes.json"
5 | },
6 | "storage": {
7 | "rules": "storage.rules"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/packages/excalidraw/main.js:
--------------------------------------------------------------------------------
1 | if (process.env.NODE_ENV === "production") {
2 | module.exports = require("./dist/excalidraw.production.min.js");
3 | } else {
4 | module.exports = require("./dist/excalidraw.development.js");
5 | }
6 |
--------------------------------------------------------------------------------
/src/packages/tsconfig.prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "es2015",
5 | "moduleResolution": "node",
6 | "resolveJsonModule": true,
7 | "jsx": "react"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/actions/register.ts:
--------------------------------------------------------------------------------
1 | import { Action } from "./types";
2 |
3 | export let actions: readonly Action[] = [];
4 |
5 | export const register = (action: Action): Action => {
6 | actions = actions.concat(action);
7 | return action;
8 | };
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://EditorConfig.org
2 |
3 | # top-level EditorConfig file
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | end_of_line = lf
9 | indent_size = 2
10 | indent_style = space
11 | insert_final_newline = true
12 | trim_trailing_whitespace = true
13 |
--------------------------------------------------------------------------------
/src/css.d.ts:
--------------------------------------------------------------------------------
1 | import "csstype";
2 |
3 | declare module "csstype" {
4 | interface Properties {
5 | "--max-width"?: number | string;
6 | "--swatch-color"?: string;
7 | "--gap"?: number | string;
8 | "--padding"?: number | string;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/packages/tsconfig.dev.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "esNext",
5 | "moduleResolution": "node",
6 | "resolveJsonModule": true,
7 | "jsx": "react-jsx",
8 | "sourceMap": true,
9 | "allowJs": true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.github/workflows/build-docker.yml:
--------------------------------------------------------------------------------
1 | name: Build Docker image
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build-docker:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | - run: docker build -t excalidraw .
14 |
--------------------------------------------------------------------------------
/src/css/variables.module.scss:
--------------------------------------------------------------------------------
1 | @import "open-color/open-color.scss";
2 |
3 | @mixin isMobile() {
4 | @at-root .excalidraw--mobile#{&} {
5 | @content;
6 | }
7 | }
8 |
9 | $theme-filter: "invert(93%) hue-rotate(180deg)";
10 |
11 | :export {
12 | themeFilter: unquote($theme-filter);
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/LoadingMessage.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { t } from "../i18n";
3 |
4 | export const LoadingMessage = () => {
5 | // !! KEEP THIS IN SYNC WITH index.html !!
6 | return (
7 |
8 | {t("labels.loadingScene")}
9 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/src/hooks/useCallbackRefState.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from "react";
2 |
3 | export const useCallbackRefState = () => {
4 | const [refValue, setRefValue] = useState(null);
5 | const refCallback = useCallback((value: T | null) => setRefValue(value), []);
6 | return [refValue, refCallback] as const;
7 | };
8 |
--------------------------------------------------------------------------------
/firebase-project/storage.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service firebase.storage {
3 | match /b/{bucket}/o {
4 | match /{migrations} {
5 | match /{scenes}/{scene} {
6 | allow get, write: if true;
7 | // redundant, but let's be explicit'
8 | allow list: if false;
9 | }
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/firebase-project/firestore.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service cloud.firestore {
3 | match /databases/{database}/documents {
4 | match /{document=**} {
5 | allow get, write: if true;
6 | // never set this to true, otherwise anyone can delete anyone else's drawing.
7 | allow list: if false;
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import ExcalidrawApp from "./excalidraw-app";
4 |
5 | import "./excalidraw-app/pwa";
6 | import "./excalidraw-app/sentry";
7 | window.__EXCALIDRAW_SHA__ = process.env.REACT_APP_GIT_SHA;
8 |
9 | ReactDOM.render(, document.getElementById("root"));
10 |
--------------------------------------------------------------------------------
/src/errors.ts:
--------------------------------------------------------------------------------
1 | type CANVAS_ERROR_NAMES = "CANVAS_ERROR" | "CANVAS_POSSIBLY_TOO_BIG";
2 | export class CanvasError extends Error {
3 | constructor(
4 | message: string = "Couldn't export canvas.",
5 | name: CANVAS_ERROR_NAMES = "CANVAS_ERROR",
6 | ) {
7 | super();
8 | this.name = name;
9 | this.message = message;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/public/fonts.css:
--------------------------------------------------------------------------------
1 | /* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
2 | @font-face {
3 | font-family: "Virgil";
4 | src: url("Virgil.woff2");
5 | font-display: swap;
6 | }
7 |
8 | /* https://github.com/microsoft/cascadia-code */
9 | @font-face {
10 | font-family: "Cascadia";
11 | src: url("Cascadia.woff2");
12 | font-display: swap;
13 | }
14 |
--------------------------------------------------------------------------------
/src/packages/excalidraw/publicPath.js:
--------------------------------------------------------------------------------
1 | import { ENV } from "../../constants";
2 | import pkg from "./package.json";
3 | if (process.env.NODE_ENV !== ENV.TEST) {
4 | /* eslint-disable */
5 | /* global __webpack_public_path__:writable */
6 | __webpack_public_path__ =
7 | window.EXCALIDRAW_ASSET_PATH ||
8 | `https://unpkg.com/${pkg.name}@${pkg.version}/dist/`;
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/Avatar.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .Avatar {
5 | width: 2.5rem;
6 | height: 2.5rem;
7 | border-radius: 1.25rem;
8 | display: flex;
9 | justify-content: center;
10 | align-items: center;
11 | color: $oc-white;
12 | cursor: pointer;
13 | font-size: 0.8rem;
14 | font-weight: 500;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/semantic-pr-title.yml:
--------------------------------------------------------------------------------
1 | name: Semantic PR title
2 |
3 | on:
4 | pull_request_target:
5 | types:
6 | - opened
7 | - edited
8 | - synchronize
9 |
10 | jobs:
11 | semantic:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: amannn/action-semantic-pull-request@v3.0.0
15 | env:
16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
17 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14-alpine AS build
2 |
3 | WORKDIR /opt/node_app
4 |
5 | COPY package.json yarn.lock ./
6 | RUN yarn --ignore-optional
7 |
8 | ARG NODE_ENV=production
9 |
10 | COPY . .
11 | RUN yarn build:app:docker
12 |
13 | FROM nginx:1.21-alpine
14 |
15 | COPY --from=build /opt/node_app/build /usr/share/nginx/html
16 |
17 | HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
18 |
--------------------------------------------------------------------------------
/src/tests/charts.test.tsx:
--------------------------------------------------------------------------------
1 | import { tryParseSpreadsheet } from "../charts";
2 |
3 | describe("tryParseSpreadsheet", () => {
4 | it("works for numbers with comma in them", () => {
5 | const result = tryParseSpreadsheet(
6 | `Week Index${"\t"}Users
7 | Week 1${"\t"}814
8 | Week 2${"\t"}10,301
9 | Week 3${"\t"}4,264`,
10 | );
11 | expect(result).toMatchSnapshot();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .env.development.local
3 | .env.local
4 | .env.production.local
5 | .env.test.local
6 | .envrc
7 | .eslintcache
8 | .history
9 | .idea
10 | .vercel
11 | .vscode
12 | .yarn
13 | *.log
14 | *.tgz
15 | build
16 | dist
17 | firebase
18 | logs
19 | node_modules
20 | npm-debug.log*
21 | package-lock.json
22 | static
23 | yarn-debug.log*
24 | yarn-error.log*
25 | src/packages/excalidraw/types
26 |
--------------------------------------------------------------------------------
/src/components/Island.scss:
--------------------------------------------------------------------------------
1 | .excalidraw {
2 | .Island {
3 | --padding: 0;
4 | background-color: var(--island-bg-color);
5 | box-shadow: var(--shadow-island);
6 | border-radius: 4px;
7 | padding: calc(var(--padding) * var(--space-factor));
8 | position: relative;
9 | transition: box-shadow 0.5s ease-in-out;
10 |
11 | &.zen-mode {
12 | box-shadow: none;
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom";
2 | import "jest-canvas-mock";
3 |
4 | jest.mock("nanoid", () => {
5 | return {
6 | nanoid: jest.fn(() => "test-id"),
7 | };
8 | });
9 | // ReactDOM is located inside index.tsx file
10 | // as a result, we need a place for it to render into
11 | const element = document.createElement("div");
12 | element.id = "root";
13 | document.body.appendChild(element);
14 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on: pull_request
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - name: Setup Node.js 14.x
11 | uses: actions/setup-node@v2
12 | with:
13 | node-version: 14.x
14 | - name: Install and test
15 | run: |
16 | yarn --frozen-lockfile
17 | yarn test:app
18 |
--------------------------------------------------------------------------------
/src/excalidraw-app/app_constants.ts:
--------------------------------------------------------------------------------
1 | // time constants (ms)
2 | export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
3 | export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
4 | export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
5 |
6 | export const BROADCAST = {
7 | SERVER_VOLATILE: "server-volatile-broadcast",
8 | SERVER: "server-broadcast",
9 | };
10 |
11 | export enum SCENE {
12 | INIT = "SCENE_INIT",
13 | UPDATE = "SCENE_UPDATE",
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/Stack.scss:
--------------------------------------------------------------------------------
1 | .excalidraw {
2 | .Stack {
3 | --gap: 0;
4 | display: grid;
5 | gap: calc(var(--space-factor) * var(--gap));
6 | }
7 |
8 | .Stack_vertical {
9 | grid-template-columns: auto;
10 | grid-auto-flow: row;
11 | grid-auto-rows: min-content;
12 | }
13 |
14 | .Stack_horizontal {
15 | grid-template-rows: auto;
16 | grid-auto-flow: column;
17 | grid-auto-columns: min-content;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.github/workflows/cancel.yml:
--------------------------------------------------------------------------------
1 | name: Cancel previous runs
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | jobs:
10 | cancel:
11 | runs-on: ubuntu-latest
12 | timeout-minutes: 3
13 | steps:
14 | - uses: styfle/cancel-workflow-action@0.6.0
15 | with:
16 | workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
17 | access_token: ${{ secrets.GITHUB_TOKEN }}
18 |
--------------------------------------------------------------------------------
/src/random.ts:
--------------------------------------------------------------------------------
1 | import { Random } from "roughjs/bin/math";
2 | import { nanoid } from "nanoid";
3 |
4 | let random = new Random(Date.now());
5 | let testIdBase = 0;
6 |
7 | export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
8 |
9 | export const reseed = (seed: number) => {
10 | random = new Random(seed);
11 | testIdBase = 0;
12 | };
13 |
14 | export const randomId = () =>
15 | process.env.NODE_ENV === "test" ? `id${testIdBase++}` : nanoid();
16 |
--------------------------------------------------------------------------------
/src/components/ActiveFile.scss:
--------------------------------------------------------------------------------
1 | .excalidraw {
2 | .ActiveFile {
3 | .ActiveFile__fileName {
4 | display: flex;
5 | align-items: center;
6 |
7 | span {
8 | text-overflow: ellipsis;
9 | overflow: hidden;
10 | white-space: nowrap;
11 | width: 9.3em;
12 | }
13 |
14 | svg {
15 | width: 1.15em;
16 | margin-inline-end: 0.3em;
17 | transform: scaleY(0.9);
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.lintstagedrc.js:
--------------------------------------------------------------------------------
1 | const { CLIEngine } = require("eslint");
2 |
3 | // see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
4 | // for explanation
5 | const cli = new CLIEngine({});
6 |
7 | module.exports = {
8 | "*.{js,ts,tsx}": files => {
9 | return (
10 | "eslint --max-warnings=0 --fix " + files.filter(file => !cli.isPathIgnored(file)).join(" ")
11 | );
12 | },
13 | "*.{css,scss,json,md,html,yml}": ["prettier --write"],
14 | };
15 |
--------------------------------------------------------------------------------
/src/components/UserList.tsx:
--------------------------------------------------------------------------------
1 | import "./UserList.scss";
2 |
3 | import React from "react";
4 | import clsx from "clsx";
5 |
6 | type UserListProps = {
7 | children: React.ReactNode;
8 | className?: string;
9 | mobile?: boolean;
10 | };
11 |
12 | export const UserList = ({ children, className, mobile }: UserListProps) => {
13 | return (
14 |
15 | {children}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/tests/__snapshots__/charts.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`tryParseSpreadsheet works for numbers with comma in them 1`] = `
4 | Object {
5 | "spreadsheet": Object {
6 | "labels": Array [
7 | "Week 1",
8 | "Week 2",
9 | "Week 3",
10 | ],
11 | "title": "Users",
12 | "values": Array [
13 | 814,
14 | 10301,
15 | 4264,
16 | ],
17 | },
18 | "type": "VALID_SPREADSHEET",
19 | }
20 | `;
21 |
--------------------------------------------------------------------------------
/src/element/textElement.ts:
--------------------------------------------------------------------------------
1 | import { measureText, getFontString } from "../utils";
2 | import { ExcalidrawTextElement } from "./types";
3 | import { mutateElement } from "./mutateElement";
4 |
5 | export const redrawTextBoundingBox = (element: ExcalidrawTextElement) => {
6 | const metrics = measureText(element.text, getFontString(element));
7 | mutateElement(element, {
8 | width: metrics.width,
9 | height: metrics.height,
10 | baseline: metrics.baseline,
11 | });
12 | };
13 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 |
3 | on: pull_request
4 |
5 | jobs:
6 | lint:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v2
11 |
12 | - name: Setup Node.js 14.x
13 | uses: actions/setup-node@v2
14 | with:
15 | node-version: 14.x
16 |
17 | - name: Install and lint
18 | run: |
19 | yarn --frozen-lockfile
20 | yarn test:other
21 | yarn test:code
22 | yarn test:typecheck
23 |
--------------------------------------------------------------------------------
/src/components/HelpIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { questionCircle } from "../components/icons";
3 |
4 | type HelpIconProps = {
5 | title?: string;
6 | name?: string;
7 | id?: string;
8 | onClick?(): void;
9 | };
10 |
11 | export const HelpIcon = (props: HelpIconProps) => (
12 |
21 | );
22 |
--------------------------------------------------------------------------------
/src/tests/utils.test.ts:
--------------------------------------------------------------------------------
1 | import * as utils from "../utils";
2 |
3 | describe("Test isTransparent", () => {
4 | it("should return true when color is rgb transparent", () => {
5 | expect(utils.isTransparent("#ff00")).toEqual(true);
6 | expect(utils.isTransparent("#fff00000")).toEqual(true);
7 | expect(utils.isTransparent("transparent")).toEqual(true);
8 | });
9 |
10 | it("should return false when color is not transparent", () => {
11 | expect(utils.isTransparent("#ced4da")).toEqual(false);
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/.github/workflows/publish-docker.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | publish-docker:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v2
14 | - uses: docker/build-push-action@v1
15 | with:
16 | username: ${{ secrets.DOCKER_USERNAME }}
17 | password: ${{ secrets.DOCKER_PASSWORD }}
18 | repository: excalidraw/excalidraw
19 | tag_with_ref: true
20 | tag_with_sha: true
21 |
--------------------------------------------------------------------------------
/src/components/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import "./Avatar.scss";
2 |
3 | import React from "react";
4 |
5 | type AvatarProps = {
6 | children: string;
7 | onClick: (e: React.MouseEvent) => void;
8 | color: string;
9 | border: string;
10 | };
11 |
12 | export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
13 |
18 | {children}
19 |
20 | );
21 |
--------------------------------------------------------------------------------
/src/components/Card.tsx:
--------------------------------------------------------------------------------
1 | import OpenColor from "open-color";
2 |
3 | import "./Card.scss";
4 |
5 | export const Card: React.FC<{
6 | color: keyof OpenColor;
7 | }> = ({ children, color }) => {
8 | return (
9 |
17 | {children}
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/element/showSelectedShapeActions.ts:
--------------------------------------------------------------------------------
1 | import { AppState } from "../types";
2 | import { NonDeletedExcalidrawElement } from "./types";
3 | import { getSelectedElements } from "../scene";
4 |
5 | export const showSelectedShapeActions = (
6 | appState: AppState,
7 | elements: readonly NonDeletedExcalidrawElement[],
8 | ) =>
9 | Boolean(
10 | !appState.viewModeEnabled &&
11 | (appState.editingElement ||
12 | getSelectedElements(elements, appState).length ||
13 | appState.elementType !== "selection"),
14 | );
15 |
--------------------------------------------------------------------------------
/src/math.test.ts:
--------------------------------------------------------------------------------
1 | import { rotate } from "./math";
2 |
3 | describe("rotate", () => {
4 | it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
5 | const x1 = 10;
6 | const y1 = 20;
7 | const x2 = 20;
8 | const y2 = 30;
9 | const angle = Math.PI / 2;
10 | const [rotatedX, rotatedY] = rotate(x1, y1, x2, y2, angle);
11 | expect([rotatedX, rotatedY]).toEqual([30, 20]);
12 | const res2 = rotate(rotatedX, rotatedY, x2, y2, -angle);
13 | expect(res2).toEqual([x1, x2]);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/ProjectName.scss:
--------------------------------------------------------------------------------
1 | .ProjectName {
2 | margin: auto;
3 | display: flex;
4 | align-items: center;
5 |
6 | .TextInput {
7 | height: calc(1rem - 3px);
8 | width: 200px;
9 | overflow: hidden;
10 | text-align: center;
11 | margin-left: 8px;
12 | text-overflow: ellipsis;
13 |
14 | &--readonly {
15 | background: none;
16 | border: none;
17 | &:hover {
18 | background: none;
19 | }
20 | width: auto;
21 | max-width: 200px;
22 | padding-left: 2px;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "public": true,
3 | "headers": [
4 | {
5 | "source": "/(.*)",
6 | "headers": [
7 | {
8 | "key": "Access-Control-Allow-Origin",
9 | "value": "*"
10 | },
11 | {
12 | "key": "X-Content-Type-Options",
13 | "value": "nosniff"
14 | },
15 | {
16 | "key": "Feature-Policy",
17 | "value": "*"
18 | },
19 | {
20 | "key": "Referrer-Policy",
21 | "value": "origin"
22 | }
23 | ]
24 | }
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/src/excalidraw-app/index.scss:
--------------------------------------------------------------------------------
1 | .excalidraw {
2 | .layer-ui__wrapper__footer-center {
3 | display: flex;
4 | justify-content: space-between;
5 | margin-top: auto;
6 | margin-bottom: auto;
7 | margin-inline-start: auto;
8 | }
9 |
10 | .encrypted-icon {
11 | border-radius: var(--space-factor);
12 | color: var(--icon-green-fill-color);
13 | margin-top: auto;
14 | margin-bottom: auto;
15 | margin-inline-start: auto;
16 | margin-inline-end: 0.6em;
17 |
18 | svg {
19 | width: 1.2rem;
20 | height: 1.2rem;
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig-types.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src/packages/excalidraw", "src/global.d.ts", "src/css.d.ts"],
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "declaration": true,
6 | "emitDeclarationOnly": true,
7 | "outDir": "src/packages/excalidraw/types",
8 | "jsx": "react-jsx",
9 | "target": "es6",
10 | "lib": ["dom", "dom.iterable", "esnext"],
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "skipLibCheck": true,
15 | "allowSyntheticDefaultImports": true,
16 | "strict": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/packages/utils/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [Unreleased]
4 |
5 | First release of `@excalidraw/utils` to provide utilities functions.
6 |
7 | - Added `exportToBlob` and `exportToSvg` to export an Excalidraw diagram definition, respectively, to a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and to a [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) ([#2246](https://github.com/excalidraw/excalidraw/pull/2246))
8 |
9 | ### Features
10 |
11 | - Flip single elements horizontally or vertically [#2520](https://github.com/excalidraw/excalidraw/pull/2520)
12 |
--------------------------------------------------------------------------------
/src/components/Island.tsx:
--------------------------------------------------------------------------------
1 | import "./Island.scss";
2 |
3 | import React from "react";
4 | import clsx from "clsx";
5 |
6 | type IslandProps = {
7 | children: React.ReactNode;
8 | padding?: number;
9 | className?: string | boolean;
10 | style?: object;
11 | };
12 |
13 | export const Island = React.forwardRef(
14 | ({ children, padding, className, style }, ref) => (
15 |
20 | {children}
21 |
22 | ),
23 | );
24 |
--------------------------------------------------------------------------------
/src/actions/actionToggleStats.tsx:
--------------------------------------------------------------------------------
1 | import { register } from "./register";
2 | import { CODES, KEYS } from "../keys";
3 |
4 | export const actionToggleStats = register({
5 | name: "stats",
6 | perform(elements, appState) {
7 | return {
8 | appState: {
9 | ...appState,
10 | showStats: !this.checked!(appState),
11 | },
12 | commitToHistory: false,
13 | };
14 | },
15 | checked: (appState) => appState.showStats,
16 | contextItemLabel: "stats.title",
17 | keyTest: (event) =>
18 | !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
19 | });
20 |
--------------------------------------------------------------------------------
/src/gesture.ts:
--------------------------------------------------------------------------------
1 | import { PointerCoords } from "./types";
2 |
3 | export const getCenter = (pointers: Map) => {
4 | const allCoords = Array.from(pointers.values());
5 | return {
6 | x: sum(allCoords, (coords) => coords.x) / allCoords.length,
7 | y: sum(allCoords, (coords) => coords.y) / allCoords.length,
8 | };
9 | };
10 |
11 | export const getDistance = ([a, b]: readonly PointerCoords[]) =>
12 | Math.hypot(a.x - b.x, a.y - b.y);
13 |
14 | const sum = (array: readonly T[], mapper: (item: T) => number): number =>
15 | array.reduce((acc, item) => acc + mapper(item), 0);
16 |
--------------------------------------------------------------------------------
/public/workbox/workbox-cacheable-response.prod.js:
--------------------------------------------------------------------------------
1 | this.workbox=this.workbox||{},this.workbox.cacheableResponse=function(t){"use strict";try{self["workbox:cacheable-response:4.3.1"]&&_()}catch(t){}class s{constructor(t={}){this.t=t.statuses,this.s=t.headers}isResponseCacheable(t){let s=!0;return this.t&&(s=this.t.includes(t.status)),this.s&&s&&(s=Object.keys(this.s).some(s=>t.headers.get(s)===this.s[s])),s}}return t.CacheableResponse=s,t.Plugin=class{constructor(t){this.i=new s(t)}cacheWillUpdate({response:t}){return this.i.isResponseCacheable(t)?t:null}},t}({});
2 | //# sourceMappingURL=workbox-cacheable-response.prod.js.map
3 |
--------------------------------------------------------------------------------
/src/components/FixedSideContainer.tsx:
--------------------------------------------------------------------------------
1 | import "./FixedSideContainer.scss";
2 |
3 | import React from "react";
4 | import clsx from "clsx";
5 |
6 | type FixedSideContainerProps = {
7 | children: React.ReactNode;
8 | side: "top" | "left" | "right";
9 | className?: string;
10 | };
11 |
12 | export const FixedSideContainer = ({
13 | children,
14 | side,
15 | className,
16 | }: FixedSideContainerProps) => (
17 |
24 | {children}
25 |
26 | );
27 |
--------------------------------------------------------------------------------
/src/colors.ts:
--------------------------------------------------------------------------------
1 | import oc from "open-color";
2 |
3 | const shades = (index: number) => [
4 | oc.red[index],
5 | oc.pink[index],
6 | oc.grape[index],
7 | oc.violet[index],
8 | oc.indigo[index],
9 | oc.blue[index],
10 | oc.cyan[index],
11 | oc.teal[index],
12 | oc.green[index],
13 | oc.lime[index],
14 | oc.yellow[index],
15 | oc.orange[index],
16 | ];
17 |
18 | export default {
19 | canvasBackground: [oc.white, oc.gray[0], oc.gray[1], ...shades(0)],
20 | elementBackground: ["transparent", oc.gray[4], oc.gray[6], ...shades(6)],
21 | elementStroke: [oc.black, oc.gray[8], oc.gray[7], ...shades(9)],
22 | };
23 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | excalidraw:
5 | build:
6 | context: .
7 | args:
8 | - NODE_ENV=development
9 | container_name: excalidraw
10 | ports:
11 | - "3000:80"
12 | restart: on-failure
13 | stdin_open: true
14 | healthcheck:
15 | disable: true
16 | environment:
17 | - NODE_ENV=development
18 | volumes:
19 | - ./:/opt/node_app/app:delegated
20 | - ./package.json:/opt/node_app/package.json
21 | - ./yarn.lock:/opt/node_app/yarn.lock
22 | - notused:/opt/node_app/app/node_modules
23 |
24 | volumes:
25 | notused:
26 |
--------------------------------------------------------------------------------
/src/components/UserList.scss:
--------------------------------------------------------------------------------
1 | .excalidraw {
2 | .UserList {
3 | pointer-events: none;
4 | /*github corner*/
5 | padding: var(--space-factor) var(--space-factor) var(--space-factor)
6 | var(--space-factor);
7 | display: flex;
8 | flex-wrap: wrap;
9 | justify-content: flex-end;
10 | }
11 |
12 | .UserList > * {
13 | pointer-events: all;
14 | margin: 0 0 var(--space-factor) var(--space-factor);
15 | }
16 |
17 | .UserList_mobile {
18 | padding: 0;
19 | justify-content: normal;
20 | }
21 |
22 | .UserList_mobile > * {
23 | margin: 0 var(--space-factor) var(--space-factor) 0;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/scene/index.ts:
--------------------------------------------------------------------------------
1 | export { isOverScrollBars } from "./scrollbars";
2 | export {
3 | isSomeElementSelected,
4 | getElementsWithinSelection,
5 | getCommonAttributeOfSelectedElements,
6 | getSelectedElements,
7 | getTargetElements,
8 | } from "./selection";
9 | export { calculateScrollCenter } from "./scroll";
10 | export {
11 | hasBackground,
12 | hasStrokeWidth,
13 | hasStrokeStyle,
14 | canHaveArrowheads,
15 | canChangeSharpness,
16 | getElementAtPosition,
17 | getElementContainingPosition,
18 | hasText,
19 | getElementsAtPosition,
20 | } from "./comparisons";
21 | export { getNormalizedZoom, getNewZoom } from "./zoom";
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "noFallthroughCasesInSwitch": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "exclude": ["src/packages/excalidraw/types"]
21 | }
22 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
2 | REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
3 | REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
4 | REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
5 | REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
6 |
--------------------------------------------------------------------------------
/src/components/TextInput.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .TextInput {
5 | color: var(--text-primary-color);
6 | display: inline-block;
7 | border: 1.5px solid var(--button-gray-1);
8 | line-height: 1;
9 | padding: 0.75rem;
10 | white-space: nowrap;
11 | border-radius: var(--space-factor);
12 | background-color: var(--input-bg-color);
13 |
14 | &:not(:focus) {
15 | &:hover {
16 | background-color: var(--input-hover-bg-color);
17 | }
18 | }
19 |
20 | &:focus {
21 | outline: none;
22 | box-shadow: 0 0 0 2px var(--focus-highlight-color);
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/BackgroundPickerAndDarkModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { ActionManager } from "../actions/manager";
3 | import { AppState } from "../types";
4 |
5 | export const BackgroundPickerAndDarkModeToggle = ({
6 | appState,
7 | setAppState,
8 | actionManager,
9 | showThemeBtn,
10 | }: {
11 | actionManager: ActionManager;
12 | appState: AppState;
13 | setAppState: React.Component["setState"];
14 | showThemeBtn: boolean;
15 | }) => (
16 |
17 | {actionManager.renderAction("changeViewBackgroundColor")}
18 | {showThemeBtn && actionManager.renderAction("toggleTheme")}
19 |
20 | );
21 |
--------------------------------------------------------------------------------
/public/workbox/workbox-navigation-preload.prod.js:
--------------------------------------------------------------------------------
1 | this.workbox=this.workbox||{},this.workbox.navigationPreload=function(t){"use strict";try{self["workbox:navigation-preload:4.3.1"]&&_()}catch(t){}function e(){return Boolean(self.registration&&self.registration.navigationPreload)}return t.disable=function(){e()&&self.addEventListener("activate",t=>{t.waitUntil(self.registration.navigationPreload.disable().then(()=>{}))})},t.enable=function(t){e()&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{t&&self.registration.navigationPreload.setHeaderValue(t)}))})},t.isSupported=e,t}({});
2 | //# sourceMappingURL=workbox-navigation-preload.prod.js.map
3 |
--------------------------------------------------------------------------------
/src/css/app.scss:
--------------------------------------------------------------------------------
1 | .visually-hidden {
2 | position: absolute !important;
3 | height: 1px;
4 | width: 1px;
5 | overflow: hidden;
6 | clip: rect(1px, 1px, 1px, 1px);
7 | white-space: nowrap; /* added line */
8 | user-select: none;
9 | }
10 |
11 | .LoadingMessage {
12 | position: absolute;
13 | top: 0;
14 | right: 0;
15 | bottom: 0;
16 | left: 0;
17 | z-index: 999;
18 | display: flex;
19 | align-items: center;
20 | justify-content: center;
21 | pointer-events: none;
22 | }
23 |
24 | .LoadingMessage span {
25 | background-color: var(--button-gray-1);
26 | border-radius: 5px;
27 | padding: 0.8em 1.2em;
28 | color: var(--popup-text-color);
29 | font-size: 1.3em;
30 | }
31 |
--------------------------------------------------------------------------------
/src/actions/actionToggleViewMode.tsx:
--------------------------------------------------------------------------------
1 | import { CODES, KEYS } from "../keys";
2 | import { register } from "./register";
3 | import { trackEvent } from "../analytics";
4 |
5 | export const actionToggleViewMode = register({
6 | name: "viewMode",
7 | perform(elements, appState) {
8 | trackEvent("view", "mode", "view");
9 | return {
10 | appState: {
11 | ...appState,
12 | viewModeEnabled: !this.checked!(appState),
13 | },
14 | commitToHistory: false,
15 | };
16 | },
17 | checked: (appState) => appState.viewModeEnabled,
18 | contextItemLabel: "labels.viewMode",
19 | keyTest: (event) =>
20 | !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
21 | });
22 |
--------------------------------------------------------------------------------
/src/actions/actionToggleZenMode.tsx:
--------------------------------------------------------------------------------
1 | import { CODES, KEYS } from "../keys";
2 | import { register } from "./register";
3 | import { trackEvent } from "../analytics";
4 |
5 | export const actionToggleZenMode = register({
6 | name: "zenMode",
7 | perform(elements, appState) {
8 | trackEvent("view", "mode", "zen");
9 |
10 | return {
11 | appState: {
12 | ...appState,
13 | zenModeEnabled: !this.checked!(appState),
14 | },
15 | commitToHistory: false,
16 | };
17 | },
18 | checked: (appState) => appState.zenModeEnabled,
19 | contextItemLabel: "buttons.zenMode",
20 | keyTest: (event) =>
21 | !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
22 | });
23 |
--------------------------------------------------------------------------------
/src/components/Toast.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .Toast {
5 | animation: fade-in 0.5s;
6 | background-color: var(--button-gray-1);
7 | border-radius: 4px;
8 | bottom: 10px;
9 | box-sizing: border-box;
10 | cursor: default;
11 | left: 50%;
12 | margin-left: -150px;
13 | padding: 4px 0;
14 | position: absolute;
15 | text-align: center;
16 | width: 300px;
17 | z-index: 999999;
18 | }
19 |
20 | .Toast__message {
21 | color: var(--popup-text-color);
22 | white-space: pre-wrap;
23 | }
24 |
25 | @keyframes fade-in {
26 | from {
27 | opacity: 0;
28 | }
29 | to {
30 | opacity: 1;
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.github/workflows/autorelease-excalidraw.yml:
--------------------------------------------------------------------------------
1 | name: Auto release @excalidraw/excalidraw-next
2 | on:
3 | push:
4 | branches:
5 | - master
6 |
7 | jobs:
8 | Auto-release-excalidraw-next:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v2
13 | with:
14 | fetch-depth: 2
15 | - name: Setup Node.js 14.x
16 | uses: actions/setup-node@v2
17 | with:
18 | node-version: 14.x
19 | - name: Set up publish access
20 | run: |
21 | npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
22 | env:
23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
24 | - name: Auto release
25 | run: |
26 | yarn autorelease
27 |
--------------------------------------------------------------------------------
/src/gadirections.ts:
--------------------------------------------------------------------------------
1 | import * as GA from "./ga";
2 | import { Line, Direction, Point } from "./ga";
3 |
4 | /**
5 | * A direction is stored as an array `[0, 0, 0, 0, y, x, 0, 0]` representing
6 | * vector `(x, y)`.
7 | */
8 |
9 | export const from = (point: Point): Point => [
10 | 0,
11 | 0,
12 | 0,
13 | 0,
14 | point[4],
15 | point[5],
16 | 0,
17 | 0,
18 | ];
19 |
20 | export const fromTo = (from: Point, to: Point): Direction =>
21 | GA.inormalized([0, 0, 0, 0, to[4] - from[4], to[5] - from[5], 0, 0]);
22 |
23 | export const orthogonal = (direction: Direction): Direction =>
24 | GA.inormalized([0, 0, 0, 0, -direction[5], direction[4], 0, 0]);
25 |
26 | export const orthogonalToLine = (line: Line): Direction => GA.mul(line, GA.I);
27 |
--------------------------------------------------------------------------------
/src/components/CollabButton.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .CollabButton.is-collaborating {
5 | background-color: var(--button-special-active-bg-color);
6 |
7 | .ToolIcon__icon svg,
8 | .ToolIcon__label {
9 | color: var(--icon-green-fill-color);
10 | }
11 | }
12 |
13 | .CollabButton-collaborators {
14 | :root[dir="ltr"] & {
15 | right: -5px;
16 | }
17 | :root[dir="rtl"] & {
18 | left: -5px;
19 | }
20 | min-width: 1em;
21 | position: absolute;
22 | bottom: -5px;
23 | padding: 3px;
24 | border-radius: 50%;
25 | background-color: $oc-green-6;
26 | color: $oc-white;
27 | font-size: 0.7em;
28 | font-family: var(--ui-font);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: /
5 | schedule:
6 | interval: weekly
7 | day: sunday
8 | time: "01:00"
9 | reviewers:
10 | - lipis
11 | assignees:
12 | - lipis
13 |
14 | - package-ecosystem: npm
15 | directory: /src/packages/excalidraw/
16 | schedule:
17 | interval: weekly
18 | day: sunday
19 | time: "01:00"
20 | reviewers:
21 | - ad1992
22 | assignees:
23 | - ad1992
24 |
25 | - package-ecosystem: npm
26 | directory: /src/packages/utils/
27 | schedule:
28 | interval: weekly
29 | day: sunday
30 | time: "01:00"
31 | reviewers:
32 | - ad1992
33 | assignees:
34 | - ad1992
35 |
--------------------------------------------------------------------------------
/src/actions/actionAddToLibrary.ts:
--------------------------------------------------------------------------------
1 | import { register } from "./register";
2 | import { getSelectedElements } from "../scene";
3 | import { getNonDeletedElements } from "../element";
4 | import { deepCopyElement } from "../element/newElement";
5 |
6 | export const actionAddToLibrary = register({
7 | name: "addToLibrary",
8 | perform: (elements, appState, _, app) => {
9 | const selectedElements = getSelectedElements(
10 | getNonDeletedElements(elements),
11 | appState,
12 | );
13 |
14 | app.library.loadLibrary().then((items) => {
15 | app.library.saveLibrary([
16 | ...items,
17 | selectedElements.map(deepCopyElement),
18 | ]);
19 | });
20 | return false;
21 | },
22 | contextItemLabel: "labels.addToLibrary",
23 | });
24 |
--------------------------------------------------------------------------------
/src/locales/README.md:
--------------------------------------------------------------------------------
1 | ## How to contribute
2 |
3 | Please do not contribute changes directly to these files, as we manage them with Crowdin. Instead:
4 |
5 | - to request a new translation, [open an issue](https://github.com/excalidraw/excalidraw/issues/new/choose).
6 | - to update existing translations, [edit them on Crowdin](https://crowdin.com/translate/excalidraw/10) and we should have them included in the app soon!
7 |
8 | ## Completion of translation
9 |
10 | [percentages.json](./percentages.json) holds a percentage of completion for each language. We generate these automatically [on build time](./../../.github/workflows/locales-coverage.yml) when a new translation PR appears.
11 |
12 | We only make a language available on the app if it exceeds a certain threshold of completion.
13 |
--------------------------------------------------------------------------------
/src/actions/actionToggleGridMode.tsx:
--------------------------------------------------------------------------------
1 | import { CODES, KEYS } from "../keys";
2 | import { register } from "./register";
3 | import { GRID_SIZE } from "../constants";
4 | import { AppState } from "../types";
5 | import { trackEvent } from "../analytics";
6 |
7 | export const actionToggleGridMode = register({
8 | name: "gridMode",
9 | perform(elements, appState) {
10 | trackEvent("view", "mode", "grid");
11 | return {
12 | appState: {
13 | ...appState,
14 | gridSize: this.checked!(appState) ? null : GRID_SIZE,
15 | },
16 | commitToHistory: false,
17 | };
18 | },
19 | checked: (appState: AppState) => appState.gridSize !== null,
20 | contextItemLabel: "labels.showGrid",
21 | keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
22 | });
23 |
--------------------------------------------------------------------------------
/src/analytics.ts:
--------------------------------------------------------------------------------
1 | export const trackEvent =
2 | typeof process !== "undefined" &&
3 | process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
4 | typeof window !== "undefined" &&
5 | window.gtag
6 | ? (category: string, name: string, label?: string, value?: number) => {
7 | window.gtag("event", name, {
8 | event_category: category,
9 | event_label: label,
10 | value,
11 | });
12 | }
13 | : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
14 | ? (category: string, name: string, label?: string, value?: number) => {}
15 | : (category: string, name: string, label?: string, value?: number) => {
16 | // Uncomment the next line to track locally
17 | // console.info("Track Event", category, name, label, value);
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/HintViewer.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | // this is loosely based on the longest hint text
4 | $wide-viewport-width: 1000px;
5 |
6 | .excalidraw {
7 | .HintViewer {
8 | pointer-events: none;
9 | box-sizing: border-box;
10 | position: absolute;
11 | display: flex;
12 | justify-content: center;
13 | left: 0;
14 | top: 100%;
15 | max-width: 100%;
16 | width: 100%;
17 | margin-top: 6px;
18 | text-align: center;
19 | color: $oc-gray-6;
20 | font-size: 0.8rem;
21 |
22 | @include isMobile {
23 | position: static;
24 | padding-right: 2em;
25 | }
26 |
27 | > span {
28 | padding: 0.2rem 0.4rem;
29 | background-color: var(--overlay-bg-color);
30 | border-radius: 4px;
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/locales/percentages.json:
--------------------------------------------------------------------------------
1 | {
2 | "ar-SA": 100,
3 | "bg-BG": 68,
4 | "ca-ES": 84,
5 | "cs-CZ": 30,
6 | "da-DK": 20,
7 | "de-DE": 100,
8 | "el-GR": 73,
9 | "en": 100,
10 | "es-ES": 83,
11 | "fa-IR": 77,
12 | "fi-FI": 84,
13 | "fr-FR": 100,
14 | "he-IL": 72,
15 | "hi-IN": 67,
16 | "hu-HU": 60,
17 | "id-ID": 100,
18 | "it-IT": 100,
19 | "ja-JP": 100,
20 | "kab-KAB": 93,
21 | "kk-KZ": 26,
22 | "ko-KR": 68,
23 | "lv-LV": 14,
24 | "my-MM": 56,
25 | "nb-NO": 100,
26 | "nl-NL": 98,
27 | "nn-NO": 74,
28 | "oc-FR": 100,
29 | "pa-IN": 100,
30 | "pl-PL": 70,
31 | "pt-BR": 100,
32 | "pt-PT": 100,
33 | "ro-RO": 100,
34 | "ru-RU": 100,
35 | "sk-SK": 100,
36 | "sv-SE": 100,
37 | "tr-TR": 78,
38 | "uk-UA": 80,
39 | "zh-CN": 100,
40 | "zh-TW": 99
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/ButtonIconCycle.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 |
3 | export const ButtonIconCycle = ({
4 | options,
5 | value,
6 | onChange,
7 | group,
8 | }: {
9 | options: { value: T; text: string; icon: JSX.Element }[];
10 | value: T | null;
11 | onChange: (value: T) => void;
12 | group: string;
13 | }) => {
14 | const current = options.find((op) => op.value === value);
15 |
16 | const cycle = () => {
17 | const index = options.indexOf(current!);
18 | const next = (index + 1) % options.length;
19 | onChange(options[next].value);
20 | };
21 |
22 | return (
23 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/.github/workflows/build-packages.yml:
--------------------------------------------------------------------------------
1 | name: Build packages
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | jobs:
10 | packages:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Setup Node.js 14.x
16 | uses: actions/setup-node@v2
17 | with:
18 | node-version: 14.x
19 | - name: Install dependencies
20 | run: |
21 | yarn --frozen-lockfile
22 | yarn --cwd src/packages/excalidraw
23 | yarn --cwd src/packages/utils
24 | - name: Build @excalidraw/excalidraw
25 | run: |
26 | yarn --cwd src/packages/excalidraw run pack
27 | - name: Build @excalidraw/utils
28 | run: |
29 | yarn --cwd src/packages/utils run pack
30 |
--------------------------------------------------------------------------------
/src/components/ButtonSelect.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import clsx from "clsx";
3 |
4 | export const ButtonSelect = ({
5 | options,
6 | value,
7 | onChange,
8 | group,
9 | }: {
10 | options: { value: T; text: string }[];
11 | value: T | null;
12 | onChange: (value: T) => void;
13 | group: string;
14 | }) => (
15 |
16 | {options.map((option) => (
17 |
29 | ))}
30 |
31 | );
32 |
--------------------------------------------------------------------------------
/src/tests/fixtures/diagramFixture.ts:
--------------------------------------------------------------------------------
1 | import {
2 | diamondFixture,
3 | ellipseFixture,
4 | rectangleFixture,
5 | } from "./elementFixture";
6 |
7 | export const diagramFixture = {
8 | type: "excalidraw",
9 | version: 2,
10 | source: "https://excalidraw.com",
11 | elements: [diamondFixture, ellipseFixture, rectangleFixture],
12 | appState: {
13 | viewBackgroundColor: "#ffffff",
14 | gridSize: null,
15 | },
16 | };
17 |
18 | export const diagramFactory = ({
19 | overrides = {},
20 | elementOverrides = {},
21 | } = {}) => ({
22 | ...diagramFixture,
23 | elements: [
24 | { ...diamondFixture, ...elementOverrides },
25 | { ...ellipseFixture, ...elementOverrides },
26 | { ...rectangleFixture, ...elementOverrides },
27 | ],
28 | ...overrides,
29 | });
30 |
31 | export default diagramFixture;
32 |
--------------------------------------------------------------------------------
/src/tests/fixtures/fixture_library.excalidrawlib:
--------------------------------------------------------------------------------
1 | {
2 | "type": "excalidrawlib",
3 | "version": 1,
4 | "library": [
5 | [
6 | {
7 | "type": "rectangle",
8 | "version": 38,
9 | "versionNonce": 1046419680,
10 | "isDeleted": false,
11 | "id": "A",
12 | "fillStyle": "hachure",
13 | "strokeWidth": 1,
14 | "strokeStyle": "solid",
15 | "roughness": 1,
16 | "opacity": 100,
17 | "angle": 0,
18 | "x": 21801,
19 | "y": 719.5,
20 | "strokeColor": "#c92a2a",
21 | "backgroundColor": "#e64980",
22 | "width": 50,
23 | "height": 30,
24 | "seed": 117297479,
25 | "groupIds": [],
26 | "strokeSharpness": "sharp",
27 | "boundElementIds": []
28 | }
29 | ]
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/InitializeApp.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { LoadingMessage } from "./LoadingMessage";
4 | import { defaultLang, Language, languages, setLanguage } from "../i18n";
5 |
6 | interface Props {
7 | langCode: Language["code"];
8 | }
9 | interface State {
10 | isLoading: boolean;
11 | }
12 | export class InitializeApp extends React.Component {
13 | public state: { isLoading: boolean } = {
14 | isLoading: true,
15 | };
16 |
17 | async componentDidMount() {
18 | const currentLang =
19 | languages.find((lang) => lang.code === this.props.langCode) ||
20 | defaultLang;
21 | await setLanguage(currentLang);
22 | this.setState({
23 | isLoading: false,
24 | });
25 | }
26 |
27 | public render() {
28 | return this.state.isLoading ? : this.props.children;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/CheckboxItem.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import clsx from "clsx";
3 | import { checkIcon } from "./icons";
4 |
5 | import "./CheckboxItem.scss";
6 |
7 | export const CheckboxItem: React.FC<{
8 | checked: boolean;
9 | onChange: (checked: boolean) => void;
10 | }> = ({ children, checked, onChange }) => {
11 | return (
12 | {
15 | onChange(!checked);
16 | ((event.currentTarget as HTMLDivElement).querySelector(
17 | ".Checkbox-box",
18 | ) as HTMLButtonElement).focus();
19 | }}
20 | >
21 |
24 |
{children}
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/ActiveFile.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Stack from "../components/Stack";
3 | import { ToolButton } from "../components/ToolButton";
4 | import { save, file } from "../components/icons";
5 | import { t } from "../i18n";
6 |
7 | import "./ActiveFile.scss";
8 |
9 | type ActiveFileProps = {
10 | fileName?: string;
11 | onSave: () => void;
12 | };
13 |
14 | export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
15 |
16 |
17 | {file}
18 | {fileName}
19 |
20 |
28 |
29 | );
30 |
--------------------------------------------------------------------------------
/src/components/FixedSideContainer.scss:
--------------------------------------------------------------------------------
1 | .excalidraw {
2 | .FixedSideContainer {
3 | position: absolute;
4 | pointer-events: none;
5 | }
6 |
7 | .FixedSideContainer > * {
8 | pointer-events: all;
9 | }
10 |
11 | .FixedSideContainer_side_top {
12 | left: var(--space-factor);
13 | top: var(--space-factor);
14 | right: var(--space-factor);
15 | z-index: 2;
16 | }
17 |
18 | .FixedSideContainer_side_top.zen-mode {
19 | right: 42px;
20 | }
21 | }
22 |
23 | /* TODO: if these are used, make sure to implement RTL support
24 | .FixedSideContainer_side_left {
25 | left: var(--space-factor);
26 | top: var(--space-factor);
27 | bottom: var(--space-factor);
28 | z-index: 1;
29 | }
30 |
31 | .FixedSideContainer_side_right {
32 | right: var(--space-factor);
33 | top: var(--space-factor);
34 | bottom: var(--space-factor);
35 | z-index: 3;
36 | }
37 | */
38 |
--------------------------------------------------------------------------------
/src/components/Section.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { t } from "../i18n";
3 | import { useExcalidrawContainer } from "./App";
4 |
5 | interface SectionProps extends React.HTMLProps {
6 | heading: string;
7 | children: React.ReactNode | ((header: React.ReactNode) => React.ReactNode);
8 | }
9 |
10 | export const Section = ({ heading, children, ...props }: SectionProps) => {
11 | const { id } = useExcalidrawContainer();
12 | const header = (
13 |
14 | {t(`headings.${heading}`)}
15 |
16 | );
17 | return (
18 |
19 | {typeof children === "function" ? (
20 | children(header)
21 | ) : (
22 | <>
23 | {header}
24 | {children}
25 | >
26 | )}
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/components/Toast.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useRef } from "react";
2 | import { TOAST_TIMEOUT } from "../constants";
3 | import "./Toast.scss";
4 |
5 | export const Toast = ({
6 | message,
7 | clearToast,
8 | }: {
9 | message: string;
10 | clearToast: () => void;
11 | }) => {
12 | const timerRef = useRef(0);
13 |
14 | const scheduleTimeout = useCallback(
15 | () =>
16 | (timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
17 | [clearToast],
18 | );
19 |
20 | useEffect(() => {
21 | scheduleTimeout();
22 | return () => clearTimeout(timerRef.current);
23 | }, [scheduleTimeout, message]);
24 |
25 | return (
26 | clearTimeout(timerRef?.current)}
29 | onMouseLeave={scheduleTimeout}
30 | >
31 |
{message}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/src/data/types.ts:
--------------------------------------------------------------------------------
1 | import { ExcalidrawElement } from "../element/types";
2 | import { AppState, LibraryItems } from "../types";
3 | import type { cleanAppStateForExport } from "../appState";
4 |
5 | export interface ExportedDataState {
6 | type: string;
7 | version: number;
8 | source: string;
9 | elements: readonly ExcalidrawElement[];
10 | appState: ReturnType;
11 | }
12 |
13 | export interface ImportedDataState {
14 | type?: string;
15 | version?: number;
16 | source?: string;
17 | elements?: readonly ExcalidrawElement[] | null;
18 | appState?: Readonly> | null;
19 | scrollToContent?: boolean;
20 | libraryItems?: LibraryItems;
21 | }
22 |
23 | export interface ExportedLibraryData {
24 | type: string;
25 | version: number;
26 | source: string;
27 | library: LibraryItems;
28 | }
29 |
30 | export interface ImportedLibraryData extends Partial {}
31 |
--------------------------------------------------------------------------------
/src/components/ButtonIconSelect.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import clsx from "clsx";
3 |
4 | // TODO: It might be "clever" to add option.icon to the existing component
5 | export const ButtonIconSelect = ({
6 | options,
7 | value,
8 | onChange,
9 | group,
10 | }: {
11 | options: { value: T; text: string; icon: JSX.Element }[];
12 | value: T | null;
13 | onChange: (value: T) => void;
14 | group: string;
15 | }) => (
16 |
17 | {options.map((option) => (
18 |
31 | ))}
32 |
33 | );
34 |
--------------------------------------------------------------------------------
/src/components/Tooltip.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | // container in body where the actual tooltip is appended to
4 | .excalidraw-tooltip {
5 | position: absolute;
6 | z-index: 1000;
7 |
8 | padding: 8px;
9 | border-radius: 6px;
10 | box-sizing: border-box;
11 | pointer-events: none;
12 | word-wrap: break-word;
13 |
14 | background: $oc-black;
15 |
16 | line-height: 1.5;
17 | text-align: center;
18 | font-size: 13px;
19 | font-weight: 500;
20 | color: $oc-white;
21 |
22 | display: none;
23 |
24 | &.excalidraw-tooltip--visible {
25 | display: block;
26 | }
27 | }
28 |
29 | // wraps the element we want to apply the tooltip to
30 | .excalidraw-tooltip-wrapper {
31 | display: flex;
32 | height: 100%;
33 | }
34 |
35 | .excalidraw-tooltip-icon {
36 | width: 0.9em;
37 | height: 0.9em;
38 | margin-left: 5px;
39 | margin-top: 1px;
40 | display: flex;
41 |
42 | @include isMobile {
43 | display: none;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/tests/fixtures/elementFixture.ts:
--------------------------------------------------------------------------------
1 | import { ExcalidrawElement } from "../../element/types";
2 |
3 | const elementBase: Omit = {
4 | id: "vWrqOAfkind2qcm7LDAGZ",
5 | x: 414,
6 | y: 237,
7 | width: 214,
8 | height: 214,
9 | angle: 0,
10 | strokeColor: "#000000",
11 | backgroundColor: "#15aabf",
12 | fillStyle: "hachure",
13 | strokeWidth: 1,
14 | strokeStyle: "solid",
15 | roughness: 1,
16 | opacity: 100,
17 | groupIds: [],
18 | strokeSharpness: "sharp",
19 | seed: 1041657908,
20 | version: 120,
21 | versionNonce: 1188004276,
22 | isDeleted: false,
23 | boundElementIds: null,
24 | };
25 |
26 | export const rectangleFixture: ExcalidrawElement = {
27 | ...elementBase,
28 | type: "rectangle",
29 | };
30 | export const ellipseFixture: ExcalidrawElement = {
31 | ...elementBase,
32 | type: "ellipse",
33 | };
34 | export const diamondFixture: ExcalidrawElement = {
35 | ...elementBase,
36 | type: "diamond",
37 | };
38 |
--------------------------------------------------------------------------------
/src/excalidraw-app/components/LanguageList.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as i18n from "../../i18n";
3 |
4 | export const LanguageList = ({
5 | onChange,
6 | languages = i18n.languages,
7 | currentLangCode = i18n.getLanguage().code,
8 | }: {
9 | languages?: { code: string; label: string }[];
10 | onChange: (langCode: i18n.Language["code"]) => void;
11 | currentLangCode?: i18n.Language["code"];
12 | }) => (
13 |
14 |
29 |
30 | );
31 |
--------------------------------------------------------------------------------
/src/actions/actionSelectAll.ts:
--------------------------------------------------------------------------------
1 | import { KEYS } from "../keys";
2 | import { register } from "./register";
3 | import { selectGroupsForSelectedElements } from "../groups";
4 | import { getNonDeletedElements } from "../element";
5 |
6 | export const actionSelectAll = register({
7 | name: "selectAll",
8 | perform: (elements, appState) => {
9 | if (appState.editingLinearElement) {
10 | return false;
11 | }
12 | return {
13 | appState: selectGroupsForSelectedElements(
14 | {
15 | ...appState,
16 | editingGroupId: null,
17 | selectedElementIds: elements.reduce((map, element) => {
18 | if (!element.isDeleted) {
19 | map[element.id] = true;
20 | }
21 | return map;
22 | }, {} as any),
23 | },
24 | getNonDeletedElements(elements),
25 | ),
26 | commitToHistory: true,
27 | };
28 | },
29 | contextItemLabel: "labels.selectAll",
30 | keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
31 | });
32 |
--------------------------------------------------------------------------------
/src/components/Stats.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .Stats {
5 | position: absolute;
6 | top: 64px;
7 | right: 12px;
8 | font-size: 12px;
9 | z-index: 10;
10 |
11 | h3 {
12 | margin: 0 24px 8px 0;
13 | white-space: nowrap;
14 | }
15 |
16 | .close {
17 | float: right;
18 | height: 16px;
19 | width: 16px;
20 | cursor: pointer;
21 | svg {
22 | width: 100%;
23 | height: 100%;
24 | }
25 | }
26 |
27 | table {
28 | width: 100%;
29 | th {
30 | border-bottom: 1px solid var(--input-border-color);
31 | padding: 4px;
32 | }
33 | tr {
34 | td:nth-child(2) {
35 | min-width: 24px;
36 | text-align: right;
37 | }
38 | }
39 | }
40 |
41 | :root[dir="rtl"] & {
42 | left: 12px;
43 | right: initial;
44 |
45 | h3 {
46 | margin: 0 0 8px 24px;
47 | }
48 | .close {
49 | float: left;
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/tests/queries/toolQueries.ts:
--------------------------------------------------------------------------------
1 | import { queries, buildQueries } from "@testing-library/react";
2 |
3 | const toolMap = {
4 | selection: "selection",
5 | rectangle: "rectangle",
6 | diamond: "diamond",
7 | ellipse: "ellipse",
8 | arrow: "arrow",
9 | line: "line",
10 | freedraw: "freedraw",
11 | text: "text",
12 | };
13 |
14 | export type ToolName = keyof typeof toolMap;
15 |
16 | const _getAllByToolName = (container: HTMLElement, tool: string) => {
17 | const toolTitle = toolMap[tool as ToolName];
18 | return queries.getAllByTestId(container, toolTitle);
19 | };
20 |
21 | const getMultipleError = (_container: any, tool: any) =>
22 | `Found multiple elements with tool name: ${tool}`;
23 | const getMissingError = (_container: any, tool: any) =>
24 | `Unable to find an element with tool name: ${tool}`;
25 |
26 | export const [
27 | queryByToolName,
28 | getAllByToolName,
29 | getByToolName,
30 | findAllByToolName,
31 | findByToolName,
32 | ] = buildQueries(
33 | _getAllByToolName,
34 | getMultipleError,
35 | getMissingError,
36 | );
37 |
--------------------------------------------------------------------------------
/scripts/build-locales-coverage.js:
--------------------------------------------------------------------------------
1 | const { readdirSync, writeFileSync } = require("fs");
2 | const files = readdirSync(`${__dirname}/../src/locales`);
3 |
4 | const flatten = (object) =>
5 | Object.keys(object).reduce(
6 | (initial, current) => ({ ...initial, ...object[current] }),
7 | {},
8 | );
9 |
10 | const locales = files.filter(
11 | (file) => file !== "README.md" && file !== "percentages.json",
12 | );
13 |
14 | const percentages = {};
15 |
16 | for (let index = 0; index < locales.length; index++) {
17 | const currentLocale = locales[index];
18 | const data = flatten(require(`${__dirname}/../src/locales/${currentLocale}`));
19 |
20 | const allKeys = Object.keys(data);
21 | const translatedKeys = allKeys.filter((item) => data[item] !== "");
22 |
23 | const percentage = (100 * translatedKeys.length) / allKeys.length;
24 |
25 | percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
26 | }
27 |
28 | writeFileSync(
29 | `${__dirname}/../src/locales/percentages.json`,
30 | `${JSON.stringify(percentages, null, 2)}\n`,
31 | "utf8",
32 | );
33 |
--------------------------------------------------------------------------------
/src/scene/zoom.ts:
--------------------------------------------------------------------------------
1 | import { NormalizedZoomValue, PointerCoords, Zoom } from "../types";
2 |
3 | export const getNewZoom = (
4 | newZoomValue: NormalizedZoomValue,
5 | prevZoom: Zoom,
6 | canvasOffset: { left: number; top: number },
7 | zoomOnViewportPoint: PointerCoords = { x: 0, y: 0 },
8 | ): Zoom => {
9 | return {
10 | value: newZoomValue,
11 | translation: {
12 | x:
13 | zoomOnViewportPoint.x -
14 | canvasOffset.left -
15 | (zoomOnViewportPoint.x - canvasOffset.left - prevZoom.translation.x) *
16 | (newZoomValue / prevZoom.value),
17 | y:
18 | zoomOnViewportPoint.y -
19 | canvasOffset.top -
20 | (zoomOnViewportPoint.y - canvasOffset.top - prevZoom.translation.y) *
21 | (newZoomValue / prevZoom.value),
22 | },
23 | };
24 | };
25 |
26 | export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {
27 | const normalizedZoom = parseFloat(zoom.toFixed(2));
28 | const clampedZoom = Math.max(0.1, Math.min(normalizedZoom, 10));
29 | return clampedZoom as NormalizedZoomValue;
30 | };
31 |
--------------------------------------------------------------------------------
/src/data/resave.ts:
--------------------------------------------------------------------------------
1 | import { ExcalidrawElement } from "../element/types";
2 | import { AppState } from "../types";
3 | import { exportCanvas } from ".";
4 | import { getNonDeletedElements } from "../element";
5 | import { getFileHandleType, isImageFileHandleType } from "./blob";
6 |
7 | export const resaveAsImageWithScene = async (
8 | elements: readonly ExcalidrawElement[],
9 | appState: AppState,
10 | ) => {
11 | const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
12 |
13 | const fileHandleType = getFileHandleType(fileHandle);
14 |
15 | if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
16 | throw new Error(
17 | "fileHandle should exist and should be of type svg or png when resaving",
18 | );
19 | }
20 | appState = {
21 | ...appState,
22 | exportEmbedScene: true,
23 | };
24 |
25 | await exportCanvas(
26 | fileHandleType,
27 | getNonDeletedElements(elements),
28 | appState,
29 | {
30 | exportBackground,
31 | viewBackgroundColor,
32 | name,
33 | fileHandle,
34 | },
35 | );
36 |
37 | return { fileHandle };
38 | };
39 |
--------------------------------------------------------------------------------
/src/gapoints.ts:
--------------------------------------------------------------------------------
1 | import * as GA from "./ga";
2 | import * as GALine from "./galines";
3 | import { Point, Line, join } from "./ga";
4 |
5 | export const from = ([x, y]: readonly [number, number]): Point => [
6 | 0,
7 | 0,
8 | 0,
9 | 0,
10 | y,
11 | x,
12 | 1,
13 | 0,
14 | ];
15 |
16 | export const toTuple = (point: Point): [number, number] => [point[5], point[4]];
17 |
18 | export const abs = (point: Point): Point => [
19 | 0,
20 | 0,
21 | 0,
22 | 0,
23 | Math.abs(point[4]),
24 | Math.abs(point[5]),
25 | 1,
26 | 0,
27 | ];
28 |
29 | export const intersect = (line1: Line, line2: Line): Point =>
30 | GA.normalized(GA.meet(line1, line2));
31 |
32 | // Projects `point` onto the `line`.
33 | // The returned point is the closest point on the `line` to the `point`.
34 | export const project = (point: Point, line: Line): Point =>
35 | intersect(GALine.orthogonal(line, point), line);
36 |
37 | export const distance = (point1: Point, point2: Point): number =>
38 | GA.norm(join(point1, point2));
39 |
40 | export const distanceToLine = (point: Point, line: Line): number =>
41 | GA.joinScalar(point, line);
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Excalidraw
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/components/CollabButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import clsx from "clsx";
3 | import { ToolButton } from "./ToolButton";
4 | import { t } from "../i18n";
5 | import { useIsMobile } from "../components/App";
6 | import { users } from "./icons";
7 |
8 | import "./CollabButton.scss";
9 |
10 | const CollabButton = ({
11 | isCollaborating,
12 | collaboratorCount,
13 | onClick,
14 | }: {
15 | isCollaborating: boolean;
16 | collaboratorCount: number;
17 | onClick: () => void;
18 | }) => {
19 | return (
20 | <>
21 |
32 | {collaboratorCount > 0 && (
33 | {collaboratorCount}
34 | )}
35 |
36 | >
37 | );
38 | };
39 |
40 | export default CollabButton;
41 |
--------------------------------------------------------------------------------
/scripts/updateReadme.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 |
3 | const updateReadme = () => {
4 | const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
5 | let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
6 |
7 | // remove note for unstable release
8 | data = data.replace(
9 | /[\s\S]*?/,
10 | "",
11 | );
12 |
13 | // replace "excalidraw-next" with "excalidraw"
14 | data = data.replace(/excalidraw-next/g, "excalidraw");
15 | data = data.trim();
16 |
17 | const demoIndex = data.indexOf("### Demo");
18 | const excalidrawNextNote =
19 | "#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
20 | // Add excalidraw next note to try out for unreleased changes
21 | data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
22 |
23 | // update readme
24 | fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
25 | };
26 |
27 | module.exports = updateReadme;
28 |
--------------------------------------------------------------------------------
/src/components/PasteChartDialog.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .PasteChartDialog {
5 | @include isMobile {
6 | .Island {
7 | display: flex;
8 | flex-direction: column;
9 | }
10 | }
11 | .container {
12 | display: flex;
13 | align-items: center;
14 | justify-content: space-around;
15 | flex-wrap: wrap;
16 | @include isMobile {
17 | flex-direction: column;
18 | justify-content: center;
19 | }
20 | }
21 | .ChartPreview {
22 | margin: 8px;
23 | text-align: center;
24 | width: 192px;
25 | height: 128px;
26 | border-radius: 2px;
27 | padding: 1px;
28 | border: 1px solid $oc-gray-4;
29 | display: flex;
30 | align-items: center;
31 | justify-content: center;
32 | background: transparent;
33 | div {
34 | display: inline-block;
35 | }
36 | svg {
37 | max-height: 120px;
38 | max-width: 186px;
39 | }
40 | &:hover {
41 | padding: 0;
42 | border: 2px solid $oc-blue-5;
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/ErrorDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { t } from "../i18n";
3 |
4 | import { Dialog } from "./Dialog";
5 | import { useExcalidrawContainer } from "./App";
6 |
7 | export const ErrorDialog = ({
8 | message,
9 | onClose,
10 | }: {
11 | message: string;
12 | onClose?: () => void;
13 | }) => {
14 | const [modalIsShown, setModalIsShown] = useState(!!message);
15 | const { container: excalidrawContainer } = useExcalidrawContainer();
16 |
17 | const handleClose = React.useCallback(() => {
18 | setModalIsShown(false);
19 |
20 | if (onClose) {
21 | onClose();
22 | }
23 | // TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
24 | excalidrawContainer?.focus();
25 | }, [onClose, excalidrawContainer]);
26 |
27 | return (
28 | <>
29 | {modalIsShown && (
30 |
37 | )}
38 | >
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/src/excalidraw-app/pwa.ts:
--------------------------------------------------------------------------------
1 | import { register as registerServiceWorker } from "../serviceWorker";
2 | import { EVENT } from "../constants";
3 |
4 | // On Apple mobile devices add the proprietary app icon and splashscreen markup.
5 | // No one should have to do this manually, and eventually this annoyance will
6 | // go away once https://bugs.webkit.org/show_bug.cgi?id=183937 is fixed.
7 | if (
8 | /\b(iPad|iPhone|iPod|Safari)\b/.test(navigator.userAgent) &&
9 | !matchMedia("(display-mode: standalone)").matches
10 | ) {
11 | import(/* webpackChunkName: "pwacompat" */ "pwacompat");
12 | }
13 |
14 | registerServiceWorker({
15 | onUpdate: (registration) => {
16 | const waitingServiceWorker = registration.waiting;
17 | if (waitingServiceWorker) {
18 | waitingServiceWorker.addEventListener(
19 | EVENT.STATE_CHANGE,
20 | (event: Event) => {
21 | const target = event.target as ServiceWorker;
22 | const state = target.state as ServiceWorkerState;
23 | if (state === "activated") {
24 | window.location.reload();
25 | }
26 | },
27 | );
28 | waitingServiceWorker.postMessage({ type: "SKIP_WAITING" });
29 | }
30 | },
31 | });
32 |
--------------------------------------------------------------------------------
/src/clients.ts:
--------------------------------------------------------------------------------
1 | import colors from "./colors";
2 | import { AppState } from "./types";
3 |
4 | export const getClientColors = (clientId: string, appState: AppState) => {
5 | if (appState?.collaborators) {
6 | const currentUser = appState.collaborators.get(clientId);
7 | if (currentUser?.color) {
8 | return currentUser.color;
9 | }
10 | }
11 | // Naive way of getting an integer out of the clientId
12 | const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
13 |
14 | // Skip transparent background.
15 | const backgrounds = colors.elementBackground.slice(1);
16 | const strokes = colors.elementStroke.slice(1);
17 | return {
18 | background: backgrounds[sum % backgrounds.length],
19 | stroke: strokes[sum % strokes.length],
20 | };
21 | };
22 |
23 | export const getClientInitials = (username?: string | null) => {
24 | if (!username) {
25 | return "?";
26 | }
27 | const names = username.trim().split(" ");
28 |
29 | if (names.length < 2) {
30 | return names[0].substring(0, 2).toUpperCase();
31 | }
32 |
33 | const firstName = names[0];
34 | const lastName = names[names.length - 1];
35 |
36 | return (firstName[0] + lastName[0]).toUpperCase();
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/Card.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .Card {
5 | display: flex;
6 | flex-direction: column;
7 | align-items: center;
8 |
9 | max-width: 290px;
10 |
11 | margin: 1em;
12 |
13 | text-align: center;
14 |
15 | .Card-icon {
16 | font-size: 2.6em;
17 | display: flex;
18 | flex: 0 0 auto;
19 | padding: 1.4rem;
20 | border-radius: 50%;
21 | background: var(--card-color);
22 | color: $oc-white;
23 |
24 | svg {
25 | width: 2.8rem;
26 | height: 2.8rem;
27 | }
28 | }
29 |
30 | .Card-details {
31 | font-size: 0.96em;
32 | min-height: 90px;
33 | padding: 0 1em;
34 | margin-bottom: auto;
35 | }
36 |
37 | & .Card-button.ToolIcon_type_button {
38 | height: 2.5rem;
39 | margin-top: 1em;
40 | margin-bottom: 0.3em;
41 | background-color: var(--card-color);
42 | &:hover {
43 | background-color: var(--card-color-darker);
44 | }
45 | &:active {
46 | background-color: var(--card-color-darkest);
47 | }
48 | .ToolIcon__label {
49 | color: $oc-white;
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/tests/clients.test.ts:
--------------------------------------------------------------------------------
1 | import { getClientInitials } from "../clients";
2 |
3 | describe("getClientInitials", () => {
4 | it("returns substring if one name provided", () => {
5 | const result = getClientInitials("Alan");
6 | expect(result).toBe("AL");
7 | });
8 |
9 | it("returns initials", () => {
10 | const result = getClientInitials("John Doe");
11 | expect(result).toBe("JD");
12 | });
13 |
14 | it("returns correct initials if many names provided", () => {
15 | const result = getClientInitials("John Alan Doe");
16 | expect(result).toBe("JD");
17 | });
18 |
19 | it("returns single initial if 1 letter provided", () => {
20 | const result = getClientInitials("z");
21 | expect(result).toBe("Z");
22 | });
23 |
24 | it("trims trailing whitespace", () => {
25 | const result = getClientInitials(" q ");
26 | expect(result).toBe("Q");
27 | });
28 |
29 | it('returns "?" if falsey value provided', () => {
30 | let result = getClientInitials("");
31 | expect(result).toBe("?");
32 |
33 | result = getClientInitials(undefined);
34 | expect(result).toBe("?");
35 |
36 | result = getClientInitials(null);
37 | expect(result).toBe("?");
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/src/createInverseContext.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const createInverseContext = (
4 | initialValue: T,
5 | ) => {
6 | const Context = React.createContext(initialValue) as React.Context & {
7 | _updateProviderValue?: (value: T) => void;
8 | };
9 |
10 | class InverseConsumer extends React.Component {
11 | state = { value: initialValue };
12 | constructor(props: any) {
13 | super(props);
14 | Context._updateProviderValue = (value: T) => this.setState({ value });
15 | }
16 | render() {
17 | return (
18 |
19 | {this.props.children}
20 |
21 | );
22 | }
23 | }
24 |
25 | class InverseProvider extends React.Component<{ value: T }> {
26 | componentDidMount() {
27 | Context._updateProviderValue?.(this.props.value);
28 | }
29 | componentDidUpdate() {
30 | Context._updateProviderValue?.(this.props.value);
31 | }
32 | render() {
33 | return {() => this.props.children};
34 | }
35 | }
36 |
37 | return {
38 | Context,
39 | Consumer: InverseConsumer,
40 | Provider: InverseProvider,
41 | };
42 | };
43 |
--------------------------------------------------------------------------------
/src/gatransforms.ts:
--------------------------------------------------------------------------------
1 | import * as GA from "./ga";
2 | import { Line, Direction, Point, Transform } from "./ga";
3 | import * as GADirection from "./gadirections";
4 |
5 | /**
6 | * TODO: docs
7 | */
8 |
9 | export const rotation = (pivot: Point, angle: number): Transform =>
10 | GA.add(GA.mul(pivot, Math.sin(angle / 2)), Math.cos(angle / 2));
11 |
12 | export const translation = (direction: Direction): Transform => [
13 | 1,
14 | 0,
15 | 0,
16 | 0,
17 | -(0.5 * direction[5]),
18 | 0.5 * direction[4],
19 | 0,
20 | 0,
21 | ];
22 |
23 | export const translationOrthogonal = (
24 | direction: Direction,
25 | distance: number,
26 | ): Transform => {
27 | const scale = 0.5 * distance;
28 | return [1, 0, 0, 0, scale * direction[4], scale * direction[5], 0, 0];
29 | };
30 |
31 | export const translationAlong = (line: Line, distance: number): Transform =>
32 | GA.add(GA.mul(GADirection.orthogonalToLine(line), 0.5 * distance), 1);
33 |
34 | export const compose = (motor1: Transform, motor2: Transform): Transform =>
35 | GA.mul(motor2, motor1);
36 |
37 | export const apply = (
38 | motor: Transform,
39 | nvector: Point | Direction | Line,
40 | ): Point | Direction | Line =>
41 | GA.normalized(GA.mul(GA.mul(motor, nvector), GA.reverse(motor)));
42 |
--------------------------------------------------------------------------------
/src/scene/types.ts:
--------------------------------------------------------------------------------
1 | import { ExcalidrawTextElement } from "../element/types";
2 | import { Zoom } from "../types";
3 |
4 | export type SceneState = {
5 | scrollX: number;
6 | scrollY: number;
7 | // null indicates transparent bg
8 | viewBackgroundColor: string | null;
9 | exportWithDarkMode?: boolean;
10 | zoom: Zoom;
11 | shouldCacheIgnoreZoom: boolean;
12 | remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
13 | remotePointerButton?: { [id: string]: string | undefined };
14 | remoteSelectedElementIds: { [elementId: string]: string[] };
15 | remotePointerUsernames: { [id: string]: string };
16 | remotePointerUserStates: { [id: string]: string };
17 | };
18 |
19 | export type SceneScroll = {
20 | scrollX: number;
21 | scrollY: number;
22 | };
23 |
24 | export interface Scene {
25 | elements: ExcalidrawTextElement[];
26 | }
27 |
28 | export type ExportType =
29 | | "png"
30 | | "clipboard"
31 | | "clipboard-svg"
32 | | "backend"
33 | | "svg";
34 |
35 | export type ScrollBars = {
36 | horizontal: {
37 | x: number;
38 | y: number;
39 | width: number;
40 | height: number;
41 | } | null;
42 | vertical: {
43 | x: number;
44 | y: number;
45 | width: number;
46 | height: number;
47 | } | null;
48 | };
49 |
--------------------------------------------------------------------------------
/src/renderer/roundRect.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * https://stackoverflow.com/a/3368118
3 | * Draws a rounded rectangle using the current state of the canvas.
4 | * @param {CanvasRenderingContext2D} context
5 | * @param {Number} x The top left x coordinate
6 | * @param {Number} y The top left y coordinate
7 | * @param {Number} width The width of the rectangle
8 | * @param {Number} height The height of the rectangle
9 | * @param {Number} radius The corner radius
10 | */
11 | export const roundRect = (
12 | context: CanvasRenderingContext2D,
13 | x: number,
14 | y: number,
15 | width: number,
16 | height: number,
17 | radius: number,
18 | ) => {
19 | context.beginPath();
20 | context.moveTo(x + radius, y);
21 | context.lineTo(x + width - radius, y);
22 | context.quadraticCurveTo(x + width, y, x + width, y + radius);
23 | context.lineTo(x + width, y + height - radius);
24 | context.quadraticCurveTo(
25 | x + width,
26 | y + height,
27 | x + width - radius,
28 | y + height,
29 | );
30 | context.lineTo(x + radius, y + height);
31 | context.quadraticCurveTo(x, y + height, x, y + height - radius);
32 | context.lineTo(x, y + radius);
33 | context.quadraticCurveTo(x, y, x + radius, y);
34 | context.closePath();
35 | context.fill();
36 | context.stroke();
37 | };
38 |
--------------------------------------------------------------------------------
/public/workbox/workbox-sw.js:
--------------------------------------------------------------------------------
1 | !function(){"use strict";try{self["workbox:sw:4.3.1"]&&_()}catch(t){}const t="https://storage.googleapis.com/workbox-cdn/releases/4.3.1",e={backgroundSync:"background-sync",broadcastUpdate:"broadcast-update",cacheableResponse:"cacheable-response",core:"core",expiration:"expiration",googleAnalytics:"offline-ga",navigationPreload:"navigation-preload",precaching:"precaching",rangeRequests:"range-requests",routing:"routing",strategies:"strategies",streams:"streams"};self.workbox=new class{constructor(){return this.v={},this.t={debug:"localhost"===self.location.hostname,modulePathPrefix:null,modulePathCb:null},this.s=this.t.debug?"dev":"prod",this.o=!1,new Proxy(this,{get(t,s){if(t[s])return t[s];const o=e[s];return o&&t.loadModule(`workbox-${o}`),t[s]}})}setConfig(t={}){if(this.o)throw new Error("Config must be set before accessing workbox.* modules");Object.assign(this.t,t),this.s=this.t.debug?"dev":"prod"}loadModule(t){const e=this.i(t);try{importScripts(e),this.o=!0}catch(s){throw console.error(`Unable to import module '${t}' from '${e}'.`),s}}i(e){if(this.t.modulePathCb)return this.t.modulePathCb(e,this.t.debug);let s=[t];const o=`${e}.${this.s}.js`,r=this.t.modulePathPrefix;return r&&""===(s=r.split("/"))[s.length-1]&&s.splice(s.length-1,1),s.push(o),s.join("/")}}}();
2 | //# sourceMappingURL=workbox-sw.js.map
3 |
--------------------------------------------------------------------------------
/scripts/release.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const util = require("util");
3 | const exec = util.promisify(require("child_process").exec);
4 | const updateReadme = require("./updateReadme");
5 | const updateChangelog = require("./updateChangelog");
6 |
7 | const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
8 | const excalidrawPackage = `${excalidrawDir}/package.json`;
9 |
10 | const updatePackageVersion = (nextVersion) => {
11 | const pkg = require(excalidrawPackage);
12 | pkg.version = nextVersion;
13 | const content = `${JSON.stringify(pkg, null, 2)}\n`;
14 | fs.writeFileSync(excalidrawPackage, content, "utf-8");
15 | };
16 |
17 | const release = async (nextVersion) => {
18 | try {
19 | updateReadme();
20 | await updateChangelog(nextVersion);
21 | updatePackageVersion(nextVersion);
22 | await exec(`git add -u`);
23 | await exec(
24 | `git commit -m "docs: release excalidraw@excalidraw@${nextVersion} 🎉"`,
25 | );
26 | /* eslint-disable no-console */
27 | console.log("Done!");
28 | } catch (e) {
29 | console.error(e);
30 | process.exit(1);
31 | }
32 | };
33 |
34 | const nextVersion = process.argv.slice(2)[0];
35 | if (!nextVersion) {
36 | console.error("Pass the next version to release!");
37 | process.exit(1);
38 | }
39 | release(nextVersion);
40 |
--------------------------------------------------------------------------------
/src/components/Stack.tsx:
--------------------------------------------------------------------------------
1 | import "./Stack.scss";
2 |
3 | import React from "react";
4 | import clsx from "clsx";
5 |
6 | type StackProps = {
7 | children: React.ReactNode;
8 | gap?: number;
9 | align?: "start" | "center" | "end" | "baseline";
10 | justifyContent?: "center" | "space-around" | "space-between";
11 | className?: string | boolean;
12 | style?: React.CSSProperties;
13 | };
14 |
15 | const RowStack = ({
16 | children,
17 | gap,
18 | align,
19 | justifyContent,
20 | className,
21 | style,
22 | }: StackProps) => {
23 | return (
24 |
33 | {children}
34 |
35 | );
36 | };
37 |
38 | const ColStack = ({
39 | children,
40 | gap,
41 | align,
42 | justifyContent,
43 | className,
44 | }: StackProps) => {
45 | return (
46 |
54 | {children}
55 |
56 | );
57 | };
58 |
59 | export default {
60 | Row: RowStack,
61 | Col: ColStack,
62 | };
63 |
--------------------------------------------------------------------------------
/public/workbox/workbox-streams.prod.js:
--------------------------------------------------------------------------------
1 | this.workbox=this.workbox||{},this.workbox.streams=function(e){"use strict";try{self["workbox:streams:4.3.1"]&&_()}catch(e){}function n(e){const n=e.map(e=>Promise.resolve(e).then(e=>(function(e){return e.body&&e.body.getReader?e.body.getReader():e.getReader?e.getReader():new Response(e).body.getReader()})(e)));let t,r;const s=new Promise((e,n)=>{t=e,r=n});let o=0;return{done:s,stream:new ReadableStream({pull(e){return n[o].then(e=>e.read()).then(r=>{if(r.done)return++o>=n.length?(e.close(),void t()):this.pull(e);e.enqueue(r.value)}).catch(e=>{throw r(e),e})},cancel(){t()}})}}function t(e={}){const n=new Headers(e);return n.has("content-type")||n.set("content-type","text/html"),n}function r(e,r){const{done:s,stream:o}=n(e),a=t(r);return{done:s,response:new Response(o,{headers:a})}}let s=void 0;function o(){if(void 0===s)try{new ReadableStream({start(){}}),s=!0}catch(e){s=!1}return s}return e.concatenate=n,e.concatenateToResponse=r,e.isSupported=o,e.strategy=function(e,n){return async({event:s,url:a,params:c})=>{if(o()){const{done:t,response:o}=r(e.map(e=>e({event:s,url:a,params:c})),n);return s.waitUntil(t),o}const i=await Promise.all(e.map(e=>e({event:s,url:a,params:c})).map(async e=>{const n=await e;return n instanceof Response?n.blob():n})),u=t(n);return new Response(new Blob(i),{headers:u})}},e}({});
2 | //# sourceMappingURL=workbox-streams.prod.js.map
3 |
--------------------------------------------------------------------------------
/src/excalidraw-app/sentry.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from "@sentry/browser";
2 | import * as SentryIntegrations from "@sentry/integrations";
3 |
4 | const SentryEnvHostnameMap: { [key: string]: string } = {
5 | "excalidraw.com": "production",
6 | "vercel.app": "staging",
7 | };
8 |
9 | const REACT_APP_DISABLE_SENTRY =
10 | process.env.REACT_APP_DISABLE_SENTRY === "true";
11 |
12 | // Disable Sentry locally or inside the Docker to avoid noise/respect privacy
13 | const onlineEnv =
14 | !REACT_APP_DISABLE_SENTRY &&
15 | Object.keys(SentryEnvHostnameMap).find(
16 | (item) => window.location.hostname.indexOf(item) >= 0,
17 | );
18 |
19 | Sentry.init({
20 | dsn: onlineEnv
21 | ? "https://7bfc596a5bf945eda6b660d3015a5460@sentry.io/5179260"
22 | : undefined,
23 | environment: onlineEnv ? SentryEnvHostnameMap[onlineEnv] : undefined,
24 | release: process.env.REACT_APP_GIT_SHA,
25 | ignoreErrors: [
26 | "undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
27 | ],
28 | integrations: [
29 | new SentryIntegrations.CaptureConsole({
30 | levels: ["error"],
31 | }),
32 | ],
33 | beforeSend(event) {
34 | if (event.request?.url) {
35 | event.request.url = event.request.url.replace(/#.*$/, "");
36 | }
37 | return event;
38 | },
39 | });
40 |
--------------------------------------------------------------------------------
/.github/workflows/sentry-production.yml:
--------------------------------------------------------------------------------
1 | name: New Sentry production release
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | sentry:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | - name: Setup Node.js 14.x
14 | uses: actions/setup-node@v2
15 | with:
16 | node-version: 14.x
17 | - name: Install and build
18 | run: |
19 | yarn --frozen-lockfile
20 | yarn build:app
21 | env:
22 | CI: true
23 | - name: Install Sentry
24 | run: |
25 | curl -sL https://sentry.io/get-cli/ | bash
26 | - name: Create new Sentry release
27 | run: |
28 | export SENTRY_RELEASE=$(sentry-cli releases propose-version)
29 | sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
30 | sentry-cli releases set-commits --auto $SENTRY_RELEASE
31 | sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
32 | sentry-cli releases finalize $SENTRY_RELEASE
33 | sentry-cli releases deploys $SENTRY_RELEASE new -e production
34 | env:
35 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
36 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
37 | SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
38 |
--------------------------------------------------------------------------------
/src/packages/utils/webpack.prod.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require("webpack");
2 | const path = require("path");
3 | const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
4 | .BundleAnalyzerPlugin;
5 |
6 | module.exports = {
7 | mode: "production",
8 | entry: { "excalidraw-utils.min": "./index.js" },
9 | output: {
10 | path: path.resolve(__dirname, "dist"),
11 | filename: "[name].js",
12 | library: "ExcalidrawUtils",
13 | libraryTarget: "umd",
14 | },
15 | resolve: {
16 | extensions: [".tsx", ".ts", ".js", ".css", ".scss"],
17 | },
18 | optimization: {
19 | runtimeChunk: false,
20 | },
21 | module: {
22 | rules: [
23 | {
24 | test: /\.(sa|sc|c)ss$/,
25 | exclude: /node_modules/,
26 | use: ["style-loader", { loader: "css-loader" }, "sass-loader"],
27 | },
28 | {
29 | test: /\.(ts|tsx|js)$/,
30 | use: [
31 | {
32 | loader: "ts-loader",
33 | options: {
34 | transpileOnly: true,
35 | configFile: path.resolve(__dirname, "../tsconfig.prod.json"),
36 | },
37 | },
38 | ],
39 | },
40 | ],
41 | },
42 | plugins: [
43 | new webpack.optimize.LimitChunkCountPlugin({
44 | maxChunks: 1,
45 | }),
46 | ...(process.env.ANALYZER === "true" ? [new BundleAnalyzerPlugin()] : []),
47 | ],
48 | };
49 |
--------------------------------------------------------------------------------
/src/components/HelpDialog.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .HelpDialog h3 {
5 | border-bottom: 1px solid var(--button-gray-2);
6 | padding-bottom: 4px;
7 | }
8 |
9 | .HelpDialog--island {
10 | border: 1px solid var(--button-gray-2);
11 | margin-bottom: 16px;
12 | }
13 |
14 | .HelpDialog--island-title {
15 | margin: 0;
16 | padding: 4px;
17 | background-color: var(--button-gray-1);
18 | text-align: center;
19 | }
20 |
21 | .HelpDialog--shortcut {
22 | border-top: 1px solid var(--button-gray-2);
23 | }
24 |
25 | .HelpDialog--key {
26 | word-break: keep-all;
27 | border: 1px solid var(--button-gray-2);
28 | padding: 2px 8px;
29 | margin: auto 4px;
30 | background-color: var(--button-gray-1);
31 | border-radius: 2px;
32 | font-size: 0.8em;
33 | min-height: 26px;
34 | box-sizing: border-box;
35 | display: flex;
36 | align-items: center;
37 | font-family: inherit;
38 | }
39 |
40 | .HelpDialog--header {
41 | display: flex;
42 | flex-direction: row;
43 | justify-content: space-evenly;
44 | margin-bottom: 32px;
45 | padding-bottom: 16px;
46 | }
47 |
48 | .HelpDialog--btn {
49 | border: 1px solid var(--link-color);
50 | padding: 8px 32px;
51 | border-radius: 4px;
52 | }
53 | .HelpDialog--btn:hover {
54 | text-decoration: none;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/scripts/build-node.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | // In order to use this, you need to install Cairo on your machine. See
4 | // instructions here: https://github.com/Automattic/node-canvas#compiling
5 |
6 | // In order to run:
7 | // npm install canvas # please do not check it in
8 | // yarn build-node
9 | // node build/static/js/build-node.js
10 | // open test.png
11 |
12 | const rewire = require("rewire");
13 | const defaults = rewire("react-scripts/scripts/build.js");
14 | const config = defaults.__get__("config");
15 |
16 | // Disable multiple chunks
17 | config.optimization.runtimeChunk = false;
18 | config.optimization.splitChunks = {
19 | cacheGroups: {
20 | default: false,
21 | },
22 | };
23 | // Set the filename to be deterministic
24 | config.output.filename = "static/js/build-node.js";
25 | // Don't choke on node-specific requires
26 | config.target = "node";
27 | // Set the node entrypoint
28 | config.entry = "./src/index-node";
29 | // By default, webpack is going to replace the require of the canvas.node file
30 | // to just a string with the path of the canvas.node file. We need to tell
31 | // webpack to avoid rewriting that dependency.
32 | config.externals = (context, request, callback) => {
33 | if (/\.node$/.test(request)) {
34 | return callback(
35 | null,
36 | "commonjs ../../../node_modules/canvas/build/Release/canvas.node",
37 | );
38 | }
39 | callback();
40 | };
41 |
--------------------------------------------------------------------------------
/.github/assets/vercel.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/firebase-project/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | firebase-debug.log*
8 | firebase-debug.*.log*
9 |
10 | # Firebase cache
11 | .firebase/
12 |
13 | # Firebase config
14 |
15 | # Uncomment this if you'd like others to create their own Firebase project.
16 | # For a team working on the same Firebase project(s), it is recommended to leave
17 | # it commented so all members can deploy to the same project(s) in .firebaserc.
18 | # .firebaserc
19 |
20 | # Runtime data
21 | pids
22 | *.pid
23 | *.seed
24 | *.pid.lock
25 |
26 | # Directory for instrumented libs generated by jscoverage/JSCover
27 | lib-cov
28 |
29 | # Coverage directory used by tools like istanbul
30 | coverage
31 |
32 | # nyc test coverage
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
36 | .grunt
37 |
38 | # Bower dependency directory (https://bower.io/)
39 | bower_components
40 |
41 | # node-waf configuration
42 | .lock-wscript
43 |
44 | # Compiled binary addons (http://nodejs.org/api/addons.html)
45 | build/Release
46 |
47 | # Dependency directories
48 | node_modules/
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Optional REPL history
57 | .node_repl_history
58 |
59 | # Output of 'npm pack'
60 | *.tgz
61 |
62 | # Yarn Integrity file
63 | .yarn-integrity
64 |
65 | # dotenv environment variables file
66 | .env
67 |
--------------------------------------------------------------------------------
/public/workbox/workbox-range-requests.prod.js:
--------------------------------------------------------------------------------
1 | this.workbox=this.workbox||{},this.workbox.rangeRequests=function(e,n){"use strict";try{self["workbox:range-requests:4.3.1"]&&_()}catch(e){}async function t(e,t){try{if(206===t.status)return t;const s=e.headers.get("range");if(!s)throw new n.WorkboxError("no-range-header");const a=function(e){const t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new n.WorkboxError("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new n.WorkboxError("single-range-only",{normalizedRangeHeader:t});const s=/(\d*)-(\d*)/.exec(t);if(null===s||!s[1]&&!s[2])throw new n.WorkboxError("invalid-range-values",{normalizedRangeHeader:t});return{start:""===s[1]?null:Number(s[1]),end:""===s[2]?null:Number(s[2])}}(s),r=await t.blob(),i=function(e,t,s){const a=e.size;if(s>a||t<0)throw new n.WorkboxError("range-not-satisfiable",{size:a,end:s,start:t});let r,i;return null===t?(r=a-s,i=a):null===s?(r=t,i=a):(r=t,i=s+1),{start:r,end:i}}(r,a.start,a.end),o=r.slice(i.start,i.end),u=o.size,l=new Response(o,{status:206,statusText:"Partial Content",headers:t.headers});return l.headers.set("Content-Length",u),l.headers.set("Content-Range",`bytes ${i.start}-${i.end-1}/`+r.size),l}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}return e.createPartialResponse=t,e.Plugin=class{async cachedResponseWillBeUsed({request:e,cachedResponse:n}){return n&&e.headers.has("range")?await t(e,n):n}},e}({},workbox.core._private);
2 | //# sourceMappingURL=workbox-range-requests.prod.js.map
3 |
--------------------------------------------------------------------------------
/scripts/build-version.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const fs = require("fs");
4 | const path = require("path");
5 | const versionFile = path.join("build", "version.json");
6 | const indexFile = path.join("build", "index.html");
7 |
8 | const versionDate = (date) => date.toISOString().replace(".000", "");
9 |
10 | const commitHash = () => {
11 | try {
12 | return require("child_process")
13 | .execSync("git rev-parse --short HEAD")
14 | .toString()
15 | .trim();
16 | } catch {
17 | return "none";
18 | }
19 | };
20 |
21 | const commitDate = (hash) => {
22 | try {
23 | const unix = require("child_process")
24 | .execSync(`git show -s --format=%ct ${hash}`)
25 | .toString()
26 | .trim();
27 | const date = new Date(parseInt(unix) * 1000);
28 | return versionDate(date);
29 | } catch {
30 | return versionDate(new Date());
31 | }
32 | };
33 |
34 | const getFullVersion = () => {
35 | const hash = commitHash();
36 | return `${commitDate(hash)}-${hash}`;
37 | };
38 |
39 | const data = JSON.stringify(
40 | {
41 | version: getFullVersion(),
42 | },
43 | undefined,
44 | 2,
45 | );
46 |
47 | fs.writeFileSync(versionFile, data);
48 |
49 | // https://stackoverflow.com/a/14181136/8418
50 | fs.readFile(indexFile, "utf8", (error, data) => {
51 | if (error) {
52 | return console.error(error);
53 | }
54 | const result = data.replace(/{version}/g, getFullVersion());
55 |
56 | fs.writeFile(indexFile, result, "utf8", (error) => {
57 | if (error) {
58 | return console.error(error);
59 | }
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/src/tests/scroll.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | mockBoundingClientRect,
4 | render,
5 | restoreOriginalGetBoundingClientRect,
6 | waitFor,
7 | } from "./test-utils";
8 | import Excalidraw from "../packages/excalidraw/index";
9 | import { API } from "./helpers/api";
10 |
11 | const { h } = window;
12 |
13 | describe("appState", () => {
14 | it("scroll-to-content on init works with non-zero offsets", async () => {
15 | const WIDTH = 200;
16 | const HEIGHT = 100;
17 | const OFFSET_LEFT = 20;
18 | const OFFSET_TOP = 10;
19 |
20 | const ELEM_WIDTH = 100;
21 | const ELEM_HEIGHT = 60;
22 |
23 | mockBoundingClientRect();
24 |
25 | await render(
26 |
27 |
40 |
,
41 | );
42 | await waitFor(() => {
43 | expect(h.state.width).toBe(200);
44 | expect(h.state.height).toBe(100);
45 | expect(h.state.offsetLeft).toBe(OFFSET_LEFT);
46 | expect(h.state.offsetTop).toBe(OFFSET_TOP);
47 |
48 | // assert scroll is in center
49 | expect(h.state.scrollX).toBe(WIDTH / 2 - ELEM_WIDTH / 2);
50 | expect(h.state.scrollY).toBe(HEIGHT / 2 - ELEM_HEIGHT / 2);
51 | });
52 | restoreOriginalGetBoundingClientRect();
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/.github/workflows/locales-coverage.yml:
--------------------------------------------------------------------------------
1 | name: Build locales coverage
2 |
3 | on:
4 | push:
5 | branches:
6 | - l10n_master
7 |
8 | jobs:
9 | locales:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v2
14 | with:
15 | token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
16 |
17 | - name: Setup Node.js 14.x
18 | uses: actions/setup-node@v2
19 | with:
20 | node-version: 14.x
21 |
22 | - name: Create report file
23 | run: |
24 | yarn locales-coverage
25 | FILE_CHANGED=$(git diff src/locales/percentages.json)
26 | if [ ! -z "${FILE_CHANGED}" ]; then
27 | git config --global user.name 'Excalidraw Bot'
28 | git config --global user.email 'bot@excalidraw.com'
29 | git add src/locales/percentages.json
30 | git commit -am "Auto commit: Calculate translation coverage"
31 | git push
32 | fi
33 | - name: Construct comment body
34 | id: getCommentBody
35 | run: |
36 | body=$(npm run locales-coverage:description | grep '^[^>]')
37 | body="${body//'%'/'%25'}"
38 | body="${body//$'\n'/'%0A'}"
39 | body="${body//$'\r'/'%0D'}"
40 | echo ::set-output name=body::$body
41 |
42 | - name: Update description with coverage
43 | uses: kt3k/update-pr-description@v1.0.1
44 | with:
45 | pr_body: ${{ steps.getCommentBody.outputs.body }}
46 | pr_title: "chore: Update translations from Crowdin"
47 | github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
48 |
--------------------------------------------------------------------------------
/src/excalidraw-app/components/GitHubCorner.tsx:
--------------------------------------------------------------------------------
1 | import oc from "open-color";
2 | import React from "react";
3 |
4 | // https://github.com/tholman/github-corners
5 | export const GitHubCorner = React.memo(
6 | ({ theme, dir }: { theme: "light" | "dark"; dir: string }) => (
7 |
43 | ),
44 | );
45 |
--------------------------------------------------------------------------------
/src/tests/appState.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, waitFor } from "./test-utils";
3 | import ExcalidrawApp from "../excalidraw-app";
4 | import { API } from "./helpers/api";
5 | import { getDefaultAppState } from "../appState";
6 | import { EXPORT_DATA_TYPES } from "../constants";
7 |
8 | const { h } = window;
9 |
10 | describe("appState", () => {
11 | it("drag&drop file doesn't reset non-persisted appState", async () => {
12 | const defaultAppState = getDefaultAppState();
13 | const exportBackground = !defaultAppState.exportBackground;
14 |
15 | await render(, {
16 | localStorageData: {
17 | appState: {
18 | exportBackground,
19 | viewBackgroundColor: "#F00",
20 | },
21 | },
22 | });
23 |
24 | await waitFor(() => {
25 | expect(h.state.exportBackground).toBe(exportBackground);
26 | expect(h.state.viewBackgroundColor).toBe("#F00");
27 | });
28 |
29 | API.drop(
30 | new Blob(
31 | [
32 | JSON.stringify({
33 | type: EXPORT_DATA_TYPES.excalidraw,
34 | appState: {
35 | viewBackgroundColor: "#000",
36 | },
37 | elements: [API.createElement({ type: "rectangle", id: "A" })],
38 | }),
39 | ],
40 | { type: "application/json" },
41 | ),
42 | );
43 |
44 | await waitFor(() => {
45 | expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
46 | // non-imported prop → retain
47 | expect(h.state.exportBackground).toBe(exportBackground);
48 | // imported prop → overwrite
49 | expect(h.state.viewBackgroundColor).toBe("#000");
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/.github/assets/sentry.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/scripts/autorelease.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const { exec, execSync } = require("child_process");
3 |
4 | const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
5 | const excalidrawPackage = `${excalidrawDir}/package.json`;
6 | const pkg = require(excalidrawPackage);
7 |
8 | const getShortCommitHash = () => {
9 | return execSync("git rev-parse --short HEAD").toString().trim();
10 | };
11 |
12 | const publish = () => {
13 | try {
14 | execSync(`yarn --frozen-lockfile`);
15 | execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
16 | execSync(`yarn run build:umd`, { cwd: excalidrawDir });
17 | execSync(`yarn --cwd ${excalidrawDir} publish`);
18 | } catch (e) {
19 | console.error(e);
20 | }
21 | };
22 |
23 | // get files changed between prev and head commit
24 | exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
25 | if (error || stderr) {
26 | console.error(error);
27 | process.exit(1);
28 | }
29 |
30 | const changedFiles = stdout.trim().split("\n");
31 | const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
32 |
33 | const excalidrawPackageFiles = changedFiles.filter((file) => {
34 | return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
35 | });
36 |
37 | if (!excalidrawPackageFiles.length) {
38 | process.exit(0);
39 | }
40 |
41 | // update package.json
42 | pkg.version = `${pkg.version}-${getShortCommitHash()}`;
43 | pkg.name = "@excalidraw/excalidraw-next";
44 | fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
45 |
46 | // update readme
47 | const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
48 | fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
49 |
50 | publish();
51 | });
52 |
--------------------------------------------------------------------------------
/src/actions/actionNavigate.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { getClientColors, getClientInitials } from "../clients";
3 | import { Avatar } from "../components/Avatar";
4 | import { centerScrollOn } from "../scene/scroll";
5 | import { Collaborator } from "../types";
6 | import { register } from "./register";
7 |
8 | export const actionGoToCollaborator = register({
9 | name: "goToCollaborator",
10 | perform: (_elements, appState, value) => {
11 | const point = value as Collaborator["pointer"];
12 | if (!point) {
13 | return { appState, commitToHistory: false };
14 | }
15 |
16 | return {
17 | appState: {
18 | ...appState,
19 | ...centerScrollOn({
20 | scenePoint: point,
21 | viewportDimensions: {
22 | width: appState.width,
23 | height: appState.height,
24 | },
25 | zoom: appState.zoom,
26 | }),
27 | // Close mobile menu
28 | openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
29 | },
30 | commitToHistory: false,
31 | };
32 | },
33 | PanelComponent: ({ appState, updateData, data }) => {
34 | const clientId: string | undefined = data?.id;
35 | if (!clientId) {
36 | return null;
37 | }
38 |
39 | const collaborator = appState.collaborators.get(clientId);
40 |
41 | if (!collaborator) {
42 | return null;
43 | }
44 |
45 | const { background, stroke } = getClientColors(clientId, appState);
46 | const shortName = getClientInitials(collaborator.username);
47 |
48 | return (
49 | updateData(collaborator.pointer)}
53 | >
54 | {shortName}
55 |
56 | );
57 | },
58 | });
59 |
--------------------------------------------------------------------------------
/src/components/LibraryUnit.scss:
--------------------------------------------------------------------------------
1 | .excalidraw {
2 | .library-unit {
3 | align-items: center;
4 | border: 1px solid var(--button-gray-2);
5 | display: flex;
6 | justify-content: center;
7 | position: relative;
8 | width: 63px;
9 | height: 63px; // match width
10 | }
11 |
12 | .library-unit__dragger {
13 | display: flex;
14 | height: 100%;
15 | width: 100%;
16 | }
17 |
18 | .library-unit__dragger > svg {
19 | filter: var(--theme-filter);
20 | flex-grow: 1;
21 | max-height: 100%;
22 | max-width: 100%;
23 | }
24 |
25 | .library-unit__removeFromLibrary,
26 | .library-unit__removeFromLibrary:hover,
27 | .library-unit__removeFromLibrary:active {
28 | align-items: center;
29 | background: none;
30 | border: none;
31 | color: var(--icon-fill-color);
32 | display: flex;
33 | justify-content: center;
34 | margin: 0;
35 | padding: 0;
36 | position: absolute;
37 | right: 5px;
38 | top: 5px;
39 | }
40 |
41 | .library-unit__removeFromLibrary > svg {
42 | height: 16px;
43 | width: 16px;
44 | }
45 |
46 | .library-unit__pulse {
47 | transform: scale(1);
48 | animation: library-unit__pulse-animation 1s ease-in infinite;
49 | }
50 |
51 | .library-unit__adder {
52 | position: absolute;
53 | left: 50%;
54 | top: 50%;
55 | width: 20px;
56 | height: 20px;
57 | margin-left: -10px;
58 | margin-top: -10px;
59 | pointer-events: none;
60 | }
61 |
62 | .library-unit__active {
63 | cursor: pointer;
64 | }
65 |
66 | @keyframes library-unit__pulse-animation {
67 | 0% {
68 | transform: scale(0.95);
69 | }
70 |
71 | 50% {
72 | transform: scale(1);
73 | }
74 |
75 | 100% {
76 | transform: scale(0.95);
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/ProjectName.tsx:
--------------------------------------------------------------------------------
1 | import "./TextInput.scss";
2 |
3 | import React, { useState } from "react";
4 | import { focusNearestParent } from "../utils";
5 |
6 | import "./ProjectName.scss";
7 | import { useExcalidrawContainer } from "./App";
8 |
9 | type Props = {
10 | value: string;
11 | onChange: (value: string) => void;
12 | label: string;
13 | isNameEditable: boolean;
14 | };
15 |
16 | export const ProjectName = (props: Props) => {
17 | const { id } = useExcalidrawContainer();
18 | const [fileName, setFileName] = useState(props.value);
19 |
20 | const handleBlur = (event: any) => {
21 | focusNearestParent(event.target);
22 | const value = event.target.value;
23 | if (value !== props.value) {
24 | props.onChange(value);
25 | }
26 | };
27 |
28 | const handleKeyDown = (event: React.KeyboardEvent) => {
29 | if (event.key === "Enter") {
30 | event.preventDefault();
31 | if (event.nativeEvent.isComposing || event.keyCode === 229) {
32 | return;
33 | }
34 | event.currentTarget.blur();
35 | }
36 | };
37 |
38 | return (
39 |
40 |
43 | {props.isNameEditable ? (
44 | setFileName(event.target.value)}
51 | />
52 | ) : (
53 |
54 | {props.value}
55 |
56 | )}
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/src/components/Popover.tsx:
--------------------------------------------------------------------------------
1 | import React, { useLayoutEffect, useRef, useEffect } from "react";
2 | import "./Popover.scss";
3 | import { unstable_batchedUpdates } from "react-dom";
4 |
5 | type Props = {
6 | top?: number;
7 | left?: number;
8 | children?: React.ReactNode;
9 | onCloseRequest?(event: PointerEvent): void;
10 | fitInViewport?: boolean;
11 | };
12 |
13 | export const Popover = ({
14 | children,
15 | left,
16 | top,
17 | onCloseRequest,
18 | fitInViewport = false,
19 | }: Props) => {
20 | const popoverRef = useRef(null);
21 |
22 | // ensure the popover doesn't overflow the viewport
23 | useLayoutEffect(() => {
24 | if (fitInViewport && popoverRef.current) {
25 | const element = popoverRef.current;
26 | const { x, y, width, height } = element.getBoundingClientRect();
27 |
28 | const viewportWidth = window.innerWidth;
29 | if (x + width > viewportWidth) {
30 | element.style.left = `${viewportWidth - width}px`;
31 | }
32 | const viewportHeight = window.innerHeight;
33 | if (y + height > viewportHeight) {
34 | element.style.top = `${viewportHeight - height}px`;
35 | }
36 | }
37 | }, [fitInViewport]);
38 |
39 | useEffect(() => {
40 | if (onCloseRequest) {
41 | const handler = (event: PointerEvent) => {
42 | if (!popoverRef.current?.contains(event.target as Node)) {
43 | unstable_batchedUpdates(() => onCloseRequest(event));
44 | }
45 | };
46 | document.addEventListener("pointerdown", handler, false);
47 | return () => document.removeEventListener("pointerdown", handler, false);
48 | }
49 | }, [onCloseRequest]);
50 |
51 | return (
52 |
53 | {children}
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/tests/fixtures/smiley_embedded_v2.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/src/points.ts:
--------------------------------------------------------------------------------
1 | import { Point } from "./types";
2 |
3 | export const getSizeFromPoints = (points: readonly Point[]) => {
4 | const xs = points.map((point) => point[0]);
5 | const ys = points.map((point) => point[1]);
6 | return {
7 | width: Math.max(...xs) - Math.min(...xs),
8 | height: Math.max(...ys) - Math.min(...ys),
9 | };
10 | };
11 |
12 | export const rescalePoints = (
13 | dimension: 0 | 1,
14 | nextDimensionSize: number,
15 | prevPoints: readonly Point[],
16 | ): Point[] => {
17 | const prevDimValues = prevPoints.map((point) => point[dimension]);
18 | const prevMaxDimension = Math.max(...prevDimValues);
19 | const prevMinDimension = Math.min(...prevDimValues);
20 | const prevDimensionSize = prevMaxDimension - prevMinDimension;
21 |
22 | const dimensionScaleFactor =
23 | prevDimensionSize === 0 ? 1 : nextDimensionSize / prevDimensionSize;
24 |
25 | let nextMinDimension = Infinity;
26 |
27 | const scaledPoints = prevPoints.map(
28 | (prevPoint) =>
29 | prevPoint.map((value, currentDimension) => {
30 | if (currentDimension !== dimension) {
31 | return value;
32 | }
33 | const scaledValue = value * dimensionScaleFactor;
34 | nextMinDimension = Math.min(scaledValue, nextMinDimension);
35 | return scaledValue;
36 | }) as [number, number],
37 | );
38 |
39 | if (scaledPoints.length === 2) {
40 | // we don't tranlate two-point lines
41 | return scaledPoints;
42 | }
43 |
44 | const translation = prevMinDimension - nextMinDimension;
45 |
46 | const nextPoints = scaledPoints.map(
47 | (scaledPoint) =>
48 | scaledPoint.map((value, currentDimension) => {
49 | return currentDimension === dimension ? value + translation : value;
50 | }) as [number, number],
51 | );
52 |
53 | return nextPoints;
54 | };
55 |
--------------------------------------------------------------------------------
/public/workbox/workbox-broadcast-update.prod.js:
--------------------------------------------------------------------------------
1 | this.workbox=this.workbox||{},this.workbox.broadcastUpdate=function(e,t){"use strict";try{self["workbox:broadcast-update:4.3.1"]&&_()}catch(e){}const s=(e,t,s)=>{return!s.some(s=>e.headers.has(s)&&t.headers.has(s))||s.every(s=>{const n=e.headers.has(s)===t.headers.has(s),a=e.headers.get(s)===t.headers.get(s);return n&&a})},n="workbox",a=1e4,i=["content-length","etag","last-modified"],o=async({channel:e,cacheName:t,url:s})=>{const n={type:"CACHE_UPDATED",meta:"workbox-broadcast-update",payload:{cacheName:t,updatedURL:s}};if(e)e.postMessage(n);else{const e=await clients.matchAll({type:"window"});for(const t of e)t.postMessage(n)}};class c{constructor({headersToCheck:e,channelName:t,deferNoticationTimeout:s}={}){this.t=e||i,this.s=t||n,this.i=s||a,this.o()}notifyIfUpdated({oldResponse:e,newResponse:t,url:n,cacheName:a,event:i}){if(!s(e,t,this.t)){const e=(async()=>{i&&i.request&&"navigate"===i.request.mode&&await this.h(i),await this.l({channel:this.u(),cacheName:a,url:n})})();if(i)try{i.waitUntil(e)}catch(e){}return e}}async l(e){await o(e)}u(){return"BroadcastChannel"in self&&!this.p&&(this.p=new BroadcastChannel(this.s)),this.p}h(e){if(!this.m.has(e)){const s=new t.Deferred;this.m.set(e,s);const n=setTimeout(()=>{s.resolve()},this.i);s.promise.then(()=>clearTimeout(n))}return this.m.get(e).promise}o(){this.m=new Map,self.addEventListener("message",e=>{if("WINDOW_READY"===e.data.type&&"workbox-window"===e.data.meta&&this.m.size>0){for(const e of this.m.values())e.resolve();this.m.clear()}})}}return e.BroadcastCacheUpdate=c,e.Plugin=class{constructor(e){this.l=new c(e)}cacheDidUpdate({cacheName:e,oldResponse:t,newResponse:s,request:n,event:a}){t&&this.l.notifyIfUpdated({cacheName:e,oldResponse:t,newResponse:s,event:a,url:n.url})}},e.broadcastUpdate=o,e.responsesAreSame=s,e}({},workbox.core._private);
2 | //# sourceMappingURL=workbox-broadcast-update.prod.js.map
3 |
--------------------------------------------------------------------------------
/public/workbox/workbox-offline-ga.prod.js:
--------------------------------------------------------------------------------
1 | this.workbox=this.workbox||{},this.workbox.googleAnalytics=function(e,t,o,n,a,c,w){"use strict";try{self["workbox:google-analytics:4.3.1"]&&_()}catch(e){}const r=/^\/(\w+\/)?collect/,s=e=>async({queue:t})=>{let o;for(;o=await t.shiftRequest();){const{request:n,timestamp:a}=o,c=new URL(n.url);try{const w="POST"===n.method?new URLSearchParams(await n.clone().text()):c.searchParams,r=a-(Number(w.get("qt"))||0),s=Date.now()-r;if(w.set("qt",s),e.parameterOverrides)for(const t of Object.keys(e.parameterOverrides)){const o=e.parameterOverrides[t];w.set(t,o)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,w),await fetch(new Request(c.origin+c.pathname,{body:w.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(o),e}}},i=e=>{const t=({url:e})=>"www.google-analytics.com"===e.hostname&&r.test(e.pathname),o=new w.NetworkOnly({plugins:[e]});return[new n.Route(t,o,"GET"),new n.Route(t,o,"POST")]},l=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,t,"GET")},m=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,t,"GET")},u=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,t,"GET")};return e.initialize=((e={})=>{const n=o.cacheNames.getGoogleAnalyticsName(e.cacheName),c=new t.Plugin("workbox-google-analytics",{maxRetentionTime:2880,onSync:s(e)}),w=[u(n),l(n),m(n),...i(c)],r=new a.Router;for(const e of w)r.registerRoute(e);r.addFetchListener()}),e}({},workbox.backgroundSync,workbox.core._private,workbox.routing,workbox.routing,workbox.strategies,workbox.strategies);
2 | //# sourceMappingURL=workbox-offline-ga.prod.js.map
3 |
--------------------------------------------------------------------------------
/src/components/LibraryButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import clsx from "clsx";
3 | import { t } from "../i18n";
4 | import { AppState } from "../types";
5 | import { capitalizeString } from "../utils";
6 |
7 | const LIBRARY_ICON = (
8 |
14 | );
15 |
16 | export const LibraryButton: React.FC<{
17 | appState: AppState;
18 | setAppState: React.Component["setState"];
19 | }> = ({ appState, setAppState }) => {
20 | return (
21 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/src/keys.ts:
--------------------------------------------------------------------------------
1 | export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
2 | export const isWindows = /^Win/.test(window.navigator.platform);
3 |
4 | export const CODES = {
5 | EQUAL: "Equal",
6 | MINUS: "Minus",
7 | NUM_ADD: "NumpadAdd",
8 | NUM_SUBTRACT: "NumpadSubtract",
9 | NUM_ZERO: "Numpad0",
10 | BRACKET_RIGHT: "BracketRight",
11 | BRACKET_LEFT: "BracketLeft",
12 | ONE: "Digit1",
13 | TWO: "Digit2",
14 | NINE: "Digit9",
15 | QUOTE: "Quote",
16 | ZERO: "Digit0",
17 | SLASH: "Slash",
18 | C: "KeyC",
19 | D: "KeyD",
20 | G: "KeyG",
21 | F: "KeyF",
22 | H: "KeyH",
23 | V: "KeyV",
24 | X: "KeyX",
25 | Z: "KeyZ",
26 | R: "KeyR",
27 | } as const;
28 |
29 | export const KEYS = {
30 | ARROW_DOWN: "ArrowDown",
31 | ARROW_LEFT: "ArrowLeft",
32 | ARROW_RIGHT: "ArrowRight",
33 | ARROW_UP: "ArrowUp",
34 | BACKSPACE: "Backspace",
35 | ALT: "Alt",
36 | CTRL_OR_CMD: isDarwin ? "metaKey" : "ctrlKey",
37 | DELETE: "Delete",
38 | ENTER: "Enter",
39 | ESCAPE: "Escape",
40 | QUESTION_MARK: "?",
41 | SPACE: " ",
42 | TAB: "Tab",
43 |
44 | A: "a",
45 | D: "d",
46 | E: "e",
47 | G: "g",
48 | L: "l",
49 | O: "o",
50 | P: "p",
51 | Q: "q",
52 | R: "r",
53 | S: "s",
54 | T: "t",
55 | V: "v",
56 | X: "x",
57 | Y: "y",
58 | Z: "z",
59 | } as const;
60 |
61 | export type Key = keyof typeof KEYS;
62 |
63 | export const isArrowKey = (key: string) =>
64 | key === KEYS.ARROW_LEFT ||
65 | key === KEYS.ARROW_RIGHT ||
66 | key === KEYS.ARROW_DOWN ||
67 | key === KEYS.ARROW_UP;
68 |
69 | export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
70 | event.altKey;
71 |
72 | export const getResizeWithSidesSameLengthKey = (
73 | event: MouseEvent | KeyboardEvent,
74 | ) => event.shiftKey;
75 |
76 | export const getRotateWithDiscreteAngleKey = (
77 | event: MouseEvent | KeyboardEvent,
78 | ) => event.shiftKey;
79 |
--------------------------------------------------------------------------------
/src/excalidraw-app/collab/RoomDialog.scss:
--------------------------------------------------------------------------------
1 | @import "../../css/variables.module";
2 |
3 | .excalidraw {
4 | .RoomDialog-linkContainer {
5 | display: flex;
6 | margin: 1.5em 0;
7 | }
8 |
9 | .RoomDialog-link {
10 | color: var(--text-primary-color);
11 | min-width: 0;
12 | flex: 1 1 auto;
13 | margin-inline-start: 1em;
14 | display: inline-block;
15 | cursor: pointer;
16 | border: none;
17 | height: 2.5rem;
18 | line-height: 2.5rem;
19 | padding: 0 0.5rem;
20 | white-space: nowrap;
21 | border-radius: var(--space-factor);
22 | background-color: var(--button-gray-1);
23 | }
24 |
25 | .RoomDialog-emoji {
26 | font-family: sans-serif;
27 | }
28 |
29 | .RoomDialog-usernameContainer {
30 | display: flex;
31 | margin: 1.5em 0;
32 | display: flex;
33 | align-items: center;
34 | justify-content: center;
35 | @include isMobile {
36 | flex-direction: column;
37 | align-items: stretch;
38 | }
39 | }
40 |
41 | @include isMobile {
42 | .RoomDialog-usernameLabel {
43 | font-weight: bold;
44 | }
45 | }
46 |
47 | .RoomDialog-username {
48 | background-color: var(--input-bg-color);
49 | border-color: var(--input-border-color);
50 | appearance: none;
51 | min-width: 0;
52 | flex: 1 1 auto;
53 | margin-inline-start: 1em;
54 | @include isMobile {
55 | margin-top: 0.5em;
56 | margin-inline-start: 0;
57 | }
58 | height: 2.5rem;
59 | font-size: 1em;
60 | line-height: 1.5;
61 | padding: 0 0.5rem;
62 | }
63 |
64 | .RoomDialog-sessionStartButtonContainer {
65 | display: flex;
66 | justify-content: center;
67 | }
68 |
69 | .Modal .RoomDialog-stopSession {
70 | background-color: var(--button-destructive-bg-color);
71 |
72 | .ToolIcon__label,
73 | .ToolIcon__icon svg {
74 | color: var(--button-destructive-color);
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Excalidraw",
3 | "name": "Excalidraw",
4 | "description": "Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
5 | "icons": [
6 | {
7 | "src": "logo-180x180.png",
8 | "sizes": "180x180",
9 | "type": "image/png"
10 | },
11 | {
12 | "src": "apple-touch-icon.png",
13 | "type": "image/png",
14 | "sizes": "256x256"
15 | }
16 | ],
17 | "start_url": "/",
18 | "display": "standalone",
19 | "theme_color": "#000000",
20 | "background_color": "#ffffff",
21 | "file_handlers": [
22 | {
23 | "action": "/",
24 | "accept": {
25 | "application/vnd.excalidraw+json": [".excalidraw"]
26 | }
27 | }
28 | ],
29 | "capture_links": "new-client",
30 | "share_target": {
31 | "action": "/web-share-target",
32 | "method": "POST",
33 | "enctype": "multipart/form-data",
34 | "params": {
35 | "files": [
36 | {
37 | "name": "file",
38 | "accept": ["application/vnd.excalidraw+json", "application/json", ".excalidraw"]
39 | }
40 | ]
41 | }
42 | },
43 | "screenshots": [
44 | {
45 | "src": "/screenshots/virtual-whiteboard.png",
46 | "type": "image/png",
47 | "sizes": "462x945"
48 | },
49 | {
50 | "src": "/screenshots/wireframe.png",
51 | "type": "image/png",
52 | "sizes": "462x945"
53 | },
54 | {
55 | "src": "/screenshots/illustration.png",
56 | "type": "image/png",
57 | "sizes": "462x945"
58 | },
59 | {
60 | "src": "/screenshots/shapes.png",
61 | "type": "image/png",
62 | "sizes": "462x945"
63 | },
64 | {
65 | "src": "/screenshots/collaboration.png",
66 | "type": "image/png",
67 | "sizes": "462x945"
68 | },
69 | {
70 | "src": "/screenshots/export.png",
71 | "type": "image/png",
72 | "sizes": "462x945"
73 | }
74 | ]
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/CheckboxItem.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .Checkbox {
5 | margin: 4px 0.3em;
6 | display: flex;
7 | align-items: center;
8 |
9 | cursor: pointer;
10 | user-select: none;
11 |
12 | -webkit-tap-highlight-color: transparent;
13 |
14 | &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
15 | box-shadow: 0 0 0 2px #{$oc-blue-4};
16 | }
17 |
18 | &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
19 | svg {
20 | display: block;
21 | opacity: 0.3;
22 | }
23 | }
24 |
25 | &:active {
26 | .Checkbox-box {
27 | box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
28 | }
29 | }
30 |
31 | &:hover {
32 | .Checkbox-box {
33 | background-color: fade-out($oc-blue-1, 0.8);
34 | }
35 | }
36 |
37 | &.is-checked {
38 | .Checkbox-box {
39 | background-color: #{$oc-blue-1};
40 | svg {
41 | display: block;
42 | }
43 | }
44 | &:hover .Checkbox-box {
45 | background-color: #{$oc-blue-2};
46 | }
47 | }
48 |
49 | .Checkbox-box {
50 | width: 22px;
51 | height: 22px;
52 | padding: 0;
53 | flex: 0 0 auto;
54 |
55 | margin: 0 1em;
56 |
57 | display: flex;
58 | align-items: center;
59 | justify-content: center;
60 |
61 | box-shadow: 0 0 0 2px #{$oc-blue-7};
62 | background-color: transparent;
63 | border-radius: 4px;
64 |
65 | color: #{$oc-blue-7};
66 |
67 | &:focus {
68 | box-shadow: 0 0 0 3px #{$oc-blue-7};
69 | }
70 |
71 | svg {
72 | display: none;
73 | width: 16px;
74 | height: 16px;
75 | stroke-width: 3px;
76 | }
77 | }
78 |
79 | .Checkbox-label {
80 | display: flex;
81 | align-items: center;
82 | }
83 |
84 | .excalidraw-tooltip-icon {
85 | width: 1em;
86 | height: 1em;
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/galines.ts:
--------------------------------------------------------------------------------
1 | import * as GA from "./ga";
2 | import { Line, Point } from "./ga";
3 |
4 | /**
5 | * A line is stored as an array `[0, c, a, b, 0, 0, 0, 0]` representing:
6 | * c * e0 + a * e1 + b*e2
7 | *
8 | * This maps to a standard formula `a * x + b * y + c`.
9 | *
10 | * `(-b, a)` correponds to a 2D vector parallel to the line. The lines
11 | * have a natural orientation, corresponding to that vector.
12 | *
13 | * The magnitude ("norm") of the line is `sqrt(a ^ 2 + b ^ 2)`.
14 | * `c / norm(line)` is the oriented distance from line to origin.
15 | */
16 |
17 | // Returns line with direction (x, y) through origin
18 | export const vector = (x: number, y: number): Line =>
19 | GA.normalized([0, 0, -y, x, 0, 0, 0, 0]);
20 |
21 | // For equation ax + by + c = 0.
22 | export const equation = (a: number, b: number, c: number): Line =>
23 | GA.normalized([0, c, a, b, 0, 0, 0, 0]);
24 |
25 | export const through = (from: Point, to: Point): Line =>
26 | GA.normalized(GA.join(to, from));
27 |
28 | export const orthogonal = (line: Line, point: Point): Line =>
29 | GA.dot(line, point);
30 |
31 | // Returns a line perpendicular to the line through `against` and `intersection`
32 | // going through `intersection`.
33 | export const orthogonalThrough = (against: Point, intersection: Point): Line =>
34 | orthogonal(through(against, intersection), intersection);
35 |
36 | export const parallel = (line: Line, distance: number): Line => {
37 | const result = line.slice();
38 | result[1] -= distance;
39 | return (result as unknown) as Line;
40 | };
41 |
42 | export const parallelThrough = (line: Line, point: Point): Line =>
43 | orthogonal(orthogonal(point, line), point);
44 |
45 | export const distance = (line1: Line, line2: Line): number =>
46 | GA.inorm(GA.meet(line1, line2));
47 |
48 | export const angle = (line1: Line, line2: Line): number =>
49 | Math.acos(GA.dot(line1, line2)[0]);
50 |
51 | // The orientation of the line
52 | export const sign = (line: Line): number => Math.sign(line[1]);
53 |
--------------------------------------------------------------------------------
/src/components/Dialog.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .Dialog {
5 | user-select: text;
6 | cursor: auto;
7 | }
8 |
9 | .Dialog__title {
10 | display: grid;
11 | align-items: center;
12 | margin-top: 0;
13 | grid-template-columns: 1fr calc(var(--space-factor) * 7);
14 | grid-gap: var(--metric);
15 | padding: calc(var(--space-factor) * 2);
16 | text-align: center;
17 | font-variant: small-caps;
18 | font-size: 1.2em;
19 | }
20 |
21 | .Dialog__titleContent {
22 | flex: 1;
23 | }
24 |
25 | .Dialog .Modal__close {
26 | color: var(--icon-fill-color);
27 | margin: 0;
28 | }
29 |
30 | .Dialog__content {
31 | padding: 0 16px 16px;
32 | }
33 |
34 | @include isMobile {
35 | .Dialog {
36 | --metric: calc(var(--space-factor) * 4);
37 | --inset-left: #{"max(var(--metric), var(--sal))"};
38 | --inset-right: #{"max(var(--metric), var(--sar))"};
39 | }
40 |
41 | .Dialog__title {
42 | grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
43 | var(--space-factor) * 7
44 | );
45 | position: sticky;
46 | top: 0;
47 | padding: calc(var(--space-factor) * 2);
48 | background: var(--island-bg-color);
49 | font-size: 1.25em;
50 |
51 | box-sizing: border-box;
52 | border-bottom: 1px solid var(--button-gray-2);
53 | z-index: 1;
54 | }
55 |
56 | .Dialog__titleContent {
57 | text-align: center;
58 | }
59 |
60 | .Dialog .Island {
61 | width: 100vw;
62 | height: 100%;
63 | box-sizing: border-box;
64 | overflow-y: auto;
65 | padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
66 | padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
67 | padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
68 | }
69 |
70 | .Dialog .Modal__close {
71 | order: -1;
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/tests/fixtures/test_embedded_v1.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/src/index-node.ts:
--------------------------------------------------------------------------------
1 | import { exportToCanvas } from "./scene/export";
2 | import { getDefaultAppState } from "./appState";
3 |
4 | const { registerFont, createCanvas } = require("canvas");
5 |
6 | const elements = [
7 | {
8 | id: "eVzaxG3YnHhqjEmD7NdYo",
9 | type: "diamond",
10 | x: 519,
11 | y: 199,
12 | width: 113,
13 | height: 115,
14 | strokeColor: "#000000",
15 | backgroundColor: "transparent",
16 | fillStyle: "hachure",
17 | strokeWidth: 1,
18 | roughness: 1,
19 | opacity: 100,
20 | seed: 749612521,
21 | },
22 | {
23 | id: "7W-iw5pEBPTU3eaCaLtFo",
24 | type: "ellipse",
25 | x: 552,
26 | y: 238,
27 | width: 49,
28 | height: 44,
29 | strokeColor: "#000000",
30 | backgroundColor: "transparent",
31 | fillStyle: "hachure",
32 | strokeWidth: 1,
33 | roughness: 1,
34 | opacity: 100,
35 | seed: 952056308,
36 | },
37 | {
38 | id: "kqKI231mvTrcsYo2DkUsR",
39 | type: "text",
40 | x: 557.5,
41 | y: 317.5,
42 | width: 43,
43 | height: 31,
44 | strokeColor: "#000000",
45 | backgroundColor: "transparent",
46 | fillStyle: "hachure",
47 | strokeWidth: 1,
48 | roughness: 1,
49 | opacity: 100,
50 | seed: 1683771448,
51 | text: "test",
52 | font: "20px Virgil",
53 | baseline: 22,
54 | },
55 | ];
56 |
57 | registerFont("./public/Virgil.woff2", { family: "Virgil" });
58 | registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
59 |
60 | const canvas = exportToCanvas(
61 | elements as any,
62 | {
63 | ...getDefaultAppState(),
64 | offsetTop: 0,
65 | offsetLeft: 0,
66 | width: 0,
67 | height: 0,
68 | },
69 | {
70 | exportBackground: true,
71 | viewBackgroundColor: "#ffffff",
72 | },
73 | createCanvas,
74 | );
75 |
76 | const fs = require("fs");
77 | const out = fs.createWriteStream("test.png");
78 | const stream = (canvas as any).createPNGStream();
79 | stream.pipe(out);
80 | out.on("finish", () => {
81 | console.info("test.png was created.");
82 | });
83 |
--------------------------------------------------------------------------------
/src/packages/utils/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@excalidraw/utils",
3 | "version": "0.1.0",
4 | "main": "dist/excalidraw-utils.min.js",
5 | "files": [
6 | "dist/*"
7 | ],
8 | "description": "Excalidraw utilities functions",
9 | "publishConfig": {
10 | "access": "public"
11 | },
12 | "license": "MIT",
13 | "keywords": [
14 | "excalidraw",
15 | "excalidraw-utils"
16 | ],
17 | "browserslist": {
18 | "production": [
19 | ">0.2%",
20 | "not dead",
21 | "not ie <= 11",
22 | "not op_mini all",
23 | "not safari < 12",
24 | "not kaios <= 2.5",
25 | "not edge < 79",
26 | "not chrome < 70",
27 | "not and_uc < 13",
28 | "not samsung < 10"
29 | ],
30 | "development": [
31 | "last 1 chrome version",
32 | "last 1 firefox version",
33 | "last 1 safari version"
34 | ]
35 | },
36 | "devDependencies": {
37 | "@babel/core": "7.14.8",
38 | "@babel/plugin-transform-arrow-functions": "7.14.5",
39 | "@babel/plugin-transform-async-to-generator": "7.14.5",
40 | "@babel/plugin-transform-runtime": "^7.14.5",
41 | "@babel/plugin-transform-typescript": "7.14.6",
42 | "@babel/preset-env": "7.15.0",
43 | "@babel/preset-typescript": "7.14.5",
44 | "babel-loader": "8.2.2",
45 | "babel-plugin-transform-class-properties": "6.24.1",
46 | "cross-env": "7.0.3",
47 | "css-loader": "6.2.0",
48 | "file-loader": "6.2.0",
49 | "sass-loader": "12.1.0",
50 | "ts-loader": "9.2.4",
51 | "webpack": "5.50.0",
52 | "webpack-bundle-analyzer": "4.4.2",
53 | "webpack-cli": "4.7.2"
54 | },
55 | "bugs": "https://github.com/excalidraw/excalidraw/issues",
56 | "repository": "https://github.com/excalidraw/excalidraw",
57 | "scripts": {
58 | "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
59 | "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
60 | "pack": "yarn build:umd && yarn pack"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/tests/__snapshots__/multiPointCreate.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`multi point mode in linear elements arrow 1`] = `
4 | Object {
5 | "angle": 0,
6 | "backgroundColor": "transparent",
7 | "boundElementIds": null,
8 | "endArrowhead": "arrow",
9 | "endBinding": null,
10 | "fillStyle": "hachure",
11 | "groupIds": Array [],
12 | "height": 110,
13 | "id": "id0",
14 | "isDeleted": false,
15 | "lastCommittedPoint": Array [
16 | 70,
17 | 110,
18 | ],
19 | "opacity": 100,
20 | "points": Array [
21 | Array [
22 | 0,
23 | 0,
24 | ],
25 | Array [
26 | 20,
27 | 30,
28 | ],
29 | Array [
30 | 70,
31 | 110,
32 | ],
33 | ],
34 | "roughness": 1,
35 | "seed": 337897,
36 | "startArrowhead": null,
37 | "startBinding": null,
38 | "strokeColor": "#000000",
39 | "strokeSharpness": "round",
40 | "strokeStyle": "solid",
41 | "strokeWidth": 1,
42 | "type": "arrow",
43 | "version": 7,
44 | "versionNonce": 1150084233,
45 | "width": 70,
46 | "x": 30,
47 | "y": 30,
48 | }
49 | `;
50 |
51 | exports[`multi point mode in linear elements line 1`] = `
52 | Object {
53 | "angle": 0,
54 | "backgroundColor": "transparent",
55 | "boundElementIds": null,
56 | "endArrowhead": null,
57 | "endBinding": null,
58 | "fillStyle": "hachure",
59 | "groupIds": Array [],
60 | "height": 110,
61 | "id": "id0",
62 | "isDeleted": false,
63 | "lastCommittedPoint": Array [
64 | 70,
65 | 110,
66 | ],
67 | "opacity": 100,
68 | "points": Array [
69 | Array [
70 | 0,
71 | 0,
72 | ],
73 | Array [
74 | 20,
75 | 30,
76 | ],
77 | Array [
78 | 70,
79 | 110,
80 | ],
81 | ],
82 | "roughness": 1,
83 | "seed": 337897,
84 | "startArrowhead": null,
85 | "startBinding": null,
86 | "strokeColor": "#000000",
87 | "strokeSharpness": "round",
88 | "strokeStyle": "solid",
89 | "strokeWidth": 1,
90 | "type": "line",
91 | "version": 7,
92 | "versionNonce": 1150084233,
93 | "width": 70,
94 | "x": 30,
95 | "y": 30,
96 | }
97 | `;
98 |
--------------------------------------------------------------------------------
/src/service-worker.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-restricted-globals
2 | // eslint-disable-next-line no-unused-expressions
3 |
4 | /* eslint-disable no-restricted-globals */
5 | /* global importScripts, workbox */
6 |
7 | /**
8 | * Welcome to your Workbox-powered service worker!
9 | *
10 | * You'll need to register this file in your web app and you should
11 | * disable HTTP caching for this file too.
12 | * See https://goo.gl/nhQhGp
13 | *
14 | * The rest of the code is auto-generated. Please don't update this file
15 | * directly; instead, make changes to your Workbox build configuration
16 | * and re-run your build process.
17 | * See https://goo.gl/2aRDsh
18 | */
19 |
20 | importScripts("/workbox/workbox-sw.js");
21 |
22 | workbox.setConfig({
23 | modulePathPrefix: "/workbox/",
24 | });
25 |
26 | self.addEventListener("message", (event) => {
27 | if (event.data && event.data.type === "SKIP_WAITING") {
28 | self.skipWaiting();
29 | }
30 | });
31 |
32 | workbox.core.clientsClaim();
33 | workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
34 |
35 | workbox.routing.registerNavigationRoute(
36 | workbox.precaching.getCacheKeyForURL("./index.html"),
37 | {
38 | blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
39 | },
40 | );
41 |
42 | // Cache relevant font files
43 | workbox.routing.registerRoute(
44 | new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"),
45 | new workbox.strategies.StaleWhileRevalidate({
46 | cacheName: "fonts",
47 | plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })],
48 | }),
49 | );
50 |
51 | self.addEventListener("fetch", (event) => {
52 | if (
53 | event.request.method === "POST" &&
54 | event.request.url.endsWith("/web-share-target")
55 | ) {
56 | return event.respondWith(
57 | (async () => {
58 | const formData = await event.request.formData();
59 | const file = formData.get("file");
60 | const webShareTargetCache = await caches.open("web-share-target");
61 | await webShareTargetCache.put("shared-file", new Response(file));
62 | return Response.redirect("/?web-share-target", 303);
63 | })(),
64 | );
65 | }
66 | });
67 |
--------------------------------------------------------------------------------
/src/components/LockButton.tsx:
--------------------------------------------------------------------------------
1 | import "./ToolIcon.scss";
2 |
3 | import React from "react";
4 | import clsx from "clsx";
5 | import { ToolButtonSize } from "./ToolButton";
6 |
7 | type LockIconProps = {
8 | title?: string;
9 | name?: string;
10 | checked: boolean;
11 | onChange?(): void;
12 | zenModeEnabled?: boolean;
13 | };
14 |
15 | const DEFAULT_SIZE: ToolButtonSize = "medium";
16 |
17 | const ICONS = {
18 | CHECKED: (
19 |
27 | ),
28 | UNCHECKED: (
29 |
38 | ),
39 | };
40 |
41 | export const LockButton = (props: LockIconProps) => {
42 | return (
43 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/src/tests/library.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, waitFor } from "./test-utils";
3 | import ExcalidrawApp from "../excalidraw-app";
4 | import { API } from "./helpers/api";
5 | import { MIME_TYPES } from "../constants";
6 | import { LibraryItem } from "../types";
7 | import { UI } from "./helpers/ui";
8 |
9 | const { h } = window;
10 |
11 | describe("library", () => {
12 | beforeEach(async () => {
13 | await render();
14 | h.app.library.resetLibrary();
15 | });
16 |
17 | it("import library via drag&drop", async () => {
18 | expect(await h.app.library.loadLibrary()).toEqual([]);
19 | await API.drop(
20 | await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
21 | );
22 | await waitFor(async () => {
23 | expect(await h.app.library.loadLibrary()).toEqual([
24 | [expect.objectContaining({ id: "A" })],
25 | ]);
26 | });
27 | });
28 |
29 | // NOTE: mocked to test logic, not actual drag&drop via UI
30 | it("drop library item onto canvas", async () => {
31 | expect(h.elements).toEqual([]);
32 | const libraryItems: LibraryItem = JSON.parse(
33 | await API.readFile("./fixtures/fixture_library.excalidrawlib", "utf8"),
34 | ).library[0];
35 | await API.drop(
36 | new Blob([JSON.stringify(libraryItems)], {
37 | type: MIME_TYPES.excalidrawlib,
38 | }),
39 | );
40 | await waitFor(() => {
41 | expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
42 | });
43 | });
44 |
45 | it("inserting library item should revert to selection tool", async () => {
46 | UI.clickTool("rectangle");
47 | expect(h.elements).toEqual([]);
48 | const libraryItems: LibraryItem = JSON.parse(
49 | await API.readFile("./fixtures/fixture_library.excalidrawlib", "utf8"),
50 | ).library[0];
51 | await API.drop(
52 | new Blob([JSON.stringify(libraryItems)], {
53 | type: MIME_TYPES.excalidrawlib,
54 | }),
55 | );
56 | await waitFor(() => {
57 | expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
58 | });
59 | expect(h.state.elementType).toBe("selection");
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/src/scene/selection.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ExcalidrawElement,
3 | NonDeletedExcalidrawElement,
4 | } from "../element/types";
5 | import { getElementAbsoluteCoords, getElementBounds } from "../element";
6 | import { AppState } from "../types";
7 |
8 | export const getElementsWithinSelection = (
9 | elements: readonly NonDeletedExcalidrawElement[],
10 | selection: NonDeletedExcalidrawElement,
11 | ) => {
12 | const [
13 | selectionX1,
14 | selectionY1,
15 | selectionX2,
16 | selectionY2,
17 | ] = getElementAbsoluteCoords(selection);
18 | return elements.filter((element) => {
19 | const [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
20 | element,
21 | );
22 |
23 | return (
24 | element.type !== "selection" &&
25 | selectionX1 <= elementX1 &&
26 | selectionY1 <= elementY1 &&
27 | selectionX2 >= elementX2 &&
28 | selectionY2 >= elementY2
29 | );
30 | });
31 | };
32 |
33 | export const isSomeElementSelected = (
34 | elements: readonly NonDeletedExcalidrawElement[],
35 | appState: AppState,
36 | ): boolean =>
37 | elements.some((element) => appState.selectedElementIds[element.id]);
38 |
39 | /**
40 | * Returns common attribute (picked by `getAttribute` callback) of selected
41 | * elements. If elements don't share the same value, returns `null`.
42 | */
43 | export const getCommonAttributeOfSelectedElements = (
44 | elements: readonly NonDeletedExcalidrawElement[],
45 | appState: AppState,
46 | getAttribute: (element: ExcalidrawElement) => T,
47 | ): T | null => {
48 | const attributes = Array.from(
49 | new Set(
50 | getSelectedElements(elements, appState).map((element) =>
51 | getAttribute(element),
52 | ),
53 | ),
54 | );
55 | return attributes.length === 1 ? attributes[0] : null;
56 | };
57 |
58 | export const getSelectedElements = (
59 | elements: readonly NonDeletedExcalidrawElement[],
60 | appState: AppState,
61 | ) => elements.filter((element) => appState.selectedElementIds[element.id]);
62 |
63 | export const getTargetElements = (
64 | elements: readonly NonDeletedExcalidrawElement[],
65 | appState: AppState,
66 | ) =>
67 | appState.editingElement
68 | ? [appState.editingElement]
69 | : getSelectedElements(elements, appState);
70 |
--------------------------------------------------------------------------------
/src/components/DarkModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import "./ToolIcon.scss";
2 |
3 | import React from "react";
4 | import { t } from "../i18n";
5 | import { ToolButton } from "./ToolButton";
6 |
7 | export type Appearence = "light" | "dark";
8 |
9 | // We chose to use only explicit toggle and not a third option for system value,
10 | // but this could be added in the future.
11 | export const DarkModeToggle = (props: {
12 | value: Appearence;
13 | onChange: (value: Appearence) => void;
14 | title?: string;
15 | }) => {
16 | const title =
17 | props.title ||
18 | (props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
19 |
20 | return (
21 | props.onChange(props.value === "dark" ? "light" : "dark")}
27 | data-testid="toggle-dark-mode"
28 | />
29 | );
30 | };
31 |
32 | const ICONS = {
33 | SUN: (
34 |
40 | ),
41 | MOON: (
42 |
48 | ),
49 | };
50 |
--------------------------------------------------------------------------------
/src/tests/viewMode.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, GlobalTestState } from "./test-utils";
3 | import ExcalidrawApp from "../excalidraw-app";
4 | import { KEYS } from "../keys";
5 | import { Keyboard, Pointer, UI } from "./helpers/ui";
6 | import { CURSOR_TYPE } from "../constants";
7 |
8 | const { h } = window;
9 | const mouse = new Pointer("mouse");
10 | const touch = new Pointer("touch");
11 | const pen = new Pointer("pen");
12 | const pointerTypes = [mouse, touch, pen];
13 |
14 | describe("view mode", () => {
15 | beforeEach(async () => {
16 | await render();
17 | });
18 |
19 | it("after switching to view mode – cursor type should be pointer", async () => {
20 | h.setState({ viewModeEnabled: true });
21 | expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
22 | });
23 |
24 | it("after switching to view mode, moving, clicking, and pressing space key – cursor type should be pointer", async () => {
25 | h.setState({ viewModeEnabled: true });
26 |
27 | pointerTypes.forEach((pointerType) => {
28 | const pointer = pointerType;
29 | pointer.reset();
30 | pointer.move(100, 100);
31 | pointer.click();
32 | Keyboard.keyPress(KEYS.SPACE);
33 | expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
34 | });
35 | });
36 |
37 | it("cursor should stay as grabbing type when hovering over canvas elements", async () => {
38 | // create a rectangle, then hover over it – cursor should be
39 | // move type for mouse and grab for touch & pen
40 | // then switch to view-mode and cursor should be grabbing type
41 | UI.createElement("rectangle", { size: 100 });
42 |
43 | pointerTypes.forEach((pointerType) => {
44 | const pointer = pointerType;
45 |
46 | pointer.moveTo(50, 50);
47 | // eslint-disable-next-line dot-notation
48 | if (pointerType["pointerType"] === "mouse") {
49 | expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.MOVE);
50 | } else {
51 | expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
52 | }
53 |
54 | h.setState({ viewModeEnabled: true });
55 | expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
56 | });
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/actions/shortcuts.ts:
--------------------------------------------------------------------------------
1 | import { t } from "../i18n";
2 | import { isDarwin } from "../keys";
3 | import { getShortcutKey } from "../utils";
4 |
5 | export type ShortcutName =
6 | | "cut"
7 | | "copy"
8 | | "paste"
9 | | "copyStyles"
10 | | "pasteStyles"
11 | | "selectAll"
12 | | "deleteSelectedElements"
13 | | "duplicateSelection"
14 | | "sendBackward"
15 | | "bringForward"
16 | | "sendToBack"
17 | | "bringToFront"
18 | | "copyAsPng"
19 | | "copyAsSvg"
20 | | "group"
21 | | "ungroup"
22 | | "gridMode"
23 | | "zenMode"
24 | | "stats"
25 | | "addToLibrary"
26 | | "viewMode"
27 | | "flipHorizontal"
28 | | "flipVertical";
29 |
30 | const shortcutMap: Record = {
31 | cut: [getShortcutKey("CtrlOrCmd+X")],
32 | copy: [getShortcutKey("CtrlOrCmd+C")],
33 | paste: [getShortcutKey("CtrlOrCmd+V")],
34 | copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
35 | pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
36 | selectAll: [getShortcutKey("CtrlOrCmd+A")],
37 | deleteSelectedElements: [getShortcutKey("Del")],
38 | duplicateSelection: [
39 | getShortcutKey("CtrlOrCmd+D"),
40 | getShortcutKey(`Alt+${t("helpDialog.drag")}`),
41 | ],
42 | sendBackward: [getShortcutKey("CtrlOrCmd+[")],
43 | bringForward: [getShortcutKey("CtrlOrCmd+]")],
44 | sendToBack: [
45 | isDarwin
46 | ? getShortcutKey("CtrlOrCmd+Alt+[")
47 | : getShortcutKey("CtrlOrCmd+Shift+["),
48 | ],
49 | bringToFront: [
50 | isDarwin
51 | ? getShortcutKey("CtrlOrCmd+Alt+]")
52 | : getShortcutKey("CtrlOrCmd+Shift+]"),
53 | ],
54 | copyAsPng: [getShortcutKey("Shift+Alt+C")],
55 | copyAsSvg: [],
56 | group: [getShortcutKey("CtrlOrCmd+G")],
57 | ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
58 | gridMode: [getShortcutKey("CtrlOrCmd+'")],
59 | zenMode: [getShortcutKey("Alt+Z")],
60 | stats: [getShortcutKey("Alt+/")],
61 | addToLibrary: [],
62 | flipHorizontal: [getShortcutKey("Shift+H")],
63 | flipVertical: [getShortcutKey("Shift+V")],
64 | viewMode: [getShortcutKey("Alt+R")],
65 | };
66 |
67 | export const getShortcutFromShortcutName = (name: ShortcutName) => {
68 | const shortcuts = shortcutMap[name];
69 | // if multiple shortcuts availiable, take the first one
70 | return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
71 | };
72 |
--------------------------------------------------------------------------------
/src/element/sizeHelpers.test.ts:
--------------------------------------------------------------------------------
1 | import { getPerfectElementSize } from "./sizeHelpers";
2 | import * as constants from "../constants";
3 |
4 | describe("getPerfectElementSize", () => {
5 | it("should return height:0 if `elementType` is line and locked angle is 0", () => {
6 | const { height, width } = getPerfectElementSize("line", 149, 10);
7 | expect(width).toEqual(149);
8 | expect(height).toEqual(0);
9 | });
10 | it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
11 | const { height, width } = getPerfectElementSize("line", 10, 140);
12 | expect(width).toEqual(0);
13 | expect(height).toEqual(140);
14 | });
15 | it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
16 | const { height, width } = getPerfectElementSize("arrow", 200, 20);
17 | expect(width).toEqual(200);
18 | expect(height).toEqual(0);
19 | });
20 | it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
21 | const { height, width } = getPerfectElementSize("arrow", 10, 100);
22 | expect(width).toEqual(0);
23 | expect(height).toEqual(100);
24 | });
25 | it("should return adjust height to be width * tan(locked angle)", () => {
26 | const { height, width } = getPerfectElementSize("arrow", 120, 185);
27 | expect(width).toEqual(120);
28 | expect(height).toEqual(208);
29 | });
30 | it("should return height equals to width if locked angle is 45 deg", () => {
31 | const { height, width } = getPerfectElementSize("arrow", 135, 145);
32 | expect(width).toEqual(135);
33 | expect(height).toEqual(135);
34 | });
35 | it("should return height:0 and width:0 when width and heigh are 0", () => {
36 | const { height, width } = getPerfectElementSize("arrow", 0, 0);
37 | expect(width).toEqual(0);
38 | expect(height).toEqual(0);
39 | });
40 |
41 | describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
42 | it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
43 | (constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
44 | const { height, width } = getPerfectElementSize("arrow", 120, 185);
45 | expect(width).toEqual(120);
46 | expect(height).toEqual(120);
47 | });
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/actions/index.ts:
--------------------------------------------------------------------------------
1 | export { actionDeleteSelected } from "./actionDeleteSelected";
2 | export {
3 | actionBringForward,
4 | actionBringToFront,
5 | actionSendBackward,
6 | actionSendToBack,
7 | } from "./actionZindex";
8 | export { actionSelectAll } from "./actionSelectAll";
9 | export { actionDuplicateSelection } from "./actionDuplicateSelection";
10 | export {
11 | actionChangeStrokeColor,
12 | actionChangeBackgroundColor,
13 | actionChangeStrokeWidth,
14 | actionChangeFillStyle,
15 | actionChangeSloppiness,
16 | actionChangeOpacity,
17 | actionChangeFontSize,
18 | actionChangeFontFamily,
19 | actionChangeTextAlign,
20 | } from "./actionProperties";
21 |
22 | export {
23 | actionChangeViewBackgroundColor,
24 | actionClearCanvas,
25 | actionZoomIn,
26 | actionZoomOut,
27 | actionResetZoom,
28 | actionZoomToFit,
29 | actionToggleTheme,
30 | } from "./actionCanvas";
31 |
32 | export { actionFinalize } from "./actionFinalize";
33 |
34 | export {
35 | actionChangeProjectName,
36 | actionChangeExportBackground,
37 | actionSaveToActiveFile,
38 | actionSaveFileToDisk,
39 | actionLoadScene,
40 | } from "./actionExport";
41 |
42 | export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
43 | export {
44 | actionToggleCanvasMenu,
45 | actionToggleEditMenu,
46 | actionFullScreen,
47 | actionShortcuts,
48 | } from "./actionMenu";
49 |
50 | export { actionGroup, actionUngroup } from "./actionGroup";
51 |
52 | export { actionGoToCollaborator } from "./actionNavigate";
53 |
54 | export { actionAddToLibrary } from "./actionAddToLibrary";
55 |
56 | export {
57 | actionAlignTop,
58 | actionAlignBottom,
59 | actionAlignLeft,
60 | actionAlignRight,
61 | actionAlignVerticallyCentered,
62 | actionAlignHorizontallyCentered,
63 | } from "./actionAlign";
64 |
65 | export {
66 | distributeHorizontally,
67 | distributeVertically,
68 | } from "./actionDistribute";
69 |
70 | export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
71 |
72 | export {
73 | actionCopy,
74 | actionCut,
75 | actionCopyAsPng,
76 | actionCopyAsSvg,
77 | } from "./actionClipboard";
78 |
79 | export { actionToggleGridMode } from "./actionToggleGridMode";
80 | export { actionToggleZenMode } from "./actionToggleZenMode";
81 |
82 | export { actionToggleStats } from "./actionToggleStats";
83 |
--------------------------------------------------------------------------------
/src/tests/packages/__snapshots__/utils.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`exportToSvg with default arguments 1`] = `
4 | Object {
5 | "collaborators": Map {},
6 | "currentChartType": "bar",
7 | "currentItemBackgroundColor": "transparent",
8 | "currentItemEndArrowhead": "arrow",
9 | "currentItemFillStyle": "hachure",
10 | "currentItemFontFamily": 1,
11 | "currentItemFontSize": 20,
12 | "currentItemLinearStrokeSharpness": "round",
13 | "currentItemOpacity": 100,
14 | "currentItemRoughness": 1,
15 | "currentItemStartArrowhead": null,
16 | "currentItemStrokeColor": "#000000",
17 | "currentItemStrokeSharpness": "sharp",
18 | "currentItemStrokeStyle": "solid",
19 | "currentItemStrokeWidth": 1,
20 | "currentItemTextAlign": "left",
21 | "cursorButton": "up",
22 | "draggingElement": null,
23 | "editingElement": null,
24 | "editingGroupId": null,
25 | "editingLinearElement": null,
26 | "elementLocked": false,
27 | "elementType": "selection",
28 | "errorMessage": null,
29 | "exportBackground": true,
30 | "exportEmbedScene": false,
31 | "exportPadding": undefined,
32 | "exportScale": 1,
33 | "exportWithDarkMode": false,
34 | "fileHandle": null,
35 | "gridSize": null,
36 | "isBindingEnabled": true,
37 | "isLibraryOpen": false,
38 | "isLoading": false,
39 | "isResizing": false,
40 | "isRotating": false,
41 | "lastPointerDownWith": "mouse",
42 | "multiElement": null,
43 | "name": "name",
44 | "openMenu": null,
45 | "openPopup": null,
46 | "pasteDialog": Object {
47 | "data": null,
48 | "shown": false,
49 | },
50 | "previousSelectedElementIds": Object {},
51 | "resizingElement": null,
52 | "scrollX": 0,
53 | "scrollY": 0,
54 | "scrolledOutside": false,
55 | "selectedElementIds": Object {},
56 | "selectedGroupIds": Object {},
57 | "selectionElement": null,
58 | "shouldCacheIgnoreZoom": false,
59 | "showHelpDialog": false,
60 | "showStats": false,
61 | "startBoundElement": null,
62 | "suggestedBindings": Array [],
63 | "theme": "light",
64 | "toastMessage": null,
65 | "viewBackgroundColor": "#ffffff",
66 | "viewModeEnabled": false,
67 | "zenModeEnabled": false,
68 | "zoom": Object {
69 | "translation": Object {
70 | "x": 0,
71 | "y": 0,
72 | },
73 | "value": 1,
74 | },
75 | }
76 | `;
77 |
--------------------------------------------------------------------------------
/src/components/Modal.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | &.excalidraw-modal-container {
5 | position: absolute;
6 | z-index: 10;
7 | }
8 |
9 | .Modal {
10 | position: absolute;
11 | top: 0;
12 | left: 0;
13 | right: 0;
14 | bottom: 0;
15 | display: flex;
16 | align-items: center;
17 | justify-content: center;
18 | overflow: auto;
19 | padding: calc(var(--space-factor) * 10);
20 | }
21 |
22 | .Modal__background {
23 | position: absolute;
24 | top: 0;
25 | left: 0;
26 | right: 0;
27 | bottom: 0;
28 | z-index: 1;
29 | background-color: transparentize($oc-black, 0.3);
30 | }
31 |
32 | .Modal__content {
33 | position: relative;
34 | z-index: 2;
35 | width: 100%;
36 | max-width: var(--max-width);
37 | max-height: 100%;
38 |
39 | opacity: 0;
40 | transform: translateY(10px);
41 | animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
42 | position: relative;
43 | overflow-y: auto;
44 |
45 | // for modals, reset blurry bg
46 | background: var(--island-bg-color);
47 |
48 | border: 1px solid var(--dialog-border-color);
49 | box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
50 | border-radius: 6px;
51 | box-sizing: border-box;
52 |
53 | &:focus {
54 | outline: none;
55 | }
56 |
57 | @include isMobile {
58 | max-width: 100%;
59 | border: 0;
60 | border-radius: 0;
61 | }
62 | }
63 |
64 | @keyframes Modal__content_fade-in {
65 | from {
66 | opacity: 0;
67 | transform: translateY(10px);
68 | }
69 | to {
70 | opacity: 1;
71 | transform: translateY(0);
72 | }
73 | }
74 |
75 | .Modal__close {
76 | width: calc(var(--space-factor) * 7);
77 | height: calc(var(--space-factor) * 7);
78 | display: flex;
79 | align-items: center;
80 | justify-content: center;
81 |
82 | svg {
83 | height: calc(var(--space-factor) * 5);
84 | }
85 | }
86 |
87 | @include isMobile {
88 | .Modal {
89 | padding: 0;
90 | }
91 |
92 | .Modal__content {
93 | position: absolute;
94 | top: 0;
95 | left: 0;
96 | right: 0;
97 | bottom: 0;
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/components/ContextMenu.scss:
--------------------------------------------------------------------------------
1 | @import "../css/variables.module";
2 |
3 | .excalidraw {
4 | .context-menu {
5 | position: relative;
6 | border-radius: 4px;
7 | box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
8 | padding: 0;
9 | list-style: none;
10 | user-select: none;
11 | margin: -0.25rem 0 0 0.125rem;
12 | padding: 0.5rem 0;
13 | background-color: var(--popup-secondary-bg-color);
14 | border: 1px solid var(--button-gray-3);
15 | cursor: default;
16 | }
17 |
18 | .context-menu button {
19 | color: var(--popup-text-color);
20 | }
21 |
22 | .context-menu-option {
23 | position: relative;
24 | width: 100%;
25 | min-width: 9.5rem;
26 | margin: 0;
27 | padding: 0.25rem 1rem 0.25rem 1.25rem;
28 | text-align: start;
29 | border-radius: 0;
30 | background-color: transparent;
31 | border: none;
32 | white-space: nowrap;
33 |
34 | display: grid;
35 | grid-template-columns: 1fr 0.2fr;
36 | align-items: center;
37 |
38 | &.checkmark::before {
39 | position: absolute;
40 | left: 6px;
41 | margin-bottom: 1px;
42 | content: "\2713";
43 | }
44 |
45 | &.dangerous {
46 | .context-menu-option__label {
47 | color: $oc-red-7;
48 | }
49 | }
50 |
51 | .context-menu-option__label {
52 | justify-self: start;
53 | margin-inline-end: 20px;
54 | }
55 | .context-menu-option__shortcut {
56 | justify-self: end;
57 | opacity: 0.6;
58 | font-family: inherit;
59 | font-size: 0.7rem;
60 | }
61 | }
62 |
63 | .context-menu-option:hover {
64 | color: var(--popup-bg-color);
65 | background-color: var(--select-highlight-color);
66 |
67 | &.dangerous {
68 | .context-menu-option__label {
69 | color: var(--popup-bg-color);
70 | }
71 | background-color: $oc-red-6;
72 | }
73 | }
74 |
75 | .context-menu-option:focus {
76 | z-index: 1;
77 | }
78 |
79 | @include isMobile {
80 | .context-menu-option {
81 | display: block;
82 |
83 | .context-menu-option__label {
84 | margin-inline-end: 0;
85 | }
86 |
87 | .context-menu-option__shortcut {
88 | display: none;
89 | }
90 | }
91 | }
92 |
93 | .context-menu-option-separator {
94 | border: none;
95 | border-top: 1px solid $oc-gray-5;
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/tests/collab.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, updateSceneData, waitFor } from "./test-utils";
3 | import ExcalidrawApp from "../excalidraw-app";
4 | import { API } from "./helpers/api";
5 | import { createUndoAction } from "../actions/actionHistory";
6 | const { h } = window;
7 |
8 | Object.defineProperty(window, "crypto", {
9 | value: {
10 | getRandomValues: (arr: number[]) =>
11 | arr.forEach((v, i) => (arr[i] = Math.floor(Math.random() * 256))),
12 | subtle: {
13 | generateKey: () => {},
14 | exportKey: () => ({ k: "sTdLvMC_M3V8_vGa3UVRDg" }),
15 | },
16 | },
17 | });
18 |
19 | jest.mock("../excalidraw-app/data/firebase.ts", () => {
20 | const loadFromFirebase = async () => null;
21 | const saveToFirebase = () => {};
22 | const isSavedToFirebase = () => true;
23 |
24 | return {
25 | loadFromFirebase,
26 | saveToFirebase,
27 | isSavedToFirebase,
28 | };
29 | });
30 |
31 | jest.mock("socket.io-client", () => {
32 | return () => {
33 | return {
34 | close: () => {},
35 | on: () => {},
36 | off: () => {},
37 | emit: () => {},
38 | };
39 | };
40 | });
41 |
42 | describe("collaboration", () => {
43 | it("creating room should reset deleted elements", async () => {
44 | await render();
45 | // To update the scene with deleted elements before starting collab
46 | updateSceneData({
47 | elements: [
48 | API.createElement({ type: "rectangle", id: "A" }),
49 | API.createElement({
50 | type: "rectangle",
51 | id: "B",
52 | isDeleted: true,
53 | }),
54 | ],
55 | });
56 | await waitFor(() => {
57 | expect(h.elements).toEqual([
58 | expect.objectContaining({ id: "A" }),
59 | expect.objectContaining({ id: "B", isDeleted: true }),
60 | ]);
61 | expect(API.getStateHistory().length).toBe(1);
62 | });
63 | window.collab.openPortal();
64 | await waitFor(() => {
65 | expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
66 | expect(API.getStateHistory().length).toBe(1);
67 | });
68 |
69 | const undoAction = createUndoAction(h.history);
70 | // noop
71 | h.app.actionManager.executeAction(undoAction);
72 | await waitFor(() => {
73 | expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
74 | expect(API.getStateHistory().length).toBe(1);
75 | });
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/excalidraw-app/CustomStats.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { debounce, getVersion, nFormatter } from "../utils";
3 | import {
4 | getElementsStorageSize,
5 | getTotalStorageSize,
6 | } from "./data/localStorage";
7 | import { DEFAULT_VERSION } from "../constants";
8 | import { t } from "../i18n";
9 | import { copyTextToSystemClipboard } from "../clipboard";
10 | type StorageSizes = { scene: number; total: number };
11 |
12 | const STORAGE_SIZE_TIMEOUT = 500;
13 |
14 | const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
15 | cb({
16 | scene: getElementsStorageSize(),
17 | total: getTotalStorageSize(),
18 | });
19 | }, STORAGE_SIZE_TIMEOUT);
20 |
21 | type Props = {
22 | setToastMessage: (message: string) => void;
23 | };
24 | const CustomStats = (props: Props) => {
25 | const [storageSizes, setStorageSizes] = useState({
26 | scene: 0,
27 | total: 0,
28 | });
29 |
30 | useEffect(() => {
31 | getStorageSizes((sizes) => {
32 | setStorageSizes(sizes);
33 | });
34 | });
35 | useEffect(() => () => getStorageSizes.cancel(), []);
36 |
37 | const version = getVersion();
38 | let hash;
39 | let timestamp;
40 |
41 | if (version !== DEFAULT_VERSION) {
42 | timestamp = version.slice(0, 16).replace("T", " ");
43 | hash = version.slice(21);
44 | } else {
45 | timestamp = t("stats.versionNotAvailable");
46 | }
47 |
48 | return (
49 | <>
50 |
51 | | {t("stats.storage")} |
52 |
53 |
54 | | {t("stats.scene")} |
55 | {nFormatter(storageSizes.scene, 1)} |
56 |
57 |
58 | | {t("stats.total")} |
59 | {nFormatter(storageSizes.total, 1)} |
60 |
61 |
62 | | {t("stats.version")} |
63 |
64 |
65 | {
69 | try {
70 | await copyTextToSystemClipboard(getVersion());
71 | props.setToastMessage(t("toast.copyToClipboard"));
72 | } catch {}
73 | }}
74 | title={t("stats.versionCopy")}
75 | >
76 | {timestamp}
77 |
78 | {hash}
79 | |
80 |
81 | >
82 | );
83 | };
84 |
85 | export default CustomStats;
86 |
--------------------------------------------------------------------------------
/src/packages/excalidraw/webpack.dev.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const webpack = require("webpack");
3 | const autoprefixer = require("autoprefixer");
4 |
5 | module.exports = {
6 | mode: "development",
7 | devtool: false,
8 | entry: {
9 | "excalidraw.development": "./entry.js",
10 | },
11 | output: {
12 | path: path.resolve(__dirname, "dist"),
13 | library: "Excalidraw",
14 | libraryTarget: "umd",
15 | filename: "[name].js",
16 | chunkFilename: "excalidraw-assets-dev/[name]-[contenthash].js",
17 | publicPath: "",
18 | },
19 | resolve: {
20 | extensions: [".js", ".ts", ".tsx", ".css", ".scss"],
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.(sa|sc|c)ss$/,
26 | exclude: /node_modules/,
27 | use: [
28 | "style-loader",
29 | { loader: "css-loader" },
30 | {
31 | loader: "postcss-loader",
32 | options: {
33 | postcssOptions: {
34 | plugins: [autoprefixer()],
35 | },
36 | },
37 | },
38 | "sass-loader",
39 | ],
40 | },
41 | {
42 | test: /\.(ts|tsx|js|jsx|mjs)$/,
43 | exclude: /node_modules/,
44 | use: [
45 | {
46 | loader: "ts-loader",
47 | options: {
48 | transpileOnly: true,
49 | configFile: path.resolve(__dirname, "../tsconfig.dev.json"),
50 | },
51 | },
52 | ],
53 | },
54 | {
55 | test: /\.(woff|woff2|eot|ttf|otf)$/,
56 | use: [
57 | {
58 | loader: "file-loader",
59 | options: {
60 | name: "[name].[ext]",
61 | outputPath: "excalidraw-assets-dev",
62 | },
63 | },
64 | ],
65 | },
66 | ],
67 | },
68 | optimization: {
69 | splitChunks: {
70 | chunks: "async",
71 | cacheGroups: {
72 | vendors: {
73 | test: /[\\/]node_modules[\\/]/,
74 | name: "vendor",
75 | },
76 | },
77 | },
78 | },
79 | plugins: [new webpack.EvalSourceMapDevToolPlugin({ exclude: /vendor/ })],
80 | externals: {
81 | react: {
82 | root: "React",
83 | commonjs2: "react",
84 | commonjs: "react",
85 | amd: "react",
86 | },
87 | "react-dom": {
88 | root: "ReactDOM",
89 | commonjs2: "react-dom",
90 | commonjs: "react-dom",
91 | amd: "react-dom",
92 | },
93 | },
94 | };
95 |
--------------------------------------------------------------------------------
/src/components/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import "./Tooltip.scss";
2 |
3 | import React, { useEffect } from "react";
4 |
5 | const getTooltipDiv = () => {
6 | const existingDiv = document.querySelector(
7 | ".excalidraw-tooltip",
8 | );
9 | if (existingDiv) {
10 | return existingDiv;
11 | }
12 | const div = document.createElement("div");
13 | document.body.appendChild(div);
14 | div.classList.add("excalidraw-tooltip");
15 | return div;
16 | };
17 |
18 | const updateTooltip = (
19 | item: HTMLDivElement,
20 | tooltip: HTMLDivElement,
21 | label: string,
22 | long: boolean,
23 | ) => {
24 | tooltip.classList.add("excalidraw-tooltip--visible");
25 | tooltip.style.minWidth = long ? "50ch" : "10ch";
26 | tooltip.style.maxWidth = long ? "50ch" : "15ch";
27 |
28 | tooltip.textContent = label;
29 |
30 | const {
31 | x: itemX,
32 | bottom: itemBottom,
33 | top: itemTop,
34 | width: itemWidth,
35 | } = item.getBoundingClientRect();
36 |
37 | const {
38 | width: labelWidth,
39 | height: labelHeight,
40 | } = tooltip.getBoundingClientRect();
41 |
42 | const viewportWidth = window.innerWidth;
43 | const viewportHeight = window.innerHeight;
44 |
45 | const margin = 5;
46 |
47 | const left = itemX + itemWidth / 2 - labelWidth / 2;
48 | const offsetLeft =
49 | left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
50 |
51 | const top = itemBottom + margin;
52 | const offsetTop =
53 | top + labelHeight >= viewportHeight
54 | ? itemBottom - itemTop + labelHeight + margin * 2
55 | : 0;
56 |
57 | Object.assign(tooltip.style, {
58 | top: `${top - offsetTop}px`,
59 | left: `${left - offsetLeft}px`,
60 | });
61 | };
62 |
63 | type TooltipProps = {
64 | children: React.ReactNode;
65 | label: string;
66 | long?: boolean;
67 | };
68 |
69 | export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
70 | useEffect(() => {
71 | return () =>
72 | getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
73 | }, []);
74 |
75 | return (
76 |
79 | updateTooltip(
80 | event.currentTarget as HTMLDivElement,
81 | getTooltipDiv(),
82 | label,
83 | long,
84 | )
85 | }
86 | onPointerLeave={() =>
87 | getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
88 | }
89 | >
90 | {children}
91 |
92 | );
93 | };
94 |
--------------------------------------------------------------------------------
/src/scene/scroll.ts:
--------------------------------------------------------------------------------
1 | import { AppState, PointerCoords, Zoom } from "../types";
2 | import { ExcalidrawElement } from "../element/types";
3 | import {
4 | getCommonBounds,
5 | getClosestElementBounds,
6 | getVisibleElements,
7 | } from "../element";
8 |
9 | import {
10 | sceneCoordsToViewportCoords,
11 | viewportCoordsToSceneCoords,
12 | } from "../utils";
13 |
14 | const isOutsideViewPort = (
15 | appState: AppState,
16 | canvas: HTMLCanvasElement | null,
17 | cords: Array,
18 | ) => {
19 | const [x1, y1, x2, y2] = cords;
20 | const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords(
21 | { sceneX: x1, sceneY: y1 },
22 | appState,
23 | );
24 | const { x: viewportX2, y: viewportY2 } = sceneCoordsToViewportCoords(
25 | { sceneX: x2, sceneY: y2 },
26 | appState,
27 | );
28 | return (
29 | viewportX2 - viewportX1 > appState.width ||
30 | viewportY2 - viewportY1 > appState.height
31 | );
32 | };
33 |
34 | export const centerScrollOn = ({
35 | scenePoint,
36 | viewportDimensions,
37 | zoom,
38 | }: {
39 | scenePoint: PointerCoords;
40 | viewportDimensions: { height: number; width: number };
41 | zoom: Zoom;
42 | }) => {
43 | return {
44 | scrollX:
45 | (viewportDimensions.width / 2) * (1 / zoom.value) -
46 | scenePoint.x -
47 | zoom.translation.x * (1 / zoom.value),
48 | scrollY:
49 | (viewportDimensions.height / 2) * (1 / zoom.value) -
50 | scenePoint.y -
51 | zoom.translation.y * (1 / zoom.value),
52 | };
53 | };
54 |
55 | export const calculateScrollCenter = (
56 | elements: readonly ExcalidrawElement[],
57 | appState: AppState,
58 | canvas: HTMLCanvasElement | null,
59 | ): { scrollX: number; scrollY: number } => {
60 | elements = getVisibleElements(elements);
61 |
62 | if (!elements.length) {
63 | return {
64 | scrollX: 0,
65 | scrollY: 0,
66 | };
67 | }
68 | let [x1, y1, x2, y2] = getCommonBounds(elements);
69 |
70 | if (isOutsideViewPort(appState, canvas, [x1, y1, x2, y2])) {
71 | [x1, y1, x2, y2] = getClosestElementBounds(
72 | elements,
73 | viewportCoordsToSceneCoords(
74 | { clientX: appState.scrollX, clientY: appState.scrollY },
75 | appState,
76 | ),
77 | );
78 | }
79 |
80 | const centerX = (x1 + x2) / 2;
81 | const centerY = (y1 + y2) / 2;
82 |
83 | return centerScrollOn({
84 | scenePoint: { x: centerX, y: centerY },
85 | viewportDimensions: { width: appState.width, height: appState.height },
86 | zoom: appState.zoom,
87 | });
88 | };
89 |
--------------------------------------------------------------------------------
/src/tests/geometricAlgebra.test.ts:
--------------------------------------------------------------------------------
1 | import * as GA from "../ga";
2 | import { point, toString, direction, offset } from "../ga";
3 | import * as GAPoint from "../gapoints";
4 | import * as GALine from "../galines";
5 | import * as GATransform from "../gatransforms";
6 |
7 | describe("geometric algebra", () => {
8 | describe("points", () => {
9 | it("distanceToLine", () => {
10 | const point = GA.point(3, 3);
11 | const line = GALine.equation(0, 1, -1);
12 | expect(GAPoint.distanceToLine(point, line)).toEqual(2);
13 | });
14 |
15 | it("distanceToLine neg", () => {
16 | const point = GA.point(-3, -3);
17 | const line = GALine.equation(0, 1, -1);
18 | expect(GAPoint.distanceToLine(point, line)).toEqual(-4);
19 | });
20 | });
21 | describe("lines", () => {
22 | it("through", () => {
23 | const a = GA.point(0, 0);
24 | const b = GA.point(2, 0);
25 | expect(toString(GALine.through(a, b))).toEqual(
26 | toString(GALine.equation(0, 2, 0)),
27 | );
28 | });
29 | it("parallel", () => {
30 | const point = GA.point(3, 3);
31 | const line = GALine.equation(0, 1, -1);
32 | const parallel = GALine.parallel(line, 2);
33 | expect(GAPoint.distanceToLine(point, parallel)).toEqual(0);
34 | });
35 | });
36 |
37 | describe("translation", () => {
38 | it("points", () => {
39 | const start = point(2, 2);
40 | const move = GATransform.translation(direction(0, 1));
41 | const end = GATransform.apply(move, start);
42 | expect(toString(end)).toEqual(toString(point(2, 3)));
43 | });
44 |
45 | it("points 2", () => {
46 | const start = point(2, 2);
47 | const move = GATransform.translation(offset(3, 4));
48 | const end = GATransform.apply(move, start);
49 | expect(toString(end)).toEqual(toString(point(5, 6)));
50 | });
51 |
52 | it("lines", () => {
53 | const original = GALine.through(point(2, 2), point(3, 4));
54 | const move = GATransform.translation(offset(3, 4));
55 | const parallel = GATransform.apply(move, original);
56 | expect(toString(parallel)).toEqual(
57 | toString(GALine.through(point(5, 6), point(6, 8))),
58 | );
59 | });
60 | });
61 | describe("rotation", () => {
62 | it("points", () => {
63 | const start = point(2, 2);
64 | const pivot = point(1, 1);
65 | const rotate = GATransform.rotation(pivot, Math.PI / 2);
66 | const end = GATransform.apply(rotate, start);
67 | expect(toString(end)).toEqual(toString(point(2, 0)));
68 | });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/src/components/HintViewer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { t } from "../i18n";
3 | import { NonDeletedExcalidrawElement } from "../element/types";
4 | import { getSelectedElements } from "../scene";
5 |
6 | import "./HintViewer.scss";
7 | import { AppState } from "../types";
8 | import { isLinearElement, isTextElement } from "../element/typeChecks";
9 | import { getShortcutKey } from "../utils";
10 |
11 | interface Hint {
12 | appState: AppState;
13 | elements: readonly NonDeletedExcalidrawElement[];
14 | }
15 |
16 | const getHints = ({ appState, elements }: Hint) => {
17 | const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
18 | const multiMode = appState.multiElement !== null;
19 | if (elementType === "arrow" || elementType === "line") {
20 | if (!multiMode) {
21 | return t("hints.linearElement");
22 | }
23 | return t("hints.linearElementMulti");
24 | }
25 |
26 | if (elementType === "freedraw") {
27 | return t("hints.freeDraw");
28 | }
29 |
30 | if (elementType === "text") {
31 | return t("hints.text");
32 | }
33 |
34 | const selectedElements = getSelectedElements(elements, appState);
35 | if (
36 | isResizing &&
37 | lastPointerDownWith === "mouse" &&
38 | selectedElements.length === 1
39 | ) {
40 | const targetElement = selectedElements[0];
41 | if (isLinearElement(targetElement) && targetElement.points.length === 2) {
42 | return t("hints.lockAngle");
43 | }
44 | return t("hints.resize");
45 | }
46 |
47 | if (isRotating && lastPointerDownWith === "mouse") {
48 | return t("hints.rotate");
49 | }
50 |
51 | if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
52 | if (appState.editingLinearElement) {
53 | return appState.editingLinearElement.activePointIndex
54 | ? t("hints.lineEditor_pointSelected")
55 | : t("hints.lineEditor_nothingSelected");
56 | }
57 | return t("hints.lineEditor_info");
58 | }
59 |
60 | if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
61 | return t("hints.text_selected");
62 | }
63 |
64 | if (appState.editingElement && isTextElement(appState.editingElement)) {
65 | return t("hints.text_editing");
66 | }
67 |
68 | return null;
69 | };
70 |
71 | export const HintViewer = ({ appState, elements }: Hint) => {
72 | let hint = getHints({
73 | appState,
74 | elements,
75 | });
76 | if (!hint) {
77 | return null;
78 | }
79 |
80 | hint = getShortcutKey(hint);
81 |
82 | return (
83 |
84 | {hint}
85 |
86 | );
87 | };
88 |
--------------------------------------------------------------------------------
/src/element/typeChecks.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ExcalidrawElement,
3 | ExcalidrawTextElement,
4 | ExcalidrawLinearElement,
5 | ExcalidrawBindableElement,
6 | ExcalidrawGenericElement,
7 | ExcalidrawFreeDrawElement,
8 | } from "./types";
9 |
10 | export const isGenericElement = (
11 | element: ExcalidrawElement | null,
12 | ): element is ExcalidrawGenericElement => {
13 | return (
14 | element != null &&
15 | (element.type === "selection" ||
16 | element.type === "rectangle" ||
17 | element.type === "diamond" ||
18 | element.type === "ellipse")
19 | );
20 | };
21 |
22 | export const isTextElement = (
23 | element: ExcalidrawElement | null,
24 | ): element is ExcalidrawTextElement => {
25 | return element != null && element.type === "text";
26 | };
27 |
28 | export const isFreeDrawElement = (
29 | element?: ExcalidrawElement | null,
30 | ): element is ExcalidrawFreeDrawElement => {
31 | return element != null && isFreeDrawElementType(element.type);
32 | };
33 |
34 | export const isFreeDrawElementType = (
35 | elementType: ExcalidrawElement["type"],
36 | ): boolean => {
37 | return elementType === "freedraw";
38 | };
39 |
40 | export const isLinearElement = (
41 | element?: ExcalidrawElement | null,
42 | ): element is ExcalidrawLinearElement => {
43 | return element != null && isLinearElementType(element.type);
44 | };
45 |
46 | export const isLinearElementType = (
47 | elementType: ExcalidrawElement["type"],
48 | ): boolean => {
49 | return (
50 | elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
51 | );
52 | };
53 |
54 | export const isBindingElement = (
55 | element?: ExcalidrawElement | null,
56 | ): element is ExcalidrawLinearElement => {
57 | return element != null && isBindingElementType(element.type);
58 | };
59 |
60 | export const isBindingElementType = (
61 | elementType: ExcalidrawElement["type"],
62 | ): boolean => {
63 | return elementType === "arrow";
64 | };
65 |
66 | export const isBindableElement = (
67 | element: ExcalidrawElement | null,
68 | ): element is ExcalidrawBindableElement => {
69 | return (
70 | element != null &&
71 | (element.type === "rectangle" ||
72 | element.type === "diamond" ||
73 | element.type === "ellipse" ||
74 | element.type === "text")
75 | );
76 | };
77 |
78 | export const isExcalidrawElement = (element: any): boolean => {
79 | return (
80 | element?.type === "text" ||
81 | element?.type === "diamond" ||
82 | element?.type === "rectangle" ||
83 | element?.type === "ellipse" ||
84 | element?.type === "arrow" ||
85 | element?.type === "freedraw" ||
86 | element?.type === "line"
87 | );
88 | };
89 |
--------------------------------------------------------------------------------
/src/packages/excalidraw/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@excalidraw/excalidraw",
3 | "version": "0.9.0",
4 | "main": "main.js",
5 | "types": "types/packages/excalidraw/index.d.ts",
6 | "files": [
7 | "dist/*",
8 | "types/*"
9 | ],
10 | "publishConfig": {
11 | "access": "public"
12 | },
13 | "description": "Excalidraw as a React component",
14 | "repository": "https://github.com/excalidraw/excalidraw",
15 | "license": "MIT",
16 | "keywords": [
17 | "excalidraw",
18 | "excalidraw-embed",
19 | "react",
20 | "npm",
21 | "npm excalidraw"
22 | ],
23 | "browserslist": {
24 | "production": [
25 | ">0.2%",
26 | "not dead",
27 | "not ie <= 11",
28 | "not op_mini all",
29 | "not safari < 12",
30 | "not kaios <= 2.5",
31 | "not edge < 79",
32 | "not chrome < 70",
33 | "not and_uc < 13",
34 | "not samsung < 10"
35 | ],
36 | "development": [
37 | "last 1 chrome version",
38 | "last 1 firefox version",
39 | "last 1 safari version"
40 | ]
41 | },
42 | "peerDependencies": {
43 | "react": "^17.0.2",
44 | "react-dom": "^17.0.2"
45 | },
46 | "devDependencies": {
47 | "@babel/core": "7.14.8",
48 | "@babel/plugin-transform-arrow-functions": "7.14.5",
49 | "@babel/plugin-transform-async-to-generator": "7.14.5",
50 | "@babel/plugin-transform-runtime": "7.14.5",
51 | "@babel/plugin-transform-typescript": "7.14.6",
52 | "@babel/preset-env": "7.14.9",
53 | "@babel/preset-react": "7.14.5",
54 | "@babel/preset-typescript": "7.14.5",
55 | "autoprefixer": "10.3.1",
56 | "babel-loader": "8.2.2",
57 | "babel-plugin-transform-class-properties": "6.24.1",
58 | "cross-env": "7.0.3",
59 | "css-loader": "5.2.6",
60 | "file-loader": "6.2.0",
61 | "mini-css-extract-plugin": "1.6.1",
62 | "postcss-loader": "6.1.1",
63 | "sass-loader": "12.1.0",
64 | "terser-webpack-plugin": "5.1.4",
65 | "ts-loader": "9.2.4",
66 | "typescript": "4.3.5",
67 | "webpack": "5.50.0",
68 | "webpack-bundle-analyzer": "4.4.2",
69 | "webpack-cli": "4.7.2"
70 | },
71 | "bugs": "https://github.com/excalidraw/excalidraw/issues",
72 | "homepage": "https://github.com/excalidraw/excalidraw/tree/master/src/packages/excalidraw",
73 | "scripts": {
74 | "gen:types": "tsc --project ../../../tsconfig-types.json",
75 | "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && yarn gen:types",
76 | "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
77 | "pack": "yarn build:umd && yarn pack"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/actions/actionStyles.ts:
--------------------------------------------------------------------------------
1 | import {
2 | isTextElement,
3 | isExcalidrawElement,
4 | redrawTextBoundingBox,
5 | } from "../element";
6 | import { CODES, KEYS } from "../keys";
7 | import { t } from "../i18n";
8 | import { register } from "./register";
9 | import { mutateElement, newElementWith } from "../element/mutateElement";
10 | import {
11 | DEFAULT_FONT_SIZE,
12 | DEFAULT_FONT_FAMILY,
13 | DEFAULT_TEXT_ALIGN,
14 | } from "../constants";
15 |
16 | // `copiedStyles` is exported only for tests.
17 | export let copiedStyles: string = "{}";
18 |
19 | export const actionCopyStyles = register({
20 | name: "copyStyles",
21 | perform: (elements, appState) => {
22 | const element = elements.find((el) => appState.selectedElementIds[el.id]);
23 | if (element) {
24 | copiedStyles = JSON.stringify(element);
25 | }
26 | return {
27 | appState: {
28 | ...appState,
29 | toastMessage: t("toast.copyStyles"),
30 | },
31 | commitToHistory: false,
32 | };
33 | },
34 | contextItemLabel: "labels.copyStyles",
35 | keyTest: (event) =>
36 | event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
37 | });
38 |
39 | export const actionPasteStyles = register({
40 | name: "pasteStyles",
41 | perform: (elements, appState) => {
42 | const pastedElement = JSON.parse(copiedStyles);
43 | if (!isExcalidrawElement(pastedElement)) {
44 | return { elements, commitToHistory: false };
45 | }
46 | return {
47 | elements: elements.map((element) => {
48 | if (appState.selectedElementIds[element.id]) {
49 | const newElement = newElementWith(element, {
50 | backgroundColor: pastedElement?.backgroundColor,
51 | strokeWidth: pastedElement?.strokeWidth,
52 | strokeColor: pastedElement?.strokeColor,
53 | strokeStyle: pastedElement?.strokeStyle,
54 | fillStyle: pastedElement?.fillStyle,
55 | opacity: pastedElement?.opacity,
56 | roughness: pastedElement?.roughness,
57 | });
58 | if (isTextElement(newElement)) {
59 | mutateElement(newElement, {
60 | fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
61 | fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
62 | textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
63 | });
64 | redrawTextBoundingBox(newElement);
65 | }
66 | return newElement;
67 | }
68 | return element;
69 | }),
70 | commitToHistory: true,
71 | };
72 | },
73 | contextItemLabel: "labels.pasteStyles",
74 | keyTest: (event) =>
75 | event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
76 | });
77 |
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import "./Modal.scss";
2 |
3 | import React, { useState, useLayoutEffect, useRef } from "react";
4 | import { createPortal } from "react-dom";
5 | import clsx from "clsx";
6 | import { KEYS } from "../keys";
7 | import { useExcalidrawContainer, useIsMobile } from "./App";
8 | import { AppState } from "../types";
9 |
10 | export const Modal = (props: {
11 | className?: string;
12 | children: React.ReactNode;
13 | maxWidth?: number;
14 | onCloseRequest(): void;
15 | labelledBy: string;
16 | theme?: AppState["theme"];
17 | }) => {
18 | const { theme = "light" } = props;
19 | const modalRoot = useBodyRoot(theme);
20 |
21 | if (!modalRoot) {
22 | return null;
23 | }
24 |
25 | const handleKeydown = (event: React.KeyboardEvent) => {
26 | if (event.key === KEYS.ESCAPE) {
27 | event.nativeEvent.stopImmediatePropagation();
28 | event.stopPropagation();
29 | props.onCloseRequest();
30 | }
31 | };
32 |
33 | return createPortal(
34 |
41 |
42 |
47 | {props.children}
48 |
49 |
,
50 | modalRoot,
51 | );
52 | };
53 |
54 | const useBodyRoot = (theme: AppState["theme"]) => {
55 | const [div, setDiv] = useState(null);
56 |
57 | const isMobile = useIsMobile();
58 | const isMobileRef = useRef(isMobile);
59 | isMobileRef.current = isMobile;
60 |
61 | const { container: excalidrawContainer } = useExcalidrawContainer();
62 |
63 | useLayoutEffect(() => {
64 | if (div) {
65 | div.classList.toggle("excalidraw--mobile", isMobile);
66 | }
67 | }, [div, isMobile]);
68 |
69 | useLayoutEffect(() => {
70 | const isDarkTheme =
71 | !!excalidrawContainer?.classList.contains("theme--dark") ||
72 | theme === "dark";
73 | const div = document.createElement("div");
74 |
75 | div.classList.add("excalidraw", "excalidraw-modal-container");
76 | div.classList.toggle("excalidraw--mobile", isMobileRef.current);
77 |
78 | if (isDarkTheme) {
79 | div.classList.add("theme--dark");
80 | div.classList.add("theme--dark-background-none");
81 | }
82 | document.body.appendChild(div);
83 |
84 | setDiv(div);
85 |
86 | return () => {
87 | document.body.removeChild(div);
88 | };
89 | }, [excalidrawContainer, theme]);
90 |
91 | return div;
92 | };
93 |
--------------------------------------------------------------------------------