├── .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 | 35 |
{message}
36 |
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 | 2 | 3 | 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 | 20 | 26 | 30 | 36 | 41 | 42 | 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 | 2 | 3 | 6 | 7 | 8 | 9 | 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 | 2 | 3 | eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nGVSy07jMFx1MDAxNN3zXHUwMDE1kdmOIE1IXztcdTAwMWVcdTAwMDNiw6ZcdTAwMTKjXHUwMDExXHUwMDFhjUxym1hcdTAwMTjbsm9pU4TEZ8xufpFPwNcpcVqyiHTPfVx1MDAxZJ9zX4+ShGFrgM1cdTAwMTNcdTAwMDabkktRWb5mP1xif1x1MDAwMeuEVj6VhdjplS1DZYNo5qenUvuGRjuc52madk0g4Vx1MDAxOVx1MDAxNDpf9uDjJHlccn+fXHUwMDExXHUwMDE1tS4up2Urr2/M9lwivV1v26vf8Pc+tIaiLy5cYlx1MDAxYozoxkPT6biPW1x1MDAxZufF5KTokbWosCE0XHUwMDE2NSDqXHUwMDA2PVZMeoyrWtL8tEdcdTAwMWNa/Vx1MDAwNJdaakt7j8tZxjNcdTAwMWVXP/LyqbZ6paq+XHUwMDA2LVfOcOufXHUwMDE565ZCylx1MDAwNbay04eXzcpcdTAwMDI72PJrR3J0gPd9Tnv9Y5dfWzdcbpzb69GGl1x1MDAwMkmCUVx1MDAxYd9BXHUwMDFjzW1cdTAwMTV0/3M4v+HW7OYwR8GAXHUwMDE5XHUwMDAw+ZJl6WSSj2dxzcD9/Fx1MDAxMLzTKlx1MDAxY0IxK/JiXHUwMDFj08Jdef8xTFxccukgykhcbv7sbqNjqVZSRtvJbk/u4/+/94GmWuFCbGHfV0Kv+bOQ7Z4sNOJcXIqaXHUwMDE4M1x0y4E3njVcbn+qfVx1MDAxYbVcdTAwMTk67EBcbkVbzkZcdTAwMDF88/+gIePGLJAj5bo7Zi9cdTAwMDLWXHUwMDE332/ieFx1MDAxOb7dVO+GqHbM6Z1HNPPtXHUwMDEz+I3nwSJ9 4 | 5 | 15 | 16 | 😀 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 | 9 | 13 | 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 | 2 | 3 | ewogICJ0eXBlIjogImV4Y2FsaWRyYXciLAogICJ2ZXJzaW9uIjogMiwKICAic291cmNlIjogImh0dHBzOi8vZXhjYWxpZHJhdy5jb20iLAogICJlbGVtZW50cyI6IFsKICAgIHsKICAgICAgImlkIjogInRabVFwa0cyQlZ2SzNxT01icHVXeiIsCiAgICAgICJ0eXBlIjogInRleHQiLAogICAgICAieCI6IDg2MS4xMTExMTExMTExMTExLAogICAgICAieSI6IDM1Ni4zMzMzMzMzMzMzMzMzLAogICAgICAid2lkdGgiOiA3NywKICAgICAgImhlaWdodCI6IDU3LAogICAgICAiYW5nbGUiOiAwLAogICAgICAic3Ryb2tlQ29sb3IiOiAiIzAwMDAwMCIsCiAgICAgICJiYWNrZ3JvdW5kQ29sb3IiOiAiIzg2OGU5NiIsCiAgICAgICJmaWxsU3R5bGUiOiAiY3Jvc3MtaGF0Y2giLAogICAgICAic3Ryb2tlV2lkdGgiOiAyLAogICAgICAic3Ryb2tlU3R5bGUiOiAic29saWQiLAogICAgICAicm91Z2huZXNzIjogMSwKICAgICAgIm9wYWNpdHkiOiAxMDAsCiAgICAgICJncm91cElkcyI6IFtdLAogICAgICAic3Ryb2tlU2hhcnBuZXNzIjogInJvdW5kIiwKICAgICAgInNlZWQiOiA0NzYzNjM3OTMsCiAgICAgICJ2ZXJzaW9uIjogMjMsCiAgICAgICJ2ZXJzaW9uTm9uY2UiOiA1OTc0MzUxMzUsCiAgICAgICJpc0RlbGV0ZWQiOiBmYWxzZSwKICAgICAgImJvdW5kRWxlbWVudElkcyI6IG51bGwsCiAgICAgICJ0ZXh0IjogInRlc3QiLAogICAgICAiZm9udFNpemUiOiAzNiwKICAgICAgImZvbnRGYW1pbHkiOiAxLAogICAgICAidGV4dEFsaWduIjogImxlZnQiLAogICAgICAidmVydGljYWxBbGlnbiI6ICJ0b3AiLAogICAgICAiYmFzZWxpbmUiOiA0MQogICAgfQogIF0sCiAgImFwcFN0YXRlIjogewogICAgInZpZXdCYWNrZ3JvdW5kQ29sb3IiOiAiI2ZmZmZmZiIsCiAgICAiZ3JpZFNpemUiOiBudWxsCiAgfQp9 4 | 5 | 15 | 16 | test 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 | 25 | 26 | 27 | ), 28 | UNCHECKED: ( 29 | 36 | 37 | 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 | 35 | 39 | 40 | ), 41 | MOON: ( 42 | 43 | 47 | 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 | --------------------------------------------------------------------------------