Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d65536360 | |||
| d731a6463c | |||
| 9f325a626e | |||
| 41200ea28d | |||
| 46a61ad4df | |||
| f4b1a30bef | |||
| 32aa79164b | |||
| b5fd904808 | |||
| 8f8dd1105f | |||
| b914ad41fc | |||
| 551c38f60b | |||
| 38e8ae46c9 | |||
| ad0c4c4c78 | |||
| 27cf5ed17e | |||
| fd946adbae | |||
| c37977af4b | |||
| a0d413ab4e | |||
| b67a2b4f65 | |||
| 5a8dbe8030 | |||
| 731093f631 | |||
| fe56975f19 | |||
| 2d800feeeb | |||
| 93cccd596a | |||
| 45b592227d | |||
| b818df1098 | |||
| 4359e2935d | |||
| 3d9d398378 | |||
| 0a5da0269f | |||
| 08ce7c7fc3 | |||
| fe7fbff7f6 | |||
| 501397cb61 | |||
| 865d29388c | |||
| 54c7ec416a | |||
| aca284057d | |||
| 2820cd112e | |||
| 426b5d9537 | |||
| e7d34677c6 | |||
| 3d5356cb8e | |||
| 46f5ce5ce0 | |||
| b00bd3d6c0 | |||
| 91fc22182c | |||
| 966ca2ffa6 | |||
| 2b049b4a65 | |||
| 339212e563 | |||
| f8b4bb66b4 | |||
| f4312bba5e | |||
| ac66665b64 | |||
| 2b71a1f0bd | |||
| 58845e450a | |||
| 15d79f8fee | |||
| 958ebeae61 | |||
| 31f51ca53b | |||
| 5abbf73050 | |||
| 7cf766630d | |||
| 59fccafeac | |||
| 19a6996e6b | |||
| 86c4f90910 | |||
| 4d88112021 | |||
| de5c63e299 | |||
| da0853a121 | |||
| 57cc4b6a29 | |||
| e2ddd7b27a | |||
| 693de8501e | |||
| c6df6d444e | |||
| ad5692c5f8 | |||
| 60ab3337af | |||
| dd847793d2 | |||
| 6d6e9f0dd3 | |||
| 0fe0d7ca6b | |||
| abcf1f1bae | |||
| 7d0b03f754 | |||
| bd8931d3d1 | |||
| 0d86c04939 | |||
| 8436ebbf68 | |||
| 824f94b3df | |||
| f9a8e686b2 | |||
| e442a44ba8 | |||
| f1fd29571a | |||
| 6a482a7ba2 | |||
| bfea434a55 | |||
| acb22c5a64 | |||
| 7cd1b621d1 | |||
| 9c37b25bab | |||
| a8bb9a78ef | |||
| e4aff04061 | |||
| c5cadc7de3 | |||
| 7dc0c0d96a | |||
| 2c9c8c8e05 | |||
| b5d7ae57e5 | |||
| 0f66ee3a41 | |||
| 771372c66b | |||
| a7937681e9 | |||
| 792f238d16 | |||
| ba16416c75 | |||
| 6e0ac52a64 | |||
| 5bc40402a6 | |||
| df14c69977 | |||
| 1ea67ba93d | |||
| a7153d9d1d | |||
| e885057a71 | |||
| 7efa081976 | |||
| 5deb93a083 | |||
| e3908e6fe3 | |||
| fe3d0b5e8b | |||
| b6bb74d08d | |||
| c725f84334 | |||
| 11a3380d83 | |||
| 76a5bb060e | |||
| dac8dda4d4 | |||
| a1a62468a6 | |||
| ba3a723e99 | |||
| c5355c08cf | |||
| 6102380051 | |||
| 655e59a707 | |||
| d05745070b | |||
| 88c313bf86 | |||
| a7705848ec | |||
| 69e1bae8dd | |||
| d361757e4a | |||
| 0ef202f2df | |||
| bbfd2b3cd3 |
@@ -11,3 +11,12 @@ REACT_APP_WS_SERVER_URL=http://localhost:3002
|
|||||||
REACT_APP_PORTAL_URL=
|
REACT_APP_PORTAL_URL=
|
||||||
|
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||||
|
|
||||||
|
# put these in your .env.local, or make sure you don't commit!
|
||||||
|
# must be lowercase `true` when turned on
|
||||||
|
#
|
||||||
|
# whether to enable Service Workers in development
|
||||||
|
REACT_APP_DEV_ENABLE_SW=
|
||||||
|
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||||
|
# debugging Service Workers.
|
||||||
|
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- lipis
|
|
||||||
assignees:
|
|
||||||
- lipis
|
|
||||||
open-pull-requests-limit: 20
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /src/packages/excalidraw/
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
||||||
open-pull-requests-limit: 20
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /src/packages/utils/
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
||||||
open-pull-requests-limit: 20
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Auto release @excalidraw/excalidraw-next
|
name: Auto release excalidraw next
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Auto release preview @excalidraw/excalidraw-preview
|
name: Auto release excalidraw preview
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
types: [created, edited]
|
types: [created, edited]
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- name: Checkout repository
|
||||||
- uses: docker/build-push-action@v2
|
uses: actions/checkout@v3
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
repository: excalidraw/excalidraw
|
- name: Build and push
|
||||||
tag_with_ref: true
|
uses: docker/build-push-action@v3
|
||||||
tag_with_sha: true
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: excalidraw/excalidraw:latest
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ logs
|
|||||||
node_modules
|
node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
package-lock.json
|
package-lock.json
|
||||||
static
|
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
src/packages/excalidraw/types
|
src/packages/excalidraw/types
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.docusaurus
|
||||||
|
.cache-loader
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Website
|
||||||
|
|
||||||
|
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Using SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ USE_SSH=true yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Not using SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 1
|
||||||
|
title: Overview
|
||||||
|
---
|
||||||
|
|
||||||
|
In development. For now, refer to [excalidraw Readme](https://github.com/excalidraw/excalidraw/blob/master/README.md).
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 1
|
||||||
|
title: Introduction
|
||||||
|
---
|
||||||
|
|
||||||
|
Want to integrate Excalidraw into your app? Head over to the [package docs](/docs/package/overview).
|
||||||
|
|
||||||
|
If you're looking into the Excalidraw codebase itself, start [here](/docs/codebase/overview).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 1
|
||||||
|
title: Overview
|
||||||
|
---
|
||||||
|
|
||||||
|
In development. For now, refer to [excalidraw package readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md).
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Note: type annotations allow type checking and IDEs autocompletion
|
||||||
|
|
||||||
|
const lightCodeTheme = require("prism-react-renderer/themes/github");
|
||||||
|
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
|
||||||
|
|
||||||
|
/** @type {import('@docusaurus/types').Config} */
|
||||||
|
const config = {
|
||||||
|
title: "Excalidraw developer docs",
|
||||||
|
tagline:
|
||||||
|
"For Excalidraw contributors or those integrating the Excalidraw editor",
|
||||||
|
url: "https://docs.excalidraw.com.com",
|
||||||
|
baseUrl: "/",
|
||||||
|
onBrokenLinks: "throw",
|
||||||
|
onBrokenMarkdownLinks: "warn",
|
||||||
|
favicon: "img/favicon.ico",
|
||||||
|
organizationName: "Excalidraw", // Usually your GitHub org/user name.
|
||||||
|
projectName: "excalidraw", // Usually your repo name.
|
||||||
|
|
||||||
|
// Even if you don't use internalization, you can use this field to set useful
|
||||||
|
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||||
|
// to replace "en" with "zh-Hans".
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en",
|
||||||
|
locales: ["en"],
|
||||||
|
},
|
||||||
|
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
"classic",
|
||||||
|
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||||
|
({
|
||||||
|
docs: {
|
||||||
|
sidebarPath: require.resolve("./sidebars.js"),
|
||||||
|
// Please change this to your repo.
|
||||||
|
editUrl: "https://github.com/excalidraw/docs/tree/master/",
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
customCss: require.resolve("./src/css/custom.css"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
themeConfig:
|
||||||
|
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||||
|
({
|
||||||
|
navbar: {
|
||||||
|
title: "Excalidraw Docs",
|
||||||
|
logo: {
|
||||||
|
alt: "Excalidraw Logo",
|
||||||
|
src: "img/logo.svg",
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
docId: "get-started",
|
||||||
|
position: "left",
|
||||||
|
label: "Get started",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "https://blog.excalidraw.com",
|
||||||
|
label: "Blog",
|
||||||
|
position: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "https://github.com/excalidraw/excalidraw",
|
||||||
|
label: "GitHub",
|
||||||
|
position: "right",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
style: "dark",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: "Docs",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Get Started",
|
||||||
|
to: "/docs/get-started",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Community",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Discord",
|
||||||
|
href: "https://discord.gg/UexuTaE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Twitter",
|
||||||
|
href: "https://twitter.com/excalidraw",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "More",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Blog",
|
||||||
|
to: "https://blog.excalidraw.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GitHub",
|
||||||
|
to: "https://github.com/excalidraw/excalidraw",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
copyright: `Made with ❤️ Built with Docusaurus`,
|
||||||
|
},
|
||||||
|
prism: {
|
||||||
|
theme: lightCodeTheme,
|
||||||
|
darkTheme: darkCodeTheme,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"docusaurus": "docusaurus",
|
||||||
|
"start": "docusaurus start --port 3003",
|
||||||
|
"build": "docusaurus build",
|
||||||
|
"swizzle": "docusaurus swizzle",
|
||||||
|
"deploy": "docusaurus deploy",
|
||||||
|
"clear": "docusaurus clear",
|
||||||
|
"serve": "docusaurus serve",
|
||||||
|
"write-translations": "docusaurus write-translations",
|
||||||
|
"write-heading-ids": "docusaurus write-heading-ids",
|
||||||
|
"typecheck": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@docusaurus/core": "2.0.0-rc.1",
|
||||||
|
"@docusaurus/preset-classic": "2.0.0-rc.1",
|
||||||
|
"@mdx-js/react": "^1.6.22",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"prism-react-renderer": "^1.3.5",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-dom": "^17.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@docusaurus/module-type-aliases": "2.0.0-rc.1",
|
||||||
|
"@tsconfig/docusaurus": "^1.0.5",
|
||||||
|
"typescript": "^4.7.4"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.5%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Creating a sidebar enables you to:
|
||||||
|
- create an ordered group of docs
|
||||||
|
- render a sidebar for each doc of that group
|
||||||
|
- provide next/previous navigation
|
||||||
|
|
||||||
|
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||||
|
|
||||||
|
Create as many sidebars as you want.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||||
|
const sidebars = {
|
||||||
|
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||||
|
tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
|
||||||
|
|
||||||
|
// But you can create a sidebar manually
|
||||||
|
/*
|
||||||
|
tutorialSidebar: [
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
label: 'Tutorial',
|
||||||
|
items: ['hello'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = sidebars;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
|
const FeatureList = [
|
||||||
|
{
|
||||||
|
title: "Learn how Excalidraw works",
|
||||||
|
Svg: require("@site/static/img/undraw_innovative.svg").default,
|
||||||
|
description: (
|
||||||
|
<>Want to contribute to Excalidraw but got lost in the codebase?</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Integrate Excalidraw",
|
||||||
|
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Want to build your own app powered by Excalidraw by don't know where to
|
||||||
|
start?
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Help us improve",
|
||||||
|
Svg: require("@site/static/img/undraw_add_files.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Are the docs missing something? Anything you had trouble understanding
|
||||||
|
or needs an explanation? Come contribute to the docs to make them even
|
||||||
|
better!
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function Feature({ Svg, title, description }) {
|
||||||
|
return (
|
||||||
|
<div className={clsx("col col--4")}>
|
||||||
|
<div className="text--center">
|
||||||
|
<Svg className={styles.featureSvg} role="img" />
|
||||||
|
</div>
|
||||||
|
<div className="text--center padding-horiz--md">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomepageFeatures() {
|
||||||
|
return (
|
||||||
|
<section className={styles.features}>
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
{FeatureList.map((props, idx) => (
|
||||||
|
<Feature key={idx} {...props} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
Svg: React.ComponentType<React.ComponentProps<"svg">>;
|
||||||
|
description: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeatureList: FeatureItem[] = [
|
||||||
|
{
|
||||||
|
title: "Easy to Use",
|
||||||
|
Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Docusaurus was designed from the ground up to be easily installed and
|
||||||
|
used to get your website up and running quickly.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Focus on What Matters",
|
||||||
|
Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Docusaurus lets you focus on your docs, and we'll do the chores. Go
|
||||||
|
ahead and move your docs into the <code>docs</code> directory.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Powered by React",
|
||||||
|
Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Extend or customize your website layout by reusing React. Docusaurus can
|
||||||
|
be extended while reusing the same header and footer.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function Feature({ title, Svg, description }: FeatureItem) {
|
||||||
|
return (
|
||||||
|
<div className={clsx("col col--4")}>
|
||||||
|
<div className="text--center">
|
||||||
|
<Svg className={styles.featureSvg} role="img" />
|
||||||
|
</div>
|
||||||
|
<div className="text--center padding-horiz--md">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomepageFeatures(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className={styles.features}>
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
{FeatureList.map((props, idx) => (
|
||||||
|
<Feature key={idx} {...props} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.features {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureSvg {
|
||||||
|
height: 200px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Any CSS included here will be global. The classic template
|
||||||
|
* bundles Infima by default. Infima is a CSS framework designed to
|
||||||
|
* work well for content-centric websites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* You can override the default Infima variables here. */
|
||||||
|
:root {
|
||||||
|
--ifm-color-primary: #6965db;
|
||||||
|
--ifm-color-primary-dark: #5b57d1;
|
||||||
|
--ifm-color-primary-darker: #5b57d1;
|
||||||
|
--ifm-color-primary-darkest: #4a47b1;
|
||||||
|
--ifm-color-primary-light: #5b57d1;
|
||||||
|
--ifm-color-primary-lighter: #5b57d1;
|
||||||
|
--ifm-color-primary-lightest: #5b57d1;
|
||||||
|
--ifm-code-font-size: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--ifm-color-primary: #5650f0;
|
||||||
|
--ifm-color-primary-dark: #4b46d8;
|
||||||
|
--ifm-color-primary-darker: #4b46d8;
|
||||||
|
--ifm-color-primary-darkest: #3e39be;
|
||||||
|
--ifm-color-primary-light: #3f3d64;
|
||||||
|
--ifm-color-primary-lighter: #3f3d64;
|
||||||
|
--ifm-color-primary-lightest: #3f3d64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docusaurus-highlight-code-line {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
display: block;
|
||||||
|
margin: 0 calc(-1 * var(--ifm-pre-padding));
|
||||||
|
padding: 0 var(--ifm-pre-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .docusaurus-highlight-code-line {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .navbar__logo {
|
||||||
|
filter: invert(93%) hue-rotate(180deg);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Layout from "@theme/Layout";
|
||||||
|
import Link from "@docusaurus/Link";
|
||||||
|
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||||
|
import styles from "./index.module.css";
|
||||||
|
import HomepageFeatures from "@site/src/components/Homepage";
|
||||||
|
|
||||||
|
function HomepageHeader() {
|
||||||
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
return (
|
||||||
|
<header className={clsx("hero hero--primary", styles.heroBanner)}>
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||||
|
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Link
|
||||||
|
className="button button--secondary button--lg"
|
||||||
|
to="/docs/get-started"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Hello from ${siteConfig.title}`}
|
||||||
|
description="Description will go into a meta tag in <head />"
|
||||||
|
>
|
||||||
|
<HomepageHeader />
|
||||||
|
<main>
|
||||||
|
<HomepageFeatures />
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* CSS files with the .module.css suffix will be treated as CSS modules
|
||||||
|
* and scoped locally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.heroBanner {
|
||||||
|
padding: 4rem 0;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .heroBanner {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 996px) {
|
||||||
|
.heroBanner {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Layout from "@theme/Layout";
|
||||||
|
import Link from "@docusaurus/Link";
|
||||||
|
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||||
|
import styles from "./index.module.css";
|
||||||
|
import HomepageFeatures from "@site/src/components/Homepage";
|
||||||
|
|
||||||
|
function HomepageHeader() {
|
||||||
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
return (
|
||||||
|
<header className={clsx("hero hero--primary", styles.heroBanner)}>
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||||
|
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Link
|
||||||
|
className="button button--secondary button--lg"
|
||||||
|
to="/docs/get-started"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Hello from ${siteConfig.title}`}
|
||||||
|
description="Description will go into a meta tag in <head />"
|
||||||
|
>
|
||||||
|
<HomepageHeader />
|
||||||
|
<main>
|
||||||
|
<HomepageFeatures />
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: Markdown page example
|
||||||
|
---
|
||||||
|
|
||||||
|
# Markdown page example
|
||||||
|
|
||||||
|
You don't need React to write simple standalone pages.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 80 180" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
|
||||||
|
<path d="M22.197 150.382c-4.179-3.359-10.618-9.051-15.702-13.946l-4.01-3.813.734-5.009c.396-2.732 1.13-8.083 1.582-11.839.508-3.757 1.017-7.286 1.186-7.798.226-.683 0-1.025-.621-1.025-1.073 0-1.13.285 1.807-9.107a617.602 617.602 0 0 1 2.203-7.229c.113-.398.565-.569 1.073-.398.508.227.791.683.621 1.081-.169.455.113.911.565 1.082.621.227.565.683-.395 2.333-1.525 2.562-5.422 24.419-5.648 31.477-.17 5.009-.17 5.066 1.92 7.912 2.033 2.789 6.721 7.001 13.951 12.351 2.033 1.537 4.067 3.245 4.631 3.814.848 1.024 1.243.74 8.36-6.887 4.123-4.383 8.698-8.88 10.166-10.018l2.711-2.049-2.089-4.44c-1.13-2.391-5.705-11.612-10.223-20.377-9.433-18.442-7.513-16.678-18.47-16.849l-7.117-.056-2.372-2.733c-2.485-2.903-2.824-3.984-1.638-5.805.452-.627.791-1.651.791-2.277 0-1.025.395-1.196 2.655-1.309 1.412-.057 2.711-.228 2.88-.399.17-.171.396-3.7.565-7.855l.226-7.513-3.784-8.197C2.485 39.844 0 33.583 0 31.533c0-1.081.226-1.992.452-1.992.565 0 .565.057 23.553 48.382 10.675 22.426 20.785 43.544 22.479 47.016 1.695 3.472 3.22 6.659 3.333 7.115.113.512-3.785 4.439-9.998 9.961-5.591 5.008-10.505 9.562-10.957 10.074-1.299 1.594-3.219 1.082-6.665-1.707Zm1.921-65.458c-2.599-5.066-2.712-5.123-9.828-5.464-6.27-.342-6.383-.285-6.383.911 0 .683-.226 1.593-.508 2.049-.339.512-.113 1.423.678 2.675l1.242 1.935h5.649c3.106.057 6.664.285 7.907.512 1.243.228 2.316.342 2.429.285.113-.057-.452-1.366-1.186-2.903Zm-4.745-9.107c-.452-1.195-1.638-3.7-2.598-5.578-1.581-3.188-1.751-3.301-2.146-1.992-.226.797-.396 3.13-.452 5.236-.057 4.155-.17 4.098 4.575 4.383l1.525.057-.904-2.106Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
|
||||||
|
<path d="M23.892 136.835c-1.017-.74-1.299-1.48-1.299-3.358 0-2.22.169-2.562 1.694-3.188 1.525-.626 1.92-.569 3.671.626 2.316 1.594 2.373 1.992.678 4.554-1.468 2.22-2.937 2.618-4.744 1.366Zm3.219-2.049c.904-1.594.339-2.789-1.355-2.789-1.525 0-2.203 1.536-1.356 3.073.678 1.253 1.977 1.139 2.711-.284ZM59.306 124.028c0-.285-.339-.569-.735-.569-.339 0-1.299-1.594-2.033-3.529-2.259-5.92-24.852-50.943-24.908-49.52 0 .74-.339 1.252-.904 1.252-.791 0-.904-.456-.565-2.675.339-2.562.113-3.131-7.907-18.841-4.519-8.936-9.376-18.271-10.788-20.775-1.469-2.619-2.598-5.465-2.711-6.66-.17-2.049.056-2.334 4.97-6.603 2.824-2.504 6.439-5.635 8.02-7.058C28.862 2.504 32.194-.114 33.098.057c1.356.228 22.31 22.369 22.367 23.622 0 .569-1.017 9.221-2.259 19.238-2.147 17.076-4.18 37.055-3.954 38.99.169 1.196-.678 7.229-1.299 9.847-.509 2.05-.283 2.903 3.784 12.238 2.372 5.521 5.479 12.295 6.834 15.027 1.299 2.732 2.429 5.123 2.429 5.294 0 .17-.395.284-.847.284-.452 0-.847-.228-.847-.569ZM46.315 81.509c.621-3.984 1.864-13.547 2.767-21.231 1.751-14.116 3.785-29.769 4.349-33.753.339-1.993.113-2.391-3.558-6.489-6.382-7.229-13.16-14.344-15.476-16.165l-2.146-1.708-11.014 10.359C11.07 21.971 10.223 22.939 10.844 24.077c.339.626 3.22 5.92 6.383 11.725 3.163 5.806 7.342 13.547 9.263 17.19 1.977 3.7 3.784 6.887 4.123 7.058.395.228.508-5.521.395-17.759-.226-18.271-.169-18.328 1.638-17.929.226 0 .396 9.221.396 20.434v20.377l5.93 11.953c3.276 6.603 5.987 11.896 6.1 11.84.113-.058.678-3.416 1.243-7.457Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.7 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||||
|
"extends": "@tsconfig/docusaurus/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
+7489
File diff suppressed because it is too large
Load Diff
+20
-14
@@ -23,17 +23,19 @@
|
|||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.16.2",
|
"@testing-library/jest-dom": "5.16.2",
|
||||||
"@testing-library/react": "12.1.5",
|
"@testing-library/react": "12.1.5",
|
||||||
"@tldraw/vec": "1.4.3",
|
"@tldraw/vec": "1.7.1",
|
||||||
"@types/jest": "27.4.0",
|
"@types/jest": "27.4.0",
|
||||||
"@types/pica": "5.1.3",
|
"@types/pica": "5.1.3",
|
||||||
"@types/react": "17.0.39",
|
"@types/react": "18.0.15",
|
||||||
"@types/react-dom": "17.0.11",
|
"@types/react-dom": "18.0.6",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
"browser-fs-access": "0.29.1",
|
"browser-fs-access": "0.29.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
|
"cross-env": "7.0.3",
|
||||||
"fake-indexeddb": "3.1.7",
|
"fake-indexeddb": "3.1.7",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.2",
|
"http-server": "14.1.1",
|
||||||
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "1.6.4",
|
"jotai": "1.6.4",
|
||||||
@@ -47,8 +49,8 @@
|
|||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
"points-on-curve": "0.2.0",
|
"points-on-curve": "0.2.0",
|
||||||
"pwacompat": "2.0.17",
|
"pwacompat": "2.0.17",
|
||||||
"react": "17.0.2",
|
"react": "18.2.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "18.2.0",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.5.2",
|
"roughjs": "4.5.2",
|
||||||
"sass": "1.51.0",
|
"sass": "1.51.0",
|
||||||
@@ -59,11 +61,11 @@
|
|||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
"@excalidraw/prettier-config": "1.0.2",
|
||||||
"@types/chai": "4.3.0",
|
"@types/chai": "4.3.0",
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.7",
|
||||||
"@types/pako": "1.0.3",
|
"@types/pako": "1.0.3",
|
||||||
"@types/resize-observer-browser": "0.1.6",
|
"@types/resize-observer-browser": "0.1.7",
|
||||||
"chai": "4.3.6",
|
"chai": "4.3.6",
|
||||||
"dotenv": "10.0.0",
|
"dotenv": "16.0.1",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
"lint-staged": "12.3.7",
|
"lint-staged": "12.3.7",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"rewire": "5.0.0"
|
"rewire": "6.0.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@typescript-eslint/typescript-estree": "5.10.2"
|
"@typescript-eslint/typescript-estree": "5.10.2"
|
||||||
@@ -91,10 +93,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||||
"build:version": "node ./scripts/build-version.js",
|
"build:version": "node ./scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build:prebuild": "node ./scripts/prebuild.js",
|
||||||
|
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"fix:code": "yarn test:code --fix",
|
"fix:code": "yarn test:code --fix",
|
||||||
"fix:other": "yarn prettier --write",
|
"fix:other": "yarn prettier --write",
|
||||||
@@ -104,6 +107,7 @@
|
|||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
"start:build": "npm run build && npx http-server build -a localhost -p 3001 -o",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||||
"test:app": "react-scripts test --passWithNoTests",
|
"test:app": "react-scripts test --passWithNoTests",
|
||||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||||
@@ -112,6 +116,8 @@
|
|||||||
"test:typecheck": "tsc",
|
"test:typecheck": "tsc",
|
||||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||||
"test": "yarn test:app",
|
"test": "yarn test:app",
|
||||||
"autorelease": "node scripts/autorelease.js"
|
"autorelease": "node scripts/autorelease.js",
|
||||||
|
"prerelease": "node scripts/prerelease.js",
|
||||||
|
"release": "node scripts/release.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,22 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
|
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
||||||
|
<script>
|
||||||
|
{
|
||||||
|
const _WebSocket = window.WebSocket;
|
||||||
|
window.WebSocket = function (url) {
|
||||||
|
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||||
|
console.info(
|
||||||
|
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return new _WebSocket(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
<script>
|
<script>
|
||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||||
// setting this so that libraries installation reuses this window tab.
|
// setting this so that libraries installation reuses this window tab.
|
||||||
|
|||||||
@@ -17,11 +17,23 @@
|
|||||||
* See https://goo.gl/2aRDsh
|
* See https://goo.gl/2aRDsh
|
||||||
*/
|
*/
|
||||||
|
|
||||||
importScripts("/workbox/workbox-sw.js");
|
// in dev, `process` is undefined because this file is not compiled until build
|
||||||
|
const IS_DEVELOPMENT =
|
||||||
|
typeof process === "undefined" || process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
workbox.setConfig({
|
if (IS_DEVELOPMENT) {
|
||||||
modulePathPrefix: "/workbox/",
|
importScripts(
|
||||||
});
|
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
|
||||||
|
);
|
||||||
|
workbox.setConfig({
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
importScripts("/workbox/workbox-sw.js");
|
||||||
|
workbox.setConfig({
|
||||||
|
modulePathPrefix: "/workbox/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("message", (event) => {
|
self.addEventListener("message", (event) => {
|
||||||
if (event.data && event.data.type === "SKIP_WAITING") {
|
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||||
@@ -30,14 +42,17 @@ self.addEventListener("message", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
workbox.core.clientsClaim();
|
workbox.core.clientsClaim();
|
||||||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
|
||||||
|
|
||||||
workbox.routing.registerNavigationRoute(
|
if (!IS_DEVELOPMENT) {
|
||||||
workbox.precaching.getCacheKeyForURL("./index.html"),
|
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
||||||
{
|
|
||||||
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
|
workbox.routing.registerNavigationRoute(
|
||||||
},
|
workbox.precaching.getCacheKeyForURL("./index.html"),
|
||||||
);
|
{
|
||||||
|
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache relevant font files
|
// Cache relevant font files
|
||||||
workbox.routing.registerRoute(
|
workbox.routing.registerRoute(
|
||||||
+6
-11
@@ -5,22 +5,25 @@ const core = require("@actions/core");
|
|||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
const pkg = require(excalidrawPackage);
|
const pkg = require(excalidrawPackage);
|
||||||
|
const isPreview = process.argv.slice(2)[0] === "preview";
|
||||||
|
|
||||||
const getShortCommitHash = () => {
|
const getShortCommitHash = () => {
|
||||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const publish = () => {
|
const publish = () => {
|
||||||
|
const tag = isPreview ? "preview" : "next";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`yarn --frozen-lockfile`);
|
execSync(`yarn --frozen-lockfile`);
|
||||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
|
||||||
console.info("Published 🎉");
|
console.info(`Published ${pkg.name}@${tag}🎉`);
|
||||||
core.setOutput(
|
core.setOutput(
|
||||||
"result",
|
"result",
|
||||||
`**Preview version has been shipped** :rocket:
|
`**Preview version has been shipped** :rocket:
|
||||||
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`,
|
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.setOutput("result", "package couldn't be published :warning:!");
|
core.setOutput("result", "package couldn't be published :warning:!");
|
||||||
@@ -51,27 +54,19 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update package.json
|
// update package.json
|
||||||
pkg.name = "@excalidraw/excalidraw-next";
|
|
||||||
let version = `${pkg.version}-${getShortCommitHash()}`;
|
let version = `${pkg.version}-${getShortCommitHash()}`;
|
||||||
|
|
||||||
// update readme
|
// update readme
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
const isPreview = process.argv.slice(2)[0] === "preview";
|
|
||||||
if (isPreview) {
|
if (isPreview) {
|
||||||
// use pullNumber-commithash as the version for preview
|
// use pullNumber-commithash as the version for preview
|
||||||
const pullRequestNumber = process.argv.slice(3)[0];
|
const pullRequestNumber = process.argv.slice(3)[0];
|
||||||
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
||||||
// replace "excalidraw-next" with "excalidraw-preview"
|
|
||||||
pkg.name = "@excalidraw/excalidraw-preview";
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
|
|
||||||
data = data.trim();
|
|
||||||
}
|
}
|
||||||
pkg.version = version;
|
pkg.version = version;
|
||||||
|
|
||||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||||
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
console.info("Publish in progress...");
|
console.info("Publish in progress...");
|
||||||
publish();
|
publish();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { exec } = require("child_process");
|
||||||
|
|
||||||
|
// get files changed between prev and head commit
|
||||||
|
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||||
|
if (error || stderr) {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const changedFiles = stdout.trim().split("\n");
|
||||||
|
|
||||||
|
const docFiles = changedFiles.filter((file) => {
|
||||||
|
return file.indexOf("docs") >= 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!docFiles.length) {
|
||||||
|
console.info("Skipping building docs as no valid diff found");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
// Exit code 1 to build the docs in ignoredBuildStep
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ const crowdinMap = {
|
|||||||
"ru-RU": "en-ru",
|
"ru-RU": "en-ru",
|
||||||
"si-LK": "en-silk",
|
"si-LK": "en-silk",
|
||||||
"sk-SK": "en-sk",
|
"sk-SK": "en-sk",
|
||||||
|
"sl-SI": "en-sl",
|
||||||
"sv-SE": "en-sv",
|
"sv-SE": "en-sv",
|
||||||
"ta-IN": "en-ta",
|
"ta-IN": "en-ta",
|
||||||
"tr-TR": "en-tr",
|
"tr-TR": "en-tr",
|
||||||
@@ -47,6 +48,8 @@ const crowdinMap = {
|
|||||||
"lv-LV": "en-lv",
|
"lv-LV": "en-lv",
|
||||||
"cs-CZ": "en-cs",
|
"cs-CZ": "en-cs",
|
||||||
"kk-KZ": "en-kk",
|
"kk-KZ": "en-kk",
|
||||||
|
"vi-vn": "en-vi",
|
||||||
|
"mr-in": "en-mr",
|
||||||
};
|
};
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
@@ -86,6 +89,7 @@ const flags = {
|
|||||||
"ru-RU": "🇷🇺",
|
"ru-RU": "🇷🇺",
|
||||||
"si-LK": "🇱🇰",
|
"si-LK": "🇱🇰",
|
||||||
"sk-SK": "🇸🇰",
|
"sk-SK": "🇸🇰",
|
||||||
|
"sl-SI": "🇸🇮",
|
||||||
"sv-SE": "🇸🇪",
|
"sv-SE": "🇸🇪",
|
||||||
"ta-IN": "🇮🇳",
|
"ta-IN": "🇮🇳",
|
||||||
"tr-TR": "🇹🇷",
|
"tr-TR": "🇹🇷",
|
||||||
@@ -93,6 +97,9 @@ const flags = {
|
|||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
"zh-HK": "🇭🇰",
|
"zh-HK": "🇭🇰",
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
|
"eu-ES": "🇪🇦",
|
||||||
|
"vi-VN": "🇻🇳",
|
||||||
|
"mr-IN": "🇮🇳",
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
@@ -133,6 +140,7 @@ const languages = {
|
|||||||
"ru-RU": "Русский",
|
"ru-RU": "Русский",
|
||||||
"si-LK": "සිංහල",
|
"si-LK": "සිංහල",
|
||||||
"sk-SK": "Slovenčina",
|
"sk-SK": "Slovenčina",
|
||||||
|
"sl-SI": "Slovenščina",
|
||||||
"sv-SE": "Svenska",
|
"sv-SE": "Svenska",
|
||||||
"ta-IN": "Tamil",
|
"ta-IN": "Tamil",
|
||||||
"tr-TR": "Türkçe",
|
"tr-TR": "Türkçe",
|
||||||
@@ -140,6 +148,8 @@ const languages = {
|
|||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
"zh-HK": "繁體中文 (香港)",
|
"zh-HK": "繁體中文 (香港)",
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
|
"vi-VN": "Tiếng Việt",
|
||||||
|
"mr-IN": "मराठी",
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
const percentages = fs.readFileSync(
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// for development purposes we want to have the service-worker.js file
|
||||||
|
// accessible from the public folder. On build though, we need to compile it
|
||||||
|
// and CRA expects that file to be in src/ folder.
|
||||||
|
const moveServiceWorkerScript = () => {
|
||||||
|
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
|
||||||
|
const newPath = path.resolve(__dirname, "../src/service-worker.js");
|
||||||
|
|
||||||
|
fs.rename(oldPath, newPath, (error) => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.info("public/service-worker.js moved to src/");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (process.env.CI) {
|
||||||
|
moveServiceWorkerScript();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const util = require("util");
|
||||||
|
const exec = util.promisify(require("child_process").exec);
|
||||||
|
const updateChangelog = require("./updateChangelog");
|
||||||
|
|
||||||
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
|
|
||||||
|
const updatePackageVersion = (nextVersion) => {
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
|
pkg.version = nextVersion;
|
||||||
|
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||||
|
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const prerelease = async (nextVersion) => {
|
||||||
|
try {
|
||||||
|
await updateChangelog(nextVersion);
|
||||||
|
updatePackageVersion(nextVersion);
|
||||||
|
await exec(`git add -u`);
|
||||||
|
await exec(
|
||||||
|
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.info("Done!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextVersion = process.argv.slice(2)[0];
|
||||||
|
if (!nextVersion) {
|
||||||
|
console.error("Pass the next version to release!");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
prerelease(nextVersion);
|
||||||
+30
-25
@@ -1,39 +1,44 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const util = require("util");
|
const { execSync } = require("child_process");
|
||||||
const exec = util.promisify(require("child_process").exec);
|
|
||||||
const updateReadme = require("./updateReadme");
|
|
||||||
const updateChangelog = require("./updateChangelog");
|
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
|
|
||||||
const updatePackageVersion = (nextVersion) => {
|
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
|
||||||
const pkg = require(excalidrawPackage);
|
|
||||||
pkg.version = nextVersion;
|
const updateReadme = () => {
|
||||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
|
||||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
|
||||||
|
// remove note for stable readme
|
||||||
|
const data = originalReadMe.slice(excalidrawIndex);
|
||||||
|
|
||||||
|
// update readme
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
};
|
};
|
||||||
|
|
||||||
const release = async (nextVersion) => {
|
const publish = () => {
|
||||||
try {
|
try {
|
||||||
updateReadme();
|
execSync(`yarn --frozen-lockfile`);
|
||||||
await updateChangelog(nextVersion);
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
updatePackageVersion(nextVersion);
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
await exec(`git add -u`);
|
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||||
await exec(
|
|
||||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
|
||||||
);
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
console.log("Done!");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextVersion = process.argv.slice(2)[0];
|
const release = () => {
|
||||||
if (!nextVersion) {
|
updateReadme();
|
||||||
console.error("Pass the next version to release!");
|
console.info("Note for stable readme removed");
|
||||||
process.exit(1);
|
|
||||||
}
|
publish();
|
||||||
release(nextVersion);
|
console.info(`Published ${pkg.version}!`);
|
||||||
|
|
||||||
|
// revert readme after release
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
|
||||||
|
console.info("Readme reverted");
|
||||||
|
};
|
||||||
|
|
||||||
|
release();
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const updateReadme = () => {
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
|
||||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
|
|
||||||
// remove note for unstable release
|
|
||||||
data = data.replace(
|
|
||||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
// replace "excalidraw-next" with "excalidraw"
|
|
||||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
|
||||||
data = data.trim();
|
|
||||||
|
|
||||||
const demoIndex = data.indexOf("### Demo");
|
|
||||||
const excalidrawNextNote =
|
|
||||||
"#### 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";
|
|
||||||
// Add excalidraw next note to try out for unreleased changes
|
|
||||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
|
||||||
|
|
||||||
// update readme
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = updateReadme;
|
|
||||||
@@ -42,7 +42,7 @@ export const actionAddToLibrary = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toastMessage: t("toast.addedToLibrary"),
|
toast: { message: t("toast.addedToLibrary") },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,14 +107,16 @@ export const actionCopyAsPng = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toastMessage: t("toast.copyToClipboardAsPng", {
|
toast: {
|
||||||
exportSelection: selectedElements.length
|
message: t("toast.copyToClipboardAsPng", {
|
||||||
? t("toast.selection")
|
exportSelection: selectedElements.length
|
||||||
: t("toast.canvas"),
|
? t("toast.selection")
|
||||||
exportColorScheme: appState.exportWithDarkMode
|
: t("toast.canvas"),
|
||||||
? t("buttons.darkMode")
|
exportColorScheme: appState.exportWithDarkMode
|
||||||
: t("buttons.lightMode"),
|
? t("buttons.darkMode")
|
||||||
}),
|
: t("buttons.lightMode"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,12 +128,15 @@ const duplicateElements = (
|
|||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
selectedElementIds: newElements.reduce(
|
||||||
if (!isBoundToContainer(element)) {
|
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
acc[element.id] = true;
|
if (!isBoundToContainer(element)) {
|
||||||
}
|
acc[element.id] = true;
|
||||||
return acc;
|
}
|
||||||
}, {} as any),
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
getNonDeletedElements(finalElements),
|
getNonDeletedElements(finalElements),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -144,13 +144,15 @@ export const actionSaveToActiveFile = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toastMessage: fileHandleExists
|
toast: fileHandleExists
|
||||||
? fileHandle?.name
|
? {
|
||||||
? t("toast.fileSavedToFilename").replace(
|
message: fileHandle?.name
|
||||||
"{filename}",
|
? t("toast.fileSavedToFilename").replace(
|
||||||
`"${fileHandle.name}"`,
|
"{filename}",
|
||||||
)
|
`"${fileHandle.name}"`,
|
||||||
: t("toast.fileSaved")
|
)
|
||||||
|
: t("toast.fileSaved"),
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
@@ -181,6 +181,11 @@ export const actionFinalize = register({
|
|||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
|
// To select the linear element when user has finished mutipoint editing
|
||||||
|
selectedLinearElement:
|
||||||
|
multiPointElement && isLinearElement(multiPointElement)
|
||||||
|
? new LinearElementEditor(multiPointElement, scene)
|
||||||
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.activeTool.type === "freedraw",
|
commitToHistory: appState.activeTool.type === "freedraw",
|
||||||
|
|||||||
@@ -2,29 +2,43 @@ import { KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements, isTextElement } from "../element";
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const selectedElementIds = elements.reduce(
|
||||||
|
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
|
if (
|
||||||
|
!element.isDeleted &&
|
||||||
|
!(isTextElement(element) && element.containerId) &&
|
||||||
|
!element.locked
|
||||||
|
) {
|
||||||
|
map[element.id] = true;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: selectGroupsForSelectedElements(
|
appState: selectGroupsForSelectedElements(
|
||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
|
selectedLinearElement:
|
||||||
|
// single linear element selected
|
||||||
|
Object.keys(selectedElementIds).length === 1 &&
|
||||||
|
isLinearElement(elements[0])
|
||||||
|
? new LinearElementEditor(elements[0], app.scene)
|
||||||
|
: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
selectedElementIds,
|
||||||
if (
|
|
||||||
!element.isDeleted &&
|
|
||||||
!(isTextElement(element) && element.containerId) &&
|
|
||||||
element.locked === false
|
|
||||||
) {
|
|
||||||
map[element.id] = true;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, {} as any),
|
|
||||||
},
|
},
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const actionCopyStyles = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toastMessage: t("toast.copyStyles"),
|
toast: { message: t("toast.copyStyles") },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,16 +17,19 @@ export const actionToggleLock = register({
|
|||||||
|
|
||||||
const operation = getOperation(selectedElements);
|
const operation = getOperation(selectedElements);
|
||||||
const selectedElementsMap = arrayToMap(selectedElements);
|
const selectedElementsMap = arrayToMap(selectedElements);
|
||||||
|
const lock = operation === "lock";
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) => {
|
elements: elements.map((element) => {
|
||||||
if (!selectedElementsMap.has(element.id)) {
|
if (!selectedElementsMap.has(element.id)) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElementWith(element, { locked: operation === "lock" });
|
return newElementWith(element, { locked: lock });
|
||||||
}),
|
}),
|
||||||
appState,
|
appState: {
|
||||||
|
...appState,
|
||||||
|
selectedLinearElement: lock ? null : appState.selectedLinearElement,
|
||||||
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-2
@@ -81,7 +81,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
showStats: false,
|
showStats: false,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
toastMessage: null,
|
toast: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: {
|
zoom: {
|
||||||
@@ -90,6 +90,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
showHyperlinkPopup: false,
|
showHyperlinkPopup: false,
|
||||||
|
selectedLinearElement: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
showStats: { browser: true, export: false, server: false },
|
showStats: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false, server: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false, server: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
toastMessage: { browser: false, export: false, server: false },
|
toast: { browser: false, export: false, server: false },
|
||||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||||
width: { browser: false, export: false, server: false },
|
width: { browser: false, export: false, server: false },
|
||||||
zenModeEnabled: { browser: true, export: false, server: false },
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
@@ -181,6 +182,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
viewModeEnabled: { browser: false, export: false, server: false },
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
pendingImageElementId: { browser: false, export: false, server: false },
|
pendingImageElementId: { browser: false, export: false, server: false },
|
||||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
|
selectedLinearElement: { browser: true, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
+618
-222
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import "./Card.scss";
|
|||||||
|
|
||||||
export const Card: React.FC<{
|
export const Card: React.FC<{
|
||||||
color: keyof OpenColor | "primary";
|
color: keyof OpenColor | "primary";
|
||||||
|
children?: React.ReactNode;
|
||||||
}> = ({ children, color }) => {
|
}> = ({ children, color }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const CheckboxItem: React.FC<{
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
}> = ({ children, checked, onChange, className }) => {
|
}> = ({ children, checked, onChange, className }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -18,13 +18,15 @@
|
|||||||
left: -5px;
|
left: -5px;
|
||||||
}
|
}
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
|
min-height: 1em;
|
||||||
|
line-height: 1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: $oc-green-6;
|
background-color: $oc-green-6;
|
||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
font-size: 0.7em;
|
font-size: 0.6em;
|
||||||
font-family: var(--ui-font);
|
font-family: "Cascadia";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const CollabButton = ({
|
|||||||
aria-label={t("labels.liveCollaboration")}
|
aria-label={t("labels.liveCollaboration")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useDevice().isMobile}
|
||||||
>
|
>
|
||||||
{collaboratorCount > 0 && (
|
{isCollaborating && (
|
||||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||||
)}
|
)}
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
<button
|
<button
|
||||||
className="Modal__close"
|
className="Modal__close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
title={t("buttons.close")}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{useDevice().isMobile ? back : close}
|
{useDevice().isMobile ? back : close}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const ExportButton: React.FC<{
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
shade?: number;
|
shade?: number;
|
||||||
|
children?: React.ReactNode;
|
||||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
|
}> = ({ children, title, onClick, color, shade = 6 }) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -170,7 +171,9 @@ const ImageExportModal = ({
|
|||||||
<Stack.Row gap={2}>
|
<Stack.Row gap={2}>
|
||||||
{actionManager.renderAction("changeExportScale")}
|
{actionManager.renderAction("changeExportScale")}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
<p style={{ marginLeft: "1em", userSelect: "none" }}>
|
||||||
|
{t("buttons.scale")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
+25
-28
@@ -39,6 +39,7 @@ import { trackEvent } from "../analytics";
|
|||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||||
|
import { actionToggleZenMode } from "../actions";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -51,16 +52,13 @@ interface LayerUIProps {
|
|||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
onPenModeToggle: () => void;
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
zenModeEnabled: boolean;
|
|
||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
toggleZenMode: () => void;
|
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||||
viewModeEnabled: boolean;
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
UIOptions: AppProps["UIOptions"];
|
UIOptions: AppProps["UIOptions"];
|
||||||
focusContainer: () => void;
|
focusContainer: () => void;
|
||||||
@@ -79,15 +77,12 @@ const LayerUI = ({
|
|||||||
onLockToggle,
|
onLockToggle,
|
||||||
onPenModeToggle,
|
onPenModeToggle,
|
||||||
onInsertElements,
|
onInsertElements,
|
||||||
zenModeEnabled,
|
|
||||||
showExitZenModeBtn,
|
showExitZenModeBtn,
|
||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
toggleZenMode,
|
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
renderCustomStats,
|
renderCustomStats,
|
||||||
viewModeEnabled,
|
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
UIOptions,
|
UIOptions,
|
||||||
focusContainer,
|
focusContainer,
|
||||||
@@ -171,7 +166,7 @@ const LayerUI = ({
|
|||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
className={clsx("zen-mode-transition", {
|
className={clsx("zen-mode-transition", {
|
||||||
"transition-left": zenModeEnabled,
|
"transition-left": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
@@ -192,7 +187,7 @@ const LayerUI = ({
|
|||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
className={clsx("zen-mode-transition", {
|
className={clsx("zen-mode-transition", {
|
||||||
"transition-left": zenModeEnabled,
|
"transition-left": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
@@ -232,7 +227,7 @@ const LayerUI = ({
|
|||||||
<Section
|
<Section
|
||||||
heading="selectedShapeActions"
|
heading="selectedShapeActions"
|
||||||
className={clsx("zen-mode-transition", {
|
className={clsx("zen-mode-transition", {
|
||||||
"transition-left": zenModeEnabled,
|
"transition-left": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Island
|
<Island
|
||||||
@@ -302,32 +297,34 @@ const LayerUI = ({
|
|||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
gap={4}
|
gap={4}
|
||||||
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
|
className={clsx({
|
||||||
|
"disable-pointerEvents": appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{viewModeEnabled
|
{appState.viewModeEnabled
|
||||||
? renderViewModeCanvasActions()
|
? renderViewModeCanvasActions()
|
||||||
: renderCanvasActions()}
|
: renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
{!viewModeEnabled && (
|
{!appState.viewModeEnabled && (
|
||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading: React.ReactNode) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row
|
<Stack.Row
|
||||||
gap={1}
|
gap={1}
|
||||||
className={clsx("App-toolbar-container", {
|
className={clsx("App-toolbar-container", {
|
||||||
"zen-mode": zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<PenModeButton
|
<PenModeButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
onChange={onPenModeToggle}
|
onChange={onPenModeToggle}
|
||||||
title={t("toolBar.penMode")}
|
title={t("toolBar.penMode")}
|
||||||
penDetected={appState.penDetected}
|
penDetected={appState.penDetected}
|
||||||
/>
|
/>
|
||||||
<LockButton
|
<LockButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
checked={appState.activeTool.locked}
|
checked={appState.activeTool.locked}
|
||||||
onChange={() => onLockToggle()}
|
onChange={() => onLockToggle()}
|
||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
@@ -335,7 +332,7 @@ const LayerUI = ({
|
|||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx("App-toolbar", {
|
className={clsx("App-toolbar", {
|
||||||
"zen-mode": zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer
|
<HintViewer
|
||||||
@@ -371,7 +368,7 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||||
{
|
{
|
||||||
"transition-right": zenModeEnabled,
|
"transition-right": appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -396,7 +393,8 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__footer-left zen-mode-transition",
|
"layer-ui__wrapper__footer-left zen-mode-transition",
|
||||||
{
|
{
|
||||||
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
|
appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -408,12 +406,12 @@ const LayerUI = ({
|
|||||||
zoom={appState.zoom}
|
zoom={appState.zoom}
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
{!viewModeEnabled && (
|
{!appState.viewModeEnabled && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
zenModeEnabled,
|
appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("undo", { size: "small" })}
|
{actionManager.renderAction("undo", { size: "small" })}
|
||||||
@@ -423,20 +421,20 @@ const LayerUI = ({
|
|||||||
<div
|
<div
|
||||||
className={clsx("eraser-buttons zen-mode-transition", {
|
className={clsx("eraser-buttons zen-mode-transition", {
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
zenModeEnabled,
|
appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("eraser", { size: "small" })}
|
{actionManager.renderAction("eraser", { size: "small" })}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!viewModeEnabled &&
|
{!appState.viewModeEnabled &&
|
||||||
appState.multiElement &&
|
appState.multiElement &&
|
||||||
device.isTouchScreen && (
|
device.isTouchScreen && (
|
||||||
<div
|
<div
|
||||||
className={clsx("finalize-button zen-mode-transition", {
|
className={clsx("finalize-button zen-mode-transition", {
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
zenModeEnabled,
|
appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{actionManager.renderAction("finalize", { size: "small" })}
|
{actionManager.renderAction("finalize", { size: "small" })}
|
||||||
@@ -450,7 +448,7 @@ const LayerUI = ({
|
|||||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||||
{
|
{
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
zenModeEnabled,
|
appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -460,7 +458,7 @@ const LayerUI = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__footer-right zen-mode-transition",
|
"layer-ui__wrapper__footer-right zen-mode-transition",
|
||||||
{
|
{
|
||||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -470,7 +468,7 @@ const LayerUI = ({
|
|||||||
className={clsx("disable-zen-mode", {
|
className={clsx("disable-zen-mode", {
|
||||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||||
})}
|
})}
|
||||||
onClick={toggleZenMode}
|
onClick={() => actionManager.executeAction(actionToggleZenMode)}
|
||||||
>
|
>
|
||||||
{t("buttons.exitZenMode")}
|
{t("buttons.exitZenMode")}
|
||||||
</button>
|
</button>
|
||||||
@@ -543,7 +541,6 @@ const LayerUI = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
renderCustomFooter={renderCustomFooter}
|
renderCustomFooter={renderCustomFooter}
|
||||||
viewModeEnabled={viewModeEnabled}
|
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
onImageAction={onImageAction}
|
onImageAction={onImageAction}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export const LibraryMenu = ({
|
|||||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||||
|
|
||||||
const onPublishLibSuccess = useCallback(
|
const onPublishLibSuccess = useCallback(
|
||||||
(data, libraryItems: LibraryItems) => {
|
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
|
||||||
setShowPublishLibraryDialog(false);
|
setShowPublishLibraryDialog(false);
|
||||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||||
const nextLibItems = libraryItems.slice();
|
const nextLibItems = libraryItems.slice();
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ type MobileMenuProps = {
|
|||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
viewModeEnabled: boolean;
|
|
||||||
showThemeBtn: boolean;
|
showThemeBtn: boolean;
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
renderTopRightUI?: (
|
renderTopRightUI?: (
|
||||||
@@ -60,7 +59,6 @@ export const MobileMenu = ({
|
|||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
|
||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
@@ -70,7 +68,7 @@ export const MobileMenu = ({
|
|||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top" className="App-top-bar">
|
<FixedSideContainer side="top" className="App-top-bar">
|
||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading: React.ReactNode) => (
|
||||||
<Stack.Col gap={4} align="center">
|
<Stack.Col gap={4} align="center">
|
||||||
<Stack.Row gap={1} className="App-toolbar-container">
|
<Stack.Row gap={1} className="App-toolbar-container">
|
||||||
<Island padding={1} className="App-toolbar">
|
<Island padding={1} className="App-toolbar">
|
||||||
@@ -125,7 +123,7 @@ export const MobileMenu = ({
|
|||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
getSelectedElements(elements, appState).length === 0;
|
getSelectedElements(elements, appState).length === 0;
|
||||||
|
|
||||||
if (viewModeEnabled) {
|
if (appState.viewModeEnabled) {
|
||||||
return (
|
return (
|
||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
{actionManager.renderAction("toggleCanvasMenu")}
|
{actionManager.renderAction("toggleCanvasMenu")}
|
||||||
@@ -151,7 +149,7 @@ export const MobileMenu = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderCanvasActions = () => {
|
const renderCanvasActions = () => {
|
||||||
if (viewModeEnabled) {
|
if (appState.viewModeEnabled) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderJSONExportDialog()}
|
{renderJSONExportDialog()}
|
||||||
@@ -185,7 +183,7 @@ export const MobileMenu = ({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!viewModeEnabled && renderToolbar()}
|
{!appState.viewModeEnabled && renderToolbar()}
|
||||||
{renderStats()}
|
{renderStats()}
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
@@ -216,7 +214,7 @@ export const MobileMenu = ({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
) : appState.openMenu === "shape" &&
|
) : appState.openMenu === "shape" &&
|
||||||
!viewModeEnabled &&
|
!appState.viewModeEnabled &&
|
||||||
showSelectedShapeActions(appState, elements) ? (
|
showSelectedShapeActions(appState, elements) ? (
|
||||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||||
<SelectedShapeActions
|
<SelectedShapeActions
|
||||||
@@ -229,18 +227,20 @@ export const MobileMenu = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<footer className="App-toolbar">
|
<footer className="App-toolbar">
|
||||||
{renderAppToolbar()}
|
{renderAppToolbar()}
|
||||||
{appState.scrolledOutside && !appState.openMenu && (
|
{appState.scrolledOutside &&
|
||||||
<button
|
!appState.openMenu &&
|
||||||
className="scroll-back-to-content"
|
!appState.isLibraryOpen && (
|
||||||
onClick={() => {
|
<button
|
||||||
setAppState({
|
className="scroll-back-to-content"
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
onClick={() => {
|
||||||
});
|
setAppState({
|
||||||
}}
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
>
|
});
|
||||||
{t("buttons.scrollBackToContent")}
|
}}
|
||||||
</button>
|
>
|
||||||
)}
|
{t("buttons.scrollBackToContent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</Island>
|
</Island>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useExcalidrawContainer, useDevice } from "./App";
|
|||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { THEME } from "../constants";
|
import { THEME } from "../constants";
|
||||||
|
|
||||||
export const Modal = (props: {
|
export const Modal: React.FC<{
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
@@ -16,7 +16,7 @@ export const Modal = (props: {
|
|||||||
labelledBy: string;
|
labelledBy: string;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
}) => {
|
}> = (props) => {
|
||||||
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
||||||
const modalRoot = useBodyRoot(theme);
|
const modalRoot = useBodyRoot(theme);
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const ChartPreviewBtn = (props: {
|
|||||||
},
|
},
|
||||||
null, // files
|
null, // files
|
||||||
);
|
);
|
||||||
|
previewNode.replaceChildren();
|
||||||
previewNode.appendChild(svg);
|
previewNode.appendChild(svg);
|
||||||
|
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
@@ -55,7 +55,7 @@ const ChartPreviewBtn = (props: {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
previewNode.removeChild(svg);
|
previewNode.replaceChildren();
|
||||||
};
|
};
|
||||||
}, [props.spreadsheet, props.chartType, props.selected]);
|
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
.popover {
|
.popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
padding: 5px 0 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,12 +69,26 @@ export const Popover = ({
|
|||||||
if (fitInViewport && popoverRef.current) {
|
if (fitInViewport && popoverRef.current) {
|
||||||
const element = popoverRef.current;
|
const element = popoverRef.current;
|
||||||
const { x, y, width, height } = element.getBoundingClientRect();
|
const { x, y, width, height } = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
//Position correctly when clicked on rightmost part or the bottom part of viewport
|
||||||
if (x + width - offsetLeft > viewportWidth) {
|
if (x + width - offsetLeft > viewportWidth) {
|
||||||
element.style.left = `${viewportWidth - width}px`;
|
element.style.left = `${viewportWidth - width - 10}px`;
|
||||||
}
|
}
|
||||||
if (y + height - offsetTop > viewportHeight) {
|
if (y + height - offsetTop > viewportHeight) {
|
||||||
element.style.top = `${viewportHeight - height}px`;
|
element.style.top = `${viewportHeight - height}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Resize to fit viewport on smaller screens
|
||||||
|
if (height >= viewportHeight) {
|
||||||
|
element.style.height = `${viewportHeight - 20}px`;
|
||||||
|
element.style.top = "10px";
|
||||||
|
element.style.overflowY = "scroll";
|
||||||
|
}
|
||||||
|
if (width >= viewportWidth) {
|
||||||
|
element.style.width = `${viewportWidth}px`;
|
||||||
|
element.style.left = "0px";
|
||||||
|
element.style.overflowX = "scroll";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
|
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import React from "react";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useExcalidrawContainer } from "./App";
|
import { useExcalidrawContainer } from "./App";
|
||||||
|
|
||||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
export const Section: React.FC<{
|
||||||
heading: string;
|
heading: string;
|
||||||
children: React.ReactNode | ((header: React.ReactNode) => React.ReactNode);
|
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
|
||||||
}
|
className?: string;
|
||||||
|
}> = ({ heading, children, ...props }) => {
|
||||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
|
||||||
const { id } = useExcalidrawContainer();
|
const { id } = useExcalidrawContainer();
|
||||||
const header = (
|
const header = (
|
||||||
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Toast {
|
.Toast {
|
||||||
|
$closeButtonSize: 1.2rem;
|
||||||
|
$closeButtonPadding: 0.4rem;
|
||||||
|
|
||||||
animation: fade-in 0.5s;
|
animation: fade-in 0.5s;
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -15,11 +18,24 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
z-index: 999999;
|
z-index: 999999;
|
||||||
}
|
|
||||||
|
|
||||||
.Toast__message {
|
.Toast__message {
|
||||||
color: var(--popup-text-color);
|
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
||||||
white-space: pre-wrap;
|
color: var(--popup-text-color);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: $closeButtonPadding;
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: $closeButtonSize;
|
||||||
|
height: $closeButtonSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
|
|||||||
+37
-12
@@ -1,34 +1,59 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { TOAST_TIMEOUT } from "../constants";
|
import { close } from "./icons";
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
const DEFAULT_TOAST_TIMEOUT = 5000;
|
||||||
|
|
||||||
export const Toast = ({
|
export const Toast = ({
|
||||||
message,
|
message,
|
||||||
clearToast,
|
onClose,
|
||||||
|
closable = false,
|
||||||
|
// To prevent autoclose, pass duration as Infinity
|
||||||
|
duration = DEFAULT_TOAST_TIMEOUT,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: string;
|
||||||
clearToast: () => void;
|
onClose: () => void;
|
||||||
|
closable?: boolean;
|
||||||
|
duration?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
|
const shouldAutoClose = duration !== Infinity;
|
||||||
const scheduleTimeout = useCallback(
|
const scheduleTimeout = useCallback(() => {
|
||||||
() =>
|
if (!shouldAutoClose) {
|
||||||
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
|
return;
|
||||||
[clearToast],
|
}
|
||||||
);
|
timerRef.current = window.setTimeout(() => onClose(), duration);
|
||||||
|
}, [onClose, duration, shouldAutoClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!shouldAutoClose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
scheduleTimeout();
|
scheduleTimeout();
|
||||||
return () => clearTimeout(timerRef.current);
|
return () => clearTimeout(timerRef.current);
|
||||||
}, [scheduleTimeout, message]);
|
}, [scheduleTimeout, message, duration, shouldAutoClose]);
|
||||||
|
|
||||||
|
const onMouseEnter = shouldAutoClose
|
||||||
|
? () => clearTimeout(timerRef?.current)
|
||||||
|
: undefined;
|
||||||
|
const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Toast"
|
className="Toast"
|
||||||
onMouseEnter={() => clearTimeout(timerRef?.current)}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={scheduleTimeout}
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
<p className="Toast__message">{message}</p>
|
<p className="Toast__message">{message}</p>
|
||||||
|
{closable && (
|
||||||
|
<ToolButton
|
||||||
|
icon={close}
|
||||||
|
aria-label="close"
|
||||||
|
type="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="close"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -212,16 +212,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ToolIcon.ToolIcon__library {
|
.ToolIcon.ToolIcon__library {
|
||||||
top: 100px;
|
top: calc(var(--sat) + 100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
margin-inline-end: 0;
|
top: calc(var(--sat) + 60px);
|
||||||
top: 60px;
|
|
||||||
}
|
}
|
||||||
.ToolIcon.ToolIcon__penMode {
|
.ToolIcon.ToolIcon__penMode {
|
||||||
margin-inline-end: 0;
|
top: calc(var(--sat) + 140px);
|
||||||
top: 140px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
margin-inline-end: var(--space-factor);
|
|
||||||
&.ToolIcon_type_floating {
|
&.ToolIcon_type_floating {
|
||||||
margin-left: 0.1rem;
|
margin-left: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ export const IMAGE_RENDER_TIMEOUT = 500;
|
|||||||
export const TAP_TWICE_TIMEOUT = 300;
|
export const TAP_TWICE_TIMEOUT = 300;
|
||||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||||
export const TITLE_TIMEOUT = 10000;
|
export const TITLE_TIMEOUT = 10000;
|
||||||
export const TOAST_TIMEOUT = 5000;
|
|
||||||
export const VERSION_TIMEOUT = 30000;
|
export const VERSION_TIMEOUT = 30000;
|
||||||
export const SCROLL_TIMEOUT = 100;
|
export const SCROLL_TIMEOUT = 100;
|
||||||
export const ZOOM_STEP = 0.1;
|
export const ZOOM_STEP = 0.1;
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
export const createInverseContext = <T extends unknown = null>(
|
|
||||||
initialValue: T,
|
|
||||||
) => {
|
|
||||||
const Context = React.createContext(initialValue) as React.Context<T> & {
|
|
||||||
_updateProviderValue?: (value: T) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
class InverseConsumer extends React.Component {
|
|
||||||
state = { value: initialValue };
|
|
||||||
constructor(props: any) {
|
|
||||||
super(props);
|
|
||||||
Context._updateProviderValue = (value: T) => this.setState({ value });
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Context.Provider value={this.state.value}>
|
|
||||||
{this.props.children}
|
|
||||||
</Context.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class InverseProvider extends React.Component<{ value: T }> {
|
|
||||||
componentDidMount() {
|
|
||||||
Context._updateProviderValue?.(this.props.value);
|
|
||||||
}
|
|
||||||
componentDidUpdate() {
|
|
||||||
Context._updateProviderValue?.(this.props.value);
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
Context,
|
|
||||||
Consumer: InverseConsumer,
|
|
||||||
Provider: InverseProvider,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
FileWithHandle,
|
|
||||||
fileOpen as _fileOpen,
|
fileOpen as _fileOpen,
|
||||||
fileSave as _fileSave,
|
fileSave as _fileSave,
|
||||||
FileSystemHandle,
|
FileSystemHandle,
|
||||||
@@ -26,13 +25,9 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
extensions?: FILE_EXTENSION[];
|
extensions?: FILE_EXTENSION[];
|
||||||
description: string;
|
description: string;
|
||||||
multiple?: M;
|
multiple?: M;
|
||||||
}): Promise<
|
}): Promise<M extends false | undefined ? File : File[]> => {
|
||||||
M extends false | undefined ? FileWithHandle : FileWithHandle[]
|
|
||||||
> => {
|
|
||||||
// an unsafe TS hack, alas not much we can do AFAIK
|
// an unsafe TS hack, alas not much we can do AFAIK
|
||||||
type RetType = M extends false | undefined
|
type RetType = M extends false | undefined ? File : File[];
|
||||||
? FileWithHandle
|
|
||||||
: FileWithHandle[];
|
|
||||||
|
|
||||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||||
mimeTypes.push(MIME_TYPES[type]);
|
mimeTypes.push(MIME_TYPES[type]);
|
||||||
|
|||||||
+6
-1
@@ -98,7 +98,12 @@ export const loadFromJSON = async (
|
|||||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||||
// extensions: ["json", "excalidraw", "png", "svg"],
|
// extensions: ["json", "excalidraw", "png", "svg"],
|
||||||
});
|
});
|
||||||
return loadFromBlob(await normalizeFile(file), localAppState, localElements);
|
return loadFromBlob(
|
||||||
|
await normalizeFile(file),
|
||||||
|
localAppState,
|
||||||
|
localElements,
|
||||||
|
file.handle,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidExcalidrawData = (data?: {
|
export const isValidExcalidrawData = (data?: {
|
||||||
|
|||||||
+9
-4
@@ -67,13 +67,14 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restoreElementWithProperties = <
|
const restoreElementWithProperties = <
|
||||||
T extends ExcalidrawElement,
|
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
customData?: ExcalidrawElement["customData"];
|
||||||
>(
|
|
||||||
element: Required<T> & {
|
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||||
},
|
},
|
||||||
|
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||||
|
>(
|
||||||
|
element: T,
|
||||||
extra: Pick<
|
extra: Pick<
|
||||||
T,
|
T,
|
||||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||||
@@ -115,6 +116,10 @@ const restoreElementWithProperties = <
|
|||||||
locked: element.locked ?? false,
|
locked: element.locked ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ("customData" in element) {
|
||||||
|
base.customData = element.customData;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
...getNormalizedDimensions(base),
|
...getNormalizedDimensions(base),
|
||||||
|
|||||||
+106
-29
@@ -18,6 +18,7 @@ import { rescalePoints } from "../points";
|
|||||||
|
|
||||||
// x and y position of top left corner, x and y position of bottom right corner
|
// x and y position of top left corner, x and y position of bottom right corner
|
||||||
export type Bounds = readonly [number, number, number, number];
|
export type Bounds = readonly [number, number, number, number];
|
||||||
|
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||||
|
|
||||||
// If the element is created from right to left, the width is going to be negative
|
// If the element is created from right to left, the width is going to be negative
|
||||||
// This set of functions retrieves the absolute position of the 4 points.
|
// This set of functions retrieves the absolute position of the 4 points.
|
||||||
@@ -68,11 +69,95 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
|
|||||||
return shape.sets[0].ops;
|
return shape.sets[0].ops;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
|
||||||
|
const getBezierValueForT = (
|
||||||
|
t: number,
|
||||||
|
p0: number,
|
||||||
|
p1: number,
|
||||||
|
p2: number,
|
||||||
|
p3: number,
|
||||||
|
) => {
|
||||||
|
const oneMinusT = 1 - t;
|
||||||
|
return (
|
||||||
|
Math.pow(oneMinusT, 3) * p0 +
|
||||||
|
3 * Math.pow(oneMinusT, 2) * t * p1 +
|
||||||
|
3 * oneMinusT * Math.pow(t, 2) * p2 +
|
||||||
|
Math.pow(t, 3) * p3
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const solveQuadratic = (
|
||||||
|
p0: number,
|
||||||
|
p1: number,
|
||||||
|
p2: number,
|
||||||
|
p3: number,
|
||||||
|
): MaybeQuadraticSolution => {
|
||||||
|
const i = p1 - p0;
|
||||||
|
const j = p2 - p1;
|
||||||
|
const k = p3 - p2;
|
||||||
|
|
||||||
|
const a = 3 * i - 6 * j + 3 * k;
|
||||||
|
const b = 6 * j - 6 * i;
|
||||||
|
const c = 3 * i;
|
||||||
|
|
||||||
|
const sqrtPart = b * b - 4 * a * c;
|
||||||
|
const hasSolution = sqrtPart >= 0;
|
||||||
|
|
||||||
|
if (!hasSolution) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
|
||||||
|
const t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
|
||||||
|
|
||||||
|
let s1 = null;
|
||||||
|
let s2 = null;
|
||||||
|
|
||||||
|
if (t1 >= 0 && t1 <= 1) {
|
||||||
|
s1 = getBezierValueForT(t1, p0, p1, p2, p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t2 >= 0 && t2 <= 1) {
|
||||||
|
s2 = getBezierValueForT(t2, p0, p1, p2, p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [s1, s2];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCubicBezierCurveBound = (
|
||||||
|
p0: Point,
|
||||||
|
p1: Point,
|
||||||
|
p2: Point,
|
||||||
|
p3: Point,
|
||||||
|
): Bounds => {
|
||||||
|
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
|
||||||
|
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
|
||||||
|
|
||||||
|
let minX = Math.min(p0[0], p3[0]);
|
||||||
|
let maxX = Math.max(p0[0], p3[0]);
|
||||||
|
|
||||||
|
if (solX) {
|
||||||
|
const xs = solX.filter((x) => x !== null) as number[];
|
||||||
|
minX = Math.min(minX, ...xs);
|
||||||
|
maxX = Math.max(maxX, ...xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let minY = Math.min(p0[1], p3[1]);
|
||||||
|
let maxY = Math.max(p0[1], p3[1]);
|
||||||
|
if (solY) {
|
||||||
|
const ys = solY.filter((y) => y !== null) as number[];
|
||||||
|
minY = Math.min(minY, ...ys);
|
||||||
|
maxY = Math.max(maxY, ...ys);
|
||||||
|
}
|
||||||
|
return [minX, minY, maxX, maxY];
|
||||||
|
};
|
||||||
|
|
||||||
const getMinMaxXYFromCurvePathOps = (
|
const getMinMaxXYFromCurvePathOps = (
|
||||||
ops: Op[],
|
ops: Op[],
|
||||||
transformXY?: (x: number, y: number) => [number, number],
|
transformXY?: (x: number, y: number) => [number, number],
|
||||||
): [number, number, number, number] => {
|
): [number, number, number, number] => {
|
||||||
let currentP: Point = [0, 0];
|
let currentP: Point = [0, 0];
|
||||||
|
|
||||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||||
(limits, { op, data }) => {
|
(limits, { op, data }) => {
|
||||||
// There are only four operation types:
|
// There are only four operation types:
|
||||||
@@ -83,38 +168,29 @@ const getMinMaxXYFromCurvePathOps = (
|
|||||||
// move operation does not draw anything; so, it always
|
// move operation does not draw anything; so, it always
|
||||||
// returns false
|
// returns false
|
||||||
} else if (op === "bcurveTo") {
|
} else if (op === "bcurveTo") {
|
||||||
// create points from bezier curve
|
const _p1 = [data[0], data[1]] as Point;
|
||||||
// bezier curve stores data as a flattened array of three positions
|
const _p2 = [data[2], data[3]] as Point;
|
||||||
// [x1, y1, x2, y2, x3, y3]
|
const _p3 = [data[4], data[5]] as Point;
|
||||||
const p1 = [data[0], data[1]] as Point;
|
|
||||||
const p2 = [data[2], data[3]] as Point;
|
|
||||||
const p3 = [data[4], data[5]] as Point;
|
|
||||||
|
|
||||||
const p0 = currentP;
|
const p1 = transformXY ? transformXY(..._p1) : _p1;
|
||||||
currentP = p3;
|
const p2 = transformXY ? transformXY(..._p2) : _p2;
|
||||||
|
const p3 = transformXY ? transformXY(..._p3) : _p3;
|
||||||
|
|
||||||
const equation = (t: number, idx: number) =>
|
const p0 = transformXY ? transformXY(...currentP) : currentP;
|
||||||
Math.pow(1 - t, 3) * p3[idx] +
|
currentP = _p3;
|
||||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
|
||||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
|
||||||
p0[idx] * Math.pow(t, 3);
|
|
||||||
|
|
||||||
let t = 0;
|
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
|
||||||
while (t <= 1.0) {
|
p0,
|
||||||
let x = equation(t, 0);
|
p1,
|
||||||
let y = equation(t, 1);
|
p2,
|
||||||
if (transformXY) {
|
p3,
|
||||||
[x, y] = transformXY(x, y);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
limits.minY = Math.min(limits.minY, y);
|
limits.minX = Math.min(limits.minX, minX);
|
||||||
limits.minX = Math.min(limits.minX, x);
|
limits.minY = Math.min(limits.minY, minY);
|
||||||
|
|
||||||
limits.maxX = Math.max(limits.maxX, x);
|
limits.maxX = Math.max(limits.maxX, maxX);
|
||||||
limits.maxY = Math.max(limits.maxY, y);
|
limits.maxY = Math.max(limits.maxY, maxY);
|
||||||
|
|
||||||
t += 0.1;
|
|
||||||
}
|
|
||||||
} else if (op === "lineTo") {
|
} else if (op === "lineTo") {
|
||||||
// TODO: Implement this
|
// TODO: Implement this
|
||||||
} else if (op === "qcurveTo") {
|
} else if (op === "qcurveTo") {
|
||||||
@@ -124,7 +200,6 @@ const getMinMaxXYFromCurvePathOps = (
|
|||||||
},
|
},
|
||||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||||
);
|
);
|
||||||
|
|
||||||
return [minX, minY, maxX, maxY];
|
return [minX, minY, maxX, maxY];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -420,6 +495,7 @@ export const getResizedElementAbsoluteCoords = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
nextWidth: number,
|
nextWidth: number,
|
||||||
nextHeight: number,
|
nextHeight: number,
|
||||||
|
normalizePoints: boolean,
|
||||||
): [number, number, number, number] => {
|
): [number, number, number, number] => {
|
||||||
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
||||||
return [
|
return [
|
||||||
@@ -433,7 +509,8 @@ export const getResizedElementAbsoluteCoords = (
|
|||||||
const points = rescalePoints(
|
const points = rescalePoints(
|
||||||
0,
|
0,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
rescalePoints(1, nextHeight, element.points),
|
rescalePoints(1, nextHeight, element.points, normalizePoints),
|
||||||
|
normalizePoints,
|
||||||
);
|
);
|
||||||
|
|
||||||
let bounds: [number, number, number, number];
|
let bounds: [number, number, number, number];
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { getShapeForElement } from "../renderer/renderElement";
|
|||||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
import { isTransparent } from "../utils";
|
import { isTransparent } from "../utils";
|
||||||
|
import { shouldShowBoundingBox } from "./transformHandles";
|
||||||
|
|
||||||
const isElementDraggableFromInside = (
|
const isElementDraggableFromInside = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
@@ -64,7 +65,10 @@ export const hitTest = (
|
|||||||
const threshold = 10 / appState.zoom.value;
|
const threshold = 10 / appState.zoom.value;
|
||||||
const point: Point = [x, y];
|
const point: Point = [x, y];
|
||||||
|
|
||||||
if (isElementSelected(appState, element)) {
|
if (
|
||||||
|
isElementSelected(appState, element) &&
|
||||||
|
shouldShowBoundingBox([element], appState)
|
||||||
|
) {
|
||||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,15 +105,26 @@ export const dragNewElement = (
|
|||||||
true */
|
true */
|
||||||
widthAspectRatio?: number | null,
|
widthAspectRatio?: number | null,
|
||||||
) => {
|
) => {
|
||||||
if (shouldMaintainAspectRatio) {
|
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
|
||||||
if (widthAspectRatio) {
|
if (widthAspectRatio) {
|
||||||
height = width / widthAspectRatio;
|
height = width / widthAspectRatio;
|
||||||
} else {
|
} else {
|
||||||
({ width, height } = getPerfectElementSize(
|
// Depending on where the cursor is at (x, y) relative to where the starting point is
|
||||||
elementType,
|
// (originX, originY), we use ONLY width or height to control size increase.
|
||||||
width,
|
// This allows the cursor to always "stick" to one of the sides of the bounding box.
|
||||||
y < originY ? -height : height,
|
if (Math.abs(y - originY) > Math.abs(x - originX)) {
|
||||||
));
|
({ width, height } = getPerfectElementSize(
|
||||||
|
elementType,
|
||||||
|
height,
|
||||||
|
x < originX ? -width : width,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
({ width, height } = getPerfectElementSize(
|
||||||
|
elementType,
|
||||||
|
width,
|
||||||
|
y < originY ? -height : height,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if (height < 0) {
|
if (height < 0) {
|
||||||
height = -height;
|
height = -height;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export { textWysiwyg } from "./textWysiwyg";
|
|||||||
export { redrawTextBoundingBox } from "./textElement";
|
export { redrawTextBoundingBox } from "./textElement";
|
||||||
export {
|
export {
|
||||||
getPerfectElementSize,
|
getPerfectElementSize,
|
||||||
|
getLockedLinearCursorAlignSize,
|
||||||
isInvisiblySmallElement,
|
isInvisiblySmallElement,
|
||||||
resizePerfectLineForNWHandler,
|
resizePerfectLineForNWHandler,
|
||||||
getNormalizedDimensions,
|
getNormalizedDimensions,
|
||||||
|
|||||||
+288
-125
@@ -5,8 +5,15 @@ import {
|
|||||||
PointBinding,
|
PointBinding,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
|
import {
|
||||||
import { getElementAbsoluteCoords } from ".";
|
distance2d,
|
||||||
|
rotate,
|
||||||
|
isPathALoop,
|
||||||
|
getGridPoint,
|
||||||
|
rotatePoint,
|
||||||
|
centerPoint,
|
||||||
|
} from "../math";
|
||||||
|
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||||
import { getElementPointsCoords } from "./bounds";
|
import { getElementPointsCoords } from "./bounds";
|
||||||
import { Point, AppState } from "../types";
|
import { Point, AppState } from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
@@ -20,26 +27,32 @@ import {
|
|||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { tupleToCoors } from "../utils";
|
import { tupleToCoors } from "../utils";
|
||||||
import { isBindingElement } from "./typeChecks";
|
import { isBindingElement } from "./typeChecks";
|
||||||
|
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||||
|
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
public elementId: ExcalidrawElement["id"] & {
|
public readonly elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
};
|
};
|
||||||
/** indices */
|
/** indices */
|
||||||
public selectedPointsIndices: readonly number[] | null;
|
public readonly selectedPointsIndices: readonly number[] | null;
|
||||||
|
|
||||||
public pointerDownState: Readonly<{
|
public readonly pointerDownState: Readonly<{
|
||||||
prevSelectedPointsIndices: readonly number[] | null;
|
prevSelectedPointsIndices: readonly number[] | null;
|
||||||
/** index */
|
/** index */
|
||||||
lastClickedPoint: number;
|
lastClickedPoint: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/** whether you're dragging a point */
|
/** whether you're dragging a point */
|
||||||
public isDragging: boolean;
|
public readonly isDragging: boolean;
|
||||||
public lastUncommittedPoint: Point | null;
|
public readonly lastUncommittedPoint: Point | null;
|
||||||
public pointerOffset: Readonly<{ x: number; y: number }>;
|
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
||||||
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
public readonly startBindingElement:
|
||||||
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
| ExcalidrawBindableElement
|
||||||
|
| null
|
||||||
|
| "keep";
|
||||||
|
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||||
|
public readonly hoverPointIndex: number;
|
||||||
|
public readonly midPointHovered: boolean;
|
||||||
|
|
||||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||||
this.elementId = element.id as string & {
|
this.elementId = element.id as string & {
|
||||||
@@ -58,13 +71,15 @@ export class LinearElementEditor {
|
|||||||
prevSelectedPointsIndices: null,
|
prevSelectedPointsIndices: null,
|
||||||
lastClickedPoint: -1,
|
lastClickedPoint: -1,
|
||||||
};
|
};
|
||||||
|
this.hoverPointIndex = -1;
|
||||||
|
this.midPointHovered = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// static methods
|
// static methods
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static POINT_HANDLE_SIZE = 20;
|
static POINT_HANDLE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id the `elementId` from the instance of this class (so that we can
|
* @param id the `elementId` from the instance of this class (so that we can
|
||||||
@@ -132,22 +147,20 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
/** @returns whether point was dragged */
|
/** @returns whether point was dragged */
|
||||||
static handlePointDragging(
|
static handlePointDragging(
|
||||||
|
event: PointerEvent,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
setState: React.Component<any, AppState>["setState"],
|
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
maybeSuggestBinding: (
|
maybeSuggestBinding: (
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
pointSceneCoords: { x: number; y: number }[],
|
pointSceneCoords: { x: number; y: number }[],
|
||||||
) => void,
|
) => void,
|
||||||
|
linearElementEditor: LinearElementEditor,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!appState.editingLinearElement) {
|
if (!linearElementEditor) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { editingLinearElement } = appState;
|
const { selectedPointsIndices, elementId } = linearElementEditor;
|
||||||
const { selectedPointsIndices, elementId, isDragging } =
|
|
||||||
editingLinearElement;
|
|
||||||
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
@@ -155,54 +168,71 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
// point that's being dragged (out of all selected points)
|
// point that's being dragged (out of all selected points)
|
||||||
const draggingPoint = element.points[
|
const draggingPoint = element.points[
|
||||||
editingLinearElement.pointerDownState.lastClickedPoint
|
linearElementEditor.pointerDownState.lastClickedPoint
|
||||||
] as [number, number] | undefined;
|
] as [number, number] | undefined;
|
||||||
|
|
||||||
if (selectedPointsIndices && draggingPoint) {
|
if (selectedPointsIndices && draggingPoint) {
|
||||||
if (isDragging === false) {
|
if (
|
||||||
setState({
|
shouldRotateWithDiscreteAngle(event) &&
|
||||||
editingLinearElement: {
|
selectedPointsIndices.length === 1 &&
|
||||||
...editingLinearElement,
|
element.points.length > 1
|
||||||
isDragging: true,
|
) {
|
||||||
},
|
const selectedIndex = selectedPointsIndices[0];
|
||||||
});
|
const referencePoint =
|
||||||
}
|
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
|
||||||
|
|
||||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||||
element,
|
element,
|
||||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
referencePoint,
|
||||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
[scenePointerX, scenePointerY],
|
||||||
appState.gridSize,
|
appState.gridSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
LinearElementEditor.movePoints(element, [
|
||||||
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
{
|
||||||
|
index: selectedIndex,
|
||||||
LinearElementEditor.movePoints(
|
point: [width + referencePoint[0], height + referencePoint[1]],
|
||||||
element,
|
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
|
||||||
const newPointPosition =
|
|
||||||
pointIndex ===
|
|
||||||
editingLinearElement.pointerDownState.lastClickedPoint
|
|
||||||
? LinearElementEditor.createPointAt(
|
|
||||||
element,
|
|
||||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
|
||||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
|
||||||
appState.gridSize,
|
|
||||||
)
|
|
||||||
: ([
|
|
||||||
element.points[pointIndex][0] + deltaX,
|
|
||||||
element.points[pointIndex][1] + deltaY,
|
|
||||||
] as const);
|
|
||||||
return {
|
|
||||||
index: pointIndex,
|
|
||||||
point: newPointPosition,
|
|
||||||
isDragging:
|
isDragging:
|
||||||
|
selectedIndex ===
|
||||||
|
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
|
element,
|
||||||
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
|
appState.gridSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||||
|
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
||||||
|
|
||||||
|
LinearElementEditor.movePoints(
|
||||||
|
element,
|
||||||
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
|
const newPointPosition =
|
||||||
pointIndex ===
|
pointIndex ===
|
||||||
editingLinearElement.pointerDownState.lastClickedPoint,
|
linearElementEditor.pointerDownState.lastClickedPoint
|
||||||
};
|
? LinearElementEditor.createPointAt(
|
||||||
}),
|
element,
|
||||||
);
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
|
appState.gridSize,
|
||||||
|
)
|
||||||
|
: ([
|
||||||
|
element.points[pointIndex][0] + deltaX,
|
||||||
|
element.points[pointIndex][1] + deltaY,
|
||||||
|
] as const);
|
||||||
|
return {
|
||||||
|
index: pointIndex,
|
||||||
|
point: newPointPosition,
|
||||||
|
isDragging:
|
||||||
|
pointIndex ===
|
||||||
|
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// suggest bindings for first and last point if selected
|
// suggest bindings for first and last point if selected
|
||||||
if (isBindingElement(element, false)) {
|
if (isBindingElement(element, false)) {
|
||||||
@@ -256,10 +286,12 @@ export class LinearElementEditor {
|
|||||||
return editingLinearElement;
|
return editingLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindings: Partial<
|
const bindings: Mutable<
|
||||||
Pick<
|
Partial<
|
||||||
InstanceType<typeof LinearElementEditor>,
|
Pick<
|
||||||
"startBindingElement" | "endBindingElement"
|
InstanceType<typeof LinearElementEditor>,
|
||||||
|
"startBindingElement" | "endBindingElement"
|
||||||
|
>
|
||||||
>
|
>
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
@@ -327,34 +359,126 @@ export class LinearElementEditor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isHittingMidPoint = (
|
||||||
|
linearElementEditor: LinearElementEditor,
|
||||||
|
scenePointer: { x: number; y: number },
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const { elementId } = linearElementEditor;
|
||||||
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
if (!element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||||
|
element,
|
||||||
|
appState.zoom,
|
||||||
|
scenePointer.x,
|
||||||
|
scenePointer.y,
|
||||||
|
);
|
||||||
|
if (clickedPointIndex >= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
|
if (points.length >= 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const midPoint = LinearElementEditor.getMidPoint(linearElementEditor);
|
||||||
|
if (midPoint) {
|
||||||
|
const threshold =
|
||||||
|
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
||||||
|
const distance = distance2d(
|
||||||
|
midPoint[0],
|
||||||
|
midPoint[1],
|
||||||
|
scenePointer.x,
|
||||||
|
scenePointer.y,
|
||||||
|
);
|
||||||
|
return distance <= threshold;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
static getMidPoint(linearElementEditor: LinearElementEditor) {
|
||||||
|
const { elementId } = linearElementEditor;
|
||||||
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
if (!element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
|
|
||||||
|
return centerPoint(points[0], points.at(-1)!);
|
||||||
|
}
|
||||||
|
|
||||||
static handlePointerDown(
|
static handlePointerDown(
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
setState: React.Component<any, AppState>["setState"],
|
|
||||||
history: History,
|
history: History,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
|
linearElementEditor: LinearElementEditor,
|
||||||
): {
|
): {
|
||||||
didAddPoint: boolean;
|
didAddPoint: boolean;
|
||||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||||
|
linearElementEditor: LinearElementEditor | null;
|
||||||
|
isMidPoint: boolean;
|
||||||
} {
|
} {
|
||||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||||
didAddPoint: false,
|
didAddPoint: false,
|
||||||
hitElement: null,
|
hitElement: null,
|
||||||
|
linearElementEditor: null,
|
||||||
|
isMidPoint: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!appState.editingLinearElement) {
|
if (!linearElementEditor) {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { elementId } = appState.editingLinearElement;
|
const { elementId } = linearElementEditor;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
const hittingMidPoint = LinearElementEditor.isHittingMidPoint(
|
||||||
if (event.altKey) {
|
linearElementEditor,
|
||||||
if (appState.editingLinearElement.lastUncommittedPoint == null) {
|
scenePointer,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
LinearElementEditor.isHittingMidPoint(
|
||||||
|
linearElementEditor,
|
||||||
|
scenePointer,
|
||||||
|
appState,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const midPoint = LinearElementEditor.getMidPoint(linearElementEditor);
|
||||||
|
if (midPoint) {
|
||||||
|
mutateElement(element, {
|
||||||
|
points: [
|
||||||
|
element.points[0],
|
||||||
|
LinearElementEditor.createPointAt(
|
||||||
|
element,
|
||||||
|
midPoint[0],
|
||||||
|
midPoint[1],
|
||||||
|
appState.gridSize,
|
||||||
|
),
|
||||||
|
...element.points.slice(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ret.didAddPoint = true;
|
||||||
|
ret.isMidPoint = true;
|
||||||
|
ret.linearElementEditor = {
|
||||||
|
...linearElementEditor,
|
||||||
|
selectedPointsIndices: element.points[1],
|
||||||
|
pointerDownState: {
|
||||||
|
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||||
|
lastClickedPoint: -1,
|
||||||
|
},
|
||||||
|
lastUncommittedPoint: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.altKey && appState.editingLinearElement) {
|
||||||
|
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
points: [
|
points: [
|
||||||
...element.points,
|
...element.points,
|
||||||
@@ -366,24 +490,23 @@ export class LinearElementEditor {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
ret.didAddPoint = true;
|
||||||
}
|
}
|
||||||
history.resumeRecording();
|
history.resumeRecording();
|
||||||
setState({
|
ret.linearElementEditor = {
|
||||||
editingLinearElement: {
|
...linearElementEditor,
|
||||||
...appState.editingLinearElement,
|
pointerDownState: {
|
||||||
pointerDownState: {
|
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||||
prevSelectedPointsIndices:
|
lastClickedPoint: -1,
|
||||||
appState.editingLinearElement.selectedPointsIndices,
|
|
||||||
lastClickedPoint: -1,
|
|
||||||
},
|
|
||||||
selectedPointsIndices: [element.points.length - 1],
|
|
||||||
lastUncommittedPoint: null,
|
|
||||||
endBindingElement: getHoveredElementForBinding(
|
|
||||||
scenePointer,
|
|
||||||
Scene.getScene(element)!,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
selectedPointsIndices: [element.points.length - 1],
|
||||||
|
lastUncommittedPoint: null,
|
||||||
|
endBindingElement: getHoveredElementForBinding(
|
||||||
|
scenePointer,
|
||||||
|
Scene.getScene(element)!,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
@@ -397,7 +520,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
// if we clicked on a point, set the element as hitElement otherwise
|
// if we clicked on a point, set the element as hitElement otherwise
|
||||||
// it would get deselected if the point is outside the hitbox area
|
// it would get deselected if the point is outside the hitbox area
|
||||||
if (clickedPointIndex > -1) {
|
if (clickedPointIndex >= 0 || hittingMidPoint) {
|
||||||
ret.hitElement = element;
|
ret.hitElement = element;
|
||||||
} else {
|
} else {
|
||||||
// You might be wandering why we are storing the binding elements on
|
// You might be wandering why we are storing the binding elements on
|
||||||
@@ -405,8 +528,7 @@ export class LinearElementEditor {
|
|||||||
// from the end points of the `linearElement` - this is to allow disabling
|
// from the end points of the `linearElement` - this is to allow disabling
|
||||||
// binding (which needs to happen at the point the user finishes moving
|
// binding (which needs to happen at the point the user finishes moving
|
||||||
// the point).
|
// the point).
|
||||||
const { startBindingElement, endBindingElement } =
|
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||||
appState.editingLinearElement;
|
|
||||||
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
||||||
bindOrUnbindLinearElement(
|
bindOrUnbindLinearElement(
|
||||||
element,
|
element,
|
||||||
@@ -432,33 +554,28 @@ export class LinearElementEditor {
|
|||||||
const nextSelectedPointsIndices =
|
const nextSelectedPointsIndices =
|
||||||
clickedPointIndex > -1 || event.shiftKey
|
clickedPointIndex > -1 || event.shiftKey
|
||||||
? event.shiftKey ||
|
? event.shiftKey ||
|
||||||
appState.editingLinearElement.selectedPointsIndices?.includes(
|
linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex)
|
||||||
clickedPointIndex,
|
|
||||||
)
|
|
||||||
? normalizeSelectedPoints([
|
? normalizeSelectedPoints([
|
||||||
...(appState.editingLinearElement.selectedPointsIndices || []),
|
...(linearElementEditor.selectedPointsIndices || []),
|
||||||
clickedPointIndex,
|
clickedPointIndex,
|
||||||
])
|
])
|
||||||
: [clickedPointIndex]
|
: [clickedPointIndex]
|
||||||
: null;
|
: null;
|
||||||
|
ret.linearElementEditor = {
|
||||||
setState({
|
...linearElementEditor,
|
||||||
editingLinearElement: {
|
pointerDownState: {
|
||||||
...appState.editingLinearElement,
|
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||||
pointerDownState: {
|
lastClickedPoint: clickedPointIndex,
|
||||||
prevSelectedPointsIndices:
|
|
||||||
appState.editingLinearElement.selectedPointsIndices,
|
|
||||||
lastClickedPoint: clickedPointIndex,
|
|
||||||
},
|
|
||||||
selectedPointsIndices: nextSelectedPointsIndices,
|
|
||||||
pointerOffset: targetPoint
|
|
||||||
? {
|
|
||||||
x: scenePointer.x - targetPoint[0],
|
|
||||||
y: scenePointer.y - targetPoint[1],
|
|
||||||
}
|
|
||||||
: { x: 0, y: 0 },
|
|
||||||
},
|
},
|
||||||
});
|
selectedPointsIndices: nextSelectedPointsIndices,
|
||||||
|
pointerOffset: targetPoint
|
||||||
|
? {
|
||||||
|
x: scenePointer.x - targetPoint[0],
|
||||||
|
y: scenePointer.y - targetPoint[1],
|
||||||
|
}
|
||||||
|
: { x: 0, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,13 +583,13 @@ export class LinearElementEditor {
|
|||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
editingLinearElement: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
gridSize: number | null,
|
gridSize: number | null,
|
||||||
): LinearElementEditor {
|
): LinearElementEditor {
|
||||||
const { elementId, lastUncommittedPoint } = editingLinearElement;
|
const { elementId, lastUncommittedPoint } = linearElementEditor;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return editingLinearElement;
|
return linearElementEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
@@ -482,15 +599,33 @@ export class LinearElementEditor {
|
|||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||||
}
|
}
|
||||||
return { ...editingLinearElement, lastUncommittedPoint: null };
|
return { ...linearElementEditor, lastUncommittedPoint: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPoint = LinearElementEditor.createPointAt(
|
let newPoint: Point;
|
||||||
element,
|
|
||||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
const lastCommittedPoint = points[points.length - 2];
|
||||||
gridSize,
|
|
||||||
);
|
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||||
|
element,
|
||||||
|
lastCommittedPoint,
|
||||||
|
[scenePointerX, scenePointerY],
|
||||||
|
gridSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
newPoint = [
|
||||||
|
width + lastCommittedPoint[0],
|
||||||
|
height + lastCommittedPoint[1],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
newPoint = LinearElementEditor.createPointAt(
|
||||||
|
element,
|
||||||
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
|
gridSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(element, [
|
||||||
@@ -504,7 +639,7 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...editingLinearElement,
|
...linearElementEditor,
|
||||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -526,14 +661,14 @@ export class LinearElementEditor {
|
|||||||
/** scene coords */
|
/** scene coords */
|
||||||
static getPointsGlobalCoordinates(
|
static getPointsGlobalCoordinates(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
) {
|
): Point[] {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
return element.points.map((point) => {
|
return element.points.map((point) => {
|
||||||
let { x, y } = element;
|
let { x, y } = element;
|
||||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||||
return [x, y];
|
return [x, y] as const;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +712,8 @@ export class LinearElementEditor {
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
) {
|
) {
|
||||||
const pointHandles = this.getPointsGlobalCoordinates(element);
|
const pointHandles =
|
||||||
|
LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
let idx = pointHandles.length;
|
let idx = pointHandles.length;
|
||||||
// loop from right to left because points on the right are rendered over
|
// loop from right to left because points on the right are rendered over
|
||||||
// points on the left, thus should take precedence when clicking, if they
|
// points on the left, thus should take precedence when clicking, if they
|
||||||
@@ -587,7 +723,7 @@ export class LinearElementEditor {
|
|||||||
if (
|
if (
|
||||||
distance2d(x, y, point[0], point[1]) * zoom.value <
|
distance2d(x, y, point[0], point[1]) * zoom.value <
|
||||||
// +1px to account for outline stroke
|
// +1px to account for outline stroke
|
||||||
this.POINT_HANDLE_SIZE / 2 + 1
|
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||||
) {
|
) {
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
@@ -775,9 +911,9 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (selectedOriginPoint) {
|
if (selectedOriginPoint) {
|
||||||
offsetX =
|
offsetX =
|
||||||
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
|
selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0];
|
||||||
offsetY =
|
offsetY =
|
||||||
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
|
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPoints = points.map((point, idx) => {
|
const nextPoints = points.map((point, idx) => {
|
||||||
@@ -840,6 +976,33 @@ export class LinearElementEditor {
|
|||||||
y: element.y + rotated[1],
|
y: element.y + rotated[1],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static _getShiftLockedDelta(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
referencePoint: Point,
|
||||||
|
scenePointer: Point,
|
||||||
|
gridSize: number | null,
|
||||||
|
) {
|
||||||
|
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||||
|
element,
|
||||||
|
referencePoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [gridX, gridY] = getGridPoint(
|
||||||
|
scenePointer[0],
|
||||||
|
scenePointer[1],
|
||||||
|
gridSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { width, height } = getLockedLinearCursorAlignSize(
|
||||||
|
referencePointCoords[0],
|
||||||
|
referencePointCoords[1],
|
||||||
|
gridX,
|
||||||
|
gridY,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rotatePoint([width, height], [0, 0], -element.angle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeSelectedPoints = (
|
const normalizeSelectedPoints = (
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ const getAdjustedDimensions = (
|
|||||||
element,
|
element,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
const deltaX1 = (x1 - nextX1) / 2;
|
const deltaX1 = (x1 - nextX1) / 2;
|
||||||
const deltaY1 = (y1 - nextY1) / 2;
|
const deltaY1 = (y1 - nextY1) / 2;
|
||||||
|
|||||||
+141
-130
@@ -18,6 +18,7 @@ import {
|
|||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
|
getCommonBoundingBox,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
@@ -137,8 +138,10 @@ export const transformElements = (
|
|||||||
transformHandleType === "se"
|
transformHandleType === "se"
|
||||||
) {
|
) {
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
|
pointerDownState,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
|
shouldResizeFromCenter,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
@@ -261,13 +264,15 @@ const rescalePointsInElement = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
|
normalizePoints: boolean,
|
||||||
) =>
|
) =>
|
||||||
isLinearElement(element) || isFreeDrawElement(element)
|
isLinearElement(element) || isFreeDrawElement(element)
|
||||||
? {
|
? {
|
||||||
points: rescalePoints(
|
points: rescalePoints(
|
||||||
0,
|
0,
|
||||||
width,
|
width,
|
||||||
rescalePoints(1, height, element.points),
|
rescalePoints(1, height, element.points, normalizePoints),
|
||||||
|
normalizePoints,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
@@ -371,6 +376,7 @@ const resizeSingleTextElement = (
|
|||||||
element,
|
element,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
const deltaX1 = (x1 - nextX1) / 2;
|
const deltaX1 = (x1 - nextX1) / 2;
|
||||||
const deltaY1 = (y1 - nextY1) / 2;
|
const deltaY1 = (y1 - nextY1) / 2;
|
||||||
@@ -412,6 +418,7 @@ export const resizeSingleElement = (
|
|||||||
stateAtResizeStart,
|
stateAtResizeStart,
|
||||||
stateAtResizeStart.width,
|
stateAtResizeStart.width,
|
||||||
stateAtResizeStart.height,
|
stateAtResizeStart.height,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
const startTopLeft: Point = [x1, y1];
|
const startTopLeft: Point = [x1, y1];
|
||||||
const startBottomRight: Point = [x2, y2];
|
const startBottomRight: Point = [x2, y2];
|
||||||
@@ -429,6 +436,7 @@ export const resizeSingleElement = (
|
|||||||
element,
|
element,
|
||||||
element.width,
|
element.width,
|
||||||
element.height,
|
element.height,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const boundsCurrentWidth = esx2 - esx1;
|
const boundsCurrentWidth = esx2 - esx1;
|
||||||
@@ -522,6 +530,7 @@ export const resizeSingleElement = (
|
|||||||
stateAtResizeStart,
|
stateAtResizeStart,
|
||||||
eleNewWidth,
|
eleNewWidth,
|
||||||
eleNewHeight,
|
eleNewHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||||
@@ -592,6 +601,7 @@ export const resizeSingleElement = (
|
|||||||
stateAtResizeStart,
|
stateAtResizeStart,
|
||||||
eleNewWidth,
|
eleNewWidth,
|
||||||
eleNewHeight,
|
eleNewHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||||
// So we need to readjust (x,y) to be where the first point should be
|
// So we need to readjust (x,y) to be where the first point should be
|
||||||
@@ -637,146 +647,147 @@ export const resizeSingleElement = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resizeMultipleElements = (
|
const resizeMultipleElements = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
pointerDownState: PointerDownState,
|
||||||
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||||
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
// map selected elements to the original elements. While it never should
|
||||||
let scale: number;
|
// happen that pointerDownState.originalElements won't contain the selected
|
||||||
let getNextXY: (
|
// elements during resize, this coupling isn't guaranteed, so to ensure
|
||||||
element: NonDeletedExcalidrawElement,
|
// type safety we need to transform only those elements we filter.
|
||||||
origCoords: readonly [number, number, number, number],
|
const targetElements = selectedElements.reduce(
|
||||||
finalCoords: readonly [number, number, number, number],
|
(
|
||||||
) => { x: number; y: number };
|
acc: {
|
||||||
switch (transformHandleType) {
|
/** element at resize start */
|
||||||
case "se":
|
orig: NonDeletedExcalidrawElement;
|
||||||
scale = Math.max(
|
/** latest element */
|
||||||
(pointerX - x1) / (x2 - x1),
|
latest: NonDeletedExcalidrawElement;
|
||||||
(pointerY - y1) / (y2 - y1),
|
}[],
|
||||||
);
|
element,
|
||||||
getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => {
|
) => {
|
||||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
const origElement = pointerDownState.originalElements.get(element.id);
|
||||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
if (origElement) {
|
||||||
return { x, y };
|
acc.push({ orig: origElement, latest: element });
|
||||||
};
|
}
|
||||||
break;
|
return acc;
|
||||||
case "nw":
|
},
|
||||||
scale = Math.max(
|
[],
|
||||||
(x2 - pointerX) / (x2 - x1),
|
);
|
||||||
(y2 - pointerY) / (y2 - y1),
|
|
||||||
);
|
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
||||||
getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => {
|
targetElements.map(({ orig }) => orig),
|
||||||
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
|
);
|
||||||
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
|
const direction = transformHandleType;
|
||||||
return { x, y };
|
|
||||||
};
|
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
|
||||||
break;
|
ne: [minX, maxY],
|
||||||
case "ne":
|
se: [minX, minY],
|
||||||
scale = Math.max(
|
sw: [maxX, minY],
|
||||||
(pointerX - x1) / (x2 - x1),
|
nw: [maxX, maxY],
|
||||||
(y2 - pointerY) / (y2 - y1),
|
};
|
||||||
);
|
|
||||||
getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => {
|
// anchor point must be on the opposite side of the dragged selection handle
|
||||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
// or be the center of the selection if alt is pressed
|
||||||
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
|
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
||||||
return { x, y };
|
? [midX, midY]
|
||||||
};
|
: mapDirectionsToAnchors[direction];
|
||||||
break;
|
|
||||||
case "sw":
|
const mapDirectionsToPointerSides: Record<
|
||||||
scale = Math.max(
|
typeof direction,
|
||||||
(x2 - pointerX) / (x2 - x1),
|
[x: boolean, y: boolean]
|
||||||
(pointerY - y1) / (y2 - y1),
|
> = {
|
||||||
);
|
ne: [pointerX >= anchorX, pointerY <= anchorY],
|
||||||
getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => {
|
se: [pointerX >= anchorX, pointerY >= anchorY],
|
||||||
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
|
sw: [pointerX <= anchorX, pointerY >= anchorY],
|
||||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
nw: [pointerX <= anchorX, pointerY <= anchorY],
|
||||||
return { x, y };
|
};
|
||||||
};
|
|
||||||
break;
|
// pointer side relative to anchor
|
||||||
|
const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
|
||||||
|
direction
|
||||||
|
].map((condition) => (condition ? 1 : -1));
|
||||||
|
|
||||||
|
// stop resizing if a pointer is on the other side of selection
|
||||||
|
if (pointerSideX < 0 && pointerSideY < 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (scale > 0) {
|
|
||||||
const updates = elements.reduce(
|
const scale =
|
||||||
(prev, element) => {
|
Math.max(
|
||||||
if (!prev) {
|
(pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
|
||||||
return prev;
|
(pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
|
||||||
|
) * (shouldResizeFromCenter ? 2 : 1);
|
||||||
|
|
||||||
|
if (scale === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetElements.forEach((element) => {
|
||||||
|
const width = element.orig.width * scale;
|
||||||
|
const height = element.orig.height * scale;
|
||||||
|
const x = anchorX + (element.orig.x - anchorX) * scale;
|
||||||
|
const y = anchorY + (element.orig.y - anchorY) * scale;
|
||||||
|
|
||||||
|
// readjust points for linear & free draw elements
|
||||||
|
const rescaledPoints = rescalePointsInElement(
|
||||||
|
element.orig,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const update: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
points?: Point[];
|
||||||
|
fontSize?: number;
|
||||||
|
baseline?: number;
|
||||||
|
} = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
...rescaledPoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(element.latest);
|
||||||
|
|
||||||
|
if (boundTextElement || isTextElement(element.orig)) {
|
||||||
|
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
|
||||||
|
const textMeasurements = measureFontSizeFromWH(
|
||||||
|
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||||
|
width - optionalPadding,
|
||||||
|
height - optionalPadding,
|
||||||
|
);
|
||||||
|
if (textMeasurements) {
|
||||||
|
if (isTextElement(element.orig)) {
|
||||||
|
update.fontSize = textMeasurements.size;
|
||||||
|
update.baseline = textMeasurements.baseline;
|
||||||
}
|
}
|
||||||
const width = element.width * scale;
|
|
||||||
const height = element.height * scale;
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
let font: { fontSize?: number; baseline?: number } = {};
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const nextFont = measureFontSizeFromWH(
|
boundTextUpdates = {
|
||||||
boundTextElement,
|
fontSize: textMeasurements.size,
|
||||||
width - BOUND_TEXT_PADDING * 2,
|
baseline: textMeasurements.baseline,
|
||||||
height - BOUND_TEXT_PADDING * 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (nextFont === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
font = {
|
|
||||||
fontSize: nextFont.size,
|
|
||||||
baseline: nextFont.baseline,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (isTextElement(element)) {
|
|
||||||
const nextFont = measureFontSizeFromWH(element, width, height);
|
|
||||||
if (nextFont === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
font = { fontSize: nextFont.size, baseline: nextFont.baseline };
|
|
||||||
}
|
|
||||||
const origCoords = getElementAbsoluteCoords(element);
|
|
||||||
|
|
||||||
const rescaledPoints = rescalePointsInElement(element, width, height);
|
|
||||||
|
|
||||||
updateBoundElements(element, {
|
|
||||||
newSize: { width, height },
|
|
||||||
simultaneouslyUpdated: elements,
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalCoords = getResizedElementAbsoluteCoords(
|
|
||||||
{
|
|
||||||
...element,
|
|
||||||
...rescaledPoints,
|
|
||||||
},
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { x, y } = getNextXY(element, origCoords, finalCoords);
|
|
||||||
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
|
|
||||||
},
|
|
||||||
[] as
|
|
||||||
| {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
points?: (readonly [number, number])[];
|
|
||||||
fontSize?: number;
|
|
||||||
baseline?: number;
|
|
||||||
}[]
|
|
||||||
| null,
|
|
||||||
);
|
|
||||||
if (updates) {
|
|
||||||
elements.forEach((element, index) => {
|
|
||||||
mutateElement(element, updates[index]);
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
mutateElement(boundTextElement, {
|
|
||||||
fontSize: updates[index].fontSize,
|
|
||||||
baseline: updates[index].baseline,
|
|
||||||
});
|
|
||||||
handleBindTextResize(element, transformHandleType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
mutateElement(element.latest, update);
|
||||||
|
|
||||||
|
if (boundTextElement && boundTextUpdates) {
|
||||||
|
mutateElement(boundTextElement, boundTextUpdates);
|
||||||
|
handleBindTextResize(element.latest, transformHandleType);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const rotateMultipleElements = (
|
const rotateMultipleElements = (
|
||||||
|
|||||||
@@ -1,49 +1,51 @@
|
|||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import * as constants from "../constants";
|
import * as constants from "../constants";
|
||||||
|
|
||||||
|
const EPSILON_DIGITS = 3;
|
||||||
|
|
||||||
describe("getPerfectElementSize", () => {
|
describe("getPerfectElementSize", () => {
|
||||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||||
expect(width).toEqual(149);
|
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(0);
|
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
|
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
|
||||||
const { height, width } = getPerfectElementSize("line", 10, 140);
|
const { height, width } = getPerfectElementSize("line", 10, 140);
|
||||||
expect(width).toEqual(0);
|
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(140);
|
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||||
expect(width).toEqual(200);
|
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(0);
|
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
|
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 10, 100);
|
const { height, width } = getPerfectElementSize("arrow", 10, 100);
|
||||||
expect(width).toEqual(0);
|
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(100);
|
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||||
expect(width).toEqual(120);
|
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(208);
|
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||||
expect(width).toEqual(135);
|
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(135);
|
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||||
expect(width).toEqual(0);
|
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(0);
|
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
||||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
||||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||||
expect(width).toEqual(120);
|
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(120);
|
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ export const getPerfectElementSize = (
|
|||||||
} else if (lockedAngle === Math.PI / 2) {
|
} else if (lockedAngle === Math.PI / 2) {
|
||||||
width = 0;
|
width = 0;
|
||||||
} else {
|
} else {
|
||||||
height =
|
height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height;
|
||||||
Math.round(absWidth * Math.tan(lockedAngle)) * Math.sign(height) ||
|
|
||||||
height;
|
|
||||||
}
|
}
|
||||||
} else if (elementType !== "selection") {
|
} else if (elementType !== "selection") {
|
||||||
height = absWidth * Math.sign(height);
|
height = absWidth * Math.sign(height);
|
||||||
@@ -47,6 +45,46 @@ export const getPerfectElementSize = (
|
|||||||
return { width, height };
|
return { width, height };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLockedLinearCursorAlignSize = (
|
||||||
|
originX: number,
|
||||||
|
originY: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
) => {
|
||||||
|
let width = x - originX;
|
||||||
|
let height = y - originY;
|
||||||
|
|
||||||
|
const lockedAngle =
|
||||||
|
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||||
|
SHIFT_LOCKING_ANGLE;
|
||||||
|
|
||||||
|
if (lockedAngle === 0) {
|
||||||
|
height = 0;
|
||||||
|
} else if (lockedAngle === Math.PI / 2) {
|
||||||
|
width = 0;
|
||||||
|
} else {
|
||||||
|
// locked angle line, y = mx + b => mx - y + b = 0
|
||||||
|
const a1 = Math.tan(lockedAngle);
|
||||||
|
const b1 = -1;
|
||||||
|
const c1 = originY - a1 * originX;
|
||||||
|
|
||||||
|
// line through cursor, perpendicular to locked angle line
|
||||||
|
const a2 = -1 / a1;
|
||||||
|
const b2 = -1;
|
||||||
|
const c2 = y - a2 * x;
|
||||||
|
|
||||||
|
// intersection of the two lines above
|
||||||
|
const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1);
|
||||||
|
const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1);
|
||||||
|
|
||||||
|
// delta
|
||||||
|
width = intersectX - originX;
|
||||||
|
height = intersectY - originY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
|
||||||
export const resizePerfectLineForNWHandler = (
|
export const resizePerfectLineForNWHandler = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
x: number,
|
x: number,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { ExcalidrawElement, PointerType } from "./types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
PointerType,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
||||||
import { rotate } from "../math";
|
import { rotate } from "../math";
|
||||||
import { Zoom } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
|
import { isLinearElement } from "./typeChecks";
|
||||||
|
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||||
|
|
||||||
export type TransformHandleDirection =
|
export type TransformHandleDirection =
|
||||||
| "n"
|
| "n"
|
||||||
@@ -59,8 +65,6 @@ const OMIT_SIDES_FOR_LINE_BACKSLASH = {
|
|||||||
s: true,
|
s: true,
|
||||||
n: true,
|
n: true,
|
||||||
w: true,
|
w: true,
|
||||||
ne: true,
|
|
||||||
sw: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateTransformHandle = (
|
const generateTransformHandle = (
|
||||||
@@ -82,6 +86,7 @@ export const getTransformHandlesFromCoords = (
|
|||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||||
|
margin = 4,
|
||||||
): TransformHandles => {
|
): TransformHandles => {
|
||||||
const size = transformHandleSizes[pointerType];
|
const size = transformHandleSizes[pointerType];
|
||||||
const handleWidth = size / zoom.value;
|
const handleWidth = size / zoom.value;
|
||||||
@@ -94,9 +99,7 @@ export const getTransformHandlesFromCoords = (
|
|||||||
const height = y2 - y1;
|
const height = y2 - y1;
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
|
const dashedLineMargin = margin / zoom.value;
|
||||||
const dashedLineMargin = 4 / zoom.value;
|
|
||||||
|
|
||||||
const centeringOffset = (size - 8) / (2 * zoom.value);
|
const centeringOffset = (size - 8) / (2 * zoom.value);
|
||||||
|
|
||||||
const transformHandles: TransformHandles = {
|
const transformHandles: TransformHandles = {
|
||||||
@@ -230,11 +233,7 @@ export const getTransformHandles = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
||||||
if (
|
if (element.type === "freedraw" || isLinearElement(element)) {
|
||||||
element.type === "arrow" ||
|
|
||||||
element.type === "line" ||
|
|
||||||
element.type === "freedraw"
|
|
||||||
) {
|
|
||||||
if (element.points.length === 2) {
|
if (element.points.length === 2) {
|
||||||
// only check the last point because starting point is always (0,0)
|
// only check the last point because starting point is always (0,0)
|
||||||
const [, p1] = element.points;
|
const [, p1] = element.points;
|
||||||
@@ -253,12 +252,33 @@ export const getTransformHandles = (
|
|||||||
} else if (isTextElement(element)) {
|
} else if (isTextElement(element)) {
|
||||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||||
}
|
}
|
||||||
|
const dashedLineMargin = isLinearElement(element)
|
||||||
|
? DEFAULT_SPACING * 3
|
||||||
|
: DEFAULT_SPACING;
|
||||||
return getTransformHandlesFromCoords(
|
return getTransformHandlesFromCoords(
|
||||||
getElementAbsoluteCoords(element),
|
getElementAbsoluteCoords(element),
|
||||||
element.angle,
|
element.angle,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
omitSides,
|
omitSides,
|
||||||
|
dashedLineMargin,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const shouldShowBoundingBox = (
|
||||||
|
elements: NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
if (appState.editingLinearElement) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (elements.length > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const element = elements[0];
|
||||||
|
if (!isLinearElement(element)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element.points.length > 2;
|
||||||
|
};
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type _ExcalidrawElementBase = Readonly<{
|
|||||||
updated: number;
|
updated: number;
|
||||||
link: string | null;
|
link: string | null;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
customData?: Record<string, any>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
|||||||
}, STORAGE_SIZE_TIMEOUT);
|
}, STORAGE_SIZE_TIMEOUT);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
setToastMessage: (message: string) => void;
|
setToast: (message: string) => void;
|
||||||
};
|
};
|
||||||
const CustomStats = (props: Props) => {
|
const CustomStats = (props: Props) => {
|
||||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||||
@@ -68,7 +68,7 @@ const CustomStats = (props: Props) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(getVersion());
|
await copyTextToSystemClipboard(getVersion());
|
||||||
props.setToastMessage(t("toast.copyToClipboard"));
|
props.setToast(t("toast.copyToClipboard"));
|
||||||
} catch {}
|
} catch {}
|
||||||
}}
|
}}
|
||||||
title={t("stats.versionCopy")}
|
title={t("stats.versionCopy")}
|
||||||
|
|||||||
+123
-113
@@ -8,10 +8,12 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { getSceneVersion } from "../../packages/excalidraw/index";
|
import {
|
||||||
|
getSceneVersion,
|
||||||
|
restoreElements,
|
||||||
|
} from "../../packages/excalidraw/index";
|
||||||
import { Collaborator, Gesture } from "../../types";
|
import { Collaborator, Gesture } from "../../types";
|
||||||
import {
|
import {
|
||||||
getFrame,
|
|
||||||
preventUnload,
|
preventUnload,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
withBatchedUpdates,
|
withBatchedUpdates,
|
||||||
@@ -47,11 +49,9 @@ import {
|
|||||||
} from "../data/localStorage";
|
} from "../data/localStorage";
|
||||||
import Portal from "./Portal";
|
import Portal from "./Portal";
|
||||||
import RoomDialog from "./RoomDialog";
|
import RoomDialog from "./RoomDialog";
|
||||||
import { createInverseContext } from "../../createInverseContext";
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { UserIdleState } from "../../types";
|
import { UserIdleState } from "../../types";
|
||||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||||
import { trackEvent } from "../../analytics";
|
|
||||||
import {
|
import {
|
||||||
encodeFilesForUpload,
|
encodeFilesForUpload,
|
||||||
FileManager,
|
FileManager,
|
||||||
@@ -70,52 +70,45 @@ import {
|
|||||||
import { decryptData } from "../../data/encryption";
|
import { decryptData } from "../../data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { jotaiStore } from "../../jotai";
|
||||||
|
|
||||||
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
|
export const collabDialogShownAtom = atom(false);
|
||||||
|
export const isCollaboratingAtom = atom(false);
|
||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
modalIsShown: boolean;
|
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
username: string;
|
username: string;
|
||||||
userState: UserIdleState;
|
|
||||||
activeRoomLink: string;
|
activeRoomLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
type CollabInstance = InstanceType<typeof Collab>;
|
||||||
|
|
||||||
export interface CollabAPI {
|
export interface CollabAPI {
|
||||||
/** function so that we can access the latest value from stale callbacks */
|
/** function so that we can access the latest value from stale callbacks */
|
||||||
isCollaborating: () => boolean;
|
isCollaborating: () => boolean;
|
||||||
username: CollabState["username"];
|
|
||||||
userState: CollabState["userState"];
|
|
||||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
startCollaboration: CollabInstance["startCollaboration"];
|
||||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||||
syncElements: CollabInstance["syncElements"];
|
syncElements: CollabInstance["syncElements"];
|
||||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||||
setUsername: (username: string) => void;
|
setUsername: (username: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface PublicProps {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
onRoomClose?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
type Props = PublicProps & { modalIsShown: boolean };
|
||||||
Context: CollabContext,
|
|
||||||
Consumer: CollabContextConsumer,
|
|
||||||
Provider: CollabContextProvider,
|
|
||||||
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
|
|
||||||
|
|
||||||
export { CollabContext, CollabContextConsumer };
|
class Collab extends PureComponent<Props, CollabState> {
|
||||||
|
|
||||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
|
||||||
portal: Portal;
|
portal: Portal;
|
||||||
fileManager: FileManager;
|
fileManager: FileManager;
|
||||||
excalidrawAPI: Props["excalidrawAPI"];
|
excalidrawAPI: Props["excalidrawAPI"];
|
||||||
activeIntervalId: number | null;
|
activeIntervalId: number | null;
|
||||||
idleTimeoutId: number | null;
|
idleTimeoutId: number | null;
|
||||||
|
|
||||||
// marked as private to ensure we don't change it outside this class
|
|
||||||
private _isCollaborating: boolean = false;
|
|
||||||
private socketInitializationTimer?: number;
|
private socketInitializationTimer?: number;
|
||||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||||
private collaborators = new Map<string, Collaborator>();
|
private collaborators = new Map<string, Collaborator>();
|
||||||
@@ -123,10 +116,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
modalIsShown: false,
|
|
||||||
errorMessage: "",
|
errorMessage: "",
|
||||||
username: importUsernameFromLocalStorage() || "",
|
username: importUsernameFromLocalStorage() || "",
|
||||||
userState: UserIdleState.ACTIVE,
|
|
||||||
activeRoomLink: "",
|
activeRoomLink: "",
|
||||||
};
|
};
|
||||||
this.portal = new Portal(this);
|
this.portal = new Portal(this);
|
||||||
@@ -164,9 +155,22 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||||
|
|
||||||
|
const collabAPI: CollabAPI = {
|
||||||
|
isCollaborating: this.isCollaborating,
|
||||||
|
onPointerUpdate: this.onPointerUpdate,
|
||||||
|
startCollaboration: this.startCollaboration,
|
||||||
|
syncElements: this.syncElements,
|
||||||
|
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||||
|
stopCollaboration: this.stopCollaboration,
|
||||||
|
setUsername: this.setUsername,
|
||||||
|
};
|
||||||
|
|
||||||
|
jotaiStore.set(collabAPIAtom, collabAPI);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.env.NODE_ENV === ENV.TEST ||
|
process.env.NODE_ENV === ENV.TEST ||
|
||||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
process.env.NODE_ENV === ENV.DEVELOPMENT ||
|
||||||
|
process.env.REACT_APP_VERCEL_ENV === "preview"
|
||||||
) {
|
) {
|
||||||
window.collab = window.collab || ({} as Window["collab"]);
|
window.collab = window.collab || ({} as Window["collab"]);
|
||||||
Object.defineProperties(window, {
|
Object.defineProperties(window, {
|
||||||
@@ -196,7 +200,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isCollaborating = () => this._isCollaborating;
|
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
|
||||||
|
|
||||||
|
private setIsCollaborating = (isCollaborating: boolean) => {
|
||||||
|
jotaiStore.set(isCollaboratingAtom, isCollaborating);
|
||||||
|
};
|
||||||
|
|
||||||
private onUnload = () => {
|
private onUnload = () => {
|
||||||
this.destroySocketClient({ isUnload: true });
|
this.destroySocketClient({ isUnload: true });
|
||||||
@@ -208,7 +216,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this._isCollaborating &&
|
this.isCollaborating() &&
|
||||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||||
!isSavedToFirebase(this.portal, syncableElements))
|
!isSavedToFirebase(this.portal, syncableElements))
|
||||||
) {
|
) {
|
||||||
@@ -252,12 +260,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
openPortal = async () => {
|
stopCollaboration = (keepRemoteState = true) => {
|
||||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
|
||||||
return this.initializeSocketClient(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
closePortal = () => {
|
|
||||||
this.queueBroadcastAllElements.cancel();
|
this.queueBroadcastAllElements.cancel();
|
||||||
this.queueSaveToFirebase.cancel();
|
this.queueSaveToFirebase.cancel();
|
||||||
this.loadImageFiles.cancel();
|
this.loadImageFiles.cancel();
|
||||||
@@ -267,16 +270,26 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
|
||||||
|
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||||
|
this.portal.socket.off(
|
||||||
|
"connect_error",
|
||||||
|
this.fallbackInitializationHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keepRemoteState) {
|
||||||
|
LocalData.fileStorage.reset();
|
||||||
|
this.destroySocketClient();
|
||||||
|
} else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||||
// hack to ensure that we prefer we disregard any new browser state
|
// hack to ensure that we prefer we disregard any new browser state
|
||||||
// that could have been saved in other tabs while we were collaborating
|
// that could have been saved in other tabs while we were collaborating
|
||||||
resetBrowserStateVersions();
|
resetBrowserStateVersions();
|
||||||
|
|
||||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||||
this.destroySocketClient();
|
this.destroySocketClient();
|
||||||
trackEvent("share", "room closed");
|
|
||||||
|
|
||||||
this.props.onRoomClose?.();
|
LocalData.fileStorage.reset();
|
||||||
|
|
||||||
const elements = this.excalidrawAPI
|
const elements = this.excalidrawAPI
|
||||||
.getSceneElementsIncludingDeleted()
|
.getSceneElementsIncludingDeleted()
|
||||||
@@ -295,20 +308,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||||
|
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||||
|
this.portal.close();
|
||||||
|
this.fileManager.reset();
|
||||||
if (!opts?.isUnload) {
|
if (!opts?.isUnload) {
|
||||||
|
this.setIsCollaborating(false);
|
||||||
|
this.setState({
|
||||||
|
activeRoomLink: "",
|
||||||
|
});
|
||||||
this.collaborators = new Map();
|
this.collaborators = new Map();
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
collaborators: this.collaborators,
|
collaborators: this.collaborators,
|
||||||
});
|
});
|
||||||
this.setState({
|
|
||||||
activeRoomLink: "",
|
|
||||||
});
|
|
||||||
this._isCollaborating = false;
|
|
||||||
LocalData.resumeSave("collaboration");
|
LocalData.resumeSave("collaboration");
|
||||||
}
|
}
|
||||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
|
||||||
this.portal.close();
|
|
||||||
this.fileManager.reset();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private fetchImageFilesFromFirebase = async (scene: {
|
private fetchImageFilesFromFirebase = async (scene: {
|
||||||
@@ -349,7 +362,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private initializeSocketClient = async (
|
private fallbackInitializationHandler: null | (() => any) = null;
|
||||||
|
|
||||||
|
startCollaboration = async (
|
||||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||||
): Promise<ImportedDataState | null> => {
|
): Promise<ImportedDataState | null> => {
|
||||||
if (this.portal.socket) {
|
if (this.portal.socket) {
|
||||||
@@ -372,13 +387,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||||
|
|
||||||
this._isCollaborating = true;
|
this.setIsCollaborating(true);
|
||||||
LocalData.pauseSave("collaboration");
|
LocalData.pauseSave("collaboration");
|
||||||
|
|
||||||
const { default: socketIOClient } = await import(
|
const { default: socketIOClient } = await import(
|
||||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fallbackInitializationHandler = () => {
|
||||||
|
this.initializeRoom({
|
||||||
|
roomLinkData: existingRoomLinkData,
|
||||||
|
fetchScene: true,
|
||||||
|
}).then((scene) => {
|
||||||
|
scenePromise.resolve(scene);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const socketServerData = await getCollabServer();
|
const socketServerData = await getCollabServer();
|
||||||
|
|
||||||
@@ -391,6 +416,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
roomId,
|
roomId,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.setState({ errorMessage: error.message });
|
this.setState({ errorMessage: error.message });
|
||||||
@@ -419,13 +446,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
// fallback in case you're not alone in the room but still don't receive
|
// fallback in case you're not alone in the room but still don't receive
|
||||||
// initial SCENE_INIT message
|
// initial SCENE_INIT message
|
||||||
this.socketInitializationTimer = window.setTimeout(() => {
|
this.socketInitializationTimer = window.setTimeout(
|
||||||
this.initializeRoom({
|
fallbackInitializationHandler,
|
||||||
roomLinkData: existingRoomLinkData,
|
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||||
fetchScene: true,
|
);
|
||||||
});
|
|
||||||
scenePromise.resolve(null);
|
|
||||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
|
||||||
|
|
||||||
// All socket listeners are moving to Portal
|
// All socket listeners are moving to Portal
|
||||||
this.portal.socket.on(
|
this.portal.socket.on(
|
||||||
@@ -530,6 +554,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
| { fetchScene: false; roomLinkData?: null }) => {
|
| { fetchScene: false; roomLinkData?: null }) => {
|
||||||
clearTimeout(this.socketInitializationTimer!);
|
clearTimeout(this.socketInitializationTimer!);
|
||||||
|
if (this.portal.socket && this.fallbackInitializationHandler) {
|
||||||
|
this.portal.socket.off(
|
||||||
|
"connect_error",
|
||||||
|
this.fallbackInitializationHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (fetchScene && roomLinkData && this.portal.socket) {
|
if (fetchScene && roomLinkData && this.portal.socket) {
|
||||||
this.excalidrawAPI.resetScene();
|
this.excalidrawAPI.resetScene();
|
||||||
|
|
||||||
@@ -567,6 +597,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
const localElements = this.getSceneElementsIncludingDeleted();
|
const localElements = this.getSceneElementsIncludingDeleted();
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
|
|
||||||
|
remoteElements = restoreElements(remoteElements, null);
|
||||||
|
|
||||||
const reconciledElements = _reconcileElements(
|
const reconciledElements = _reconcileElements(
|
||||||
localElements,
|
localElements,
|
||||||
remoteElements,
|
remoteElements,
|
||||||
@@ -672,19 +704,17 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setCollaborators(sockets: string[]) {
|
setCollaborators(sockets: string[]) {
|
||||||
this.setState((state) => {
|
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||||
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
|
new Map();
|
||||||
new Map();
|
for (const socketId of sockets) {
|
||||||
for (const socketId of sockets) {
|
if (this.collaborators.has(socketId)) {
|
||||||
if (this.collaborators.has(socketId)) {
|
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
} else {
|
||||||
} else {
|
collaborators.set(socketId, {});
|
||||||
collaborators.set(socketId, {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.collaborators = collaborators;
|
}
|
||||||
this.excalidrawAPI.updateScene({ collaborators });
|
this.collaborators = collaborators;
|
||||||
});
|
this.excalidrawAPI.updateScene({ collaborators });
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||||
@@ -713,7 +743,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
onIdleStateChange = (userState: UserIdleState) => {
|
onIdleStateChange = (userState: UserIdleState) => {
|
||||||
this.setState({ userState });
|
|
||||||
this.portal.broadcastIdleChange(userState);
|
this.portal.broadcastIdleChange(userState);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -747,18 +776,22 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||||
|
|
||||||
queueSaveToFirebase = throttle(() => {
|
queueSaveToFirebase = throttle(
|
||||||
if (this.portal.socketInitialized) {
|
() => {
|
||||||
this.saveCollabRoomToFirebase(
|
if (this.portal.socketInitialized) {
|
||||||
getSyncableElements(
|
this.saveCollabRoomToFirebase(
|
||||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
getSyncableElements(
|
||||||
),
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
}
|
||||||
|
},
|
||||||
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||||
|
{ leading: false },
|
||||||
|
);
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = () => {
|
||||||
this.setState({ modalIsShown: false });
|
jotaiStore.set(collabDialogShownAtom, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
setUsername = (username: string) => {
|
setUsername = (username: string) => {
|
||||||
@@ -770,35 +803,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
saveUsernameToLocalStorage(username);
|
saveUsernameToLocalStorage(username);
|
||||||
};
|
};
|
||||||
|
|
||||||
onCollabButtonClick = () => {
|
|
||||||
this.setState({
|
|
||||||
modalIsShown: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** PRIVATE. Use `this.getContextValue()` instead. */
|
|
||||||
private contextValue: CollabAPI | null = null;
|
|
||||||
|
|
||||||
/** Getter of context value. Returned object is stable. */
|
|
||||||
getContextValue = (): CollabAPI => {
|
|
||||||
if (!this.contextValue) {
|
|
||||||
this.contextValue = {} as CollabAPI;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.contextValue.isCollaborating = this.isCollaborating;
|
|
||||||
this.contextValue.username = this.state.username;
|
|
||||||
this.contextValue.onPointerUpdate = this.onPointerUpdate;
|
|
||||||
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
|
||||||
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
|
||||||
this.contextValue.syncElements = this.syncElements;
|
|
||||||
this.contextValue.fetchImageFilesFromFirebase =
|
|
||||||
this.fetchImageFilesFromFirebase;
|
|
||||||
this.contextValue.setUsername = this.setUsername;
|
|
||||||
return this.contextValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
const { username, errorMessage, activeRoomLink } = this.state;
|
||||||
|
|
||||||
|
const { modalIsShown } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -808,8 +816,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
activeRoomLink={activeRoomLink}
|
activeRoomLink={activeRoomLink}
|
||||||
username={username}
|
username={username}
|
||||||
onUsernameChange={this.onUsernameChange}
|
onUsernameChange={this.onUsernameChange}
|
||||||
onRoomCreate={this.openPortal}
|
onRoomCreate={() => this.startCollaboration(null)}
|
||||||
onRoomDestroy={this.closePortal}
|
onRoomDestroy={this.stopCollaboration}
|
||||||
setErrorMessage={(errorMessage) => {
|
setErrorMessage={(errorMessage) => {
|
||||||
this.setState({ errorMessage });
|
this.setState({ errorMessage });
|
||||||
}}
|
}}
|
||||||
@@ -822,11 +830,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
onClose={() => this.setState({ errorMessage: "" })}
|
onClose={() => this.setState({ errorMessage: "" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CollabContextProvider
|
|
||||||
value={{
|
|
||||||
api: this.getContextValue(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -834,7 +837,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
collab: InstanceType<typeof CollabWrapper>;
|
collab: InstanceType<typeof Collab>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,4 +848,11 @@ if (
|
|||||||
window.collab = window.collab || ({} as Window["collab"]);
|
window.collab = window.collab || ({} as Window["collab"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CollabWrapper;
|
const _Collab: React.FC<PublicProps> = (props) => {
|
||||||
|
const [collabDialogShown] = useAtom(collabDialogShownAtom);
|
||||||
|
return <Collab {...props} modalIsShown={collabDialogShown} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default _Collab;
|
||||||
|
|
||||||
|
export type TCollabClass = Collab;
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
|
|
||||||
import CollabWrapper from "./CollabWrapper";
|
import { TCollabClass } from "./Collab";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import {
|
import {
|
||||||
@@ -20,14 +20,14 @@ import { BroadcastedExcalidrawElement } from "./reconciliation";
|
|||||||
import { encryptData } from "../../data/encryption";
|
import { encryptData } from "../../data/encryption";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: CollabWrapper;
|
collab: TCollabClass;
|
||||||
socket: SocketIOClient.Socket | null = null;
|
socket: SocketIOClient.Socket | null = null;
|
||||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||||
roomId: string | null = null;
|
roomId: string | null = null;
|
||||||
roomKey: string | null = null;
|
roomKey: string | null = null;
|
||||||
broadcastedElementVersions: Map<string, number> = new Map();
|
broadcastedElementVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
constructor(collab: CollabWrapper) {
|
constructor(collab: TCollabClass) {
|
||||||
this.collab = collab;
|
this.collab = collab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { t } from "../../i18n";
|
|||||||
import "./RoomDialog.scss";
|
import "./RoomDialog.scss";
|
||||||
import Stack from "../../components/Stack";
|
import Stack from "../../components/Stack";
|
||||||
import { AppState } from "../../types";
|
import { AppState } from "../../types";
|
||||||
|
import { trackEvent } from "../../analytics";
|
||||||
|
import { getFrame } from "../../utils";
|
||||||
|
|
||||||
const getShareIcon = () => {
|
const getShareIcon = () => {
|
||||||
const navigator = window.navigator as any;
|
const navigator = window.navigator as any;
|
||||||
@@ -95,7 +97,10 @@ const RoomDialog = ({
|
|||||||
title={t("roomDialog.button_startSession")}
|
title={t("roomDialog.button_startSession")}
|
||||||
aria-label={t("roomDialog.button_startSession")}
|
aria-label={t("roomDialog.button_startSession")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={onRoomCreate}
|
onClick={() => {
|
||||||
|
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||||
|
onRoomCreate();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -160,7 +165,10 @@ const RoomDialog = ({
|
|||||||
title={t("roomDialog.button_stopSession")}
|
title={t("roomDialog.button_stopSession")}
|
||||||
aria-label={t("roomDialog.button_stopSession")}
|
aria-label={t("roomDialog.button_stopSession")}
|
||||||
showAriaLabel={true}
|
showAriaLabel={true}
|
||||||
onClick={onRoomDestroy}
|
onClick={() => {
|
||||||
|
trackEvent("share", "room closed");
|
||||||
|
onRoomDestroy();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -134,9 +134,16 @@ export type SocketUpdateData =
|
|||||||
_brand: "socketUpdateData";
|
_brand: "socketUpdateData";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
|
||||||
|
|
||||||
|
export const isCollaborationLink = (link: string) => {
|
||||||
|
const hash = new URL(link).hash;
|
||||||
|
return RE_COLLAB_LINK.test(hash);
|
||||||
|
};
|
||||||
|
|
||||||
export const getCollaborationLinkData = (link: string) => {
|
export const getCollaborationLinkData = (link: string) => {
|
||||||
const hash = new URL(link).hash;
|
const hash = new URL(link).hash;
|
||||||
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
const match = hash.match(RE_COLLAB_LINK);
|
||||||
if (match && match[2].length !== 22) {
|
if (match && match[2].length !== 22) {
|
||||||
window.alert(t("alerts.invalidEncryptionKey"));
|
window.alert(t("alerts.invalidEncryptionKey"));
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import polyfill from "../polyfill";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { ErrorDialog } from "../components/ErrorDialog";
|
import { ErrorDialog } from "../components/ErrorDialog";
|
||||||
@@ -45,20 +46,26 @@ import {
|
|||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
SYNC_BROWSER_TABS_TIMEOUT,
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
} from "./app_constants";
|
} from "./app_constants";
|
||||||
import CollabWrapper, {
|
import Collab, {
|
||||||
CollabAPI,
|
CollabAPI,
|
||||||
CollabContext,
|
collabAPIAtom,
|
||||||
CollabContextConsumer,
|
collabDialogShownAtom,
|
||||||
} from "./collab/CollabWrapper";
|
isCollaboratingAtom,
|
||||||
|
} from "./collab/Collab";
|
||||||
import { LanguageList } from "./components/LanguageList";
|
import { LanguageList } from "./components/LanguageList";
|
||||||
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
import {
|
||||||
|
exportToBackend,
|
||||||
|
getCollaborationLinkData,
|
||||||
|
isCollaborationLink,
|
||||||
|
loadScene,
|
||||||
|
} from "./data";
|
||||||
import {
|
import {
|
||||||
getLibraryItemsFromStorage,
|
getLibraryItemsFromStorage,
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
} from "./data/localStorage";
|
} from "./data/localStorage";
|
||||||
import CustomStats from "./CustomStats";
|
import CustomStats from "./CustomStats";
|
||||||
import { restoreAppState, RestoredDataState } from "../data/restore";
|
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { shield } from "../components/icons";
|
import { shield } from "../components/icons";
|
||||||
|
|
||||||
@@ -72,8 +79,14 @@ import { loadFilesFromFirebase } from "./data/firebase";
|
|||||||
import { LocalData } from "./data/LocalData";
|
import { LocalData } from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Provider, useAtom } from "jotai";
|
||||||
|
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
|
||||||
|
import { reconcileElements } from "./collab/reconciliation";
|
||||||
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||||
|
|
||||||
|
polyfill();
|
||||||
|
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||||
|
|
||||||
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||||
COOKIES.AUTH_STATE_COOKIE,
|
COOKIES.AUTH_STATE_COOKIE,
|
||||||
);
|
);
|
||||||
@@ -85,6 +98,7 @@ languageDetector.init({
|
|||||||
|
|
||||||
const initializeScene = async (opts: {
|
const initializeScene = async (opts: {
|
||||||
collabAPI: CollabAPI;
|
collabAPI: CollabAPI;
|
||||||
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
}): Promise<
|
}): Promise<
|
||||||
{ scene: ExcalidrawInitialDataState | null } & (
|
{ scene: ExcalidrawInitialDataState | null } & (
|
||||||
| { isExternalScene: true; id: string; key: string }
|
| { isExternalScene: true; id: string; key: string }
|
||||||
@@ -169,8 +183,28 @@ const initializeScene = async (opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (roomLinkData) {
|
if (roomLinkData) {
|
||||||
|
const { excalidrawAPI } = opts;
|
||||||
|
|
||||||
|
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
|
// when collaborating, the state may have already been updated at this
|
||||||
|
// point (we may have received updates from other clients), so reconcile
|
||||||
|
// elements and appState with existing state
|
||||||
|
scene: {
|
||||||
|
...scene,
|
||||||
|
appState: {
|
||||||
|
...restoreAppState(scene?.appState, excalidrawAPI.getAppState()),
|
||||||
|
// necessary if we're invoking from a hashchange handler which doesn't
|
||||||
|
// go through App.initializeScene() that resets this flag
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
|
elements: reconcileElements(
|
||||||
|
scene?.elements || [],
|
||||||
|
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
),
|
||||||
|
},
|
||||||
isExternalScene: true,
|
isExternalScene: true,
|
||||||
id: roomLinkData.roomId,
|
id: roomLinkData.roomId,
|
||||||
key: roomLinkData.roomKey,
|
key: roomLinkData.roomKey,
|
||||||
@@ -242,7 +276,11 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [excalidrawAPI, excalidrawRefCallback] =
|
const [excalidrawAPI, excalidrawRefCallback] =
|
||||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||||
|
|
||||||
const collabAPI = useContext(CollabContext)?.api;
|
const [collabAPI] = useAtom(collabAPIAtom);
|
||||||
|
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||||
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||||
|
return isCollaborationLink(window.location.href);
|
||||||
|
});
|
||||||
|
|
||||||
useHandleLibrary({
|
useHandleLibrary({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
@@ -320,7 +358,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeScene({ collabAPI }).then((data) => {
|
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||||
loadImages(data, /* isInitialLoad */ true);
|
loadImages(data, /* isInitialLoad */ true);
|
||||||
initialStatePromiseRef.current.promise.resolve(data.scene);
|
initialStatePromiseRef.current.promise.resolve(data.scene);
|
||||||
});
|
});
|
||||||
@@ -329,12 +367,21 @@ const ExcalidrawWrapper = () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||||
if (!libraryUrlTokens) {
|
if (!libraryUrlTokens) {
|
||||||
initializeScene({ collabAPI }).then((data) => {
|
if (
|
||||||
|
collabAPI.isCollaborating() &&
|
||||||
|
!isCollaborationLink(window.location.href)
|
||||||
|
) {
|
||||||
|
collabAPI.stopCollaboration(false);
|
||||||
|
}
|
||||||
|
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
||||||
|
|
||||||
|
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
|
||||||
loadImages(data);
|
loadImages(data);
|
||||||
if (data.scene) {
|
if (data.scene) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...data.scene,
|
...data.scene,
|
||||||
appState: restoreAppState(data.scene.appState, null),
|
...restore(data.scene, null, null),
|
||||||
|
commitToHistory: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -622,7 +669,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
const renderCustomStats = () => {
|
const renderCustomStats = () => {
|
||||||
return (
|
return (
|
||||||
<CustomStats
|
<CustomStats
|
||||||
setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
|
setToast={(message) => excalidrawAPI!.setToast({ message })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -636,23 +683,19 @@ const ExcalidrawWrapper = () => {
|
|||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRoomClose = useCallback(() => {
|
|
||||||
LocalData.fileStorage.reset();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
className={clsx("excalidraw-app", {
|
className={clsx("excalidraw-app", {
|
||||||
"is-collaborating": collabAPI?.isCollaborating(),
|
"is-collaborating": isCollaborating,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={excalidrawRefCallback}
|
ref={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
onCollabButtonClick={() => setCollabDialogShown(true)}
|
||||||
isCollaborating={collabAPI?.isCollaborating()}
|
isCollaborating={isCollaborating}
|
||||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
UIOptions={{
|
UIOptions={{
|
||||||
canvasActions: {
|
canvasActions: {
|
||||||
@@ -686,12 +729,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
onLibraryChange={onLibraryChange}
|
onLibraryChange={onLibraryChange}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
/>
|
/>
|
||||||
{excalidrawAPI && (
|
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||||
<CollabWrapper
|
|
||||||
excalidrawAPI={excalidrawAPI}
|
|
||||||
onRoomClose={onRoomClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
@@ -705,9 +743,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
const ExcalidrawApp = () => {
|
const ExcalidrawApp = () => {
|
||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<CollabContextConsumer>
|
<Provider unstable_createStore={() => jotaiStore}>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
</CollabContextConsumer>
|
</Provider>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+4
@@ -14,7 +14,11 @@ interface Window {
|
|||||||
__EXCALIDRAW_SHA__: string | undefined;
|
__EXCALIDRAW_SHA__: string | undefined;
|
||||||
EXCALIDRAW_ASSET_PATH: string | undefined;
|
EXCALIDRAW_ASSET_PATH: string | undefined;
|
||||||
EXCALIDRAW_EXPORT_SOURCE: string;
|
EXCALIDRAW_EXPORT_SOURCE: string;
|
||||||
|
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
|
||||||
gtag: Function;
|
gtag: Function;
|
||||||
|
logTime: (name: string, time?: number) => void;
|
||||||
|
logTimeAverage: (name: string, time?: number) => void;
|
||||||
|
DEBUG_LOG_TIMES: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts
|
// https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts
|
||||||
|
|||||||
+4
-1
@@ -48,10 +48,13 @@ const allLanguages: Language[] = [
|
|||||||
{ code: "ru-RU", label: "Русский" },
|
{ code: "ru-RU", label: "Русский" },
|
||||||
{ code: "sk-SK", label: "Slovenčina" },
|
{ code: "sk-SK", label: "Slovenčina" },
|
||||||
{ code: "sv-SE", label: "Svenska" },
|
{ code: "sv-SE", label: "Svenska" },
|
||||||
|
{ code: "sl-SI", label: "Slovenščina" },
|
||||||
{ code: "tr-TR", label: "Türkçe" },
|
{ code: "tr-TR", label: "Türkçe" },
|
||||||
{ code: "uk-UA", label: "Українська" },
|
{ code: "uk-UA", label: "Українська" },
|
||||||
{ code: "zh-CN", label: "简体中文" },
|
{ code: "zh-CN", label: "简体中文" },
|
||||||
{ code: "zh-TW", label: "繁體中文" },
|
{ code: "zh-TW", label: "繁體中文" },
|
||||||
|
{ code: "vi-VN", label: "Tiếng Việt" },
|
||||||
|
{ code: "mr-IN", label: "मराठी" },
|
||||||
].concat([defaultLang]);
|
].concat([defaultLang]);
|
||||||
|
|
||||||
export const languages: Language[] = allLanguages
|
export const languages: Language[] = allLanguages
|
||||||
@@ -86,7 +89,7 @@ export const setLanguage = async (lang: Language) => {
|
|||||||
currentLangData = {};
|
currentLangData = {};
|
||||||
} else {
|
} else {
|
||||||
currentLangData = await import(
|
currentLangData = await import(
|
||||||
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-3
@@ -1,8 +1,14 @@
|
|||||||
import ReactDOM from "react-dom";
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
import ExcalidrawApp from "./excalidraw-app";
|
import ExcalidrawApp from "./excalidraw-app";
|
||||||
|
|
||||||
import "./excalidraw-app/pwa";
|
import "./excalidraw-app/pwa";
|
||||||
import "./excalidraw-app/sentry";
|
import "./excalidraw-app/sentry";
|
||||||
window.__EXCALIDRAW_SHA__ = process.env.REACT_APP_GIT_SHA;
|
window.__EXCALIDRAW_SHA__ = process.env.REACT_APP_GIT_SHA;
|
||||||
|
const rootElement = document.getElementById("root")!;
|
||||||
ReactDOM.render(<ExcalidrawApp />, document.getElementById("root"));
|
const root = createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<ExcalidrawApp />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|||||||
+24
-1
@@ -1,4 +1,27 @@
|
|||||||
import { unstable_createStore } from "jotai";
|
import { unstable_createStore, useAtom, WritableAtom } from "jotai";
|
||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
|
||||||
export const jotaiScope = Symbol();
|
export const jotaiScope = Symbol();
|
||||||
export const jotaiStore = unstable_createStore();
|
export const jotaiStore = unstable_createStore();
|
||||||
|
|
||||||
|
export const useAtomWithInitialValue = <
|
||||||
|
T extends unknown,
|
||||||
|
A extends WritableAtom<T, T>,
|
||||||
|
>(
|
||||||
|
atom: A,
|
||||||
|
initialValue: T | (() => T),
|
||||||
|
) => {
|
||||||
|
const [value, setValue] = useAtom(atom);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (typeof initialValue === "function") {
|
||||||
|
// @ts-ignore
|
||||||
|
setValue(initialValue());
|
||||||
|
} else {
|
||||||
|
setValue(initialValue);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
};
|
||||||
|
|||||||
+1
-1
@@ -80,5 +80,5 @@ export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
|
|||||||
event.shiftKey;
|
event.shiftKey;
|
||||||
|
|
||||||
export const shouldRotateWithDiscreteAngle = (
|
export const shouldRotateWithDiscreteAngle = (
|
||||||
event: MouseEvent | KeyboardEvent,
|
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
|
||||||
) => event.shiftKey;
|
) => event.shiftKey;
|
||||||
|
|||||||
@@ -120,7 +120,13 @@
|
|||||||
"lockAll": "",
|
"lockAll": "",
|
||||||
"unlockAll": ""
|
"unlockAll": ""
|
||||||
},
|
},
|
||||||
"statusPublished": ""
|
"statusPublished": "",
|
||||||
|
"sidebarLock": ""
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"noItems": "",
|
||||||
|
"hint_emptyLibrary": "",
|
||||||
|
"hint_emptyPrivateLibrary": ""
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "إعادة تعيين اللوحة",
|
"clearReset": "إعادة تعيين اللوحة",
|
||||||
|
|||||||
@@ -120,7 +120,13 @@
|
|||||||
"lockAll": "",
|
"lockAll": "",
|
||||||
"unlockAll": ""
|
"unlockAll": ""
|
||||||
},
|
},
|
||||||
"statusPublished": ""
|
"statusPublished": "",
|
||||||
|
"sidebarLock": ""
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"noItems": "",
|
||||||
|
"hint_emptyLibrary": "",
|
||||||
|
"hint_emptyPrivateLibrary": ""
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Нулиране на платно",
|
"clearReset": "Нулиране на платно",
|
||||||
|
|||||||
@@ -120,7 +120,13 @@
|
|||||||
"lockAll": "",
|
"lockAll": "",
|
||||||
"unlockAll": ""
|
"unlockAll": ""
|
||||||
},
|
},
|
||||||
"statusPublished": ""
|
"statusPublished": "",
|
||||||
|
"sidebarLock": ""
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"noItems": "",
|
||||||
|
"hint_emptyLibrary": "",
|
||||||
|
"hint_emptyPrivateLibrary": ""
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "",
|
"clearReset": "",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user