mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-18 15:57:04 +02:00
Compare commits
54 Commits
arrow-fram
...
aakansha/d
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5f8321cf62 | ||
![]() |
dc6b0be87d | ||
![]() |
3b350ee904 | ||
![]() |
27fd150a20 | ||
![]() |
188921c247 | ||
![]() |
de1ebad755 | ||
![]() |
9cd5e15917 | ||
![]() |
8101a351db | ||
![]() |
49e9a2ab33 | ||
![]() |
d140d1b8b3 | ||
![]() |
1bd416002c | ||
![]() |
991f5570ce | ||
![]() |
3cf8259e71 | ||
![]() |
9b727025fd | ||
![]() |
59b53eb9cb | ||
![]() |
9e0bfd178e | ||
![]() |
c29f19a88b | ||
![]() |
2b14a5c233 | ||
![]() |
a376bd9495 | ||
![]() |
3ea07076ad | ||
![]() |
ded0222e8d | ||
![]() |
e7983bc493 | ||
![]() |
083bcf802c | ||
![]() |
bb985eba3a | ||
![]() |
23c88a38d0 | ||
![]() |
b85d5fa12b | ||
![]() |
9391a09e54 | ||
![]() |
50450a7dab | ||
![]() |
fc9a9a2571 | ||
![]() |
6126c34dc0 | ||
![]() |
12e37e3dd2 | ||
![]() |
9ca27c62c7 | ||
![]() |
1acc646534 | ||
![]() |
66bac50de3 | ||
![]() |
5dd1efde8a | ||
![]() |
a7c590d459 | ||
![]() |
48924688c7 | ||
![]() |
8af9ea3cf3 | ||
![]() |
cbd908097f | ||
![]() |
c1247742ea | ||
![]() |
8104068bd5 | ||
![]() |
dcc75ed007 | ||
![]() |
e93bbc5776 | ||
![]() |
2a2495175b | ||
![]() |
b57b3b573d | ||
![]() |
744e5b2ab3 | ||
![]() |
d36c72c435 | ||
![]() |
e57dc405fa | ||
![]() |
41ed019bc2 | ||
![]() |
f7c3644342 | ||
![]() |
5e3550fc14 | ||
![]() |
70888327a3 | ||
![]() |
9fc15d81a0 | ||
![]() |
a80ac4c748 |
5
.codesandbox/Dockerfile
Normal file
5
.codesandbox/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM node:18-bullseye
|
||||
|
||||
# Vite wants to open the browser using `open`, so we
|
||||
# need to install those utils.
|
||||
RUN apt update -y && apt install -y xdg-utils
|
@@ -27,7 +27,10 @@
|
||||
"start": {
|
||||
"name": "Start Excalidraw",
|
||||
"command": "yarn start",
|
||||
"runAtStart": true
|
||||
"runAtStart": true,
|
||||
"preview": {
|
||||
"port": 3000
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"name": "Run Tests",
|
||||
@@ -37,7 +40,11 @@
|
||||
"install-deps": {
|
||||
"name": "Install Dependencies",
|
||||
"command": "yarn install",
|
||||
"restartOn": { "files": ["yarn.lock"] }
|
||||
"restartOn": {
|
||||
"files": ["yarn.lock"],
|
||||
"branch": false,
|
||||
"resume": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,30 +1,39 @@
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
|
||||
VITE_APP_WS_SERVER_URL=http://localhost:3002
|
||||
|
||||
# set this only if using the collaboration workflow we use on excalidraw.com
|
||||
REACT_APP_PORTAL_URL=
|
||||
VITE_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"}'
|
||||
VITE_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=
|
||||
VITE_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=
|
||||
REACT_APP_DISABLE_TRACKING=true
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
VITE_APP_DISABLE_TRACKING=true
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
||||
# The port the run the dev server
|
||||
VITE_APP_PORT=3000
|
||||
|
||||
#Debug flags
|
||||
|
||||
# To enable bounding box for text containers
|
||||
REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
|
||||
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
|
||||
|
||||
# Set this flag to false if you want to open the overlay by default
|
||||
VITE_APP_COLLAPSE_OVERLAY=true
|
||||
|
||||
# Set this flag to false to disable eslint
|
||||
VITE_APP_ENABLE_ESLINT=true
|
||||
|
@@ -1,15 +1,15 @@
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
|
||||
VITE_APP_PORTAL_URL=https://portal.excalidraw.com
|
||||
# Fill to set socket server URL used for collaboration.
|
||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
|
||||
REACT_APP_WS_SERVER_URL=
|
||||
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
|
||||
VITE_APP_WS_SERVER_URL=
|
||||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
||||
REACT_APP_DISABLE_TRACKING=
|
||||
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
||||
VITE_APP_DISABLE_TRACKING=
|
||||
|
4
.github/workflows/autorelease-excalidraw.yml
vendored
4
.github/workflows/autorelease-excalidraw.yml
vendored
@@ -12,10 +12,10 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 14.x
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 18.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
|
4
.github/workflows/autorelease-preview.yml
vendored
4
.github/workflows/autorelease-preview.yml
vendored
@@ -32,10 +32,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ steps.sha.outputs.result }}
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 14.x
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 18.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
|
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js 14.x
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Install and lint
|
||||
run: |
|
||||
|
4
.github/workflows/locales-coverage.yml
vendored
4
.github/workflows/locales-coverage.yml
vendored
@@ -14,10 +14,10 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js 14.x
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Create report file
|
||||
run: |
|
||||
|
2
.github/workflows/semantic-pr-title.yml
vendored
2
.github/workflows/semantic-pr-title.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Semantic PR title
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
4
.github/workflows/sentry-production.yml
vendored
4
.github/workflows/sentry-production.yml
vendored
@@ -10,10 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js 14.x
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 18.x
|
||||
- name: Install and build
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
|
34
.github/workflows/size-limit.yml
vendored
Normal file
34
.github/workflows/size-limit.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: "Bundle Size check @excalidraw/excalidraw"
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
size:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_JOB_NUMBER: 1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Install
|
||||
run: yarn --frozen-lockfile
|
||||
- name: Install in src/packages/excalidraw
|
||||
run: yarn --frozen-lockfile
|
||||
working-directory: src/packages/excalidraw
|
||||
env:
|
||||
CI: true
|
||||
- uses: andresz1/size-limit-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_script: build:umd
|
||||
skip_step: install
|
||||
directory: src/packages/excalidraw
|
26
.github/workflows/test-coverage-pr.yml
vendored
Normal file
26
.github/workflows/test-coverage-pr.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Test Coverage PR
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: "Install Node"
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "18.x"
|
||||
- name: "Install Deps"
|
||||
run: yarn --frozen-lockfile
|
||||
- name: "Test Coverage"
|
||||
run: yarn test:coverage
|
||||
- name: "Report Coverage"
|
||||
if: always() # Also generate the report if tests are failing
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -7,10 +7,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js 14.x
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
node-version: 18.x
|
||||
- name: Install and test
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -26,3 +26,5 @@ src/packages/excalidraw/example/public/bundle.js
|
||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
||||
src/packages/excalidraw/example/public/excalidraw.development.js
|
||||
coverage
|
||||
dev-dist
|
||||
html
|
||||
|
@@ -1,4 +1,4 @@
|
||||
FROM node:14-alpine AS build
|
||||
FROM node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
|
@@ -29,6 +29,8 @@ All `props` are *optional*.
|
||||
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
|
||||
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
|
||||
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
|
||||
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
|
||||
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
|
||||
|
||||
### Storing custom data on Excalidraw elements
|
||||
|
||||
@@ -215,7 +217,6 @@ Indicates whether to bind keyboard events to `document`. Disabled by default, me
|
||||
|
||||
Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar).
|
||||
|
||||
|
||||
### autoFocus
|
||||
|
||||
This prop indicates whether to `focus` the Excalidraw component on page load. Defaults to false.
|
||||
@@ -228,3 +229,12 @@ Allows you to override `id` generation for files added on canvas (images). By de
|
||||
(file: File) => string | Promise<string>
|
||||
```
|
||||
|
||||
### validateEmbeddable
|
||||
|
||||
```tsx
|
||||
validateEmbeddable?: boolean | string[] | RegExp | RegExp[] | ((link: string) => boolean | undefined)
|
||||
```
|
||||
|
||||
This is an optional property. By default we support a handful of well-known sites. You may allow additional sites or disallow the default ones by supplying a custom validator. If you pass `true`, all URLs will be allowed. You can also supply a list of hostnames, RegExp (or list of RegExp objects), or a function. If the function returns `undefined`, the built-in validator will be used.
|
||||
|
||||
Supplying a list of hostnames (with or without `www.`) is the preferred way to allow a specific list of domains.
|
@@ -121,3 +121,16 @@ function App() {
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## renderEmbeddable
|
||||
|
||||
<pre>
|
||||
(element: NonDeleted<ExcalidrawEmbeddableElement>, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>) => JSX.Element | null
|
||||
</pre>
|
||||
|
||||
Allows you to replace the renderer for embeddable elements (which renders `<iframe>` elements).
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `element` | `NonDeleted<ExcalidrawEmbeddableElement>` | The embeddable element to be rendered. |
|
||||
| `appState` | `AppState` | The current state of the UI. |
|
||||
|
@@ -69,6 +69,10 @@ It's also a good idea to consider if your change should include additional tests
|
||||
|
||||
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
|
||||
|
||||
:::note
|
||||
Some checks, such as the `lint` and `test`, require approval from the maintainers to run.
|
||||
They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval.
|
||||
:::
|
||||
|
||||
## Translating
|
||||
|
||||
|
@@ -18,7 +18,7 @@
|
||||
"@docusaurus/core": "2.2.0",
|
||||
"@docusaurus/preset-classic": "2.2.0",
|
||||
"@docusaurus/theme-live-codeblock": "2.2.0",
|
||||
"@excalidraw/excalidraw": "0.15.2",
|
||||
"@excalidraw/excalidraw": "0.15.3",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-sass": "0.2.3",
|
||||
|
@@ -1631,10 +1631,10 @@
|
||||
url-loader "^4.1.1"
|
||||
webpack "^5.73.0"
|
||||
|
||||
"@excalidraw/excalidraw@0.15.2":
|
||||
version "0.15.2"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c"
|
||||
integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw==
|
||||
"@excalidraw/excalidraw@0.15.3":
|
||||
version "0.15.3"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.3.tgz#5dea570f76451adf68bc24d4bfdd67a375cfeab1"
|
||||
integrity sha512-/gpY7fgMO/AEaFLWnPqzbY8H7ly+/zocFf7D0Is5sWNMD2mhult5tana12lXKLSJ6EAz7ubo1A7LajXzvJXJDA==
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
version "9.3.0"
|
||||
@@ -6611,19 +6611,19 @@ semver@7.0.0:
|
||||
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
|
||||
|
||||
semver@^5.4.1:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
|
||||
version "5.7.2"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
|
||||
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
|
||||
|
||||
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
||||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
|
||||
version "7.3.7"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
|
||||
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
|
||||
version "7.5.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
|
||||
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
|
@@ -78,8 +78,7 @@
|
||||
}
|
||||
</style>
|
||||
<!------------------------------------------------------------------------->
|
||||
|
||||
<% if (process.env.NODE_ENV === "production") { %>
|
||||
<% if ("%PROD%" === "true") { %>
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
@@ -100,41 +99,35 @@
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="Virgil.woff2"
|
||||
href="/Virgil.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="Cascadia.woff2"
|
||||
href="/Cascadia.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link
|
||||
rel="manifest"
|
||||
href="manifest.json"
|
||||
style="--pwacompat-splash-font: 24px Virgil"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %>
|
||||
<link rel="stylesheet" href="/fonts.css" type="text/css" />
|
||||
<% if ("%VITE_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 [!!!]",
|
||||
"[!!!] Live reload is disabled via VITE_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||
);
|
||||
} else {
|
||||
return new _WebSocket(url);
|
||||
@@ -200,7 +193,8 @@
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
|
||||
<!-- 100% privacy friendly analytics -->
|
||||
<script>
|
||||
// need to load this script dynamically bcs. of iframe embed tracking
|
69
package.json
69
package.json
@@ -32,6 +32,7 @@
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
"clsx": "1.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
@@ -51,26 +52,13 @@
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "2.3.1",
|
||||
"tunnel-rat": "0.1.2",
|
||||
"workbox-background-sync": "^6.5.4",
|
||||
"workbox-broadcast-update": "^6.5.4",
|
||||
"workbox-cacheable-response": "^6.5.4",
|
||||
"workbox-core": "^6.5.4",
|
||||
"workbox-expiration": "^6.5.4",
|
||||
"workbox-google-analytics": "^6.5.4",
|
||||
"workbox-navigation-preload": "^6.5.4",
|
||||
"workbox-precaching": "^6.5.4",
|
||||
"workbox-range-requests": "^6.5.4",
|
||||
"workbox-routing": "^6.5.4",
|
||||
"workbox-strategies": "^6.5.4",
|
||||
"workbox-streams": "^6.5.4"
|
||||
"tunnel-rat": "0.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@excalidraw/eslint-config": "1.0.0",
|
||||
"@excalidraw/eslint-config": "1.0.3",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
@@ -81,48 +69,43 @@
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/resize-observer-browser": "0.1.7",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
"@vitest/ui": "0.32.2",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "16.0.1",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"http-server": "14.1.1",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.4.0",
|
||||
"jsdom": "22.1.0",
|
||||
"lint-staged": "12.3.7",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"typescript": "4.9.4"
|
||||
"typescript": "4.9.4",
|
||||
"vite": "4.4.2",
|
||||
"vite-plugin-checker": "0.6.1",
|
||||
"vite-plugin-ejs": "1.6.4",
|
||||
"vite-plugin-pwa": "0.16.4",
|
||||
"vite-plugin-svgr": "2.4.0",
|
||||
"vitest": "0.34.1",
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"homepage": ".",
|
||||
"jest": {
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"<rootDir>/locales",
|
||||
"<rootDir>/src/packages/excalidraw/dist/",
|
||||
"<rootDir>/src/packages/excalidraw/types",
|
||||
"<rootDir>/src/packages/excalidraw/example"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access|canvas-roundrect-polyfill)/)"
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
"name": "excalidraw",
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build",
|
||||
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"eject": "react-scripts eject",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
"fix": "yarn fix:other && yarn fix:code",
|
||||
@@ -130,19 +113,21 @@
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"start": "react-scripts start",
|
||||
"start": "vite",
|
||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||
"test:app": "react-scripts test --passWithNoTests",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||
"test:app": "vitest --config vitest.config.ts",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
|
||||
"test:other": "yarn prettier --list-different",
|
||||
"test:typecheck": "tsc",
|
||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||
"test:update": "yarn test:app --update --watch=false",
|
||||
"test": "yarn test:app",
|
||||
"test:coverage": "react-scripts test --passWithNoTests --coverage --watchAll",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:coverage:watch": "vitest --coverage --watch",
|
||||
"test:ui": "yarn test --ui",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease": "node scripts/prerelease.js",
|
||||
"build:preview": "yarn build && vite preview --port 5000",
|
||||
"release": "node scripts/release.js"
|
||||
}
|
||||
}
|
||||
|
20
public/service-worker.js
Normal file
20
public/service-worker.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// Since we migrated to Vite, the service worker strategy changed, in CRA it was a custom service worker named service-worker.js and in Vite its sw.js handled by vite-plugin-pwa
|
||||
// Due to this the existing CRA users were not able to migrate to Vite or any new changes post Vite unless browser is hard refreshed
|
||||
// Hence adding a self destroying worker so all CRA service workers are destroyed and migrated to Vite
|
||||
// We should remove this code after sometime when we are confident that
|
||||
// all users have migrated to Vite
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", () => {
|
||||
self.registration
|
||||
.unregister()
|
||||
.then(() => {
|
||||
return self.clients.matchAll();
|
||||
})
|
||||
.then((clients) => {
|
||||
clients.forEach((client) => client.navigate(client.url));
|
||||
});
|
||||
});
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.backgroundSync=function(t,e,s){"use strict";try{self["workbox:background-sync:4.3.1"]&&_()}catch(t){}const i=3,n="workbox-background-sync",a="requests",r="queueName";class c{constructor(t){this.t=t,this.s=new s.DBWrapper(n,i,{onupgradeneeded:this.i})}async pushEntry(t){delete t.id,t.queueName=this.t,await this.s.add(a,t)}async unshiftEntry(t){const[e]=await this.s.getAllMatching(a,{count:1});e?t.id=e.id-1:delete t.id,t.queueName=this.t,await this.s.add(a,t)}async popEntry(){return this.h({direction:"prev"})}async shiftEntry(){return this.h({direction:"next"})}async getAll(){return await this.s.getAllMatching(a,{index:r,query:IDBKeyRange.only(this.t)})}async deleteEntry(t){await this.s.delete(a,t)}async h({direction:t}){const[e]=await this.s.getAllMatching(a,{direction:t,index:r,query:IDBKeyRange.only(this.t),count:1});if(e)return await this.deleteEntry(e.id),e}i(t){const e=t.target.result;t.oldVersion>0&&t.oldVersion<i&&e.objectStoreNames.contains(a)&&e.deleteObjectStore(a),e.createObjectStore(a,{autoIncrement:!0,keyPath:"id"}).createIndex(r,r,{unique:!1})}}const h=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];class o{static async fromRequest(t){const e={url:t.url,headers:{}};"GET"!==t.method&&(e.body=await t.clone().arrayBuffer());for(const[s,i]of t.headers.entries())e.headers[s]=i;for(const s of h)void 0!==t[s]&&(e[s]=t[s]);return new o(e)}constructor(t){"navigate"===t.mode&&(t.mode="same-origin"),this.o=t}toObject(){const t=Object.assign({},this.o);return t.headers=Object.assign({},this.o.headers),t.body&&(t.body=t.body.slice(0)),t}toRequest(){return new Request(this.o.url,this.o)}clone(){return new o(this.toObject())}}const u="workbox-background-sync",y=10080,w=new Set;class d{constructor(t,{onSync:s,maxRetentionTime:i}={}){if(w.has(t))throw new e.WorkboxError("duplicate-queue-name",{name:t});w.add(t),this.u=t,this.l=s||this.replayRequests,this.q=i||y,this.m=new c(this.u),this.p()}get name(){return this.u}async pushRequest(t){await this.g(t,"push")}async unshiftRequest(t){await this.g(t,"unshift")}async popRequest(){return this.R("pop")}async shiftRequest(){return this.R("shift")}async getAll(){const t=await this.m.getAll(),e=Date.now(),s=[];for(const i of t){const t=60*this.q*1e3;e-i.timestamp>t?await this.m.deleteEntry(i.id):s.push(f(i))}return s}async g({request:t,metadata:e,timestamp:s=Date.now()},i){const n={requestData:(await o.fromRequest(t.clone())).toObject(),timestamp:s};e&&(n.metadata=e),await this.m[`${i}Entry`](n),this.k?this.D=!0:await this.registerSync()}async R(t){const e=Date.now(),s=await this.m[`${t}Entry`]();if(s){const i=60*this.q*1e3;return e-s.timestamp>i?this.R(t):f(s)}}async replayRequests(){let t;for(;t=await this.shiftRequest();)try{await fetch(t.request.clone())}catch(s){throw await this.unshiftRequest(t),new e.WorkboxError("queue-replay-failed",{name:this.u})}}async registerSync(){if("sync"in registration)try{await registration.sync.register(`${u}:${this.u}`)}catch(t){}}p(){"sync"in registration?self.addEventListener("sync",t=>{if(t.tag===`${u}:${this.u}`){const e=async()=>{let e;this.k=!0;try{await this.l({queue:this})}catch(t){throw e=t}finally{!this.D||e&&!t.lastChance||await this.registerSync(),this.k=!1,this.D=!1}};t.waitUntil(e())}}):this.l({queue:this})}static get _(){return w}}const f=t=>{const e={request:new o(t.requestData).toRequest(),timestamp:t.timestamp};return t.metadata&&(e.metadata=t.metadata),e};return t.Queue=d,t.Plugin=class{constructor(...t){this.v=new d(...t),this.fetchDidFail=this.fetchDidFail.bind(this)}async fetchDidFail({request:t}){await this.v.pushRequest({request:t})}},t}({},workbox.core._private,workbox.core._private);
|
||||
//# sourceMappingURL=workbox-background-sync.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.broadcastUpdate=function(e,t){"use strict";try{self["workbox:broadcast-update:4.3.1"]&&_()}catch(e){}const s=(e,t,s)=>{return!s.some(s=>e.headers.has(s)&&t.headers.has(s))||s.every(s=>{const n=e.headers.has(s)===t.headers.has(s),a=e.headers.get(s)===t.headers.get(s);return n&&a})},n="workbox",a=1e4,i=["content-length","etag","last-modified"],o=async({channel:e,cacheName:t,url:s})=>{const n={type:"CACHE_UPDATED",meta:"workbox-broadcast-update",payload:{cacheName:t,updatedURL:s}};if(e)e.postMessage(n);else{const e=await clients.matchAll({type:"window"});for(const t of e)t.postMessage(n)}};class c{constructor({headersToCheck:e,channelName:t,deferNoticationTimeout:s}={}){this.t=e||i,this.s=t||n,this.i=s||a,this.o()}notifyIfUpdated({oldResponse:e,newResponse:t,url:n,cacheName:a,event:i}){if(!s(e,t,this.t)){const e=(async()=>{i&&i.request&&"navigate"===i.request.mode&&await this.h(i),await this.l({channel:this.u(),cacheName:a,url:n})})();if(i)try{i.waitUntil(e)}catch(e){}return e}}async l(e){await o(e)}u(){return"BroadcastChannel"in self&&!this.p&&(this.p=new BroadcastChannel(this.s)),this.p}h(e){if(!this.m.has(e)){const s=new t.Deferred;this.m.set(e,s);const n=setTimeout(()=>{s.resolve()},this.i);s.promise.then(()=>clearTimeout(n))}return this.m.get(e).promise}o(){this.m=new Map,self.addEventListener("message",e=>{if("WINDOW_READY"===e.data.type&&"workbox-window"===e.data.meta&&this.m.size>0){for(const e of this.m.values())e.resolve();this.m.clear()}})}}return e.BroadcastCacheUpdate=c,e.Plugin=class{constructor(e){this.l=new c(e)}cacheDidUpdate({cacheName:e,oldResponse:t,newResponse:s,request:n,event:a}){t&&this.l.notifyIfUpdated({cacheName:e,oldResponse:t,newResponse:s,event:a,url:n.url})}},e.broadcastUpdate=o,e.responsesAreSame=s,e}({},workbox.core._private);
|
||||
//# sourceMappingURL=workbox-broadcast-update.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.cacheableResponse=function(t){"use strict";try{self["workbox:cacheable-response:4.3.1"]&&_()}catch(t){}class s{constructor(t={}){this.t=t.statuses,this.s=t.headers}isResponseCacheable(t){let s=!0;return this.t&&(s=this.t.includes(t.status)),this.s&&s&&(s=Object.keys(this.s).some(s=>t.headers.get(s)===this.s[s])),s}}return t.CacheableResponse=s,t.Plugin=class{constructor(t){this.i=new s(t)}cacheWillUpdate({response:t}){return this.i.isResponseCacheable(t)?t:null}},t}({});
|
||||
//# sourceMappingURL=workbox-cacheable-response.prod.js.map
|
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.expiration=function(t,e,s,i,a,n){"use strict";try{self["workbox:expiration:4.3.1"]&&_()}catch(t){}const h="workbox-expiration",c="cache-entries",r=t=>{const e=new URL(t,location);return e.hash="",e.href};class o{constructor(t){this.t=t,this.s=new e.DBWrapper(h,1,{onupgradeneeded:t=>this.i(t)})}i(t){const e=t.target.result.createObjectStore(c,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1}),s.deleteDatabase(this.t)}async setTimestamp(t,e){t=r(t),await this.s.put(c,{url:t,timestamp:e,cacheName:this.t,id:this.h(t)})}async getTimestamp(t){return(await this.s.get(c,this.h(t))).timestamp}async expireEntries(t,e){const s=await this.s.transaction(c,"readwrite",(s,i)=>{const a=s.objectStore(c),n=[];let h=0;a.index("timestamp").openCursor(null,"prev").onsuccess=(({target:s})=>{const a=s.result;if(a){const s=a.value;s.cacheName===this.t&&(t&&s.timestamp<t||e&&h>=e?n.push(a.value):h++),a.continue()}else i(n)})}),i=[];for(const t of s)await this.s.delete(c,t.id),i.push(t.url);return i}h(t){return this.t+"|"+r(t)}}class u{constructor(t,e={}){this.o=!1,this.u=!1,this.l=e.maxEntries,this.p=e.maxAgeSeconds,this.t=t,this.m=new o(t)}async expireEntries(){if(this.o)return void(this.u=!0);this.o=!0;const t=this.p?Date.now()-1e3*this.p:void 0,e=await this.m.expireEntries(t,this.l),s=await caches.open(this.t);for(const t of e)await s.delete(t);this.o=!1,this.u&&(this.u=!1,this.expireEntries())}async updateTimestamp(t){await this.m.setTimestamp(t,Date.now())}async isURLExpired(t){return await this.m.getTimestamp(t)<Date.now()-1e3*this.p}async delete(){this.u=!1,await this.m.expireEntries(1/0)}}return t.CacheExpiration=u,t.Plugin=class{constructor(t={}){this.D=t,this.p=t.maxAgeSeconds,this.g=new Map,t.purgeOnQuotaError&&n.registerQuotaErrorCallback(()=>this.deleteCacheAndMetadata())}k(t){if(t===a.cacheNames.getRuntimeName())throw new i.WorkboxError("expire-custom-caches-only");let e=this.g.get(t);return e||(e=new u(t,this.D),this.g.set(t,e)),e}cachedResponseWillBeUsed({event:t,request:e,cacheName:s,cachedResponse:i}){if(!i)return null;let a=this.N(i);const n=this.k(s);n.expireEntries();const h=n.updateTimestamp(e.url);if(t)try{t.waitUntil(h)}catch(t){}return a?i:null}N(t){if(!this.p)return!0;const e=this._(t);return null===e||e>=Date.now()-1e3*this.p}_(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async cacheDidUpdate({cacheName:t,request:e}){const s=this.k(t);await s.updateTimestamp(e.url),await s.expireEntries()}async deleteCacheAndMetadata(){for(const[t,e]of this.g)await caches.delete(t),await e.delete();this.g=new Map}},t}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private,workbox.core);
|
||||
//# sourceMappingURL=workbox-expiration.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.navigationPreload=function(t){"use strict";try{self["workbox:navigation-preload:4.3.1"]&&_()}catch(t){}function e(){return Boolean(self.registration&&self.registration.navigationPreload)}return t.disable=function(){e()&&self.addEventListener("activate",t=>{t.waitUntil(self.registration.navigationPreload.disable().then(()=>{}))})},t.enable=function(t){e()&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{t&&self.registration.navigationPreload.setHeaderValue(t)}))})},t.isSupported=e,t}({});
|
||||
//# sourceMappingURL=workbox-navigation-preload.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.googleAnalytics=function(e,t,o,n,a,c,w){"use strict";try{self["workbox:google-analytics:4.3.1"]&&_()}catch(e){}const r=/^\/(\w+\/)?collect/,s=e=>async({queue:t})=>{let o;for(;o=await t.shiftRequest();){const{request:n,timestamp:a}=o,c=new URL(n.url);try{const w="POST"===n.method?new URLSearchParams(await n.clone().text()):c.searchParams,r=a-(Number(w.get("qt"))||0),s=Date.now()-r;if(w.set("qt",s),e.parameterOverrides)for(const t of Object.keys(e.parameterOverrides)){const o=e.parameterOverrides[t];w.set(t,o)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,w),await fetch(new Request(c.origin+c.pathname,{body:w.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(o),e}}},i=e=>{const t=({url:e})=>"www.google-analytics.com"===e.hostname&&r.test(e.pathname),o=new w.NetworkOnly({plugins:[e]});return[new n.Route(t,o,"GET"),new n.Route(t,o,"POST")]},l=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,t,"GET")},m=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,t,"GET")},u=e=>{const t=new c.NetworkFirst({cacheName:e});return new n.Route(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,t,"GET")};return e.initialize=((e={})=>{const n=o.cacheNames.getGoogleAnalyticsName(e.cacheName),c=new t.Plugin("workbox-google-analytics",{maxRetentionTime:2880,onSync:s(e)}),w=[u(n),l(n),m(n),...i(c)],r=new a.Router;for(const e of w)r.registerRoute(e);r.addFetchListener()}),e}({},workbox.backgroundSync,workbox.core._private,workbox.routing,workbox.routing,workbox.strategies,workbox.strategies);
|
||||
//# sourceMappingURL=workbox-offline-ga.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.precaching=function(t,e,n,s,c){"use strict";try{self["workbox:precaching:4.3.1"]&&_()}catch(t){}const o=[],i={get:()=>o,add(t){o.push(...t)}};const a="__WB_REVISION__";function r(t){if(!t)throw new c.WorkboxError("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new c.WorkboxError("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location);return{cacheKey:t.href,url:t.href}}const s=new URL(n,location),o=new URL(n,location);return o.searchParams.set(a,e),{cacheKey:o.href,url:s.href}}class l{constructor(t){this.t=e.cacheNames.getPrecacheName(t),this.s=new Map}addToCacheList(t){for(const e of t){const{cacheKey:t,url:n}=r(e);if(this.s.has(n)&&this.s.get(n)!==t)throw new c.WorkboxError("add-to-cache-list-conflicting-entries",{firstEntry:this.s.get(n),secondEntry:t});this.s.set(n,t)}}async install({event:t,plugins:e}={}){const n=[],s=[],c=await caches.open(this.t),o=await c.keys(),i=new Set(o.map(t=>t.url));for(const t of this.s.values())i.has(t)?s.push(t):n.push(t);const a=n.map(n=>this.o({event:t,plugins:e,url:n}));return await Promise.all(a),{updatedURLs:n,notUpdatedURLs:s}}async activate(){const t=await caches.open(this.t),e=await t.keys(),n=new Set(this.s.values()),s=[];for(const c of e)n.has(c.url)||(await t.delete(c),s.push(c.url));return{deletedURLs:s}}async o({url:t,event:e,plugins:o}){const i=new Request(t,{credentials:"same-origin"});let a,r=await s.fetchWrapper.fetch({event:e,plugins:o,request:i});for(const t of o||[])"cacheWillUpdate"in t&&(a=t.cacheWillUpdate.bind(t));if(!(a?a({event:e,request:i,response:r}):r.status<400))throw new c.WorkboxError("bad-precaching-response",{url:t,status:r.status});r.redirected&&(r=await async function(t){const e=t.clone(),n="body"in e?Promise.resolve(e.body):e.blob(),s=await n;return new Response(s,{headers:e.headers,status:e.status,statusText:e.statusText})}(r)),await n.cacheWrapper.put({event:e,plugins:o,request:i,response:r,cacheName:this.t,matchOptions:{ignoreSearch:!0}})}getURLsToCacheKeys(){return this.s}getCachedURLs(){return[...this.s.keys()]}getCacheKeyForURL(t){const e=new URL(t,location);return this.s.get(e.href)}}let u;const h=()=>(u||(u=new l),u);const d=(t,e)=>{const n=h().getURLsToCacheKeys();for(const s of function*(t,{ignoreURLParametersMatching:e,directoryIndex:n,cleanURLs:s,urlManipulation:c}={}){const o=new URL(t,location);o.hash="",yield o.href;const i=function(t,e){for(const n of[...t.searchParams.keys()])e.some(t=>t.test(n))&&t.searchParams.delete(n);return t}(o,e);if(yield i.href,n&&i.pathname.endsWith("/")){const t=new URL(i);t.pathname+=n,yield t.href}if(s){const t=new URL(i);t.pathname+=".html",yield t.href}if(c){const t=c({url:o});for(const e of t)yield e.href}}(t,e)){const t=n.get(s);if(t)return t}};let w=!1;const f=t=>{w||((({ignoreURLParametersMatching:t=[/^utm_/],directoryIndex:n="index.html",cleanURLs:s=!0,urlManipulation:c=null}={})=>{const o=e.cacheNames.getPrecacheName();addEventListener("fetch",e=>{const i=d(e.request.url,{cleanURLs:s,directoryIndex:n,ignoreURLParametersMatching:t,urlManipulation:c});if(!i)return;let a=caches.open(o).then(t=>t.match(i)).then(t=>t||fetch(i));e.respondWith(a)})})(t),w=!0)},y=t=>{const e=h(),n=i.get();t.waitUntil(e.install({event:t,plugins:n}).catch(t=>{throw t}))},p=t=>{const e=h(),n=i.get();t.waitUntil(e.activate({event:t,plugins:n}))},L=t=>{h().addToCacheList(t),t.length>0&&(addEventListener("install",y),addEventListener("activate",p))};return t.addPlugins=(t=>{i.add(t)}),t.addRoute=f,t.cleanupOutdatedCaches=(()=>{addEventListener("activate",t=>{const n=e.cacheNames.getPrecacheName();t.waitUntil((async(t,e="-precache-")=>{const n=(await caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==t);return await Promise.all(n.map(t=>caches.delete(t))),n})(n).then(t=>{}))})}),t.getCacheKeyForURL=(t=>{return h().getCacheKeyForURL(t)}),t.precache=L,t.precacheAndRoute=((t,e)=>{L(t),f(e)}),t.PrecacheController=l,t}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private);
|
||||
//# sourceMappingURL=workbox-precaching.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.rangeRequests=function(e,n){"use strict";try{self["workbox:range-requests:4.3.1"]&&_()}catch(e){}async function t(e,t){try{if(206===t.status)return t;const s=e.headers.get("range");if(!s)throw new n.WorkboxError("no-range-header");const a=function(e){const t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new n.WorkboxError("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new n.WorkboxError("single-range-only",{normalizedRangeHeader:t});const s=/(\d*)-(\d*)/.exec(t);if(null===s||!s[1]&&!s[2])throw new n.WorkboxError("invalid-range-values",{normalizedRangeHeader:t});return{start:""===s[1]?null:Number(s[1]),end:""===s[2]?null:Number(s[2])}}(s),r=await t.blob(),i=function(e,t,s){const a=e.size;if(s>a||t<0)throw new n.WorkboxError("range-not-satisfiable",{size:a,end:s,start:t});let r,i;return null===t?(r=a-s,i=a):null===s?(r=t,i=a):(r=t,i=s+1),{start:r,end:i}}(r,a.start,a.end),o=r.slice(i.start,i.end),u=o.size,l=new Response(o,{status:206,statusText:"Partial Content",headers:t.headers});return l.headers.set("Content-Length",u),l.headers.set("Content-Range",`bytes ${i.start}-${i.end-1}/`+r.size),l}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}return e.createPartialResponse=t,e.Plugin=class{async cachedResponseWillBeUsed({request:e,cachedResponse:n}){return n&&e.headers.has("range")?await t(e,n):n}},e}({},workbox.core._private);
|
||||
//# sourceMappingURL=workbox-range-requests.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.routing=function(t,e,r){"use strict";try{self["workbox:routing:4.3.1"]&&_()}catch(t){}const s="GET",n=t=>t&&"object"==typeof t?t:{handle:t};class o{constructor(t,e,r){this.handler=n(e),this.match=t,this.method=r||s}}class i extends o{constructor(t,{whitelist:e=[/./],blacklist:r=[]}={}){super(t=>this.t(t),t),this.s=e,this.o=r}t({url:t,request:e}){if("navigate"!==e.mode)return!1;const r=t.pathname+t.search;for(const t of this.o)if(t.test(r))return!1;return!!this.s.some(t=>t.test(r))}}class u extends o{constructor(t,e,r){super(({url:e})=>{const r=t.exec(e.href);return r?e.origin!==location.origin&&0!==r.index?null:r.slice(1):null},e,r)}}class c{constructor(){this.i=new Map}get routes(){return this.i}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,r=this.handleRequest({request:e,event:t});r&&t.respondWith(r)})}addCacheListener(){self.addEventListener("message",async t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,r=Promise.all(e.urlsToCache.map(t=>{"string"==typeof t&&(t=[t]);const e=new Request(...t);return this.handleRequest({request:e})}));t.waitUntil(r),t.ports&&t.ports[0]&&(await r,t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const r=new URL(t.url,location);if(!r.protocol.startsWith("http"))return;let s,{params:n,route:o}=this.findMatchingRoute({url:r,request:t,event:e}),i=o&&o.handler;if(!i&&this.u&&(i=this.u),i){try{s=i.handle({url:r,request:t,event:e,params:n})}catch(t){s=Promise.reject(t)}return s&&this.h&&(s=s.catch(t=>this.h.handle({url:r,event:e,err:t}))),s}}findMatchingRoute({url:t,request:e,event:r}){const s=this.i.get(e.method)||[];for(const n of s){let s,o=n.match({url:t,request:e,event:r});if(o)return Array.isArray(o)&&o.length>0?s=o:o.constructor===Object&&Object.keys(o).length>0&&(s=o),{route:n,params:s}}return{}}setDefaultHandler(t){this.u=n(t)}setCatchHandler(t){this.h=n(t)}registerRoute(t){this.i.has(t.method)||this.i.set(t.method,[]),this.i.get(t.method).push(t)}unregisterRoute(t){if(!this.i.has(t.method))throw new r.WorkboxError("unregister-route-but-not-found-with-method",{method:t.method});const e=this.i.get(t.method).indexOf(t);if(!(e>-1))throw new r.WorkboxError("unregister-route-route-not-registered");this.i.get(t.method).splice(e,1)}}let a;const h=()=>(a||((a=new c).addFetchListener(),a.addCacheListener()),a);return t.NavigationRoute=i,t.RegExpRoute=u,t.registerNavigationRoute=((t,r={})=>{const s=e.cacheNames.getPrecacheName(r.cacheName),n=new i(async()=>{try{const e=await caches.match(t,{cacheName:s});if(e)return e;throw new Error(`The cache ${s} did not have an entry for `+`${t}.`)}catch(e){return fetch(t)}},{whitelist:r.whitelist,blacklist:r.blacklist});return h().registerRoute(n),n}),t.registerRoute=((t,e,s="GET")=>{let n;if("string"==typeof t){const r=new URL(t,location);n=new o(({url:t})=>t.href===r.href,e,s)}else if(t instanceof RegExp)n=new u(t,e,s);else if("function"==typeof t)n=new o(t,e,s);else{if(!(t instanceof o))throw new r.WorkboxError("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});n=t}return h().registerRoute(n),n}),t.Route=o,t.Router=c,t.setCatchHandler=(t=>{h().setCatchHandler(t)}),t.setDefaultHandler=(t=>{h().setDefaultHandler(t)}),t}({},workbox.core._private,workbox.core._private);
|
||||
//# sourceMappingURL=workbox-routing.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.strategies=function(e,t,s,n,r){"use strict";try{self["workbox:strategies:4.3.1"]&&_()}catch(e){}class i{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));let n,i=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(!i)try{i=await this.u(t,e)}catch(e){n=e}if(!i)throw new r.WorkboxError("no-response",{url:t.url,error:n});return i}async u(e,t){const r=await n.fetchWrapper.fetch({request:e,event:t,fetchOptions:this.i,plugins:this.s}),i=r.clone(),h=s.cacheWrapper.put({cacheName:this.t,request:e,response:i,event:t,plugins:this.s});if(t)try{t.waitUntil(h)}catch(e){}return r}}class h{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));const n=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(!n)throw new r.WorkboxError("no-response",{url:t.url});return n}}const u={cacheWillUpdate:({response:e})=>200===e.status||0===e.status?e:null};class a{constructor(e={}){if(this.t=t.cacheNames.getRuntimeName(e.cacheName),e.plugins){let t=e.plugins.some(e=>!!e.cacheWillUpdate);this.s=t?e.plugins:[u,...e.plugins]}else this.s=[u];this.o=e.networkTimeoutSeconds,this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){const s=[];"string"==typeof t&&(t=new Request(t));const n=[];let i;if(this.o){const{id:r,promise:h}=this.l({request:t,event:e,logs:s});i=r,n.push(h)}const h=this.q({timeoutId:i,request:t,event:e,logs:s});n.push(h);let u=await Promise.race(n);if(u||(u=await h),!u)throw new r.WorkboxError("no-response",{url:t.url});return u}l({request:e,logs:t,event:s}){let n;return{promise:new Promise(t=>{n=setTimeout(async()=>{t(await this.p({request:e,event:s}))},1e3*this.o)}),id:n}}async q({timeoutId:e,request:t,logs:r,event:i}){let h,u;try{u=await n.fetchWrapper.fetch({request:t,event:i,fetchOptions:this.i,plugins:this.s})}catch(e){h=e}if(e&&clearTimeout(e),h||!u)u=await this.p({request:t,event:i});else{const e=u.clone(),n=s.cacheWrapper.put({cacheName:this.t,request:t,response:e,event:i,plugins:this.s});if(i)try{i.waitUntil(n)}catch(e){}}return u}p({event:e,request:t}){return s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s})}}class c{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.i=e.fetchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){let s,i;"string"==typeof t&&(t=new Request(t));try{i=await n.fetchWrapper.fetch({request:t,event:e,fetchOptions:this.i,plugins:this.s})}catch(e){s=e}if(!i)throw new r.WorkboxError("no-response",{url:t.url,error:s});return i}}class o{constructor(e={}){if(this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],e.plugins){let t=e.plugins.some(e=>!!e.cacheWillUpdate);this.s=t?e.plugins:[u,...e.plugins]}else this.s=[u];this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));const n=this.u({request:t,event:e});let i,h=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(h){if(e)try{e.waitUntil(n)}catch(i){}}else try{h=await n}catch(e){i=e}if(!h)throw new r.WorkboxError("no-response",{url:t.url,error:i});return h}async u({request:e,event:t}){const r=await n.fetchWrapper.fetch({request:e,event:t,fetchOptions:this.i,plugins:this.s}),i=s.cacheWrapper.put({cacheName:this.t,request:e,response:r.clone(),event:t,plugins:this.s});if(t)try{t.waitUntil(i)}catch(e){}return r}}const l={cacheFirst:i,cacheOnly:h,networkFirst:a,networkOnly:c,staleWhileRevalidate:o},q=e=>{const t=l[e];return e=>new t(e)},w=q("cacheFirst"),p=q("cacheOnly"),v=q("networkFirst"),y=q("networkOnly"),m=q("staleWhileRevalidate");return e.CacheFirst=i,e.CacheOnly=h,e.NetworkFirst=a,e.NetworkOnly=c,e.StaleWhileRevalidate=o,e.cacheFirst=w,e.cacheOnly=p,e.networkFirst=v,e.networkOnly=y,e.staleWhileRevalidate=m,e}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private);
|
||||
//# sourceMappingURL=workbox-strategies.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
this.workbox=this.workbox||{},this.workbox.streams=function(e){"use strict";try{self["workbox:streams:4.3.1"]&&_()}catch(e){}function n(e){const n=e.map(e=>Promise.resolve(e).then(e=>(function(e){return e.body&&e.body.getReader?e.body.getReader():e.getReader?e.getReader():new Response(e).body.getReader()})(e)));let t,r;const s=new Promise((e,n)=>{t=e,r=n});let o=0;return{done:s,stream:new ReadableStream({pull(e){return n[o].then(e=>e.read()).then(r=>{if(r.done)return++o>=n.length?(e.close(),void t()):this.pull(e);e.enqueue(r.value)}).catch(e=>{throw r(e),e})},cancel(){t()}})}}function t(e={}){const n=new Headers(e);return n.has("content-type")||n.set("content-type","text/html"),n}function r(e,r){const{done:s,stream:o}=n(e),a=t(r);return{done:s,response:new Response(o,{headers:a})}}let s=void 0;function o(){if(void 0===s)try{new ReadableStream({start(){}}),s=!0}catch(e){s=!1}return s}return e.concatenate=n,e.concatenateToResponse=r,e.isSupported=o,e.strategy=function(e,n){return async({event:s,url:a,params:c})=>{if(o()){const{done:t,response:o}=r(e.map(e=>e({event:s,url:a,params:c})),n);return s.waitUntil(t),o}const i=await Promise.all(e.map(e=>e({event:s,url:a,params:c})).map(async e=>{const n=await e;return n instanceof Response?n.blob():n})),u=t(n);return new Response(new Blob(i),{headers:u})}},e}({});
|
||||
//# sourceMappingURL=workbox-streams.prod.js.map
|
@@ -1,2 +0,0 @@
|
||||
!function(){"use strict";try{self["workbox:sw:4.3.1"]&&_()}catch(t){}const t="https://storage.googleapis.com/workbox-cdn/releases/4.3.1",e={backgroundSync:"background-sync",broadcastUpdate:"broadcast-update",cacheableResponse:"cacheable-response",core:"core",expiration:"expiration",googleAnalytics:"offline-ga",navigationPreload:"navigation-preload",precaching:"precaching",rangeRequests:"range-requests",routing:"routing",strategies:"strategies",streams:"streams"};self.workbox=new class{constructor(){return this.v={},this.t={debug:"localhost"===self.location.hostname,modulePathPrefix:null,modulePathCb:null},this.s=this.t.debug?"dev":"prod",this.o=!1,new Proxy(this,{get(t,s){if(t[s])return t[s];const o=e[s];return o&&t.loadModule(`workbox-${o}`),t[s]}})}setConfig(t={}){if(this.o)throw new Error("Config must be set before accessing workbox.* modules");Object.assign(this.t,t),this.s=this.t.debug?"dev":"prod"}loadModule(t){const e=this.i(t);try{importScripts(e),this.o=!0}catch(s){throw console.error(`Unable to import module '${t}' from '${e}'.`),s}}i(e){if(this.t.modulePathCb)return this.t.modulePathCb(e,this.t.debug);let s=[t];const o=`${e}.${this.s}.js`,r=this.t.modulePathPrefix;return r&&""===(s=r.split("/"))[s.length-1]&&s.splice(s.length-1,1),s.push(o),s.join("/")}}}();
|
||||
//# sourceMappingURL=workbox-sw.js.map
|
@@ -1,2 +0,0 @@
|
||||
try{self["workbox:window:4.3.1"]&&_()}catch(n){}var n=function(n,t){return new Promise(function(i){var e=new MessageChannel;e.port1.onmessage=function(n){return i(n.data)},n.postMessage(t,[e.port2])})};function t(n,t){for(var i=0;i<t.length;i++){var e=t[i];e.enumerable=e.enumerable||!1,e.configurable=!0,"value"in e&&(e.writable=!0),Object.defineProperty(n,e.key,e)}}function i(n){if(void 0===n)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return n}try{self["workbox:core:4.3.1"]&&_()}catch(n){}var e=function(){var n=this;this.promise=new Promise(function(t,i){n.resolve=t,n.reject=i})},r=function(n,t){return new URL(n,location).href===new URL(t,location).href},o=function(n,t){Object.assign(this,t,{type:n})};function u(n){return function(){for(var t=[],i=0;i<arguments.length;i++)t[i]=arguments[i];try{return Promise.resolve(n.apply(this,t))}catch(n){return Promise.reject(n)}}}function a(n,t,i){return i?t?t(n):n:(n&&n.then||(n=Promise.resolve(n)),t?n.then(t):n)}function s(){}var c=function(c){var f,h;function v(n,t){var r;return void 0===t&&(t={}),(r=c.call(this)||this).t=n,r.i=t,r.o=0,r.u=new e,r.s=new e,r.h=new e,r.v=r.v.bind(i(i(r))),r.l=r.l.bind(i(i(r))),r.g=r.g.bind(i(i(r))),r.m=r.m.bind(i(i(r))),r}h=c,(f=v).prototype=Object.create(h.prototype),f.prototype.constructor=f,f.__proto__=h;var l,w,g,d=v.prototype;return d.register=u(function(n){var t,i,e=this,u=(void 0===n?{}:n).immediate,c=void 0!==u&&u;return t=function(){return e.p=Boolean(navigator.serviceWorker.controller),e.P=e.R(),a(e.k(),function(n){e.B=n,e.P&&(e.O=e.P,e.s.resolve(e.P),e.h.resolve(e.P),e.j(e.P),e.P.addEventListener("statechange",e.l,{once:!0}));var t=e.B.waiting;return t&&r(t.scriptURL,e.t)&&(e.O=t,Promise.resolve().then(function(){e.dispatchEvent(new o("waiting",{sw:t,wasWaitingBeforeRegister:!0}))})),e.O&&e.u.resolve(e.O),e.B.addEventListener("updatefound",e.g),navigator.serviceWorker.addEventListener("controllerchange",e.m,{once:!0}),"BroadcastChannel"in self&&(e.C=new BroadcastChannel("workbox"),e.C.addEventListener("message",e.v)),navigator.serviceWorker.addEventListener("message",e.v),e.B})},(i=function(){if(!c&&"complete"!==document.readyState)return function(n,t){if(!t)return n&&n.then?n.then(s):Promise.resolve()}(new Promise(function(n){return addEventListener("load",n)}))}())&&i.then?i.then(t):t(i)}),d.getSW=u(function(){return this.O||this.u.promise}),d.messageSW=u(function(t){return a(this.getSW(),function(i){return n(i,t)})}),d.R=function(){var n=navigator.serviceWorker.controller;if(n&&r(n.scriptURL,this.t))return n},d.k=u(function(){var n=this;return function(n,t){try{var i=n()}catch(n){return t(n)}return i&&i.then?i.then(void 0,t):i}(function(){return a(navigator.serviceWorker.register(n.t,n.i),function(t){return n.L=performance.now(),t})},function(n){throw n})}),d.j=function(t){n(t,{type:"WINDOW_READY",meta:"workbox-window"})},d.g=function(){var n=this.B.installing;this.o>0||!r(n.scriptURL,this.t)||performance.now()>this.L+6e4?(this.W=n,this.B.removeEventListener("updatefound",this.g)):(this.O=n,this.u.resolve(n)),++this.o,n.addEventListener("statechange",this.l)},d.l=function(n){var t=this,i=n.target,e=i.state,r=i===this.W,u=r?"external":"",a={sw:i,originalEvent:n};!r&&this.p&&(a.isUpdate=!0),this.dispatchEvent(new o(u+e,a)),"installed"===e?this._=setTimeout(function(){"installed"===e&&t.B.waiting===i&&t.dispatchEvent(new o(u+"waiting",a))},200):"activating"===e&&(clearTimeout(this._),r||this.s.resolve(i))},d.m=function(n){var t=this.O;t===navigator.serviceWorker.controller&&(this.dispatchEvent(new o("controlling",{sw:t,originalEvent:n})),this.h.resolve(t))},d.v=function(n){var t=n.data;this.dispatchEvent(new o("message",{data:t,originalEvent:n}))},l=v,(w=[{key:"active",get:function(){return this.s.promise}},{key:"controlling",get:function(){return this.h.promise}}])&&t(l.prototype,w),g&&t(l,g),v}(function(){function n(){this.D={}}var t=n.prototype;return t.addEventListener=function(n,t){this.T(n).add(t)},t.removeEventListener=function(n,t){this.T(n).delete(t)},t.dispatchEvent=function(n){n.target=this,this.T(n.type).forEach(function(t){return t(n)})},t.T=function(n){return this.D[n]=this.D[n]||new Set},n}());export{c as Workbox,n as messageSW};
|
||||
//# sourceMappingURL=workbox-window.prod.es5.mjs.map
|
@@ -1,2 +0,0 @@
|
||||
try{self["workbox:window:4.3.1"]&&_()}catch(t){}const t=(t,s)=>new Promise(i=>{let e=new MessageChannel;e.port1.onmessage=(t=>i(t.data)),t.postMessage(s,[e.port2])});try{self["workbox:core:4.3.1"]&&_()}catch(t){}class s{constructor(){this.promise=new Promise((t,s)=>{this.resolve=t,this.reject=s})}}class i{constructor(){this.t={}}addEventListener(t,s){this.s(t).add(s)}removeEventListener(t,s){this.s(t).delete(s)}dispatchEvent(t){t.target=this,this.s(t.type).forEach(s=>s(t))}s(t){return this.t[t]=this.t[t]||new Set}}const e=(t,s)=>new URL(t,location).href===new URL(s,location).href;class n{constructor(t,s){Object.assign(this,s,{type:t})}}const h=200,a=6e4;class o extends i{constructor(t,i={}){super(),this.i=t,this.h=i,this.o=0,this.l=new s,this.g=new s,this.u=new s,this.m=this.m.bind(this),this.v=this.v.bind(this),this.p=this.p.bind(this),this._=this._.bind(this)}async register({immediate:t=!1}={}){t||"complete"===document.readyState||await new Promise(t=>addEventListener("load",t)),this.C=Boolean(navigator.serviceWorker.controller),this.W=this.L(),this.S=await this.B(),this.W&&(this.R=this.W,this.g.resolve(this.W),this.u.resolve(this.W),this.P(this.W),this.W.addEventListener("statechange",this.v,{once:!0}));const s=this.S.waiting;return s&&e(s.scriptURL,this.i)&&(this.R=s,Promise.resolve().then(()=>{this.dispatchEvent(new n("waiting",{sw:s,wasWaitingBeforeRegister:!0}))})),this.R&&this.l.resolve(this.R),this.S.addEventListener("updatefound",this.p),navigator.serviceWorker.addEventListener("controllerchange",this._,{once:!0}),"BroadcastChannel"in self&&(this.T=new BroadcastChannel("workbox"),this.T.addEventListener("message",this.m)),navigator.serviceWorker.addEventListener("message",this.m),this.S}get active(){return this.g.promise}get controlling(){return this.u.promise}async getSW(){return this.R||this.l.promise}async messageSW(s){const i=await this.getSW();return t(i,s)}L(){const t=navigator.serviceWorker.controller;if(t&&e(t.scriptURL,this.i))return t}async B(){try{const t=await navigator.serviceWorker.register(this.i,this.h);return this.U=performance.now(),t}catch(t){throw t}}P(s){t(s,{type:"WINDOW_READY",meta:"workbox-window"})}p(){const t=this.S.installing;this.o>0||!e(t.scriptURL,this.i)||performance.now()>this.U+a?(this.k=t,this.S.removeEventListener("updatefound",this.p)):(this.R=t,this.l.resolve(t)),++this.o,t.addEventListener("statechange",this.v)}v(t){const s=t.target,{state:i}=s,e=s===this.k,a=e?"external":"",o={sw:s,originalEvent:t};!e&&this.C&&(o.isUpdate=!0),this.dispatchEvent(new n(a+i,o)),"installed"===i?this.D=setTimeout(()=>{"installed"===i&&this.S.waiting===s&&this.dispatchEvent(new n(a+"waiting",o))},h):"activating"===i&&(clearTimeout(this.D),e||this.g.resolve(s))}_(t){const s=this.R;s===navigator.serviceWorker.controller&&(this.dispatchEvent(new n("controlling",{sw:s,originalEvent:t})),this.u.resolve(s))}m(t){const{data:s}=t;this.dispatchEvent(new n("message",{data:s,originalEvent:t}))}}export{o as Workbox,t as messageSW};
|
||||
//# sourceMappingURL=workbox-window.prod.mjs.map
|
@@ -1,2 +0,0 @@
|
||||
!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((n=n||self).workbox={})}(this,function(n){"use strict";try{self["workbox:window:4.3.1"]&&_()}catch(n){}var t=function(n,t){return new Promise(function(i){var e=new MessageChannel;e.port1.onmessage=function(n){return i(n.data)},n.postMessage(t,[e.port2])})};function i(n,t){for(var i=0;i<t.length;i++){var e=t[i];e.enumerable=e.enumerable||!1,e.configurable=!0,"value"in e&&(e.writable=!0),Object.defineProperty(n,e.key,e)}}function e(n){if(void 0===n)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return n}try{self["workbox:core:4.3.1"]&&_()}catch(n){}var r=function(){var n=this;this.promise=new Promise(function(t,i){n.resolve=t,n.reject=i})},o=function(n,t){return new URL(n,location).href===new URL(t,location).href},u=function(n,t){Object.assign(this,t,{type:n})};function s(n){return function(){for(var t=[],i=0;i<arguments.length;i++)t[i]=arguments[i];try{return Promise.resolve(n.apply(this,t))}catch(n){return Promise.reject(n)}}}function a(n,t,i){return i?t?t(n):n:(n&&n.then||(n=Promise.resolve(n)),t?n.then(t):n)}function c(){}var f=function(n){var f,h;function v(t,i){var o;return void 0===i&&(i={}),(o=n.call(this)||this).t=t,o.i=i,o.o=0,o.u=new r,o.s=new r,o.h=new r,o.v=o.v.bind(e(e(o))),o.l=o.l.bind(e(e(o))),o.g=o.g.bind(e(e(o))),o.m=o.m.bind(e(e(o))),o}h=n,(f=v).prototype=Object.create(h.prototype),f.prototype.constructor=f,f.__proto__=h;var l,w,d,g=v.prototype;return g.register=s(function(n){var t,i,e=this,r=(void 0===n?{}:n).immediate,s=void 0!==r&&r;return t=function(){return e.p=Boolean(navigator.serviceWorker.controller),e.P=e.j(),a(e.O(),function(n){e.R=n,e.P&&(e._=e.P,e.s.resolve(e.P),e.h.resolve(e.P),e.k(e.P),e.P.addEventListener("statechange",e.l,{once:!0}));var t=e.R.waiting;return t&&o(t.scriptURL,e.t)&&(e._=t,Promise.resolve().then(function(){e.dispatchEvent(new u("waiting",{sw:t,wasWaitingBeforeRegister:!0}))})),e._&&e.u.resolve(e._),e.R.addEventListener("updatefound",e.g),navigator.serviceWorker.addEventListener("controllerchange",e.m,{once:!0}),"BroadcastChannel"in self&&(e.B=new BroadcastChannel("workbox"),e.B.addEventListener("message",e.v)),navigator.serviceWorker.addEventListener("message",e.v),e.R})},(i=function(){if(!s&&"complete"!==document.readyState)return function(n,t){if(!t)return n&&n.then?n.then(c):Promise.resolve()}(new Promise(function(n){return addEventListener("load",n)}))}())&&i.then?i.then(t):t(i)}),g.getSW=s(function(){return this._||this.u.promise}),g.messageSW=s(function(n){return a(this.getSW(),function(i){return t(i,n)})}),g.j=function(){var n=navigator.serviceWorker.controller;if(n&&o(n.scriptURL,this.t))return n},g.O=s(function(){var n=this;return function(n,t){try{var i=n()}catch(n){return t(n)}return i&&i.then?i.then(void 0,t):i}(function(){return a(navigator.serviceWorker.register(n.t,n.i),function(t){return n.C=performance.now(),t})},function(n){throw n})}),g.k=function(n){t(n,{type:"WINDOW_READY",meta:"workbox-window"})},g.g=function(){var n=this.R.installing;this.o>0||!o(n.scriptURL,this.t)||performance.now()>this.C+6e4?(this.L=n,this.R.removeEventListener("updatefound",this.g)):(this._=n,this.u.resolve(n)),++this.o,n.addEventListener("statechange",this.l)},g.l=function(n){var t=this,i=n.target,e=i.state,r=i===this.L,o=r?"external":"",s={sw:i,originalEvent:n};!r&&this.p&&(s.isUpdate=!0),this.dispatchEvent(new u(o+e,s)),"installed"===e?this.W=setTimeout(function(){"installed"===e&&t.R.waiting===i&&t.dispatchEvent(new u(o+"waiting",s))},200):"activating"===e&&(clearTimeout(this.W),r||this.s.resolve(i))},g.m=function(n){var t=this._;t===navigator.serviceWorker.controller&&(this.dispatchEvent(new u("controlling",{sw:t,originalEvent:n})),this.h.resolve(t))},g.v=function(n){var t=n.data;this.dispatchEvent(new u("message",{data:t,originalEvent:n}))},l=v,(w=[{key:"active",get:function(){return this.s.promise}},{key:"controlling",get:function(){return this.h.promise}}])&&i(l.prototype,w),d&&i(l,d),v}(function(){function n(){this.D={}}var t=n.prototype;return t.addEventListener=function(n,t){this.M(n).add(t)},t.removeEventListener=function(n,t){this.M(n).delete(t)},t.dispatchEvent=function(n){n.target=this,this.M(n.type).forEach(function(t){return t(n)})},t.M=function(n){return this.D[n]=this.D[n]||new Set},n}());n.Workbox=f,n.messageSW=t,Object.defineProperty(n,"__esModule",{value:!0})});
|
||||
//# sourceMappingURL=workbox-window.prod.umd.js.map
|
@@ -2,6 +2,7 @@ import { register } from "./register";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { randomId } from "../random";
|
||||
import { t } from "../i18n";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
@@ -12,14 +13,17 @@ export const actionAddToLibrary = register({
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
if (selectedElements.some((element) => element.type === "image")) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
},
|
||||
};
|
||||
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (selectedElements.some((element) => element.type === type)) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return app.library
|
||||
|
@@ -396,6 +396,7 @@ export const actionToggleEraserTool = register({
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
@@ -422,7 +423,7 @@ export const actionToggleHandTool = register({
|
||||
type: "hand",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
setCursor(app.canvas, CURSOR_TYPE.GRAB);
|
||||
setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -430,6 +431,7 @@ export const actionToggleHandTool = register({
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
|
@@ -158,6 +158,7 @@ export const actionDeleteSelected = register({
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
getNonDeletedElements(elements),
|
||||
|
@@ -259,23 +259,25 @@ const duplicateElements = (
|
||||
|
||||
return {
|
||||
elements: finalElements,
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@@ -65,7 +65,7 @@ export const actionChangeExportScale = register({
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
"imageExportDialog.label.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
@@ -102,7 +102,7 @@ export const actionChangeExportBackground = register({
|
||||
checked={appState.exportBackground}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.withBackground")}
|
||||
{t("imageExportDialog.label.withBackground")}
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
@@ -121,8 +121,8 @@ export const actionChangeExportEmbedScene = register({
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||
{t("imageExportDialog.label.embedScene")}
|
||||
<Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
|
||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</CheckboxItem>
|
||||
@@ -277,7 +277,7 @@ export const actionExportWithDarkMode = register({
|
||||
onChange={(theme: Theme) => {
|
||||
updateData(theme === THEME.DARK);
|
||||
}}
|
||||
title={t("labels.toggleExportColorScheme")}
|
||||
title={t("imageExportDialog.label.darkMode")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
@@ -19,7 +19,12 @@ import { AppState } from "../types";
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
_,
|
||||
{ interactiveCanvas, focusContainer, scene },
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
@@ -132,7 +137,7 @@ export const actionFinalize = register({
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
resetCursor(interactiveCanvas);
|
||||
}
|
||||
|
||||
let activeTool: AppState["activeTool"];
|
||||
@@ -160,6 +165,7 @@ export const actionFinalize = register({
|
||||
multiPointElement
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
|
@@ -108,7 +108,7 @@ export const actionSetFrameAsActiveTool = register({
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
setCursorForShape(app.canvas, {
|
||||
setCursorForShape(app.interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
@@ -149,11 +149,14 @@ export const actionGroup = register({
|
||||
];
|
||||
|
||||
return {
|
||||
appState: selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
},
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
@@ -212,7 +215,7 @@ export const actionUngroup = register({
|
||||
});
|
||||
|
||||
const updateAppState = selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
appState,
|
||||
getNonDeletedElements(nextElements),
|
||||
appState,
|
||||
null,
|
||||
@@ -243,7 +246,7 @@ export const actionUngroup = register({
|
||||
);
|
||||
|
||||
return {
|
||||
appState: updateAppState,
|
||||
appState: { ...appState, ...updateAppState },
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
@@ -28,22 +28,24 @@ export const actionSelectAll = register({
|
||||
}, {});
|
||||
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedLinearElement:
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
editingGroupId: null,
|
||||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: null,
|
||||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
selectedLinearElement:
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
@@ -121,6 +121,7 @@ export type ActionName =
|
||||
| "removeAllElementsFromFrame"
|
||||
| "updateFrameRendering"
|
||||
| "setFrameAsActiveTool"
|
||||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
|
||||
|
@@ -11,7 +11,7 @@ export const trackEvent = (
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
|
||||
if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
|
||||
if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@@ -38,6 +38,7 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
cursorButton: "up",
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
@@ -139,6 +140,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
activeEmbeddable: { browser: false, export: false, server: false },
|
||||
draggingElement: { browser: false, export: false, server: false },
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
|
@@ -6,7 +6,6 @@ import {
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
ENV,
|
||||
VERTICAL_ALIGN,
|
||||
} from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
@@ -384,7 +383,7 @@ const chartTypeBar = (
|
||||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||
import.meta.env.DEV,
|
||||
),
|
||||
];
|
||||
};
|
||||
@@ -473,7 +472,7 @@ const chartTypeLine = (
|
||||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||
import.meta.env.DEV,
|
||||
),
|
||||
line,
|
||||
...lines,
|
||||
|
@@ -24,6 +24,7 @@ export interface ClipboardData {
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
programmaticAPI?: boolean;
|
||||
}
|
||||
|
||||
let CLIPBOARD = "";
|
||||
@@ -48,6 +49,7 @@ const clipboardContainsElements = (
|
||||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
|
||||
].includes(contents?.type) &&
|
||||
Array.isArray(contents.elements)
|
||||
) {
|
||||
@@ -191,6 +193,8 @@ export const parseClipboard = async (
|
||||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
const programmaticAPI =
|
||||
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
@@ -198,6 +202,7 @@ export const parseClipboard = async (
|
||||
text: isPlainPaste
|
||||
? JSON.stringify(systemClipboardData.elements, null, 2)
|
||||
: undefined,
|
||||
programmaticAPI,
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
@@ -21,7 +21,7 @@ export type ColorPickerColor =
|
||||
export type ColorTuple = readonly [string, string, string, string, string];
|
||||
export type ColorPalette = Merge<
|
||||
Record<ColorPickerColor, ColorTuple>,
|
||||
{ black: string; white: string; transparent: string }
|
||||
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
|
||||
>;
|
||||
|
||||
// used general type instead of specific type (ColorPalette) to support custom colors
|
||||
|
@@ -36,7 +36,7 @@ import {
|
||||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
@@ -213,13 +213,13 @@ export const SelectedShapeActions = ({
|
||||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
activeTool,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
activeTool: UIAppState["activeTool"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
@@ -266,10 +266,11 @@ export const ShapesSwitcher = ({
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
activeEmbeddable: null,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
setCursorForShape(interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
@@ -283,39 +284,72 @@ export const ShapesSwitcher = ({
|
||||
<div className="App-toolbar__divider" />
|
||||
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
|
||||
{device.isMobile ? (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={frameToolIcon}
|
||||
checked={activeTool.type === "frame"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(
|
||||
t("toolBar.frame"),
|
||||
)} — ${KEYS.F.toLocaleUpperCase()}`}
|
||||
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
|
||||
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid={`toolbar-frame`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
<>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={frameToolIcon}
|
||||
checked={activeTool.type === "frame"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(
|
||||
t("toolBar.frame"),
|
||||
)} — ${KEYS.F.toLocaleUpperCase()}`}
|
||||
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
|
||||
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid={`toolbar-frame`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={EmbedIcon}
|
||||
checked={activeTool.type === "embeddable"}
|
||||
name="editor-current-shape"
|
||||
title={capitalizeString(t("toolBar.embeddable"))}
|
||||
aria-label={capitalizeString(t("toolBar.embeddable"))}
|
||||
data-testid={`toolbar-embeddable`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "embeddable", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
@@ -347,6 +381,22 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
@@ -4,15 +4,16 @@ import { reseed } from "../random";
|
||||
import { render, queryByTestId } from "../tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const renderScene = jest.spyOn(Renderer, "renderScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
describe("Test <App/>", () => {
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import {
|
||||
} from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { ColorPaletteCustom } from "../../colors";
|
||||
import { t } from "../../i18n";
|
||||
import { TranslationKeys, t } from "../../i18n";
|
||||
|
||||
interface PickerColorListProps {
|
||||
palette: ColorPaletteCustom;
|
||||
@@ -48,7 +48,11 @@ const PickerColorList = ({
|
||||
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
|
||||
|
||||
const keybinding = colorPickerHotkeyBindings[index];
|
||||
const label = t(`colors.${key.replace(/\d+/, "")}`, null, "");
|
||||
const label = t(
|
||||
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
|
||||
null,
|
||||
"",
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
import { t } from "../i18n";
|
||||
import { t, TranslationKeys } from "../i18n";
|
||||
|
||||
import "./ContextMenu.scss";
|
||||
import {
|
||||
@@ -83,10 +83,14 @@ export const ContextMenu = React.memo(
|
||||
if (item.contextItemLabel) {
|
||||
if (typeof item.contextItemLabel === "function") {
|
||||
label = t(
|
||||
item.contextItemLabel(elements, appState, actionManager.app),
|
||||
item.contextItemLabel(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app,
|
||||
) as unknown as TranslationKeys,
|
||||
);
|
||||
} else {
|
||||
label = t(item.contextItemLabel);
|
||||
label = t(item.contextItemLabel as unknown as TranslationKeys);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -8,9 +8,9 @@ import { mutateElement } from "../element/mutateElement";
|
||||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { KEYS } from "../keys";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import Scene from "../scene/Scene";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
|
||||
|
||||
import "./EyeDropper.scss";
|
||||
@@ -58,7 +58,7 @@ export const EyeDropper: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
let currentColor = COLOR_PALETTE.black;
|
||||
let currentColor: string = COLOR_PALETTE.black;
|
||||
let isHoldingPointerDown = false;
|
||||
|
||||
const ctx = app.canvas.getContext("2d")!;
|
||||
@@ -77,8 +77,8 @@ export const EyeDropper: React.FC<{
|
||||
colorPreviewDiv.style.left = `${clientX + 20}px`;
|
||||
|
||||
const pixel = ctx.getImageData(
|
||||
clientX * window.devicePixelRatio - appState.offsetLeft,
|
||||
clientY * window.devicePixelRatio - appState.offsetTop,
|
||||
(clientX - appState.offsetLeft) * window.devicePixelRatio,
|
||||
(clientY - appState.offsetTop) * window.devicePixelRatio,
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
@@ -98,7 +98,7 @@ export const EyeDropper: React.FC<{
|
||||
},
|
||||
false,
|
||||
);
|
||||
invalidateShapeForElement(element);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
Scene.getScene(
|
||||
metaStuffRef.current.selectedElements[0],
|
||||
|
@@ -44,6 +44,10 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (activeTool.type === "embeddable") {
|
||||
return t("hints.embeddable");
|
||||
}
|
||||
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
@@ -34,7 +34,7 @@ const JSONExportModal = ({
|
||||
actionManager: ActionManager;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
}) => {
|
||||
const { onExportToBackend } = exportOpts;
|
||||
return (
|
||||
@@ -100,7 +100,7 @@ export const JSONExportDialog = ({
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
}) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
|
@@ -57,7 +57,8 @@ interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
@@ -117,6 +118,7 @@ const LayerUI = ({
|
||||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
@@ -272,7 +274,7 @@ const LayerUI = ({
|
||||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
@@ -413,7 +415,7 @@ const LayerUI = ({
|
||||
onLockToggle={onLockToggle}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
@@ -464,7 +466,7 @@ const LayerUI = ({
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
@@ -507,8 +509,18 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
|
||||
const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
|
||||
const {
|
||||
canvas: _pC,
|
||||
interactiveCanvas: _pIC,
|
||||
appState: prevAppState,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
canvas: _nC,
|
||||
interactiveCanvas: _nIC,
|
||||
appState: nextAppState,
|
||||
...next
|
||||
} = nextProps;
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
|
@@ -29,6 +29,7 @@ import "./LibraryMenu.scss";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
||||
@@ -68,11 +69,12 @@ export const LibraryMenuContent = ({
|
||||
libraryItems: LibraryItems,
|
||||
) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
if (processedElements.some((element) => element.type === "image")) {
|
||||
return setAppState({
|
||||
errorMessage:
|
||||
"Support for adding images to the library coming soon!",
|
||||
});
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (processedElements.some((element) => element.type === type)) {
|
||||
return setAppState({
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
@@ -197,6 +199,7 @@ export const LibraryMenu = () => {
|
||||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
|
@@ -16,7 +16,7 @@ const LibraryMenuBrowseButton = ({
|
||||
return (
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
href={`${import.meta.env.VITE_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
|
@@ -12,6 +12,11 @@
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--border-radius-lg);
|
||||
|
||||
svg {
|
||||
// to prevent clicks on links and such
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
@@ -36,7 +36,7 @@ type MobileMenuProps = {
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
@@ -58,7 +58,7 @@ export const MobileMenu = ({
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
@@ -85,7 +85,7 @@ export const MobileMenu = ({
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
@@ -202,7 +202,7 @@ export const MobileMenu = ({
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
|
@@ -319,7 +319,7 @@ const PublishLibrary = ({
|
||||
formData.append("twitterHandle", libraryData.twitterHandle);
|
||||
formData.append("website", libraryData.website);
|
||||
|
||||
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
|
||||
fetch(`${import.meta.env.VITE_APP_LIBRARY_BACKEND}/submit`, {
|
||||
method: "post",
|
||||
body: formData,
|
||||
})
|
||||
|
@@ -3,7 +3,7 @@ import { t } from "../i18n";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
export const Section: React.FC<{
|
||||
heading: string;
|
||||
heading: "canvasActions" | "selectedShapeActions" | "shapes";
|
||||
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
|
||||
className?: string;
|
||||
}> = ({ heading, children, ...props }) => {
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
waitFor,
|
||||
withExcalidrawDimensions,
|
||||
} from "../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const assertSidebarDockButton = async <T extends boolean>(
|
||||
hasDockButton: T,
|
||||
@@ -205,7 +206,7 @@ describe("Sidebar", () => {
|
||||
});
|
||||
|
||||
it("<Sidebar.Header> should render close button", async () => {
|
||||
const onStateChange = jest.fn();
|
||||
const onStateChange = vi.fn();
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
|
@@ -53,7 +53,7 @@ export const SidebarInner = forwardRef(
|
||||
}: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
if (process.env.NODE_ENV === "development" && onDock && docked == null) {
|
||||
if (import.meta.env.DEV && onDock && docked == null) {
|
||||
console.warn(
|
||||
"Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
|
||||
);
|
||||
|
@@ -3,6 +3,7 @@ import { render } from "@testing-library/react";
|
||||
import fallbackLangData from "../locales/en.json";
|
||||
|
||||
import Trans from "./Trans";
|
||||
import { TranslationKeys } from "../i18n";
|
||||
|
||||
describe("Test <Trans/>", () => {
|
||||
it("should translate the the strings correctly", () => {
|
||||
@@ -18,24 +19,27 @@ describe("Test <Trans/>", () => {
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<div data-testid="test1">
|
||||
<Trans i18nKey="transTest.key1" audience="world" />
|
||||
<Trans
|
||||
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
||||
audience="world"
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test2">
|
||||
<Trans
|
||||
i18nKey="transTest.key2"
|
||||
i18nKey={"transTest.key2" as unknown as TranslationKeys}
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test3">
|
||||
<Trans
|
||||
i18nKey="transTest.key3"
|
||||
i18nKey={"transTest.key3" as unknown as TranslationKeys}
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
location="the button"
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test4">
|
||||
<Trans
|
||||
i18nKey="transTest.key4"
|
||||
i18nKey={"transTest.key4" as unknown as TranslationKeys}
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
location="the button"
|
||||
bold={(el) => <strong>{el}</strong>}
|
||||
@@ -43,7 +47,7 @@ describe("Test <Trans/>", () => {
|
||||
</div>
|
||||
<div data-testid="test5">
|
||||
<Trans
|
||||
i18nKey="transTest.key5"
|
||||
i18nKey={"transTest.key5" as unknown as TranslationKeys}
|
||||
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
||||
/>
|
||||
</div>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { useI18n } from "../i18n";
|
||||
import { TranslationKeys, useI18n } from "../i18n";
|
||||
|
||||
// Used for splitting i18nKey into tokens in Trans component
|
||||
// Example:
|
||||
@@ -153,7 +153,7 @@ const Trans = ({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
i18nKey: string;
|
||||
i18nKey: TranslationKeys;
|
||||
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <App/> should show error modal when using brave and measureText API is not working 1`] = `
|
||||
exports[`Test <App/> > should show error modal when using brave and measureText API is not working 1`] = `
|
||||
<div
|
||||
data-testid="brave-measure-text-error"
|
||||
>
|
||||
|
226
src/components/canvases/InteractiveCanvas.tsx
Normal file
226
src/components/canvases/InteractiveCanvas.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { renderInteractiveScene } from "../../renderer/renderScene";
|
||||
import {
|
||||
isRenderThrottlingEnabled,
|
||||
isShallowEqual,
|
||||
sceneCoordsToViewportCoords,
|
||||
} from "../../utils";
|
||||
import { CURSOR_TYPE } from "../../constants";
|
||||
import { t } from "../../i18n";
|
||||
import type { DOMAttributes } from "react";
|
||||
import type { AppState, InteractiveCanvasAppState } from "../../types";
|
||||
import type {
|
||||
InteractiveCanvasRenderConfig,
|
||||
RenderInteractiveSceneCallback,
|
||||
} from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
|
||||
type InteractiveCanvasProps = {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: number | undefined;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
renderInteractiveSceneCallback: (
|
||||
data: RenderInteractiveSceneCallback,
|
||||
) => void;
|
||||
handleCanvasRef: (canvas: HTMLCanvasElement | null) => void;
|
||||
onContextMenu: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
|
||||
undefined
|
||||
>;
|
||||
onPointerMove: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerMove"],
|
||||
undefined
|
||||
>;
|
||||
onPointerUp: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerUp"],
|
||||
undefined
|
||||
>;
|
||||
onPointerCancel: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerCancel"],
|
||||
undefined
|
||||
>;
|
||||
onTouchMove: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onTouchMove"],
|
||||
undefined
|
||||
>;
|
||||
onPointerDown: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerDown"],
|
||||
undefined
|
||||
>;
|
||||
onDoubleClick: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onDoubleClick"],
|
||||
undefined
|
||||
>;
|
||||
};
|
||||
|
||||
const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||
const isComponentMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isComponentMounted.current) {
|
||||
isComponentMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||
{};
|
||||
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
||||
{};
|
||||
const pointerUsernames: { [id: string]: string } = {};
|
||||
const pointerUserStates: { [id: string]: string } = {};
|
||||
|
||||
props.appState.collaborators.forEach((user, socketId) => {
|
||||
if (user.selectedElementIds) {
|
||||
for (const id of Object.keys(user.selectedElementIds)) {
|
||||
if (!(id in remoteSelectedElementIds)) {
|
||||
remoteSelectedElementIds[id] = [];
|
||||
}
|
||||
remoteSelectedElementIds[id].push(socketId);
|
||||
}
|
||||
}
|
||||
if (!user.pointer) {
|
||||
return;
|
||||
}
|
||||
if (user.username) {
|
||||
pointerUsernames[socketId] = user.username;
|
||||
}
|
||||
if (user.userState) {
|
||||
pointerUserStates[socketId] = user.userState;
|
||||
}
|
||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: user.pointer.x,
|
||||
sceneY: user.pointer.y,
|
||||
},
|
||||
props.appState,
|
||||
);
|
||||
cursorButton[socketId] = user.button;
|
||||
});
|
||||
|
||||
const selectionColor =
|
||||
(props.containerRef?.current &&
|
||||
getComputedStyle(props.containerRef.current).getPropertyValue(
|
||||
"--color-selection",
|
||||
)) ||
|
||||
"#6965db";
|
||||
|
||||
renderInteractiveScene(
|
||||
{
|
||||
canvas: props.canvas,
|
||||
elements: props.elements,
|
||||
visibleElements: props.visibleElements,
|
||||
selectedElements: props.selectedElements,
|
||||
scale: window.devicePixelRatio,
|
||||
appState: props.appState,
|
||||
renderConfig: {
|
||||
remotePointerViewportCoords: pointerViewportCoords,
|
||||
remotePointerButton: cursorButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
selectionColor,
|
||||
renderScrollbars: false,
|
||||
},
|
||||
callback: props.renderInteractiveSceneCallback,
|
||||
},
|
||||
isRenderThrottlingEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<canvas
|
||||
className="excalidraw__canvas interactive"
|
||||
style={{
|
||||
width: props.appState.width,
|
||||
height: props.appState.height,
|
||||
cursor: props.appState.viewModeEnabled
|
||||
? CURSOR_TYPE.GRAB
|
||||
: CURSOR_TYPE.AUTO,
|
||||
}}
|
||||
width={props.appState.width * props.scale}
|
||||
height={props.appState.height * props.scale}
|
||||
ref={props.handleCanvasRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onPointerMove={props.onPointerMove}
|
||||
onPointerUp={props.onPointerUp}
|
||||
onPointerCancel={props.onPointerCancel}
|
||||
onTouchMove={props.onTouchMove}
|
||||
onPointerDown={props.onPointerDown}
|
||||
onDoubleClick={
|
||||
props.appState.viewModeEnabled ? undefined : props.onDoubleClick
|
||||
}
|
||||
>
|
||||
{t("labels.drawingCanvas")}
|
||||
</canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const getRelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): InteractiveCanvasAppState => ({
|
||||
zoom: appState.zoom,
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
viewModeEnabled: appState.viewModeEnabled,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
editingLinearElement: appState.editingLinearElement,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
offsetLeft: appState.offsetLeft,
|
||||
offsetTop: appState.offsetTop,
|
||||
theme: appState.theme,
|
||||
pendingImageElementId: appState.pendingImageElementId,
|
||||
selectionElement: appState.selectionElement,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
selectedLinearElement: appState.selectedLinearElement,
|
||||
multiElement: appState.multiElement,
|
||||
isBindingEnabled: appState.isBindingEnabled,
|
||||
suggestedBindings: appState.suggestedBindings,
|
||||
isRotating: appState.isRotating,
|
||||
elementsToHighlight: appState.elementsToHighlight,
|
||||
openSidebar: appState.openSidebar,
|
||||
showHyperlinkPopup: appState.showHyperlinkPopup,
|
||||
collaborators: appState.collaborators, // Necessary for collab. sessions
|
||||
activeEmbeddable: appState.activeEmbeddable,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
prevProps: InteractiveCanvasProps,
|
||||
nextProps: InteractiveCanvasProps,
|
||||
) => {
|
||||
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
||||
if (
|
||||
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on element arrays because they may have renewed
|
||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elements !== nextProps.elements ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements ||
|
||||
prevProps.selectedElements !== nextProps.selectedElements
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Comparing the interactive appState for changes in case of some edge cases
|
||||
return isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the InteractiveCanvas-relevant props
|
||||
getRelevantAppStateProps(prevProps.appState as AppState),
|
||||
getRelevantAppStateProps(nextProps.appState as AppState),
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InteractiveCanvas, areEqual);
|
125
src/components/canvases/StaticCanvas.tsx
Normal file
125
src/components/canvases/StaticCanvas.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { renderStaticScene } from "../../renderer/renderScene";
|
||||
import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils";
|
||||
import type { AppState, StaticCanvasAppState } from "../../types";
|
||||
import type { StaticCanvasRenderConfig } from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
|
||||
type StaticCanvasProps = {
|
||||
canvas: HTMLCanvasElement;
|
||||
rc: RoughCanvas;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: number | undefined;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: StaticCanvasAppState;
|
||||
renderConfig: StaticCanvasRenderConfig;
|
||||
};
|
||||
|
||||
const StaticCanvas = (props: StaticCanvasProps) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const isComponentMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = props.canvas;
|
||||
|
||||
if (!isComponentMounted.current) {
|
||||
isComponentMounted.current = true;
|
||||
|
||||
wrapper.replaceChildren(canvas);
|
||||
canvas.classList.add("excalidraw__canvas", "static");
|
||||
}
|
||||
|
||||
const widthString = `${props.appState.width}px`;
|
||||
const heightString = `${props.appState.height}px`;
|
||||
if (canvas.style.width !== widthString) {
|
||||
canvas.style.width = widthString;
|
||||
}
|
||||
if (canvas.style.height !== heightString) {
|
||||
canvas.style.height = heightString;
|
||||
}
|
||||
|
||||
const scaledWidth = props.appState.width * props.scale;
|
||||
const scaledHeight = props.appState.height * props.scale;
|
||||
// setting width/height resets the canvas even if dimensions not changed,
|
||||
// which would cause flicker when we skip frame (due to throttling)
|
||||
if (canvas.width !== scaledWidth) {
|
||||
canvas.width = scaledWidth;
|
||||
}
|
||||
if (canvas.height !== scaledHeight) {
|
||||
canvas.height = scaledHeight;
|
||||
}
|
||||
|
||||
renderStaticScene(
|
||||
{
|
||||
canvas,
|
||||
rc: props.rc,
|
||||
scale: props.scale,
|
||||
elements: props.elements,
|
||||
visibleElements: props.visibleElements,
|
||||
appState: props.appState,
|
||||
renderConfig: props.renderConfig,
|
||||
},
|
||||
isRenderThrottlingEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
|
||||
};
|
||||
|
||||
const getRelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): StaticCanvasAppState => ({
|
||||
zoom: appState.zoom,
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
viewModeEnabled: appState.viewModeEnabled,
|
||||
offsetLeft: appState.offsetLeft,
|
||||
offsetTop: appState.offsetTop,
|
||||
theme: appState.theme,
|
||||
pendingImageElementId: appState.pendingImageElementId,
|
||||
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
exportScale: appState.exportScale,
|
||||
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
|
||||
gridSize: appState.gridSize,
|
||||
frameRendering: appState.frameRendering,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
prevProps: StaticCanvasProps,
|
||||
nextProps: StaticCanvasProps,
|
||||
) => {
|
||||
if (
|
||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on element arrays because they may have renewed
|
||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elements !== nextProps.elements ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the StaticCanvas-relevant props
|
||||
getRelevantAppStateProps(prevProps.appState as AppState),
|
||||
getRelevantAppStateProps(nextProps.appState as AppState),
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(StaticCanvas, areEqual);
|
4
src/components/canvases/index.tsx
Normal file
4
src/components/canvases/index.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import InteractiveCanvas from "./InteractiveCanvas";
|
||||
import StaticCanvas from "./StaticCanvas";
|
||||
|
||||
export { InteractiveCanvas, StaticCanvas };
|
@@ -396,6 +396,14 @@ export const TrashIcon = createIcon(
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const EmbedIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<polyline points="12 16 18 10 12 4" />
|
||||
<polyline points="8 4 2 10 8 16" />
|
||||
</g>,
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const DuplicateIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<path d="M14.375 6.458H8.958a2.5 2.5 0 0 0-2.5 2.5v5.417a2.5 2.5 0 0 0 2.5 2.5h5.417a2.5 2.5 0 0 0 2.5-2.5V8.958a2.5 2.5 0 0 0-2.5-2.5Z" />
|
||||
|
@@ -4,6 +4,7 @@ import {
|
||||
useExcalidrawSetAppState,
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawElements,
|
||||
useAppProps,
|
||||
} from "../App";
|
||||
import {
|
||||
ExportIcon,
|
||||
@@ -198,13 +199,20 @@ export const ChangeCanvasBackground = () => {
|
||||
const { t } = useI18n();
|
||||
const appState = useUIAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
const appProps = useAppProps();
|
||||
|
||||
if (appState.viewModeEnabled) {
|
||||
if (
|
||||
appState.viewModeEnabled ||
|
||||
!appProps.UIOptions.canvasActions.changeViewBackgroundColor
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
|
||||
<div
|
||||
data-testid="canvas-background-label"
|
||||
style={{ fontSize: ".75rem", marginBottom: ".5rem" }}
|
||||
>
|
||||
{t("labels.canvasBackground")}
|
||||
</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
|
@@ -71,8 +71,18 @@ export enum EVENT {
|
||||
// custom events
|
||||
EXCALIDRAW_LINK = "excalidraw-link",
|
||||
MENU_ITEM_SELECT = "menu.itemSelect",
|
||||
MESSAGE = "message",
|
||||
}
|
||||
|
||||
export const YOUTUBE_STATES = {
|
||||
UNSTARTED: -1,
|
||||
ENDED: 0,
|
||||
PLAYING: 1,
|
||||
PAUSED: 2,
|
||||
BUFFERING: 3,
|
||||
CUED: 5,
|
||||
} as const;
|
||||
|
||||
export const ENV = {
|
||||
TEST: "test",
|
||||
DEVELOPMENT: "development",
|
||||
@@ -92,7 +102,7 @@ export const FONT_FAMILY = {
|
||||
export const THEME = {
|
||||
LIGHT: "light",
|
||||
DARK: "dark",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const FRAME_STYLE = {
|
||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||
@@ -107,6 +117,7 @@ export const FRAME_STYLE = {
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
@@ -153,6 +164,7 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidraw: "excalidraw",
|
||||
excalidrawClipboard: "excalidraw/clipboard",
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE =
|
||||
@@ -229,6 +241,8 @@ export const VERSIONS = {
|
||||
} as const;
|
||||
|
||||
export const BOUND_TEXT_PADDING = 5;
|
||||
export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
|
||||
export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
|
||||
|
||||
export const VERTICAL_ALIGN = {
|
||||
TOP: "top",
|
||||
@@ -300,3 +314,5 @@ export const DEFAULT_SIDEBAR = {
|
||||
name: "default",
|
||||
defaultTab: LIBRARY_SIDEBAR_TAB,
|
||||
} as const;
|
||||
|
||||
export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);
|
||||
|
@@ -3,8 +3,9 @@
|
||||
|
||||
:root {
|
||||
--zIndex-canvas: 1;
|
||||
--zIndex-wysiwyg: 2;
|
||||
--zIndex-layerUI: 3;
|
||||
--zIndex-interactiveCanvas: 2;
|
||||
--zIndex-wysiwyg: 3;
|
||||
--zIndex-layerUI: 4;
|
||||
|
||||
--zIndex-modal: 1000;
|
||||
--zIndex-popup: 1001;
|
||||
@@ -69,14 +70,36 @@
|
||||
|
||||
z-index: var(--zIndex-canvas);
|
||||
|
||||
&.interactive {
|
||||
z-index: var(--zIndex-interactiveCanvas);
|
||||
}
|
||||
|
||||
// Remove the main canvas from document flow to avoid resizeObserver
|
||||
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
|
||||
}
|
||||
|
||||
&__canvas-wrapper,
|
||||
&__canvas.static {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__canvas {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&__embeddable {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&__embeddable-container {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
// The percentage is inspired by
|
||||
// https://material.io/design/color/dark-theme.html#properties, which
|
||||
@@ -661,3 +684,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw__embeddable-container {
|
||||
.excalidraw__embeddable-container__inner {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--embeddable-radius);
|
||||
}
|
||||
|
||||
.excalidraw__embeddable__outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
& > * {
|
||||
border-radius: var(--embeddable-radius);
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw__embeddable-hint {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem 1.6rem;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.6px;
|
||||
font-family: "Assistant";
|
||||
}
|
||||
}
|
||||
|
2032
src/data/__snapshots__/transform.test.ts.snap
Normal file
2032
src/data/__snapshots__/transform.test.ts.snap
Normal file
File diff suppressed because it is too large
Load Diff
@@ -144,11 +144,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
fileHandle: fileHandle || blob.handle || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(
|
||||
data.elements || [],
|
||||
localAppState,
|
||||
null,
|
||||
)
|
||||
? calculateScrollCenter(data.elements || [], localAppState)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
|
@@ -3,6 +3,7 @@ import {
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
PointBinding,
|
||||
StrokeRoundness,
|
||||
} from "../element/types";
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
DEFAULT_SIDEBAR,
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
@@ -40,7 +42,6 @@ import {
|
||||
getDefaultLineHeight,
|
||||
measureBaseline,
|
||||
} from "../element/textElement";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
@@ -64,6 +65,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
eraser: false,
|
||||
custom: true,
|
||||
frame: true,
|
||||
embeddable: true,
|
||||
hand: true,
|
||||
};
|
||||
|
||||
@@ -82,6 +84,13 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
};
|
||||
|
||||
const repairBinding = (binding: PointBinding | null) => {
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
return { ...binding, focus: binding.focus || 0 };
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
@@ -113,16 +122,18 @@ const restoreElementWithProperties = <
|
||||
versionNonce: element.versionNonce ?? 0,
|
||||
isDeleted: element.isDeleted ?? false,
|
||||
id: element.id || randomId(),
|
||||
fillStyle: element.fillStyle || "hachure",
|
||||
strokeWidth: element.strokeWidth || 1,
|
||||
strokeStyle: element.strokeStyle ?? "solid",
|
||||
roughness: element.roughness ?? 1,
|
||||
opacity: element.opacity == null ? 100 : element.opacity,
|
||||
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
|
||||
opacity:
|
||||
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
|
||||
angle: element.angle || 0,
|
||||
x: extra.x ?? element.x ?? 0,
|
||||
y: extra.y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor || COLOR_PALETTE.black,
|
||||
backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
|
||||
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
backgroundColor:
|
||||
element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
width: element.width || 0,
|
||||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
@@ -237,7 +248,6 @@ const restoreElement = (
|
||||
startArrowhead = null,
|
||||
endArrowhead = element.type === "arrow" ? "arrow" : null,
|
||||
} = element;
|
||||
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
@@ -257,8 +267,8 @@ const restoreElement = (
|
||||
(element.type as ExcalidrawElement["type"] | "draw") === "draw"
|
||||
? "line"
|
||||
: element.type,
|
||||
startBinding: element.startBinding,
|
||||
endBinding: element.endBinding,
|
||||
startBinding: repairBinding(element.startBinding),
|
||||
endBinding: repairBinding(element.endBinding),
|
||||
lastCommittedPoint: null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
@@ -275,6 +285,10 @@ const restoreElement = (
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "diamond":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "embeddable":
|
||||
return restoreElementWithProperties(element, {
|
||||
validated: null,
|
||||
});
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
name: element.name ?? null,
|
||||
@@ -397,7 +411,6 @@ export const restoreElements = (
|
||||
): ExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
|
||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||
const restoredElements = (elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
@@ -416,6 +429,7 @@ export const restoreElements = (
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
}
|
||||
|
706
src/data/transform.test.ts
Normal file
706
src/data/transform.test.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
ExcalidrawElementSkeleton,
|
||||
convertToExcalidrawElements,
|
||||
} from "./transform";
|
||||
import { ExcalidrawArrowElement } from "../element/types";
|
||||
|
||||
describe("Test Transform", () => {
|
||||
it("should transform regular shapes", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 100,
|
||||
y: 250,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#c0eb75",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 300,
|
||||
y: 250,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#ffc9c9",
|
||||
strokeStyle: "dotted",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 300,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#a5d8ff",
|
||||
strokeColor: "#1971c2",
|
||||
strokeStyle: "dashed",
|
||||
fillStyle: "cross-hatch",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
];
|
||||
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform text element", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 100,
|
||||
text: "HELLO WORLD!",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 150,
|
||||
text: "STYLED HELLO WORLD!",
|
||||
fontSize: 20,
|
||||
strokeColor: "#5f3dc4",
|
||||
},
|
||||
];
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform linear elements", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "dot",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 100,
|
||||
y: 60,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 450,
|
||||
y: 60,
|
||||
strokeColor: "#2f9e44",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "dotted",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to text containers when label provided", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "RECTANGLE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 200,
|
||||
label: {
|
||||
text: "ELLIPSE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 150,
|
||||
width: 280,
|
||||
label: {
|
||||
text: "DIAMOND\nTEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "STYLED DIAMOND TEXT CONTAINER",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 500,
|
||||
y: 300,
|
||||
width: 200,
|
||||
strokeColor: "#c2255c",
|
||||
label: {
|
||||
text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 500,
|
||||
strokeColor: "#f08c00",
|
||||
backgroundColor: "#ffec99",
|
||||
width: 200,
|
||||
label: {
|
||||
text: "STYLED ELLIPSE TEXT CONTAINER",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(12);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to labelled arrows when label provided for arrows", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "LABELED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 200,
|
||||
label: {
|
||||
text: "STYLED LABELED ARROW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 300,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 400,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
strokeColor: "#099268",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(8);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test arrow bindings", () => {
|
||||
it("should bind arrows to shapes when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "rectangle",
|
||||
},
|
||||
end: {
|
||||
type: "ellipse",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [arrow, text, rectangle, ellipse] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: rectangle.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: ellipse.id,
|
||||
focus: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toMatchObject({
|
||||
x: 340,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(rectangle).toMatchObject({
|
||||
x: 155,
|
||||
y: 189,
|
||||
type: "rectangle",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ellipse).toMatchObject({
|
||||
x: 555,
|
||||
y: 189,
|
||||
type: "ellipse",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to text when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
},
|
||||
end: {
|
||||
type: "text",
|
||||
text: "WHATS UP ?",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
const [arrow, text1, text2, text3] = excaldrawElements;
|
||||
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [{ id: text1.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: text2.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: text3.id,
|
||||
focus: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text1).toMatchObject({
|
||||
x: 340,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(text2).toMatchObject({
|
||||
x: 185,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(text3).toMatchObject({
|
||||
x: 555,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing shapes when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "ellipse-1",
|
||||
strokeColor: "#66a80f",
|
||||
x: 630,
|
||||
y: 316,
|
||||
width: 300,
|
||||
height: 300,
|
||||
backgroundColor: "#d8f5a2",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
id: "diamond-1",
|
||||
strokeColor: "#9c36b5",
|
||||
width: 140,
|
||||
x: 96,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 247,
|
||||
y: 420,
|
||||
width: 395,
|
||||
height: 35,
|
||||
strokeColor: "#1864ab",
|
||||
start: {
|
||||
type: "rectangle",
|
||||
width: 300,
|
||||
height: 300,
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 227,
|
||||
y: 450,
|
||||
width: 400,
|
||||
strokeColor: "#e67700",
|
||||
start: {
|
||||
id: "diamond-1",
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(5);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing text elements when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "text-2",
|
||||
x: 560,
|
||||
y: 239,
|
||||
text: "Whats up ?",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-1",
|
||||
},
|
||||
end: {
|
||||
id: "text-2",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing elements if ids are correct", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-13",
|
||||
},
|
||||
end: {
|
||||
id: "rect-11",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [, , arrow] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [
|
||||
{
|
||||
id: "id46",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"No element for start binding with id text-13 found",
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"No element for end binding with id rect-11 found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should bind when ids referenced before the element data", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
end: {
|
||||
id: "rect-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
expect(excaldrawElements.length).toBe(2);
|
||||
const [arrow, rect] = excaldrawElements;
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
id: "id47",
|
||||
type: "arrow",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow duplicate ids", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 200,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(1);
|
||||
expect(excaldrawElements[0]).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Duplicate id found for rect-1",
|
||||
);
|
||||
});
|
||||
});
|
561
src/data/transform.ts
Normal file
561
src/data/transform.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
newElement,
|
||||
newLinearElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { bindLinearElement } from "../element/binding";
|
||||
import {
|
||||
ElementConstructorOpts,
|
||||
newImageElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
measureText,
|
||||
normalizeText,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FileId,
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "../element/types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import { assertNever, getFontString } from "../utils";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
x: number;
|
||||
y: number;
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
end?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
start?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & Partial<ExcalidrawLinearElement>;
|
||||
|
||||
export type ValidContainer =
|
||||
| {
|
||||
type: Exclude<ExcalidrawGenericElement["type"], "selection">;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & ElementConstructorOpts;
|
||||
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawFrameElement
|
||||
>
|
||||
| ({
|
||||
type: Extract<ExcalidrawLinearElement["type"], "line">;
|
||||
x: number;
|
||||
y: number;
|
||||
} & Partial<ExcalidrawLinearElement>)
|
||||
| ValidContainer
|
||||
| ValidLinearElement
|
||||
| ({
|
||||
type: "text";
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
id?: ExcalidrawTextElement["id"];
|
||||
} & Partial<ExcalidrawTextElement>)
|
||||
| ({
|
||||
type: Extract<ExcalidrawImageElement["type"], "image">;
|
||||
x: number;
|
||||
y: number;
|
||||
fileId: FileId;
|
||||
} & Partial<ExcalidrawImageElement>);
|
||||
|
||||
const DEFAULT_LINEAR_ELEMENT_PROPS = {
|
||||
width: 300,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DIMENSION = 100;
|
||||
|
||||
const bindTextToContainer = (
|
||||
container: ExcalidrawElement,
|
||||
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
|
||||
) => {
|
||||
const textElement: ExcalidrawTextElement = newTextElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
...textProps,
|
||||
containerId: container.id,
|
||||
strokeColor: textProps.strokeColor || container.strokeColor,
|
||||
});
|
||||
|
||||
Object.assign(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
return [container, textElement] as const;
|
||||
};
|
||||
|
||||
const bindLinearElementToElement = (
|
||||
linearElement: ExcalidrawArrowElement,
|
||||
start: ValidLinearElement["start"],
|
||||
end: ValidLinearElement["end"],
|
||||
elementStore: ElementStore,
|
||||
): {
|
||||
linearElement: ExcalidrawLinearElement;
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
endBoundElement?: ExcalidrawElement;
|
||||
} => {
|
||||
let startBoundElement;
|
||||
let endBoundElement;
|
||||
|
||||
Object.assign(linearElement, {
|
||||
startBinding: linearElement?.startBinding || null,
|
||||
endBinding: linearElement.endBinding || null,
|
||||
});
|
||||
|
||||
if (start) {
|
||||
const width = start?.width ?? DEFAULT_DIMENSION;
|
||||
const height = start?.height ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (start.id) {
|
||||
existingElement = elementStore.getElement(start.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for start binding with id ${start.id} found`);
|
||||
}
|
||||
}
|
||||
|
||||
const startX = start.x || linearElement.x - width;
|
||||
const startY = start.y || linearElement.y - height / 2;
|
||||
const startType = existingElement ? existingElement.type : start.type;
|
||||
|
||||
if (startType) {
|
||||
if (startType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (start.type === "text") {
|
||||
text = start.text;
|
||||
}
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for start binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
startBoundElement = newTextElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...start,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(startBoundElement, {
|
||||
x: start.x || linearElement.x - startBoundElement.width,
|
||||
y: start.y || linearElement.y - startBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (startType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
startBoundElement = newElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...start,
|
||||
type: startType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element start type "${start.type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
startBoundElement as ExcalidrawBindableElement,
|
||||
"start",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (end) {
|
||||
const height = end?.height ?? DEFAULT_DIMENSION;
|
||||
const width = end?.width ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (end.id) {
|
||||
existingElement = elementStore.getElement(end.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for end binding with id ${end.id} found`);
|
||||
}
|
||||
}
|
||||
const endX = end.x || linearElement.x + linearElement.width;
|
||||
const endY = end.y || linearElement.y - height / 2;
|
||||
const endType = existingElement ? existingElement.type : end.type;
|
||||
|
||||
if (endType) {
|
||||
if (endType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (end.type === "text") {
|
||||
text = end.text;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for end binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
endBoundElement = newTextElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...end,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(endBoundElement, {
|
||||
y: end.y || linearElement.y - endBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (endType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
endBoundElement = newElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...end,
|
||||
type: endType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element end type "${endType}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
endBoundElement as ExcalidrawBindableElement,
|
||||
"end",
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
linearElement,
|
||||
startBoundElement,
|
||||
endBoundElement,
|
||||
};
|
||||
};
|
||||
|
||||
class ElementStore {
|
||||
excalidrawElements = new Map<string, ExcalidrawElement>();
|
||||
|
||||
add = (ele?: ExcalidrawElement) => {
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.excalidrawElements.set(ele.id, ele);
|
||||
};
|
||||
getElements = () => {
|
||||
return Array.from(this.excalidrawElements.values());
|
||||
};
|
||||
|
||||
getElement = (id: string) => {
|
||||
return this.excalidrawElements.get(id);
|
||||
};
|
||||
}
|
||||
|
||||
export const convertToExcalidrawElements = (
|
||||
elements: ExcalidrawElementSkeleton[] | null,
|
||||
) => {
|
||||
if (!elements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const elementStore = new ElementStore();
|
||||
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
||||
|
||||
// Create individual elements
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
const width =
|
||||
element?.label?.text && element.width === undefined
|
||||
? 0
|
||||
: element?.width || DEFAULT_DIMENSION;
|
||||
const height =
|
||||
element?.label?.text && element.height === undefined
|
||||
? 0
|
||||
: element?.height || DEFAULT_DIMENSION;
|
||||
excalidrawElement = newElement({
|
||||
...element,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "line": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "text": {
|
||||
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight =
|
||||
element?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const text = element.text ?? "";
|
||||
const normalizedText = normalizeText(text);
|
||||
const metrics = measureText(
|
||||
normalizedText,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
);
|
||||
|
||||
excalidrawElement = newTextElement({
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
excalidrawElement = newImageElement({
|
||||
width: element?.width || DEFAULT_DIMENSION,
|
||||
height: element?.height || DEFAULT_DIMENSION,
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "frame":
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
excalidrawElement = element;
|
||||
assertNever(
|
||||
element,
|
||||
`Unhandled element type "${(element as any).type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingElement = elementStore.getElement(excalidrawElement.id);
|
||||
if (existingElement) {
|
||||
console.error(`Duplicate id found for ${excalidrawElement.id}`);
|
||||
} else {
|
||||
elementStore.add(excalidrawElement);
|
||||
elementsWithIds.set(excalidrawElement.id, element);
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels and arrow bindings
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
const excalidrawElement = elementStore.getElement(id)!;
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond":
|
||||
case "arrow": {
|
||||
if (element.label?.text) {
|
||||
let [container, text] = bindTextToContainer(
|
||||
excalidrawElement,
|
||||
element?.label,
|
||||
);
|
||||
elementStore.add(container);
|
||||
elementStore.add(text);
|
||||
|
||||
if (container.type === "arrow") {
|
||||
const originalStart =
|
||||
element.type === "arrow" ? element?.start : undefined;
|
||||
const originalEnd =
|
||||
element.type === "arrow" ? element?.end : undefined;
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
container as ExcalidrawArrowElement,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
elementStore,
|
||||
);
|
||||
container = linearElement;
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
}
|
||||
} else {
|
||||
switch (element.type) {
|
||||
case "arrow": {
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
excalidrawElement as ExcalidrawArrowElement,
|
||||
element.start,
|
||||
element.end,
|
||||
elementStore,
|
||||
);
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return elementStore.getElements();
|
||||
};
|
@@ -1,9 +1,35 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (!link) {
|
||||
return link;
|
||||
}
|
||||
return sanitizeUrl(link);
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns URL sanitized and safe for usage in places such as
|
||||
* iframe's src attribute or <a> href attributes.
|
||||
*/
|
||||
export const toValidURL = (link: string) => {
|
||||
link = normalizeLink(link);
|
||||
|
||||
// make relative links into fully-qualified urls
|
||||
if (link.startsWith("/")) {
|
||||
return `${location.origin}${link}`;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(link);
|
||||
} catch {
|
||||
// if link does not parse as URL, assume invalid and return blank page
|
||||
return "about:blank";
|
||||
}
|
||||
|
||||
return link;
|
||||
};
|
||||
|
@@ -55,10 +55,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.d-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--remove .ToolIcon__icon svg {
|
||||
color: $oc-red-6;
|
||||
}
|
||||
|
@@ -5,8 +5,12 @@ import {
|
||||
viewportCoordsToSceneCoords,
|
||||
wrapEvent,
|
||||
} from "../utils";
|
||||
import { getEmbedLink, embeddableURLValidator } from "./embeddable";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import {
|
||||
ExcalidrawEmbeddableElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import { register } from "../actions/register";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@@ -33,7 +37,9 @@ import { isLocalLink, normalizeLink } from "../data/url";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useExcalidrawAppState } from "../components/App";
|
||||
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@@ -48,37 +54,112 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
||||
|
||||
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
||||
|
||||
const embeddableLinkCache = new Map<
|
||||
ExcalidrawEmbeddableElement["id"],
|
||||
string
|
||||
>();
|
||||
|
||||
export const Hyperlink = ({
|
||||
element,
|
||||
setAppState,
|
||||
onLinkOpen,
|
||||
setToast,
|
||||
}: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
||||
setToast: (
|
||||
toast: { message: string; closable?: boolean; duration?: number } | null,
|
||||
) => void;
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const appProps = useAppProps();
|
||||
|
||||
const linkVal = element.link || "";
|
||||
|
||||
const [inputVal, setInputVal] = useState(linkVal);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
|
||||
const isEditing = appState.showHyperlinkPopup === "editor";
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = normalizeLink(inputRef.current.value);
|
||||
const link = normalizeLink(inputRef.current.value) || null;
|
||||
|
||||
if (!element.link && link) {
|
||||
trackEvent("hyperlink", "create");
|
||||
}
|
||||
|
||||
mutateElement(element, { link });
|
||||
setAppState({ showHyperlinkPopup: "info" });
|
||||
}, [element, setAppState]);
|
||||
if (isEmbeddableElement(element)) {
|
||||
if (appState.activeEmbeddable?.element === element) {
|
||||
setAppState({ activeEmbeddable: null });
|
||||
}
|
||||
if (!link) {
|
||||
mutateElement(element, {
|
||||
validated: false,
|
||||
link: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!embeddableURLValidator(link, appProps.validateEmbeddable)) {
|
||||
if (link) {
|
||||
setToast({ message: t("toast.unableToEmbed"), closable: true });
|
||||
}
|
||||
element.link && embeddableLinkCache.set(element.id, element.link);
|
||||
mutateElement(element, {
|
||||
validated: false,
|
||||
link,
|
||||
});
|
||||
ShapeCache.delete(element);
|
||||
} else {
|
||||
const { width, height } = element;
|
||||
const embedLink = getEmbedLink(link);
|
||||
if (embedLink?.warning) {
|
||||
setToast({ message: embedLink.warning, closable: true });
|
||||
}
|
||||
const ar = embedLink
|
||||
? embedLink.aspectRatio.w / embedLink.aspectRatio.h
|
||||
: 1;
|
||||
const hasLinkChanged =
|
||||
embeddableLinkCache.get(element.id) !== element.link;
|
||||
mutateElement(element, {
|
||||
...(hasLinkChanged
|
||||
? {
|
||||
width:
|
||||
embedLink?.type === "video"
|
||||
? width > height
|
||||
? width
|
||||
: height * ar
|
||||
: width,
|
||||
height:
|
||||
embedLink?.type === "video"
|
||||
? width > height
|
||||
? width / ar
|
||||
: height
|
||||
: height,
|
||||
}
|
||||
: {}),
|
||||
validated: true,
|
||||
link,
|
||||
});
|
||||
ShapeCache.delete(element);
|
||||
if (embeddableLinkCache.has(element.id)) {
|
||||
embeddableLinkCache.delete(element.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mutateElement(element, { link });
|
||||
}
|
||||
}, [
|
||||
element,
|
||||
setToast,
|
||||
appProps.validateEmbeddable,
|
||||
appState.activeEmbeddable,
|
||||
setAppState,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
@@ -132,10 +213,12 @@ export const Hyperlink = ({
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
appState.isRotating ||
|
||||
appState.openMenu
|
||||
appState.openMenu ||
|
||||
appState.viewModeEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw-hyperlinkContainer"
|
||||
@@ -145,6 +228,11 @@ export const Hyperlink = ({
|
||||
width: CONTAINER_WIDTH,
|
||||
padding: CONTAINER_PADDING,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!element.link && !isEditing) {
|
||||
setAppState({ showHyperlinkPopup: "editor" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
@@ -162,15 +250,14 @@ export const Hyperlink = ({
|
||||
}
|
||||
if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
|
||||
handleSubmit();
|
||||
setAppState({ showHyperlinkPopup: "info" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : element.link ? (
|
||||
<a
|
||||
href={normalizeLink(element.link || "")}
|
||||
className={clsx("excalidraw-hyperlinkContainer-link", {
|
||||
"d-none": isEditing,
|
||||
})}
|
||||
className="excalidraw-hyperlinkContainer-link"
|
||||
target={isLocalLink(element.link) ? "_self" : "_blank"}
|
||||
onClick={(event) => {
|
||||
if (element.link && onLinkOpen) {
|
||||
@@ -194,6 +281,10 @@ export const Hyperlink = ({
|
||||
>
|
||||
{element.link}
|
||||
</a>
|
||||
) : (
|
||||
<div className="excalidraw-hyperlinkContainer-link">
|
||||
{t("labels.link.empty")}
|
||||
</div>
|
||||
)}
|
||||
<div className="excalidraw-hyperlinkContainer__buttons">
|
||||
{!isEditing && (
|
||||
@@ -207,8 +298,7 @@ export const Hyperlink = ({
|
||||
icon={FreedrawIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{linkVal && (
|
||||
{linkVal && !isEmbeddableElement(element) && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.remove")}
|
||||
@@ -271,7 +361,11 @@ export const actionLink = register({
|
||||
type="button"
|
||||
icon={LinkIcon}
|
||||
aria-label={t(getContextMenuLabel(elements, appState))}
|
||||
title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||
title={`${
|
||||
isEmbeddableElement(elements[0])
|
||||
? t("labels.link.labelEmbed")
|
||||
: t("labels.link.label")
|
||||
} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||
onClick={() => updateData(null)}
|
||||
selected={selectedElements.length === 1 && !!selectedElements[0].link}
|
||||
/>
|
||||
@@ -285,7 +379,11 @@ export const getContextMenuLabel = (
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const label = selectedElements[0]!.link
|
||||
? "labels.link.edit"
|
||||
? isEmbeddableElement(selectedElements[0])
|
||||
? "labels.link.editEmbed"
|
||||
: "labels.link.edit"
|
||||
: isEmbeddableElement(selectedElements[0])
|
||||
? "labels.link.createEmbed"
|
||||
: "labels.link.create";
|
||||
return label;
|
||||
};
|
||||
@@ -293,7 +391,7 @@ export const getContextMenuLabel = (
|
||||
export const getLinkHandleFromCoords = (
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
angle: number,
|
||||
appState: UIAppState,
|
||||
appState: Pick<UIAppState, "zoom">,
|
||||
): [x: number, y: number, width: number, height: number] => {
|
||||
const size = DEFAULT_LINK_SIZE;
|
||||
const linkWidth = size / appState.zoom.value;
|
||||
@@ -327,6 +425,26 @@ export const isPointHittingLinkIcon = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
[x, y]: Point,
|
||||
) => {
|
||||
const threshold = 4 / appState.zoom.value;
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
||||
[x1, y1, x2, y2],
|
||||
element.angle,
|
||||
appState,
|
||||
);
|
||||
const hitLink =
|
||||
x > linkX - threshold &&
|
||||
x < linkX + threshold + linkWidth &&
|
||||
y > linkY - threshold &&
|
||||
y < linkY + linkHeight + threshold;
|
||||
return hitLink;
|
||||
};
|
||||
|
||||
export const isPointHittingLink = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
[x, y]: Point,
|
||||
isMobile: boolean,
|
||||
) => {
|
||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||
@@ -340,19 +458,7 @@ export const isPointHittingLinkIcon = (
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
|
||||
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
||||
[x1, y1, x2, y2],
|
||||
element.angle,
|
||||
appState,
|
||||
);
|
||||
const hitLink =
|
||||
x > linkX - threshold &&
|
||||
x < linkX + threshold + linkWidth &&
|
||||
y > linkY - threshold &&
|
||||
y < linkY + linkHeight + threshold;
|
||||
return hitLink;
|
||||
return isPointHittingLinkIcon(element, appState, [x, y]);
|
||||
};
|
||||
|
||||
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
|
||||
|
@@ -27,7 +27,6 @@ import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { arrayToMap, tupleToCoors } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getContainingFrame, isPointInFrame } from "../frame";
|
||||
|
||||
export type SuggestedBinding =
|
||||
| NonDeleted<ExcalidrawBindableElement>
|
||||
@@ -191,7 +190,7 @@ export const maybeBindLinearElement = (
|
||||
}
|
||||
};
|
||||
|
||||
const bindLinearElement = (
|
||||
export const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
@@ -275,18 +274,6 @@ export const getHoveredElementForBinding = (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, pointerCoords),
|
||||
);
|
||||
|
||||
if (hoveredElement) {
|
||||
const frame = getContainingFrame(hoveredElement);
|
||||
|
||||
if (frame) {
|
||||
if (isPointInFrame(pointerCoords, frame)) {
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||
};
|
||||
|
||||
@@ -487,6 +474,7 @@ const maybeCalculateNewGapWhenScaling = (
|
||||
return { elementId, gap: newGap, focus };
|
||||
};
|
||||
|
||||
// TODO: this is a bottleneck, optimise
|
||||
export const getEligibleElementsForBinding = (
|
||||
elements: NonDeleted<ExcalidrawElement>[],
|
||||
): SuggestedBinding[] => {
|
||||
@@ -512,22 +500,10 @@ const getElligibleElementsForBindingElement = (
|
||||
return [
|
||||
getElligibleElementForBindingElement(linearElement, "start"),
|
||||
getElligibleElementForBindingElement(linearElement, "end"),
|
||||
].filter((element): element is NonDeleted<ExcalidrawBindableElement> => {
|
||||
if (element != null) {
|
||||
const frame = getContainingFrame(element);
|
||||
return frame
|
||||
? isPointInFrame(
|
||||
getLinearElementEdgeCoors(linearElement, "start"),
|
||||
frame,
|
||||
) ||
|
||||
isPointInFrame(
|
||||
getLinearElementEdgeCoors(linearElement, "end"),
|
||||
frame,
|
||||
)
|
||||
: true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
].filter(
|
||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||
element != null,
|
||||
);
|
||||
};
|
||||
|
||||
const getElligibleElementForBindingElement = (
|
||||
|
@@ -10,10 +10,7 @@ import { distance2d, rotate, rotatePoint } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
getShapeForElement,
|
||||
generateRoughOptions,
|
||||
} from "../renderer/renderElement";
|
||||
import { generateRoughOptions } from "../scene/Shape";
|
||||
import {
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
@@ -24,6 +21,7 @@ import { rescalePoints } from "../points";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
@@ -621,7 +619,7 @@ const getLinearElementRotatedBounds = (
|
||||
}
|
||||
|
||||
// first element is always the curve
|
||||
const cachedShape = getShapeForElement(element)?.[0];
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = (x: number, y: number) =>
|
||||
|
@@ -18,6 +18,7 @@ import {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawEllipseElement,
|
||||
@@ -38,13 +39,17 @@ import {
|
||||
import { FrameNameBoundsCache, Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isEmbeddableElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
import { shouldShowBoundingBox } from "./transformHandles";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@@ -57,7 +62,9 @@ const isElementDraggableFromInside = (
|
||||
return true;
|
||||
}
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) || hasBoundTextElement(element);
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
hasBoundTextElement(element) ||
|
||||
isEmbeddableElement(element);
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
@@ -248,6 +255,7 @@ type HitTestArgs = {
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
switch (args.element.type) {
|
||||
case "rectangle":
|
||||
case "embeddable":
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
@@ -306,6 +314,7 @@ export const distanceToBindableElement = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
@@ -337,6 +346,7 @@ const distanceToRectangle = (
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
@@ -479,7 +489,7 @@ const hitTestFreeDrawElement = (
|
||||
B = element.points[i + 1];
|
||||
}
|
||||
|
||||
const shape = getShapeForElement(element);
|
||||
const shape = ShapeCache.get(element);
|
||||
|
||||
// for filled freedraw shapes, support
|
||||
// selecting from inside
|
||||
@@ -492,7 +502,7 @@ const hitTestFreeDrawElement = (
|
||||
|
||||
const hitTestLinear = (args: HitTestArgs): boolean => {
|
||||
const { element, threshold } = args;
|
||||
if (!getShapeForElement(element)) {
|
||||
if (!ShapeCache.get(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -510,7 +520,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
|
||||
}
|
||||
const [relX, relY] = GAPoint.toTuple(point);
|
||||
|
||||
const shape = getShapeForElement(element as ExcalidrawLinearElement);
|
||||
const shape = ShapeCache.get(element as ExcalidrawLinearElement);
|
||||
|
||||
if (!shape) {
|
||||
return false;
|
||||
@@ -645,17 +655,23 @@ export const determineFocusDistance = (
|
||||
const c = line[1];
|
||||
const mabs = Math.abs(m);
|
||||
const nabs = Math.abs(n);
|
||||
let ret;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
ret = c / (hwidth * (nabs + q * mabs));
|
||||
break;
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
break;
|
||||
case "ellipse":
|
||||
return c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
|
||||
ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
|
||||
break;
|
||||
}
|
||||
return ret || 0;
|
||||
};
|
||||
|
||||
export const determineFocusPoint = (
|
||||
@@ -682,6 +698,7 @@ export const determineFocusPoint = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
@@ -733,6 +750,7 @@ const getSortedElementLineIntersections = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
@@ -768,6 +786,7 @@ const getCorners = (
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
@@ -777,6 +796,7 @@ const getCorners = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
@@ -926,6 +946,7 @@ export const findFocusPointForRectangulars = (
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
|
350
src/element/embeddable.ts
Normal file
350
src/element/embeddable.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { ExcalidrawProps } from "../types";
|
||||
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { newTextElement } from "./newElement";
|
||||
import { getContainerElement, wrapText } from "./textElement";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "./types";
|
||||
|
||||
type EmbeddedLink =
|
||||
| ({
|
||||
aspectRatio: { w: number; h: number };
|
||||
warning?: string;
|
||||
} & (
|
||||
| { type: "video" | "generic"; link: string }
|
||||
| { type: "document"; srcdoc: (theme: Theme) => string }
|
||||
))
|
||||
| null;
|
||||
|
||||
const embeddedLinkCache = new Map<string, EmbeddedLink>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
||||
|
||||
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
|
||||
const RE_GH_GIST_EMBED =
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
||||
|
||||
// not anchored to start to allow <blockquote> twitter embeds
|
||||
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
|
||||
const RE_TWITTER_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
|
||||
|
||||
const RE_VALTOWN =
|
||||
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||
|
||||
const RE_GENERIC_EMBED =
|
||||
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"figma.com",
|
||||
"link.excalidraw.com",
|
||||
"gist.github.com",
|
||||
"twitter.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
]);
|
||||
|
||||
const createSrcDoc = (body: string) => {
|
||||
return `<html><body>${body}</body></html>`;
|
||||
};
|
||||
|
||||
export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (embeddedLinkCache.has(link)) {
|
||||
return embeddedLinkCache.get(link)!;
|
||||
}
|
||||
|
||||
const originalLink = link;
|
||||
|
||||
let type: "video" | "generic" = "generic";
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
if (ytLink?.[2]) {
|
||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
||||
const isPortrait = link.includes("shorts");
|
||||
type = "video";
|
||||
switch (ytLink[1]) {
|
||||
case "embed/":
|
||||
case "watch?v=":
|
||||
case "shorts/":
|
||||
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
|
||||
break;
|
||||
case "playlist?list=":
|
||||
case "embed/videoseries?list=":
|
||||
link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
|
||||
break;
|
||||
default:
|
||||
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
|
||||
break;
|
||||
}
|
||||
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
}
|
||||
|
||||
const vimeoLink = link.match(RE_VIMEO);
|
||||
if (vimeoLink?.[1]) {
|
||||
const target = vimeoLink?.[1];
|
||||
const warning = !/^\d+$/.test(target)
|
||||
? t("toast.unrecognizedLinkFormat")
|
||||
: undefined;
|
||||
type = "video";
|
||||
link = `https://player.vimeo.com/video/${target}?api=1`;
|
||||
aspectRatio = { w: 560, h: 315 };
|
||||
//warning deliberately ommited so it is displayed only once per link
|
||||
//same link next time will be served from cache
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type, warning };
|
||||
}
|
||||
|
||||
const figmaLink = link.match(RE_FIGMA);
|
||||
if (figmaLink) {
|
||||
type = "generic";
|
||||
link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(
|
||||
link,
|
||||
)}`;
|
||||
aspectRatio = { w: 550, h: 550 };
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
}
|
||||
|
||||
const valLink = link.match(RE_VALTOWN);
|
||||
if (valLink) {
|
||||
link =
|
||||
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
}
|
||||
|
||||
if (RE_TWITTER.test(link)) {
|
||||
let ret: EmbeddedLink;
|
||||
// assume embed code
|
||||
if (/<blockquote/.test(link)) {
|
||||
const srcDoc = createSrcDoc(link);
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () => srcDoc,
|
||||
aspectRatio: { w: 480, h: 480 },
|
||||
};
|
||||
// assume regular tweet url
|
||||
} else {
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
||||
),
|
||||
aspectRatio: { w: 480, h: 480 },
|
||||
};
|
||||
}
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
let ret: EmbeddedLink;
|
||||
// assume embed code
|
||||
if (/<script>/.test(link)) {
|
||||
const srcDoc = createSrcDoc(link);
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () => srcDoc,
|
||||
aspectRatio: { w: 550, h: 720 },
|
||||
};
|
||||
// assume regular url
|
||||
} else {
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () =>
|
||||
createSrcDoc(`
|
||||
<script src="${link}.js"></script>
|
||||
<style type="text/css">
|
||||
* { margin: 0px; }
|
||||
table, .gist { height: 100%; }
|
||||
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
|
||||
</style>
|
||||
`),
|
||||
aspectRatio: { w: 550, h: 720 },
|
||||
};
|
||||
}
|
||||
embeddedLinkCache.set(link, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
embeddedLinkCache.set(link, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
};
|
||||
|
||||
export const isEmbeddableOrFrameLabel = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
): Boolean => {
|
||||
if (isEmbeddableElement(element)) {
|
||||
return true;
|
||||
}
|
||||
if (element.type === "text") {
|
||||
const container = getContainerElement(element);
|
||||
if (container && isEmbeddableElement(container)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const createPlaceholderEmbeddableLabel = (
|
||||
element: ExcalidrawEmbeddableElement,
|
||||
): ExcalidrawElement => {
|
||||
const text =
|
||||
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
|
||||
const fontSize = Math.max(
|
||||
Math.min(element.width / 2, element.width / text.length),
|
||||
element.width / 30,
|
||||
);
|
||||
const fontFamily = FONT_FAMILY.Helvetica;
|
||||
|
||||
const fontString = getFontString({
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
|
||||
return newTextElement({
|
||||
x: element.x + element.width / 2,
|
||||
y: element.y + element.height / 2,
|
||||
strokeColor:
|
||||
element.strokeColor !== "transparent" ? element.strokeColor : "black",
|
||||
backgroundColor: "transparent",
|
||||
fontFamily,
|
||||
fontSize,
|
||||
text: wrapText(text, fontString, element.width - 20),
|
||||
textAlign: "center",
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
angle: element.angle ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const actionSetEmbeddableAsActiveTool = register({
|
||||
name: "setEmbeddableAsActiveTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
|
||||
setCursorForShape(app.canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const validateHostname = (
|
||||
url: string,
|
||||
/** using a Set assumes it already contains normalized bare domains */
|
||||
allowedHostnames: Set<string> | string,
|
||||
): boolean => {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
const bareDomain = hostname.replace(/^www\./, "");
|
||||
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
||||
/^([^.]+)/,
|
||||
"*",
|
||||
);
|
||||
|
||||
if (allowedHostnames instanceof Set) {
|
||||
return (
|
||||
ALLOWED_DOMAINS.has(bareDomain) ||
|
||||
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
|
||||
);
|
||||
}
|
||||
|
||||
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const extractSrc = (htmlString: string): string => {
|
||||
const twitterMatch = htmlString.match(RE_TWITTER_EMBED);
|
||||
if (twitterMatch && twitterMatch.length === 2) {
|
||||
return twitterMatch[1];
|
||||
}
|
||||
|
||||
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
|
||||
if (gistMatch && gistMatch.length === 2) {
|
||||
return gistMatch[1];
|
||||
}
|
||||
|
||||
const match = htmlString.match(RE_GENERIC_EMBED);
|
||||
if (match && match.length === 2) {
|
||||
return match[1];
|
||||
}
|
||||
return htmlString;
|
||||
};
|
||||
|
||||
export const embeddableURLValidator = (
|
||||
url: string | null | undefined,
|
||||
validateEmbeddable: ExcalidrawProps["validateEmbeddable"],
|
||||
): boolean => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
if (validateEmbeddable != null) {
|
||||
if (typeof validateEmbeddable === "function") {
|
||||
const ret = validateEmbeddable(url);
|
||||
// if return value is undefined, leave validation to default
|
||||
if (typeof ret === "boolean") {
|
||||
return ret;
|
||||
}
|
||||
} else if (typeof validateEmbeddable === "boolean") {
|
||||
return validateEmbeddable;
|
||||
} else if (validateEmbeddable instanceof RegExp) {
|
||||
return validateEmbeddable.test(url);
|
||||
} else if (Array.isArray(validateEmbeddable)) {
|
||||
for (const domain of validateEmbeddable) {
|
||||
if (domain instanceof RegExp) {
|
||||
if (url.match(domain)) {
|
||||
return true;
|
||||
}
|
||||
} else if (validateHostname(url, domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return validateHostname(url, ALLOWED_DOMAINS);
|
||||
};
|
@@ -25,7 +25,12 @@ import {
|
||||
getElementPointsCoords,
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
} from "./bounds";
|
||||
import { Point, AppState, PointerCoords } from "../types";
|
||||
import {
|
||||
Point,
|
||||
AppState,
|
||||
PointerCoords,
|
||||
InteractiveCanvasAppState,
|
||||
} from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import History from "../history";
|
||||
|
||||
@@ -39,9 +44,9 @@ import { tupleToCoors } from "../utils";
|
||||
import { isBindingElement } from "./typeChecks";
|
||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { DRAGGING_THRESHOLD } from "../constants";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
@@ -264,11 +269,11 @@ export class LinearElementEditor {
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
@@ -398,7 +403,7 @@ export class LinearElementEditor {
|
||||
|
||||
static getEditorMidPoints = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
appState: InteractiveCanvasAppState,
|
||||
): typeof editorMidPointsCache["points"] => {
|
||||
const boundText = getBoundTextElement(element);
|
||||
|
||||
@@ -422,7 +427,7 @@ export class LinearElementEditor {
|
||||
|
||||
static updateEditorMidPointsCache = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
|
||||
@@ -1418,7 +1423,7 @@ export class LinearElementEditor {
|
||||
let y1;
|
||||
let x2;
|
||||
let y2;
|
||||
if (element.points.length < 2 || !getShapeForElement(element)) {
|
||||
if (element.points.length < 2 || !ShapeCache.get(element)) {
|
||||
// XXX this is just a poor estimate and not very useful
|
||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
||||
(limits, [x, y]) => {
|
||||
@@ -1437,7 +1442,7 @@ export class LinearElementEditor {
|
||||
x2 = maxX + element.x;
|
||||
y2 = maxY + element.y;
|
||||
} else {
|
||||
const shape = getShapeForElement(element)!;
|
||||
const shape = ShapeCache.generateElementShape(element);
|
||||
|
||||
// first element is always the curve
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import { ExcalidrawElement } from "./types";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import Scene from "../scene/Scene";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { Point } from "../types";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
@@ -89,7 +89,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
typeof fileId != "undefined" ||
|
||||
typeof points !== "undefined"
|
||||
) {
|
||||
invalidateShapeForElement(element);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
|
||||
element.version++;
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
arrayToMap,
|
||||
@@ -45,7 +46,7 @@ import {
|
||||
} from "../constants";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
| "width"
|
||||
| "height"
|
||||
@@ -130,6 +131,18 @@ export const newElement = (
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
validated: ExcalidrawEmbeddableElement["validated"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
|
||||
validated: opts.validated,
|
||||
};
|
||||
};
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
@@ -174,10 +187,9 @@ export const newTextElement = (
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
isFrameName?: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
@@ -212,7 +224,6 @@ export const newTextElement = (
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
isFrameName: opts.isFrameName || false,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -350,8 +361,8 @@ export const newFreeDrawElement = (
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
@@ -361,8 +372,8 @@ export const newLinearElement = (
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead,
|
||||
endArrowhead: opts.endArrowhead,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -432,7 +443,7 @@ const _deepCopyElement = (val: any, depth: number = 0) => {
|
||||
// we're not cloning non-array & non-plain-object objects because we
|
||||
// don't support them on excalidraw elements yet. If we do, we need to make
|
||||
// sure we start cloning them, so let's warn about it.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (import.meta.env.DEV) {
|
||||
if (
|
||||
objectType !== "[object Object]" &&
|
||||
objectType !== "[object Array]" &&
|
||||
@@ -466,7 +477,7 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
||||
* for test assertions.
|
||||
*/
|
||||
const regenerateId = (
|
||||
export const regenerateId = (
|
||||
/** supply null if no previous id exists */
|
||||
previousId: string | null,
|
||||
) => {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import {
|
||||
@@ -204,8 +204,6 @@ const rescalePointsInElement = (
|
||||
}
|
||||
: {};
|
||||
|
||||
const MIN_FONT_SIZE = 1;
|
||||
|
||||
const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
nextWidth: number,
|
||||
@@ -589,24 +587,42 @@ export const resizeSingleElement = (
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isArrowElement(element) &&
|
||||
boundTextElement &&
|
||||
shouldMaintainAspectRatio
|
||||
) {
|
||||
const fontSize =
|
||||
(resizedElement.width / element.width) * boundTextElement.fontSize;
|
||||
if (fontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
boundTextFont.fontSize = fontSize;
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
) {
|
||||
mutateElement(element, resizedElement);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
baseline: boundTextFont.baseline,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
handleBindTextResize(
|
||||
element,
|
||||
transformHandleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -722,12 +738,8 @@ export const resizeMultipleElements = (
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
baseline?: ExcalidrawTextElement["baseline"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
};
|
||||
boundText: {
|
||||
element: ExcalidrawTextElementWithContainer;
|
||||
fontSize: ExcalidrawTextElement["fontSize"];
|
||||
baseline: ExcalidrawTextElement["baseline"];
|
||||
} | null;
|
||||
}[] = [];
|
||||
|
||||
for (const { orig, latest } of targetElements) {
|
||||
@@ -798,50 +810,39 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
}
|
||||
|
||||
let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(latest);
|
||||
|
||||
if (boundTextElement || isTextElement(orig)) {
|
||||
const updatedElement = {
|
||||
...latest,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
boundTextElement
|
||||
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||
: updatedElement.height,
|
||||
);
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
const metrics = measureFontSizeFromWidth(orig, width, height);
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundText = {
|
||||
element: boundTextElement,
|
||||
fontSize: metrics.size,
|
||||
baseline: metrics.baseline,
|
||||
};
|
||||
}
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({ element: latest, update, boundText });
|
||||
const boundTextElement = pointerDownState.originalElements.get(
|
||||
getBoundTextElementId(orig) ?? "",
|
||||
) as ExcalidrawTextElementWithContainer | undefined;
|
||||
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
if (newFontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
update.boundTextFontSize = newFontSize;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({
|
||||
element: latest,
|
||||
update,
|
||||
});
|
||||
}
|
||||
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
|
||||
for (const { element, update, boundText } of elementsAndUpdates) {
|
||||
for (const {
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
mutateElement(element, update, false);
|
||||
@@ -851,17 +852,17 @@ export const resizeMultipleElements = (
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
if (boundText) {
|
||||
const { element: boundTextElement, ...boundTextUpdates } = boundText;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
mutateElement(
|
||||
boundTextElement,
|
||||
{
|
||||
...boundTextUpdates,
|
||||
fontSize: boundTextFontSize,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
handleBindTextResize(element, transformHandleType, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -7,11 +7,11 @@ export const showSelectedShapeActions = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) =>
|
||||
Boolean(
|
||||
(!appState.viewModeEnabled &&
|
||||
appState.activeTool.type !== "custom" &&
|
||||
(appState.editingElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand"))) ||
|
||||
getSelectedElements(elements, appState).length,
|
||||
!appState.viewModeEnabled &&
|
||||
((appState.activeTool.type !== "custom" &&
|
||||
(appState.editingElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand"))) ||
|
||||
getSelectedElements(elements, appState).length),
|
||||
);
|
||||
|
@@ -1,19 +1,32 @@
|
||||
import { vi } from "vitest";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import * as constants from "../constants";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
|
||||
// Needed so that we can mock the value of constants which is done in
|
||||
// below tests. In Jest this wasn't needed as global override was possible
|
||||
// but vite doesn't allow that hence we need to mock
|
||||
vi.mock(
|
||||
"../constants.ts",
|
||||
//@ts-ignore
|
||||
async (importOriginal) => {
|
||||
const module: any = await importOriginal();
|
||||
return { ...module };
|
||||
},
|
||||
);
|
||||
describe("getPerfectElementSize", () => {
|
||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||
@@ -24,16 +37,19 @@ describe("getPerfectElementSize", () => {
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user