mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
79 Commits
docs-next
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
e7349c1271 | |||
c641860cb1 | |||
84d89b9a8a | |||
e63dd025c9 | |||
15e019706d | |||
a133a70e87 | |||
80ea7ca23f | |||
e844580b14 | |||
5a0771ad9c | |||
adcdbe2907 | |||
230d0edc44 | |||
d0a380758e | |||
7b36de0476 | |||
2427e622b0 | |||
62228e0bbb | |||
4c5408263c | |||
bd7b778f41 | |||
43b2476dfe | |||
df8875a497 | |||
6fbc44fd1f | |||
d25a7d365b | |||
e52c2cd0b6 | |||
96eeec5119 | |||
f5221d521b | |||
db2c235cd4 | |||
148b895f46 | |||
d9258a736b | |||
2e1f08c796 | |||
1d5b41dabb | |||
66a2f24296 | |||
04668d8263 | |||
abbeed3d5f | |||
ba8c09d529 | |||
744b3e5d09 | |||
6ba9bd60e8 | |||
a1ffa064df | |||
4dc4590f24 | |||
d2f67e619f | |||
22b39277f5 | |||
63dee03ef0 | |||
08b13f971d | |||
69f4cc70cb | |||
860308eb27 | |||
4eb9463f26 | |||
6ed6131169 | |||
1ed98f9c93 | |||
a71bb63d1f | |||
661d6a4a75 | |||
defd34923a | |||
c540bd68aa | |||
eddbe55f50 | |||
2f9526da24 | |||
1b6e3fe05b | |||
afe52c89a7 | |||
be4e127f6c | |||
ff0b4394b1 | |||
7d8b7fc14d | |||
971b4d4ae6 | |||
cc4c51996c | |||
79257a1923 | |||
dc66261c19 | |||
273ba803d9 | |||
301e83805d | |||
ed5ce8d3de | |||
1ed53b153c | |||
c1926f33bb | |||
6539029d2a | |||
d1f37cc64f | |||
f0d25e34c3 | |||
d9bbf1eda6 | |||
f79fb9aae2 | |||
275f6fbe24 | |||
88812e0386 | |||
6e5aeb112d | |||
4d83d1c91e | |||
a04676d423 | |||
c851aaaf7b | |||
1bd2b1fe55 | |||
015b46ab23 |
@ -4,8 +4,15 @@
|
||||
!.eslintrc.json
|
||||
!.npmrc
|
||||
!.prettierrc
|
||||
!excalidraw-app/
|
||||
!package.json
|
||||
!public/
|
||||
!packages/
|
||||
!tsconfig.json
|
||||
!yarn.lock
|
||||
|
||||
# keep (sub)sub directories at the end to exclude from explicit included
|
||||
# e.g. ./packages/excalidraw/{dist,node_modules}
|
||||
**/build
|
||||
**/dist
|
||||
**/node_modules
|
||||
|
@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
VITE_APP_DISABLE_TRACKING=true
|
||||
VITE_APP_ENABLE_TRACKING=true
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
||||
|
@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
||||
|
||||
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"}'
|
||||
|
||||
VITE_APP_DISABLE_TRACKING=
|
||||
VITE_APP_ENABLE_TRACKING=false
|
||||
|
@ -2,6 +2,7 @@
|
||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||
"rules": {
|
||||
"import/no-anonymous-default-export": "off",
|
||||
"no-restricted-globals": "off"
|
||||
"no-restricted-globals": "off",
|
||||
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
|
||||
}
|
||||
}
|
||||
|
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@ -1,14 +1,17 @@
|
||||
name: Tests
|
||||
|
||||
on: pull_request
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Install and test
|
||||
|
12
Dockerfile
12
Dockerfile
@ -2,16 +2,18 @@ FROM node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn --ignore-optional --network-timeout 600000
|
||||
COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN yarn --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
COPY . .
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM nginx:1.21-alpine
|
||||
FROM nginx:1.24-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
||||
|
@ -8,15 +8,15 @@
|
||||
import { FONT_FAMILY } from "@excalidraw/excalidraw";
|
||||
```
|
||||
|
||||
`FONT_FAMILY` contains all the font families used in `Excalidraw`. The default families are the following:
|
||||
`FONT_FAMILY` contains all the font families used in `Excalidraw` as explained below
|
||||
|
||||
| Font Family | Description |
|
||||
| ----------- | ---------------------- |
|
||||
| `Excalifont` | The `Hand-drawn` font |
|
||||
| `Nunito` | The `Normal` Font |
|
||||
| `Comic Shanns` | The `Code` Font |
|
||||
| `Virgil` | The `handwritten` font |
|
||||
| `Helvetica` | The `Normal` Font |
|
||||
| `Cascadia` | The `Code` Font |
|
||||
|
||||
Pre-selected family is `FONT_FAMILY.Excalifont`, unless it's overriden with `initialData.appState.currentItemFontFamily`.
|
||||
Defaults to `FONT_FAMILY.Virgil` unless passed in `initialData.appState.currentItemFontFamily`.
|
||||
|
||||
### THEME
|
||||
|
||||
|
@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
|
||||
```jsx showLineNumbers
|
||||
export default function App() {
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
|
||||
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
|
||||
return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
|
||||
}
|
||||
```
|
||||
|
||||
@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
|
||||
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
|
||||
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
|
||||
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
|
||||
| `storeAction` | [`StoreAction`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/store.ts#L40) | Parameter to control which updates should be captured by the `Store`. Captured updates are emmitted as increments and listened to by other components, such as `History` for undo / redo purposes. |
|
||||
| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
|
||||
|
||||
```jsx live
|
||||
function App() {
|
||||
@ -105,7 +105,6 @@ function App() {
|
||||
appState: {
|
||||
viewBackgroundColor: "#edf2ff",
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
excalidrawAPI.updateScene(sceneData);
|
||||
};
|
||||
@ -116,25 +115,12 @@ function App() {
|
||||
<button className="custom-button" onClick={updateScene}>
|
||||
Update Scene
|
||||
</button>
|
||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
||||
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### storeAction
|
||||
|
||||
You can use the `storeAction` to influence undo / redo behaviour.
|
||||
|
||||
> **NOTE**: Some updates are not observed by the store / history - i.e. updates to `collaborators` object or parts of `AppState` which are not observed (not `ObservedAppState`). Such updates will never make it to the undo / redo stacks, regardless of the passed `storeAction` value.
|
||||
|
||||
| | `storeAction` value | Notes |
|
||||
| --- | --- | --- |
|
||||
| _Immediately undoable_ | `StoreAction.CAPTURE` | Use for updates which should be captured. Should be used for most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||
| _Eventually undoable_ | `StoreAction.NONE` | Use for updates which should not be captured immediately - likely exceptions which are part of some async multi-step process. Otherwise, all such updates would end up being captured with the next `StoreAction.CAPTURE` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||
| _Never undoable_ | `StoreAction.UPDATE` | Use for updates which should never be recorded, such as remote updates or scene initialization. These updates will _never_ make it to the local undo / redo stacks. |
|
||||
|
||||
|
||||
### updateLibrary
|
||||
|
||||
<pre>
|
||||
@ -202,7 +188,7 @@ function App() {
|
||||
Update Library
|
||||
</button>
|
||||
<Excalidraw
|
||||
ref={(api) => setExcalidrawAPI(api)}
|
||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js
|
||||
initialData={{
|
||||
libraryItems: initialData.libraryItems,
|
||||
|
@ -90,7 +90,7 @@ function App() {
|
||||
<img src={canvasUrl} alt="" />
|
||||
</div>
|
||||
<div style={{ height: "400px" }}>
|
||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
|
||||
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
@ -31,7 +31,7 @@ You can pass `null` / `undefined` if not applicable.
|
||||
restoreElements(
|
||||
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>
|
||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>
|
||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean, normalizeIndices?: boolean }<br/>
|
||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
||||
)
|
||||
</pre>
|
||||
|
||||
@ -51,9 +51,8 @@ The extra optional parameter to configure restored elements. It has the followin
|
||||
|
||||
| Prop | Type | Description|
|
||||
| --- | --- | ------|
|
||||
| `refreshDimensions` | `boolean` | Indicates whether we should also _recalculate_ text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
|
||||
| `repairBindings` |`boolean` | Indicates whether the _bindings_ for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
|
||||
| `normalizeIndices` |`boolean` | Indicates whether _fractional indices_ for the elements should be normalized. This is to prevent possible issues caused by using stale (too old, too long) indices. |
|
||||
| `refreshDimensions` | `boolean` | Indicates whether we should also `recalculate` text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
|
||||
| `repairBindings` |`boolean` | Indicates whether the `bindings` for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
|
||||
|
||||
**_How to use_**
|
||||
|
||||
@ -74,7 +73,7 @@ restore(
|
||||
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState</a>,<br/>
|
||||
localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null | undefined,<br/>
|
||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L4">DataState</a><br/>
|
||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean, normalizeIndices?: boolean }<br/>
|
||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
||||
|
||||
)
|
||||
</pre>
|
||||
|
@ -24,7 +24,7 @@ yarn add react react-dom @excalidraw/excalidraw
|
||||
|
||||
Excalidraw depends on assets such as localization files (if you opt to use them), fonts, and others.
|
||||
|
||||
By default these assets are loaded from a public CDN [`https://unpkg.com/@excalidraw/excalidraw/dist/prod/`](https://unpkg.com/@excalidraw/excalidraw/dist/prod/), so you don't need to do anything on your end.
|
||||
By default these assets are loaded from a public CDN [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist), so you don't need to do anything on your end.
|
||||
|
||||
However, if you want to host these files yourself, you can find them in your `node_modules/@excalidraw/excalidraw/dist` directory, in folders `excalidraw-assets` (for production) and `excalidraw-assets-dev` (for development).
|
||||
|
||||
@ -34,26 +34,6 @@ Copy these folders to your static assets directory, and add a `window.EXCALIDRAW
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
```
|
||||
|
||||
or, if you serve your assets from the root of your CDN, you would do:
|
||||
|
||||
```js
|
||||
// Vanilla
|
||||
<head>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "https://my.cdn.com/assets/";
|
||||
</script>
|
||||
</head>
|
||||
```
|
||||
|
||||
or, if you prefer the path to be dynamicly set based on the `location.origin`, you could do the following:
|
||||
|
||||
```jsx
|
||||
// Next.js
|
||||
<Script id="load-env-variables" strategy="beforeInteractive" >
|
||||
{ `window["EXCALIDRAW_ASSET_PATH"] = location.origin;` } // or use just "/"!
|
||||
</Script>
|
||||
```
|
||||
|
||||
### Dimensions of Excalidraw
|
||||
|
||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||
|
@ -23,8 +23,8 @@
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-sass": "0.2.3",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"sass": "1.57.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -59,7 +59,7 @@ pre a {
|
||||
padding: 5px;
|
||||
background: #70b1ec;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
@ -12,9 +12,9 @@ import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import type { ResolvablePromise } from "../utils";
|
||||
import {
|
||||
resolvablePromise,
|
||||
ResolvablePromise,
|
||||
distance2d,
|
||||
fileOpen,
|
||||
withBatchedUpdates,
|
||||
@ -872,7 +872,7 @@ export default function App({
|
||||
files: excalidrawAPI.getFiles(),
|
||||
});
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "30px Virgil";
|
||||
ctx.font = "30px Excalifont";
|
||||
ctx.strokeText("My custom text", 50, 60);
|
||||
setCanvasUrl(canvas.toDataURL());
|
||||
}}
|
||||
@ -893,7 +893,7 @@ export default function App({
|
||||
files: excalidrawAPI.getFiles(),
|
||||
});
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = "30px Virgil";
|
||||
ctx.font = "30px Excalifont";
|
||||
ctx.strokeText("My custom text", 50, 60);
|
||||
setCanvasUrl(canvas.toDataURL());
|
||||
}}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||
import CustomFooter from "./CustomFooter";
|
||||
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||
|
||||
|
@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
|
||||
];
|
||||
export default {
|
||||
elements,
|
||||
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
|
||||
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
|
||||
scrollToContent: true,
|
||||
libraryItems: [
|
||||
[
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import { fileOpen as _fileOpen } from "browser-fs-access";
|
||||
import type { MIME_TYPES } from "@excalidraw/excalidraw";
|
||||
import { MIME_TYPES } from "@excalidraw/excalidraw";
|
||||
import { AbortError } from "../../packages/excalidraw/errors";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
3
examples/excalidraw/with-nextjs/.gitignore
vendored
3
examples/excalidraw/with-nextjs/.gitignore
vendored
@ -34,3 +34,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# copied assets
|
||||
public/*.woff2
|
@ -3,7 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
"start": "next start -p 3006",
|
||||
@ -12,13 +13,13 @@
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"next": "14.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"path2d-polyfill": "2.0.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import Script from "next/script";
|
||||
import "../common.scss";
|
||||
|
||||
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
||||
@ -15,7 +16,9 @@ export default function Page() {
|
||||
<>
|
||||
<a href="/excalidraw-in-pages">Switch to Pages router</a>
|
||||
<h1 className="page-title">App Router</h1>
|
||||
|
||||
<Script id="load-env-variables" strategy="beforeInteractive">
|
||||
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
|
||||
</Script>
|
||||
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
|
||||
<ExcalidrawWithClientOnly />
|
||||
</>
|
||||
|
@ -7,7 +7,7 @@ a {
|
||||
color: #1c7ed6;
|
||||
font-size: 20px;
|
||||
text-decoration: none;
|
||||
font-weight: 550;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
|
2
examples/excalidraw/with-script-in-browser/.gitignore
vendored
Normal file
2
examples/excalidraw/with-script-in-browser/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# copied assets
|
||||
public/*.woff2
|
@ -11,6 +11,7 @@
|
||||
<title>React App</title>
|
||||
<script>
|
||||
window.name = "codesandbox";
|
||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||
</script>
|
||||
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
||||
</head>
|
||||
|
@ -12,8 +12,10 @@
|
||||
"typescript": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
|
||||
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||
"start": "yarn build:workspace && vite",
|
||||
"build": "yarn build:workspace && vite build",
|
||||
"build:preview": "yarn build && vite preview --port 5002"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import polyfill from "../packages/excalidraw/polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||
@ -13,7 +12,7 @@ import {
|
||||
VERSION_TIMEOUT,
|
||||
} from "../packages/excalidraw/constants";
|
||||
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
||||
import {
|
||||
import type {
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
@ -22,25 +21,26 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
} from "../packages/excalidraw/index";
|
||||
import {
|
||||
StoreAction,
|
||||
reconcileElements,
|
||||
} from "../packages/excalidraw";
|
||||
import type {
|
||||
AppState,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
} from "../packages/excalidraw/types";
|
||||
import type { ResolvablePromise } from "../packages/excalidraw/utils";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
getFrame,
|
||||
isTestEnv,
|
||||
preventUnload,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
} from "../packages/excalidraw/utils";
|
||||
@ -50,8 +50,8 @@ import {
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
import type { CollabAPI } from "./collab/Collab";
|
||||
import Collab, {
|
||||
CollabAPI,
|
||||
collabAPIAtom,
|
||||
isCollaboratingAtom,
|
||||
isOfflineAtom,
|
||||
@ -67,11 +67,8 @@ import {
|
||||
importUsernameFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import {
|
||||
restore,
|
||||
restoreAppState,
|
||||
RestoredDataState,
|
||||
} from "../packages/excalidraw/data/restore";
|
||||
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
|
||||
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
|
||||
import {
|
||||
ExportToExcalidrawPlus,
|
||||
exportToExcalidrawPlus,
|
||||
@ -94,22 +91,19 @@ import {
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||
import { appJotaiStore } from "./app-jotai";
|
||||
|
||||
import "./index.scss";
|
||||
import { ResolutionType } from "../packages/excalidraw/utility-types";
|
||||
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
||||
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../packages/excalidraw/components/Trans";
|
||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||
import {
|
||||
RemoteExcalidrawElement,
|
||||
reconcileElements,
|
||||
} from "../packages/excalidraw/data/reconcile";
|
||||
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
|
||||
import {
|
||||
CommandPalette,
|
||||
DEFAULT_CATEGORIES,
|
||||
@ -125,11 +119,45 @@ import {
|
||||
youtubeIcon,
|
||||
} from "../packages/excalidraw/components/icons";
|
||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||
import { useAppLangCode } from "./app-language/language-state";
|
||||
|
||||
polyfill();
|
||||
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
declare global {
|
||||
interface BeforeInstallPromptEventChoiceResult {
|
||||
outcome: "accepted" | "dismissed";
|
||||
}
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
|
||||
}
|
||||
|
||||
interface WindowEventMap {
|
||||
beforeinstallprompt: BeforeInstallPromptEvent;
|
||||
}
|
||||
}
|
||||
|
||||
let pwaEvent: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
// Adding a listener outside of the component as it may (?) need to be
|
||||
// subscribed early to catch the event.
|
||||
//
|
||||
// Also note that it will fire only if certain heuristics are met (user has
|
||||
// used the app for some time, etc.)
|
||||
window.addEventListener(
|
||||
"beforeinstallprompt",
|
||||
(event: BeforeInstallPromptEvent) => {
|
||||
// prevent Chrome <= 67 from automatically showing the prompt
|
||||
event.preventDefault();
|
||||
// cache for later use
|
||||
pwaEvent = event;
|
||||
},
|
||||
);
|
||||
|
||||
let isSelfEmbedding = false;
|
||||
|
||||
if (window.self !== window.top) {
|
||||
@ -144,11 +172,6 @@ if (window.self !== window.top) {
|
||||
}
|
||||
}
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
});
|
||||
|
||||
const shareableLinkConfirmDialog = {
|
||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||
description: (
|
||||
@ -294,19 +317,15 @@ const initializeScene = async (opts: {
|
||||
return { scene: null, isExternalScene: false };
|
||||
};
|
||||
|
||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
||||
export const appLangCodeAtom = atom(
|
||||
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
||||
);
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||
const { editorTheme } = useHandleAppTheme();
|
||||
|
||||
const [langCode, setLangCode] = useAppLangCode();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -438,7 +457,7 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
commitToStore: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -462,13 +481,10 @@ const ExcalidrawWrapper = () => {
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
const username = importUsernameFromLocalStorage();
|
||||
let langCode = languageDetector.detect() || defaultLang.code;
|
||||
if (Array.isArray(langCode)) {
|
||||
langCode = langCode[0];
|
||||
}
|
||||
setLangCode(langCode);
|
||||
setLangCode(getPreferredLanguage());
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
LibraryIndexedDBAdapter.load().then((data) => {
|
||||
if (data) {
|
||||
@ -566,10 +582,6 @@ const ExcalidrawWrapper = () => {
|
||||
};
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
const onChange = (
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
@ -604,6 +616,7 @@ const ExcalidrawWrapper = () => {
|
||||
if (didChange) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1102,6 +1115,21 @@ const ExcalidrawWrapper = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.installPWA"),
|
||||
category: DEFAULT_CATEGORIES.app,
|
||||
predicate: () => !!pwaEvent,
|
||||
perform: () => {
|
||||
if (pwaEvent) {
|
||||
pwaEvent.prompt();
|
||||
pwaEvent.userChoice.then(() => {
|
||||
// event cannot be reused, but we'll hopefully
|
||||
// grab new one as the event should be fired again
|
||||
pwaEvent = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Excalidraw>
|
||||
|
@ -7,8 +7,8 @@ import {
|
||||
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||
import { UIAppState } from "../packages/excalidraw/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||
import type { UIAppState } from "../packages/excalidraw/types";
|
||||
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { appLangCodeAtom } from "../App";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { languages } from "../../packages/excalidraw/i18n";
|
||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||
import { appLangCodeAtom } from "./language-state";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const { t, langCode } = useI18n();
|
25
excalidraw-app/app-language/language-detector.ts
Normal file
25
excalidraw-app/app-language/language-detector.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { defaultLang, languages } from "../../packages/excalidraw";
|
||||
|
||||
export const languageDetector = new LanguageDetector();
|
||||
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
});
|
||||
|
||||
export const getPreferredLanguage = () => {
|
||||
const detectedLanguages = languageDetector.detect();
|
||||
|
||||
const detectedLanguage = Array.isArray(detectedLanguages)
|
||||
? detectedLanguages[0]
|
||||
: detectedLanguages;
|
||||
|
||||
const initialLanguage =
|
||||
(detectedLanguage
|
||||
? // region code may not be defined if user uses generic preferred language
|
||||
// (e.g. chinese vs instead of chinese-simplified)
|
||||
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
|
||||
: null) || defaultLang.code;
|
||||
|
||||
return initialLanguage;
|
||||
};
|
15
excalidraw-app/app-language/language-state.ts
Normal file
15
excalidraw-app/app-language/language-state.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||
|
||||
export const appLangCodeAtom = atom(getPreferredLanguage());
|
||||
|
||||
export const useAppLangCode = () => {
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
return [langCode, setLangCode] as const;
|
||||
};
|
@ -1,23 +1,25 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawImperativeAPI,
|
||||
SocketId,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import {
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
StoreAction,
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
zoomToFitBounds,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||
reconcileElements,
|
||||
} from "../../packages/excalidraw";
|
||||
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
assertNever,
|
||||
preventUnload,
|
||||
@ -34,12 +36,14 @@ import {
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
WS_EVENTS,
|
||||
} from "../app_constants";
|
||||
import type {
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} from "../data";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getSyncableElements,
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} from "../data";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@ -75,14 +79,13 @@ import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom } from "jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||
import {
|
||||
import type {
|
||||
ReconciledExcalidrawElement,
|
||||
RemoteExcalidrawElement,
|
||||
reconcileElements,
|
||||
} from "../../packages/excalidraw/data/reconcile";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
@ -356,6 +359,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -506,6 +510,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
// to database even if deleted before creating the room.
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||
@ -743,6 +748,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
this.loadImageFiles();
|
||||
|
@ -1,15 +1,15 @@
|
||||
import {
|
||||
isSyncableElement,
|
||||
import type {
|
||||
SocketUpdateData,
|
||||
SocketUpdateDataSource,
|
||||
SyncableExcalidrawElement,
|
||||
} from "../data";
|
||||
import { isSyncableElement } from "../data";
|
||||
|
||||
import { TCollabClass } from "./Collab";
|
||||
import type { TCollabClass } from "./Collab";
|
||||
|
||||
import { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import type { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||
import {
|
||||
import type {
|
||||
OnUserFollowedPayload,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
@ -19,6 +19,7 @@ import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { StoreAction } from "../../packages/excalidraw";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
@ -127,6 +128,7 @@ class Portal {
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import {
|
||||
arrowBarToLeftIcon,
|
||||
loginIcon,
|
||||
ExcalLogo,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { Theme } from "../../packages/excalidraw/element/types";
|
||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
import { LanguageList } from "../app-language/LanguageList";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
@ -34,7 +34,7 @@ export const AppMainMenu: React.FC<{
|
||||
<MainMenu.ItemLink
|
||||
icon={ExcalLogo}
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_APP
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||
className=""
|
||||
>
|
||||
@ -42,7 +42,7 @@ export const AppMainMenu: React.FC<{
|
||||
</MainMenu.ItemLink>
|
||||
<MainMenu.DefaultItems.Socials />
|
||||
<MainMenu.ItemLink
|
||||
icon={arrowBarToLeftIcon}
|
||||
icon={loginIcon}
|
||||
href={`${import.meta.env.VITE_APP_PLUS_APP}${
|
||||
isExcalidrawPlusSignedUser ? "" : "/sign-up"
|
||||
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { arrowBarToLeftIcon } from "../../packages/excalidraw/components/icons";
|
||||
import { loginIcon } from "../../packages/excalidraw/components/icons";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
@ -61,7 +61,7 @@ export const AppWelcomeScreen: React.FC<{
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
|
||||
shortcut={null}
|
||||
icon={arrowBarToLeftIcon}
|
||||
icon={loginIcon}
|
||||
>
|
||||
Sign up
|
||||
</WelcomeScreen.Center.MenuItemLink>
|
||||
|
@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card";
|
||||
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||
import {
|
||||
import type {
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../packages/excalidraw/constants";
|
||||
import { Theme } from "../../packages/excalidraw/element/types";
|
||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { StoreAction } from "../../packages/excalidraw";
|
||||
import { compressData } from "../../packages/excalidraw/data/encode";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
import type {
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
ExcalidrawImperativeAPI,
|
||||
@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
};
|
||||
|
@ -20,19 +20,19 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||
import { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { MaybePromise } from "../../packages/excalidraw/utility-types";
|
||||
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
|
||||
import { debounce } from "../../packages/excalidraw/utils";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
import { FileManager } from "./FileManager";
|
||||
|
@ -1,12 +1,13 @@
|
||||
import {
|
||||
import { reconcileElements } from "../../packages/excalidraw";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { getSceneVersion } from "../../packages/excalidraw/element";
|
||||
import Portal from "../collab/Portal";
|
||||
import type Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../packages/excalidraw/data/restore";
|
||||
import {
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
@ -19,13 +20,11 @@ import {
|
||||
decryptData,
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { SyncableExcalidrawElement } from ".";
|
||||
import { getSyncableElements } from ".";
|
||||
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import {
|
||||
RemoteExcalidrawElement,
|
||||
reconcileElements,
|
||||
} from "../../packages/excalidraw/data/reconcile";
|
||||
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@ -9,30 +9,30 @@ import {
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||
import { restore } from "../../packages/excalidraw/data/restore";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { MakeBrand } from "../../packages/excalidraw/utility-types";
|
||||
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
|
||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
||||
import type { WS_SUBTYPES } from "../app_constants";
|
||||
import {
|
||||
DELETED_ELEMENT_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
ROOM_ID_BYTES,
|
||||
WS_SUBTYPES,
|
||||
} from "../app_constants";
|
||||
import { encodeFilesForUpload } from "./FileManager";
|
||||
import { saveFilesToFirebase } from "./firebase";
|
||||
@ -269,7 +269,6 @@ export const loadScene = async (
|
||||
// in the scene database/localStorage, and instead fetch them async
|
||||
// from a different database
|
||||
files: data.files,
|
||||
commitToStore: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import type { AppState } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
|
@ -20,7 +20,7 @@
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
|
||||
<meta name="image" content="https://excalidraw.com/og-image-3.png" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:site_name" content="Excalidraw" />
|
||||
@ -35,7 +35,7 @@
|
||||
property="og:description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
|
||||
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
@ -51,7 +51,7 @@
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://excalidraw.com/og-twitter-v2.png"
|
||||
content="https://excalidraw.com/og-image-3.png"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
@ -95,6 +95,11 @@
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Warmup the connection for Google fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
<!------------------------------------------------------------------------->
|
||||
<% if (typeof PROD != 'undefined' && PROD == true) { %>
|
||||
<script>
|
||||
@ -115,8 +120,55 @@
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Following placeholder is replaced during the build step -->
|
||||
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
||||
|
||||
<% } else { %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||
</script>
|
||||
|
||||
<!-- in DEV we need to preload from the local server and without the hash -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<% } %>
|
||||
|
||||
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="../packages/excalidraw/fonts/assets/fonts.css"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
@ -124,22 +176,6 @@
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Virgil.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Cascadia.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
||||
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
||||
<script>
|
||||
@ -158,7 +194,6 @@
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
|
@ -25,6 +25,7 @@
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 0.6em;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
@ -40,6 +41,10 @@
|
||||
}
|
||||
&.highlighted {
|
||||
color: var(--color-promo);
|
||||
font-weight: 700;
|
||||
.dropdown-menu-item__icon g {
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,17 +26,28 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"vite-plugin-html": "3.2.2"
|
||||
"firebase": "8.3.3",
|
||||
"idb-keyval": "6.0.3",
|
||||
"jotai": "1.13.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"vite-plugin-html": "3.2.2",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"socket.io-client": "4.7.2"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"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:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"start:production": "yarn build && yarn serve",
|
||||
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "yarn build && vite preview --port 5000"
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,8 @@ import {
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
|
||||
import type { CollabAPI } from "../collab/Collab";
|
||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
|
@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
class="welcome-screen-center"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-center__logo virgil welcome-screen-decor"
|
||||
class="welcome-screen-center__logo excalifont welcome-screen-decor"
|
||||
>
|
||||
<div
|
||||
class="ExcalidrawLogo is-small"
|
||||
@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-center__heading welcome-screen-decor virgil"
|
||||
class="welcome-screen-center__heading welcome-screen-decor excalifont"
|
||||
>
|
||||
All your data is saved locally in your browser.
|
||||
</div>
|
||||
@ -216,23 +216,22 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M10 12l10 0"
|
||||
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
|
||||
/>
|
||||
<path
|
||||
d="M10 12l4 4"
|
||||
d="M21 12h-13l3 -3"
|
||||
/>
|
||||
<path
|
||||
d="M10 12l4 -4"
|
||||
/>
|
||||
<path
|
||||
d="M4 4l0 16"
|
||||
d="M11 15l-3 -3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
createRedoAction,
|
||||
createUndoAction,
|
||||
} from "../../packages/excalidraw/actions/actionHistory";
|
||||
import { newElementWith } from "../../packages/excalidraw";
|
||||
import { StoreAction, newElementWith } from "../../packages/excalidraw";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@ -90,7 +90,7 @@ describe("collaboration", () => {
|
||||
|
||||
updateSceneData({
|
||||
elements: syncInvalidIndices([rect1, rect2]),
|
||||
commitToStore: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
|
||||
updateSceneData({
|
||||
@ -98,7 +98,7 @@ describe("collaboration", () => {
|
||||
rect1,
|
||||
newElementWith(h.elements[1], { isDeleted: true }),
|
||||
]),
|
||||
commitToStore: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@ -145,6 +145,7 @@ describe("collaboration", () => {
|
||||
// simulate force deleting the element remotely
|
||||
updateSceneData({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@ -182,7 +183,7 @@ describe("collaboration", () => {
|
||||
h.elements[0],
|
||||
newElementWith(h.elements[1], { x: 100 }),
|
||||
]),
|
||||
commitToStore: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@ -217,6 +218,7 @@ describe("collaboration", () => {
|
||||
// simulate force deleting the element remotely
|
||||
updateSceneData({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
// snapshot was correctly updated and marked the element as deleted
|
||||
|
@ -2,7 +2,7 @@ import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { THEME } from "../packages/excalidraw";
|
||||
import { EVENT } from "../packages/excalidraw/constants";
|
||||
import { Theme } from "../packages/excalidraw/element/types";
|
||||
import type { Theme } from "../packages/excalidraw/element/types";
|
||||
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
||||
import { STORAGE_KEYS } from "./app_constants";
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
|
||||
|
||||
// To load .env.local variables
|
||||
const envVars = loadEnv("", `../`);
|
||||
@ -22,6 +23,14 @@ export default defineConfig({
|
||||
outDir: "build",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames(chunkInfo) {
|
||||
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||
// put on root so we are flexible about the CDN path
|
||||
return '[name]-[hash][extname]';
|
||||
}
|
||||
|
||||
return 'assets/[name]-[hash][extname]';
|
||||
},
|
||||
// Creating separate chunk for locales except for en and percentages.json so they
|
||||
// can be cached at runtime and not merged with
|
||||
// app precache. en.json and percentages.json are needed for first load
|
||||
@ -35,12 +44,13 @@ export default defineConfig({
|
||||
// Taking the substring after "locales/"
|
||||
return `locales/${id.substring(index + 8)}`;
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
woff2BrowserPlugin(),
|
||||
react(),
|
||||
checker({
|
||||
typescript: true,
|
||||
|
29
package.json
29
package.json
@ -1,6 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "excalidraw-monorepo",
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"workspaces": [
|
||||
"excalidraw-app",
|
||||
"packages/excalidraw",
|
||||
@ -8,26 +9,15 @@
|
||||
"examples/excalidraw",
|
||||
"examples/excalidraw/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"idb-keyval": "6.0.3",
|
||||
"jotai": "1.13.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"socket.io-client": "4.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
|
||||
"@excalidraw/eslint-config": "1.0.3",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
@ -50,7 +40,7 @@
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
"vite-plugin-pwa": "0.17.4",
|
||||
"vite-plugin-svgr": "2.4.0",
|
||||
"vitest": "1.0.1",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
@ -60,9 +50,9 @@
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"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:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||
"build": "yarn --cwd ./excalidraw-app build",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
@ -87,5 +77,8 @@
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"build:preview": "yarn build && vite preview --port 5000",
|
||||
"release:excalidraw": "node scripts/release.js"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.0"
|
||||
}
|
||||
}
|
||||
|
@ -15,8 +15,12 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### Features
|
||||
|
||||
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||
|
||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||
|
||||
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
|
||||
|
||||
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||
|
||||
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
@ -29,17 +33,19 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||
|
||||
- Extended `window.EXCALIDRAW_ASSET_PATH` to accept array of paths `string[]` as a value, allowing to specify multiple base `URL` fallbacks. [#8286](https://github.com/excalidraw/excalidraw/pull/8286)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
|
||||
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
|
||||
|
||||
### Breaking Changes
|
||||
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
|
||||
|
||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||
|
||||
|
@ -20,7 +20,7 @@ After installation you will see a folder `excalidraw-assets` and `excalidraw-ass
|
||||
|
||||
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
|
||||
|
||||
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/prod/`](https://unpkg.com/@excalidraw/excalidraw/dist/prod/)
|
||||
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist)
|
||||
|
||||
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import type { Alignment } from "../align";
|
||||
import { alignElements } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
@ -10,13 +11,13 @@ import {
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
@ -23,14 +23,14 @@ import {
|
||||
isTextBindableContainer,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import type { AppState } from "../types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { arrayToMap, getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
@ -142,6 +142,7 @@ export const actionBindText = register({
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
@ -296,6 +297,7 @@ export const actionWrapTextInContainer = register({
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
@ -18,13 +18,13 @@ import {
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import type { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
@ -35,7 +35,7 @@ import {
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { SceneBounds } from "../element/bounds";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
@ -104,7 +104,7 @@ export const actionClearCanvas = register({
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
stats: appState.stats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
|
@ -4,20 +4,26 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
} from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
@ -29,6 +35,26 @@ const deleteSelectedElements = (
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
if (el.boundElements) {
|
||||
el.boundElements.forEach((candidate) => {
|
||||
const bound = app.scene
|
||||
.getNonDeletedElementsMap()
|
||||
.get(candidate.id);
|
||||
if (bound && isElbowArrow(bound)) {
|
||||
mutateElement(bound, {
|
||||
startBinding:
|
||||
el.id === bound.startBinding?.elementId
|
||||
? null
|
||||
: bound.startBinding,
|
||||
endBinding:
|
||||
el.id === bound.endBinding?.elementId
|
||||
? null
|
||||
: bound.endBinding,
|
||||
});
|
||||
mutateElbowArrow(bound, app.scene, bound.points);
|
||||
}
|
||||
});
|
||||
}
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
@ -130,7 +156,11 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
selectedPointsIndices,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
@ -149,7 +179,7 @@ export const actionDeleteSelected = register({
|
||||
};
|
||||
}
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState);
|
||||
deleteSelectedElements(elements, appState, app);
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
|
@ -3,16 +3,17 @@ import {
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import type { Distribution } from "../distribute";
|
||||
import { distributeElements } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@ -12,9 +12,9 @@ import {
|
||||
getSelectedGroupForElement,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import { ActionResult } from "./types";
|
||||
import type { ActionResult } from "./types";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
@ -40,12 +40,11 @@ export const actionDuplicateSelection = register({
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
if (!ret) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
|
@ -16,7 +16,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
import type { Theme } from "../element/types";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
import { StoreAction } from "../store";
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
@ -38,6 +38,7 @@ export const actionFinalize = register({
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
return {
|
||||
@ -131,7 +132,13 @@ export const actionFinalize = register({
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
{ x, y },
|
||||
elementsMap,
|
||||
elements,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,24 +1,24 @@
|
||||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { resizeMultipleElements } from "../element/resizeElements";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import {
|
||||
bindOrUnbindSelectedElements,
|
||||
bindOrUnbindLinearElements,
|
||||
isBindingEnabled,
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
@ -89,7 +89,6 @@ const flipSelectedElements = (
|
||||
|
||||
const updatedElements = flipElements(
|
||||
selectedElements,
|
||||
elements,
|
||||
elementsMap,
|
||||
appState,
|
||||
flipDirection,
|
||||
@ -105,7 +104,6 @@ const flipSelectedElements = (
|
||||
|
||||
const flipElements = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
@ -119,13 +117,20 @@ const flipElements = (
|
||||
elementsMap,
|
||||
"nw",
|
||||
true,
|
||||
true,
|
||||
flipDirection === "horizontal" ? maxX : minX,
|
||||
flipDirection === "horizontal" ? minY : maxY,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
isBindingEnabled(appState)
|
||||
? bindOrUnbindSelectedElements(selectedElements, app)
|
||||
: unbindLinearElements(selectedElements, elementsMap);
|
||||
bindOrUnbindLinearElements(
|
||||
selectedElements.filter(isLinearElement),
|
||||
elementsMap,
|
||||
app.scene.getNonDeletedElements(),
|
||||
app.scene,
|
||||
isBindingEnabled(appState),
|
||||
[],
|
||||
);
|
||||
|
||||
return selectedElements;
|
||||
};
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameChildren } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { register } from "./register";
|
||||
|
@ -17,12 +17,12 @@ import {
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { Action, ActionResult } from "./types";
|
||||
import type { Action, ActionResult } from "./types";
|
||||
import { UndoIcon, RedoIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { History, HistoryChangedEvent } from "../history";
|
||||
import { AppState } from "../types";
|
||||
import type { History } from "../history";
|
||||
import { HistoryChangedEvent } from "../history";
|
||||
import type { AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
import { SceneElementsMap } from "../element/types";
|
||||
import { IStore, StoreAction } from "../store";
|
||||
import type { SceneElementsMap } from "../element/types";
|
||||
import type { Store } from "../store";
|
||||
import { StoreAction } from "../store";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
|
||||
const writeData = (
|
||||
@ -40,7 +42,7 @@ const writeData = (
|
||||
return { storeAction: StoreAction.NONE };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History, store: IStore) => Action;
|
||||
type ActionCreator = (history: History, store: Store) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
name: "undo",
|
||||
@ -48,12 +50,13 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
icon: UndoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, value, app) =>
|
||||
writeData(appState, () =>
|
||||
history.undo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
app.scene,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@ -63,7 +66,10 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(),
|
||||
new HistoryChangedEvent(
|
||||
history.isUndoStackEmpty,
|
||||
history.isRedoStackEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
@ -74,6 +80,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isUndoStackEmpty}
|
||||
data-testid="button-undo"
|
||||
/>
|
||||
);
|
||||
},
|
||||
@ -85,12 +92,13 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
icon: RedoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
writeData(appState, () =>
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
app.scene,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@ -101,7 +109,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(),
|
||||
new HistoryChangedEvent(
|
||||
history.isUndoStackEmpty,
|
||||
history.isRedoStackEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
@ -112,6 +123,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isRedoStackEmpty}
|
||||
data-testid="button-redo"
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawLinearElement } from "../element/types";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { lineEditorIcon } from "../components/icons";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
name: "toggleLinearEditor",
|
||||
@ -11,18 +14,24 @@ export const actionToggleLinearEditor = register({
|
||||
label: (elements, appState, app) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement?.id
|
||||
? "labels.lineEditor.exit"
|
||||
})[0] as ExcalidrawLinearElement | undefined;
|
||||
|
||||
return selectedElement?.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
: "labels.lineEditor.edit";
|
||||
},
|
||||
keywords: ["line"],
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -45,4 +54,24 @@ export const actionToggleLinearEditor = register({
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const label = t(
|
||||
selectedElement.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
: "labels.lineEditor.edit",
|
||||
);
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={lineEditorIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import type { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import {
|
||||
eyeIcon,
|
||||
microphoneIcon,
|
||||
@ -8,7 +8,7 @@ import {
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { StoreAction } from "../store";
|
||||
import { Collaborator } from "../types";
|
||||
import type { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import clsx from "clsx";
|
||||
|
||||
|
@ -155,13 +155,15 @@ describe("element locking", () => {
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
fontFamily: FONT_FAMILY.Cascadia,
|
||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||
});
|
||||
h.elements = [rect, text];
|
||||
API.setSelectedElements([rect, text]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||
"active",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
|
||||
import type { StoreActionType } from "../store";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
||||
// ArrowHead icons
|
||||
@ -38,9 +41,6 @@ import {
|
||||
FontSizeExtraLargeIcon,
|
||||
EdgeSharpIcon,
|
||||
EdgeRoundIcon,
|
||||
FreedrawIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyCodeIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignRightIcon,
|
||||
@ -50,8 +50,12 @@ import {
|
||||
ArrowheadDiamondIcon,
|
||||
ArrowheadDiamondOutlineIcon,
|
||||
fontSizeIcon,
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
ARROW_TYPE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
@ -65,17 +69,17 @@ import {
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
Arrowhead,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
@ -94,9 +98,23 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import {
|
||||
arrayToMap,
|
||||
getFontFamilyString,
|
||||
getShortcutKey,
|
||||
tupleToCoors,
|
||||
} from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "../store";
|
||||
import { Fonts, getLineHeight } from "../fonts";
|
||||
import {
|
||||
bindLinearElement,
|
||||
bindPointToSnapToElementOutline,
|
||||
calculateFixedPointForElbowArrowBinding,
|
||||
getHoveredElementForBinding,
|
||||
} from "../element/binding";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@ -167,7 +185,7 @@ const offsetElementAfterFontResize = (
|
||||
prevElement: ExcalidrawTextElement,
|
||||
nextElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
if (isBoundToContainer(nextElement)) {
|
||||
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
|
||||
return nextElement;
|
||||
}
|
||||
return mutateElement(
|
||||
@ -729,104 +747,391 @@ export const actionIncreaseFontSize = register({
|
||||
},
|
||||
});
|
||||
|
||||
type ChangeFontFamilyData = Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
|
||||
>
|
||||
> & {
|
||||
/** cache of selected & editing elements populated on opened popup */
|
||||
cachedElements?: Map<string, ExcalidrawElement>;
|
||||
/** flag to reset all elements to their cached versions */
|
||||
resetAll?: true;
|
||||
/** flag to reset all containers to their cached versions */
|
||||
resetContainers?: true;
|
||||
};
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
label: "labels.fontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
|
||||
value as ChangeFontFamilyData;
|
||||
|
||||
if (resetAll) {
|
||||
const nextElements = changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(oldElement) => {
|
||||
if (isTextElement(oldElement)) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: value,
|
||||
lineHeight: getDefaultLineHeight(value),
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
(element) => {
|
||||
const cachedElement = cachedElements?.get(element.id);
|
||||
if (cachedElement) {
|
||||
const newElement = newElementWith(element, {
|
||||
...cachedElement,
|
||||
});
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
return oldElement;
|
||||
return element;
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...appState,
|
||||
...nextAppState,
|
||||
},
|
||||
storeAction: StoreAction.UPDATE,
|
||||
};
|
||||
}
|
||||
|
||||
const { currentItemFontFamily, currentHoveredFontFamily } = value;
|
||||
|
||||
let nexStoreAction: StoreActionType = StoreAction.NONE;
|
||||
let nextFontFamily: FontFamilyValues | undefined;
|
||||
let skipOnHoverRender = false;
|
||||
|
||||
if (currentItemFontFamily) {
|
||||
nextFontFamily = currentItemFontFamily;
|
||||
nexStoreAction = StoreAction.CAPTURE;
|
||||
} else if (currentHoveredFontFamily) {
|
||||
nextFontFamily = currentHoveredFontFamily;
|
||||
nexStoreAction = StoreAction.NONE;
|
||||
|
||||
const selectedTextElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
}).filter((element) => isTextElement(element));
|
||||
|
||||
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
|
||||
if (selectedTextElements.length > 200) {
|
||||
skipOnHoverRender = true;
|
||||
} else {
|
||||
let i = 0;
|
||||
let textLengthAccumulator = 0;
|
||||
|
||||
while (
|
||||
i < selectedTextElements.length &&
|
||||
textLengthAccumulator < 5000
|
||||
) {
|
||||
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
|
||||
textLengthAccumulator += textElement?.originalText.length || 0;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (textLengthAccumulator > 5000) {
|
||||
skipOnHoverRender = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemFontFamily: value,
|
||||
...nextAppState,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: nexStoreAction,
|
||||
};
|
||||
|
||||
if (nextFontFamily && !skipOnHoverRender) {
|
||||
const elementContainerMapping = new Map<
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawElement | null
|
||||
>();
|
||||
let uniqueGlyphs = new Set<string>();
|
||||
let skipFontFaceCheck = false;
|
||||
|
||||
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
||||
const fontFamily = Object.entries(FONT_FAMILY).find(
|
||||
([_, value]) => value === nextFontFamily,
|
||||
)?.[0];
|
||||
|
||||
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
|
||||
if (
|
||||
currentHoveredFontFamily &&
|
||||
fontFamily &&
|
||||
fontsCache.some((sig) => sig.startsWith(fontFamily))
|
||||
) {
|
||||
skipFontFaceCheck = true;
|
||||
}
|
||||
|
||||
// following causes re-render so make sure we changed the family
|
||||
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
|
||||
Object.assign(result, {
|
||||
elements: changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(oldElement) => {
|
||||
if (
|
||||
isTextElement(oldElement) &&
|
||||
(oldElement.fontFamily !== nextFontFamily ||
|
||||
currentItemFontFamily) // force update on selection
|
||||
) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: nextFontFamily,
|
||||
lineHeight: getLineHeight(nextFontFamily!),
|
||||
},
|
||||
);
|
||||
|
||||
const cachedContainer =
|
||||
cachedElements?.get(oldElement.containerId || "") || {};
|
||||
|
||||
const container = app.scene.getContainerElement(oldElement);
|
||||
|
||||
if (resetContainers && container && cachedContainer) {
|
||||
// reset the container back to it's cached version
|
||||
mutateElement(container, { ...cachedContainer }, false);
|
||||
}
|
||||
|
||||
if (!skipFontFaceCheck) {
|
||||
uniqueGlyphs = new Set([
|
||||
...uniqueGlyphs,
|
||||
...Array.from(newElement.originalText),
|
||||
]);
|
||||
}
|
||||
|
||||
elementContainerMapping.set(newElement, container);
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
return oldElement;
|
||||
},
|
||||
true,
|
||||
),
|
||||
});
|
||||
|
||||
// size is irrelevant, but necessary
|
||||
const fontString = `10px ${getFontFamilyString({
|
||||
fontFamily: nextFontFamily,
|
||||
})}`;
|
||||
const glyphs = Array.from(uniqueGlyphs.values()).join();
|
||||
|
||||
if (
|
||||
skipFontFaceCheck ||
|
||||
window.document.fonts.check(fontString, glyphs)
|
||||
) {
|
||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// trigger synchronous redraw
|
||||
redrawTextBoundingBox(
|
||||
element,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||
const latestElement = app.scene.getElement(element.id);
|
||||
const latestContainer = container
|
||||
? app.scene.getElement(container.id)
|
||||
: null;
|
||||
|
||||
if (latestElement) {
|
||||
// trigger async redraw
|
||||
redrawTextBoundingBox(
|
||||
latestElement as ExcalidrawTextElement,
|
||||
latestContainer,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// trigger update once we've mutated all the elements, which also updates our cache
|
||||
app.fonts.onLoaded(fontFaces);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const options: {
|
||||
value: FontFamilyValues;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
testId: string;
|
||||
}[] = [
|
||||
{
|
||||
value: FONT_FAMILY.Virgil,
|
||||
text: t("labels.handDrawn"),
|
||||
icon: FreedrawIcon,
|
||||
testId: "font-family-virgil",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Helvetica,
|
||||
text: t("labels.normal"),
|
||||
icon: FontFamilyNormalIcon,
|
||||
testId: "font-family-normal",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Cascadia,
|
||||
text: t("labels.code"),
|
||||
icon: FontFamilyCodeIcon,
|
||||
testId: "font-family-code",
|
||||
},
|
||||
];
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
|
||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
|
||||
const isUnmounted = useRef(true);
|
||||
|
||||
const selectedFontFamily = useMemo(() => {
|
||||
const getFontFamily = (
|
||||
elementsArray: readonly ExcalidrawElement[],
|
||||
elementsMap: Map<string, ExcalidrawElement>,
|
||||
) =>
|
||||
getFormValue(
|
||||
elementsArray,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontFamily;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) =>
|
||||
isTextElement(element) ||
|
||||
getBoundTextElement(element, elementsMap) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
);
|
||||
|
||||
// popup opened, use cached elements
|
||||
if (
|
||||
batchedData.openPopup === "fontFamily" &&
|
||||
appState.openPopup === "fontFamily"
|
||||
) {
|
||||
return getFontFamily(
|
||||
Array.from(cachedElementsRef.current?.values() ?? []),
|
||||
cachedElementsRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
// popup closed, use all elements
|
||||
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
|
||||
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
|
||||
}
|
||||
|
||||
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
|
||||
return prevSelectedFontFamilyRef.current;
|
||||
}, [batchedData.openPopup, appState, elements, app.scene]);
|
||||
|
||||
useEffect(() => {
|
||||
prevSelectedFontFamilyRef.current = selectedFontFamily;
|
||||
}, [selectedFontFamily]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(batchedData).length) {
|
||||
updateData(batchedData);
|
||||
// reset the data after we've used the data
|
||||
setBatchedData({});
|
||||
}
|
||||
// call update only on internal state changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchedData]);
|
||||
|
||||
useEffect(() => {
|
||||
isUnmounted.current = false;
|
||||
|
||||
return () => {
|
||||
isUnmounted.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
<FontPicker
|
||||
isOpened={appState.openPopup === "fontFamily"}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||
onSelect={(fontFamily) => {
|
||||
setBatchedData({
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
currentItemFontFamily: fontFamily,
|
||||
});
|
||||
|
||||
// defensive clear so immediate close won't abuse the cached elements
|
||||
cachedElementsRef.current.clear();
|
||||
}}
|
||||
onHover={(fontFamily) => {
|
||||
setBatchedData({
|
||||
currentHoveredFontFamily: fontFamily,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetContainers: true,
|
||||
});
|
||||
}}
|
||||
onLeave={() => {
|
||||
setBatchedData({
|
||||
currentHoveredFontFamily: null,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetAll: true,
|
||||
});
|
||||
}}
|
||||
onPopupChange={(open) => {
|
||||
if (open) {
|
||||
// open, populate the cache from scratch
|
||||
cachedElementsRef.current.clear();
|
||||
|
||||
const { editingElement } = appState;
|
||||
|
||||
if (editingElement?.type === "text") {
|
||||
// retrieve the latest version from the scene, as `editingElement` isn't mutated
|
||||
const latestEditingElement = app.scene.getElement(
|
||||
editingElement.id,
|
||||
);
|
||||
|
||||
// inside the wysiwyg editor
|
||||
cachedElementsRef.current.set(
|
||||
editingElement.id,
|
||||
newElementWith(
|
||||
latestEditingElement || editingElement,
|
||||
{},
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const selectedElements = getSelectedElements(
|
||||
elements,
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
|
||||
for (const element of selectedElements) {
|
||||
cachedElementsRef.current.set(
|
||||
element.id,
|
||||
newElementWith(element, {}, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontFamily;
|
||||
|
||||
setBatchedData({
|
||||
openPopup: "fontFamily",
|
||||
});
|
||||
} else {
|
||||
// close, use the cache and clear it afterwards
|
||||
const data = {
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetAll: true,
|
||||
} as ChangeFontFamilyData;
|
||||
|
||||
if (isUnmounted.current) {
|
||||
// in case the component was unmounted by the parent, trigger the update directly
|
||||
updateData({ ...batchedData, ...data });
|
||||
} else {
|
||||
setBatchedData(data);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) =>
|
||||
isTextElement(element) ||
|
||||
getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
|
||||
cachedElementsRef.current.clear();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
@ -1019,8 +1324,12 @@ export const actionChangeRoundness = register({
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (isElbowArrow(el)) {
|
||||
return el;
|
||||
}
|
||||
|
||||
return newElementWith(el, {
|
||||
roundness:
|
||||
value === "round"
|
||||
? {
|
||||
@ -1029,8 +1338,8 @@ export const actionChangeRoundness = register({
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemRoundness: value,
|
||||
@ -1070,7 +1379,8 @@ export const actionChangeRoundness = register({
|
||||
appState,
|
||||
(element) =>
|
||||
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
||||
(element) => element.hasOwnProperty("roundness"),
|
||||
(element) =>
|
||||
!isArrowElement(element) && element.hasOwnProperty("roundness"),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemRoundness,
|
||||
)}
|
||||
@ -1233,3 +1543,219 @@ export const actionChangeArrowhead = register({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeArrowType = register({
|
||||
name: "changeArrowType",
|
||||
label: "Change arrow types",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (!isArrowElement(el)) {
|
||||
return el;
|
||||
}
|
||||
const newElement = newElementWith(el, {
|
||||
roundness:
|
||||
value === ARROW_TYPE.round
|
||||
? {
|
||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
elbowed: value === ARROW_TYPE.elbow,
|
||||
points:
|
||||
value === ARROW_TYPE.elbow || el.elbowed
|
||||
? [el.points[0], el.points[el.points.length - 1]]
|
||||
: el.points,
|
||||
});
|
||||
|
||||
if (isElbowArrow(newElement)) {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
app.dismissLinearEditor();
|
||||
|
||||
const startGlobalPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
newElement,
|
||||
0,
|
||||
elementsMap,
|
||||
);
|
||||
const endGlobalPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
newElement,
|
||||
-1,
|
||||
elementsMap,
|
||||
);
|
||||
const startHoveredElement =
|
||||
!newElement.startBinding &&
|
||||
getHoveredElementForBinding(
|
||||
tupleToCoors(startGlobalPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
const endHoveredElement =
|
||||
!newElement.endBinding &&
|
||||
getHoveredElementForBinding(
|
||||
tupleToCoors(endGlobalPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
const startElement = startHoveredElement
|
||||
? startHoveredElement
|
||||
: newElement.startBinding &&
|
||||
(elementsMap.get(
|
||||
newElement.startBinding.elementId,
|
||||
) as ExcalidrawBindableElement);
|
||||
const endElement = endHoveredElement
|
||||
? endHoveredElement
|
||||
: newElement.endBinding &&
|
||||
(elementsMap.get(
|
||||
newElement.endBinding.elementId,
|
||||
) as ExcalidrawBindableElement);
|
||||
|
||||
const finalStartPoint = startHoveredElement
|
||||
? bindPointToSnapToElementOutline(
|
||||
startGlobalPoint,
|
||||
endGlobalPoint,
|
||||
startHoveredElement,
|
||||
elementsMap,
|
||||
)
|
||||
: startGlobalPoint;
|
||||
const finalEndPoint = endHoveredElement
|
||||
? bindPointToSnapToElementOutline(
|
||||
endGlobalPoint,
|
||||
startGlobalPoint,
|
||||
endHoveredElement,
|
||||
elementsMap,
|
||||
)
|
||||
: endGlobalPoint;
|
||||
|
||||
startHoveredElement &&
|
||||
bindLinearElement(
|
||||
newElement,
|
||||
startHoveredElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
);
|
||||
endHoveredElement &&
|
||||
bindLinearElement(
|
||||
newElement,
|
||||
endHoveredElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
mutateElbowArrow(
|
||||
newElement,
|
||||
app.scene,
|
||||
[finalStartPoint, finalEndPoint].map(
|
||||
(point) =>
|
||||
[point[0] - newElement.x, point[1] - newElement.y] as Point,
|
||||
),
|
||||
[0, 0],
|
||||
{
|
||||
...(startElement && newElement.startBinding
|
||||
? {
|
||||
startBinding: {
|
||||
// @ts-ignore TS cannot discern check above
|
||||
...newElement.startBinding!,
|
||||
...calculateFixedPointForElbowArrowBinding(
|
||||
newElement,
|
||||
startElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(endElement && newElement.endBinding
|
||||
? {
|
||||
endBinding: {
|
||||
// @ts-ignore TS cannot discern check above
|
||||
...newElement.endBinding,
|
||||
...calculateFixedPointForElbowArrowBinding(
|
||||
newElement,
|
||||
endElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
startBinding: newElement.startBinding
|
||||
? { ...newElement.startBinding, fixedPoint: null }
|
||||
: null,
|
||||
endBinding: newElement.endBinding
|
||||
? { ...newElement.endBinding, fixedPoint: null }
|
||||
: null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemArrowType: value,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.arrowtypes")}</legend>
|
||||
<ButtonIconSelect
|
||||
group="arrowtypes"
|
||||
options={[
|
||||
{
|
||||
value: ARROW_TYPE.sharp,
|
||||
text: t("labels.arrowtype_sharp"),
|
||||
icon: sharpArrowIcon,
|
||||
testId: "sharp-arrow",
|
||||
},
|
||||
{
|
||||
value: ARROW_TYPE.round,
|
||||
text: t("labels.arrowtype_round"),
|
||||
icon: roundArrowIcon,
|
||||
testId: "round-arrow",
|
||||
},
|
||||
{
|
||||
value: ARROW_TYPE.elbow,
|
||||
text: t("labels.arrowtype_elbowed"),
|
||||
icon: elbowArrowIcon,
|
||||
testId: "elbow-arrow",
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isArrowElement(element)) {
|
||||
return element.elbowed
|
||||
? ARROW_TYPE.elbow
|
||||
: element.roundness
|
||||
? ARROW_TYPE.round
|
||||
: ARROW_TYPE.sharp;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
(element) => isArrowElement(element),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemArrowType,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
|
@ -12,10 +12,7 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
@ -24,9 +21,10 @@ import {
|
||||
isArrowElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { paintIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@ -122,7 +120,7 @@ export const actionPasteStyles = register({
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
getLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
|
48
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
48
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { isTextElement } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties } from "../types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionTextAutoResize = register({
|
||||
name: "autoResize",
|
||||
label: "labels.autoResize",
|
||||
icon: null,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 1 &&
|
||||
isTextElement(selectedElements[0]) &&
|
||||
!selectedElements[0].autoResize
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
return {
|
||||
appState,
|
||||
elements: elements.map((element) => {
|
||||
if (element.id === selectedElements[0].id && isTextElement(element)) {
|
||||
const metrics = measureText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
autoResize: true,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
text: element.originalText,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
@ -1,7 +1,7 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { gridIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
|
@ -5,21 +5,22 @@ import { StoreAction } from "../store";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
label: "stats.title",
|
||||
label: "stats.fullTitle",
|
||||
icon: abacusIcon,
|
||||
paletteName: "Toggle stats",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
keywords: ["edit", "attributes", "customize"],
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showStats: !this.checked!(appState),
|
||||
stats: { ...appState.stats, open: !this.checked!(appState) },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
checked: (appState) => appState.stats.open,
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
||||
|
@ -20,6 +20,7 @@ import { StoreAction } from "../store";
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
label: "labels.sendBackward",
|
||||
keywords: ["move down", "zindex", "layer"],
|
||||
icon: SendBackwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
@ -49,6 +50,7 @@ export const actionSendBackward = register({
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
label: "labels.bringForward",
|
||||
keywords: ["move up", "zindex", "layer"],
|
||||
icon: BringForwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
@ -78,6 +80,7 @@ export const actionBringForward = register({
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
label: "labels.sendToBack",
|
||||
keywords: ["move down", "zindex", "layer"],
|
||||
icon: SendToBackIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
@ -114,6 +117,7 @@ export const actionSendToBack = register({
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
label: "labels.bringToFront",
|
||||
keywords: ["move up", "zindex", "layer"],
|
||||
icon: BringToFrontIcon,
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import {
|
||||
import type {
|
||||
Action,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
@ -7,8 +7,11 @@ import {
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { isPromiseLike } from "../utils";
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Action } from "./types";
|
||||
import type { Action } from "./types";
|
||||
|
||||
export let actions: readonly Action[] = [];
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { isDarwin } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { SubtypeOf } from "../utility-types";
|
||||
import type { SubtypeOf } from "../utility-types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { ActionName } from "./types";
|
||||
import type { ActionName } from "./types";
|
||||
|
||||
export type ShortcutName =
|
||||
| SubtypeOf<
|
||||
|
@ -1,14 +1,17 @@
|
||||
import React from "react";
|
||||
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
import type React from "react";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import { StoreAction } from "../store";
|
||||
import type { MarkOptional } from "../utility-types";
|
||||
import type { StoreActionType } from "../store";
|
||||
|
||||
export type ActionSource =
|
||||
| "ui"
|
||||
@ -26,7 +29,7 @@ export type ActionResult =
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
storeAction: keyof typeof StoreAction;
|
||||
storeAction: StoreActionType;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
@ -67,6 +70,7 @@ export type ActionName =
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeArrowType"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
@ -131,7 +135,9 @@ export type ActionName =
|
||||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette";
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||
import type { BoundingBox } from "./element/bounds";
|
||||
import { getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
export interface Alignment {
|
||||
|
@ -1,6 +1,6 @@
|
||||
// place here categories that you want to track. We want to track just a
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
@ -9,17 +9,20 @@ export const trackEvent = (
|
||||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// prettier-ignore
|
||||
if (
|
||||
typeof window === "undefined"
|
||||
|| import.meta.env.VITE_WORKER_ID
|
||||
// comment out to debug locally
|
||||
|| import.meta.env.PROD
|
||||
typeof window === "undefined" ||
|
||||
import.meta.env.VITE_WORKER_ID ||
|
||||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// comment out to debug in dev
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
import { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import { AppState } from "./types";
|
||||
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
import type { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import type { AppState } from "./types";
|
||||
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
|
||||
import type App from "./components/App";
|
||||
import { SVG_NS } from "./constants";
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
ARROW_TYPE,
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
EXPORT_SCALES,
|
||||
STATS_PANELS,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { AppState, NormalizedZoomValue } from "./types";
|
||||
import type { AppState, NormalizedZoomValue } from "./types";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
@ -32,9 +34,11 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemRoundness: "round",
|
||||
currentItemArrowType: ARROW_TYPE.round,
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
currentHoveredFontFamily: null,
|
||||
cursorButton: "up",
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
@ -80,7 +84,10 @@ export const getDefaultAppState = (): Omit<
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showStats: false,
|
||||
stats: {
|
||||
open: false,
|
||||
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||
},
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
@ -138,6 +145,11 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemArrowType: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemOpacity: { browser: true, export: false, server: false },
|
||||
currentItemRoughness: { browser: true, export: false, server: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
@ -145,6 +157,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
currentHoveredFontFamily: { browser: false, 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 },
|
||||
@ -196,7 +209,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
},
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
stats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
|
105
packages/excalidraw/binaryheap.ts
Normal file
105
packages/excalidraw/binaryheap.ts
Normal file
@ -0,0 +1,105 @@
|
||||
export default class BinaryHeap<T> {
|
||||
private content: T[] = [];
|
||||
|
||||
constructor(private scoreFunction: (node: T) => number) {}
|
||||
|
||||
sinkDown(idx: number) {
|
||||
const node = this.content[idx];
|
||||
while (idx > 0) {
|
||||
const parentN = ((idx + 1) >> 1) - 1;
|
||||
const parent = this.content[parentN];
|
||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
||||
this.content[parentN] = node;
|
||||
this.content[idx] = parent;
|
||||
idx = parentN; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bubbleUp(idx: number) {
|
||||
const length = this.content.length;
|
||||
const node = this.content[idx];
|
||||
const score = this.scoreFunction(node);
|
||||
|
||||
while (true) {
|
||||
const child2N = (idx + 1) << 1;
|
||||
const child1N = child2N - 1;
|
||||
let swap = null;
|
||||
let child1Score = 0;
|
||||
|
||||
if (child1N < length) {
|
||||
const child1 = this.content[child1N];
|
||||
child1Score = this.scoreFunction(child1);
|
||||
if (child1Score < score) {
|
||||
swap = child1N;
|
||||
}
|
||||
}
|
||||
|
||||
if (child2N < length) {
|
||||
const child2 = this.content[child2N];
|
||||
const child2Score = this.scoreFunction(child2);
|
||||
if (child2Score < (swap === null ? score : child1Score)) {
|
||||
swap = child2N;
|
||||
}
|
||||
}
|
||||
|
||||
if (swap !== null) {
|
||||
this.content[idx] = this.content[swap];
|
||||
this.content[swap] = node;
|
||||
idx = swap; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push(node: T) {
|
||||
this.content.push(node);
|
||||
this.sinkDown(this.content.length - 1);
|
||||
}
|
||||
|
||||
pop(): T | null {
|
||||
if (this.content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = this.content[0];
|
||||
const end = this.content.pop()!;
|
||||
|
||||
if (this.content.length > 0) {
|
||||
this.content[0] = end;
|
||||
this.bubbleUp(0);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
remove(node: T) {
|
||||
if (this.content.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const i = this.content.indexOf(node);
|
||||
const end = this.content.pop()!;
|
||||
|
||||
if (i < this.content.length) {
|
||||
this.content[i] = end;
|
||||
|
||||
if (this.scoreFunction(end) < this.scoreFunction(node)) {
|
||||
this.sinkDown(i);
|
||||
} else {
|
||||
this.bubbleUp(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.content.length;
|
||||
}
|
||||
|
||||
rescoreElement(node: T) {
|
||||
this.sinkDown(this.content.indexOf(node));
|
||||
}
|
||||
}
|
@ -1,18 +1,14 @@
|
||||
import { ENV } from "./constants";
|
||||
import type { BindableProp, BindingProp } from "./element/binding";
|
||||
import {
|
||||
BoundElement,
|
||||
BindableElement,
|
||||
BindableProp,
|
||||
BindingProp,
|
||||
bindingProperties,
|
||||
updateBoundElements,
|
||||
} from "./element/binding";
|
||||
import { LinearElementEditor } from "./element/linearElementEditor";
|
||||
import {
|
||||
ElementUpdate,
|
||||
mutateElement,
|
||||
newElementWith,
|
||||
} from "./element/mutateElement";
|
||||
import type { ElementUpdate } from "./element/mutateElement";
|
||||
import { mutateElement, newElementWith } from "./element/mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
redrawTextBoundingBox,
|
||||
@ -23,7 +19,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
@ -33,14 +29,15 @@ import {
|
||||
} from "./element/types";
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
import { getNonDeletedGroupIds } from "./groups";
|
||||
import type Scene from "./scene/Scene";
|
||||
import { getObservedAppState } from "./store";
|
||||
import {
|
||||
import type {
|
||||
AppState,
|
||||
ObservedAppState,
|
||||
ObservedElementsAppState,
|
||||
ObservedStandaloneAppState,
|
||||
} from "./types";
|
||||
import { SubtypeOf, ValueOf } from "./utility-types";
|
||||
import type { SubtypeOf, ValueOf } from "./utility-types";
|
||||
import {
|
||||
arrayToMap,
|
||||
arrayToObject,
|
||||
@ -1057,6 +1054,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||
scene: Scene,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
@ -1104,7 +1102,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
try {
|
||||
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
||||
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
|
||||
ElementsChange.redrawBoundArrows(nextElements, changedElements);
|
||||
ElementsChange.redrawBoundArrows(nextElements, changedElements, scene);
|
||||
|
||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
@ -1461,10 +1459,13 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
private static redrawBoundArrows(
|
||||
elements: SceneElementsMap,
|
||||
changed: Map<string, OrderedExcalidrawElement>,
|
||||
scene: Scene,
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
updateBoundElements(element, elements);
|
||||
updateBoundElements(element, elements, scene, {
|
||||
changedElements: changed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1481,19 +1482,28 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const previous = Array.from(elements.values());
|
||||
const reordered = orderByFractionalIndex([...previous]);
|
||||
const unordered = Array.from(elements.values());
|
||||
const ordered = orderByFractionalIndex([...unordered]);
|
||||
const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
|
||||
(acc, arrayIndex) => {
|
||||
const candidate = unordered[Number(arrayIndex)];
|
||||
if (candidate && changed.has(candidate.id)) {
|
||||
acc.set(candidate.id, candidate);
|
||||
}
|
||||
|
||||
if (
|
||||
!flags.containsVisibleDifference &&
|
||||
Delta.isRightDifferent(previous, reordered, true)
|
||||
) {
|
||||
return acc;
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
|
||||
if (!flags.containsVisibleDifference && moved.size) {
|
||||
// we found a difference in order!
|
||||
flags.containsVisibleDifference = true;
|
||||
}
|
||||
|
||||
// let's synchronize all invalid indices of moved elements
|
||||
return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
|
||||
// synchronize all elements that were actually moved
|
||||
// could fallback to synchronizing all invalid indices
|
||||
return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,9 +1,5 @@
|
||||
import {
|
||||
Spreadsheet,
|
||||
tryParseCells,
|
||||
tryParseNumber,
|
||||
VALID_SPREADSHEET,
|
||||
} from "./charts";
|
||||
import type { Spreadsheet } from "./charts";
|
||||
import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts";
|
||||
|
||||
describe("charts", () => {
|
||||
describe("tryParseNumber", () => {
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
VERTICAL_ALIGN,
|
||||
} from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import type { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
@ -257,8 +257,6 @@ const chartLines = (
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
width: chartWidth,
|
||||
points: [
|
||||
[0, 0],
|
||||
@ -273,8 +271,6 @@ const chartLines = (
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: chartHeight,
|
||||
points: [
|
||||
[0, 0],
|
||||
@ -289,8 +285,6 @@ const chartLines = (
|
||||
type: "line",
|
||||
x,
|
||||
y: y - BAR_HEIGHT - BAR_GAP,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
@ -418,8 +412,6 @@ const chartTypeLine = (
|
||||
type: "line",
|
||||
x: x + BAR_GAP + BAR_WIDTH / 2,
|
||||
y: y - BAR_GAP,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: maxY - minY,
|
||||
width: maxX - minX,
|
||||
strokeWidth: 2,
|
||||
@ -453,8 +445,6 @@ const chartTypeLine = (
|
||||
type: "line",
|
||||
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
||||
y: y - cy,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
|
@ -5,13 +5,13 @@ import {
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { roundRect } from "./renderer/roundRect";
|
||||
import { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||
import {
|
||||
import type { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||
import type {
|
||||
Collaborator,
|
||||
InteractiveCanvasAppState,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "./types";
|
||||
import { UserIdleState } from "./types";
|
||||
|
||||
function hashToInteger(id: string) {
|
||||
let hash = 0;
|
||||
|
@ -1,9 +1,10 @@
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { BinaryFiles } from "./types";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import type { BinaryFiles } from "./types";
|
||||
import type { Spreadsheet } from "./charts";
|
||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import {
|
||||
ALLOWED_PASTE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import oc from "open-color";
|
||||
import { Merge } from "./utility-types";
|
||||
import type { Merge } from "./utility-types";
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import {
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
NonDeletedElementsMap,
|
||||
@ -17,13 +17,18 @@ import {
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
||||
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
||||
import { capitalizeString, isTransparent } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@ -114,6 +119,12 @@ export const SelectedShapeActions = ({
|
||||
const showLinkIcon =
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
const showLineEditorAction =
|
||||
!appState.editingLinearElement &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
@ -146,13 +157,16 @@ export const SelectedShapeActions = ({
|
||||
<>{renderAction("changeRoundness")}</>
|
||||
)}
|
||||
|
||||
{(toolIsArrow(appState.activeTool.type) ||
|
||||
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||
<>{renderAction("changeArrowType")}</>
|
||||
)}
|
||||
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
|
||||
{renderAction("changeFontFamily")}
|
||||
|
||||
{renderAction("changeFontSize")}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||
renderAction("changeTextAlign")}
|
||||
@ -173,8 +187,8 @@ export const SelectedShapeActions = ({
|
||||
<div className="buttonList">
|
||||
{renderAction("sendToBack")}
|
||||
{renderAction("sendBackward")}
|
||||
{renderAction("bringToFront")}
|
||||
{renderAction("bringForward")}
|
||||
{renderAction("bringToFront")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@ -229,6 +243,7 @@ export const SelectedShapeActions = ({
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{showLineEditorAction && renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
@ -333,8 +348,8 @@ export const ShapesSwitcher = ({
|
||||
fontSize: 8,
|
||||
fontFamily: "Cascadia, monospace",
|
||||
position: "absolute",
|
||||
background: "pink",
|
||||
color: "black",
|
||||
background: "var(--color-promo)",
|
||||
color: "var(--color-surface-lowest)",
|
||||
bottom: 3,
|
||||
right: 4,
|
||||
}}
|
||||
@ -458,6 +473,7 @@ export const ExitZenModeAction = ({
|
||||
showExitZenModeBtn: boolean;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
|
File diff suppressed because it is too large
Load Diff
12
packages/excalidraw/components/ButtonIcon.scss
Normal file
12
packages/excalidraw/components/ButtonIcon.scss
Normal file
@ -0,0 +1,12 @@
|
||||
@import "../css/theme";
|
||||
|
||||
.excalidraw {
|
||||
button.standalone {
|
||||
@include outlineButtonIconStyles;
|
||||
|
||||
& > * {
|
||||
// dissalow pointer events on children, so we always have event.target on the button itself
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
36
packages/excalidraw/components/ButtonIcon.tsx
Normal file
36
packages/excalidraw/components/ButtonIcon.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./ButtonIcon.scss";
|
||||
|
||||
interface ButtonIconProps {
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
/** if not supplied, defaults to value identity check */
|
||||
active?: boolean;
|
||||
/** include standalone style (could interfere with parent styles) */
|
||||
standalone?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||
(props, ref) => {
|
||||
const { title, className, testId, active, standalone, icon, onClick } =
|
||||
props;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
key={title}
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
className={clsx(className, { standalone, active })}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
@ -1,4 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { ButtonIcon } from "./ButtonIcon";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
export const ButtonIconSelect = <T extends Object>(
|
||||
@ -24,20 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
|
||||
}
|
||||
),
|
||||
) => (
|
||||
<div className="buttonList buttonListIcon">
|
||||
<div className="buttonList">
|
||||
{props.options.map((option) =>
|
||||
props.type === "button" ? (
|
||||
<button
|
||||
<ButtonIcon
|
||||
key={option.text}
|
||||
onClick={(event) => props.onClick(option.value, event)}
|
||||
className={clsx({
|
||||
active: option.active ?? props.value === option.value,
|
||||
})}
|
||||
data-testid={option.testId}
|
||||
icon={option.icon}
|
||||
title={option.text}
|
||||
>
|
||||
{option.icon}
|
||||
</button>
|
||||
testId={option.testId}
|
||||
active={option.active ?? props.value === option.value}
|
||||
onClick={(event) => props.onClick(option.value, event)}
|
||||
/>
|
||||
) : (
|
||||
<label
|
||||
key={option.text}
|
||||
|
10
packages/excalidraw/components/ButtonSeparator.tsx
Normal file
10
packages/excalidraw/components/ButtonSeparator.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
export const ButtonSeparator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: "1rem",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
);
|
@ -22,7 +22,12 @@ export const CheckboxItem: React.FC<{
|
||||
).focus();
|
||||
}}
|
||||
>
|
||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||
<button
|
||||
type="button"
|
||||
className="Checkbox-box"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
{checkIcon}
|
||||
</button>
|
||||
<div className="Checkbox-label">{children}</div>
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getColor } from "./ColorPicker";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
ColorPickerType,
|
||||
activeColorPickerSectionAtom,
|
||||
} from "./colorPickerUtils";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { eyeDropperIcon } from "../icons";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { KEYS } from "../../keys";
|
||||
|
@ -20,7 +20,7 @@
|
||||
align-items: center;
|
||||
|
||||
@include isMobile {
|
||||
max-width: 175px;
|
||||
max-width: 11rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,23 +1,24 @@
|
||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { isTransparent } from "../../utils";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import type { AppState } from "../../types";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { Picker } from "./Picker";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
activeColorPickerSectionAtom,
|
||||
ColorPickerType,
|
||||
} from "./colorPickerUtils";
|
||||
import { useDevice, useExcalidrawContainer } from "../App";
|
||||
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { useExcalidrawContainer } from "../App";
|
||||
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
||||
import { COLOR_PALETTE } from "../../colors";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import { t } from "../../i18n";
|
||||
import clsx from "clsx";
|
||||
import { useRef } from "react";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { useRef } from "react";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
|
||||
@ -72,6 +73,7 @@ const ColorPickerPopupContent = ({
|
||||
| "palette"
|
||||
| "updateData"
|
||||
>) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||
@ -79,9 +81,6 @@ const ColorPickerPopupContent = ({
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const { container } = useExcalidrawContainer();
|
||||
const device = useDevice();
|
||||
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||
@ -95,6 +94,7 @@ const ColorPickerPopupContent = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const focusPickerContent = () => {
|
||||
@ -104,120 +104,73 @@ const ColorPickerPopupContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
ref={popoverRef}
|
||||
className="focus-visible-none"
|
||||
data-prevent-outside-click
|
||||
onFocusOutside={(event) => {
|
||||
focusPickerContent();
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
style={{ maxWidth: "208px" }}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
focusPickerContent();
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (eyeDropperState) {
|
||||
// prevent from closing if we click outside the popover
|
||||
// while eyedropping (e.g. click when clicking the sidebar;
|
||||
// the eye-dropper-backdrop is prevented downstream)
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (eyeDropperState) {
|
||||
// prevent from closing if we click outside the popover
|
||||
// while eyedropping (e.g. click when clicking the sidebar;
|
||||
// the eye-dropper-backdrop is prevented downstream)
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container unless
|
||||
// user focuses an interactive element, such as a button, or
|
||||
// enters the text editor by clicking on canvas with the text tool
|
||||
if (container && !isInteractive(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
updateData({ openPopup: null });
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "bottom"
|
||||
: "right"
|
||||
}
|
||||
align={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
zIndex: "var(--zIndex-layerUI)",
|
||||
backgroundColor: "var(--popup-bg-color)",
|
||||
maxWidth: "208px",
|
||||
maxHeight: window.innerHeight,
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
boxSizing: "border-box",
|
||||
overflowY: "auto",
|
||||
boxShadow:
|
||||
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
<Picker
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
if (force) {
|
||||
state = state || {
|
||||
keepOpenOnAlt: true,
|
||||
}}
|
||||
onClose={() => {
|
||||
updateData({ openPopup: null });
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
<Picker
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
if (force) {
|
||||
state = state || {
|
||||
keepOpenOnAlt: true,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
state.keepOpenOnAlt = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
return force === false || state
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
state.keepOpenOnAlt = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
return force === false || state
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
});
|
||||
}}
|
||||
onEscape={(event) => {
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else if (isWritableElement(event.target)) {
|
||||
focusPickerContent();
|
||||
} else {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
>
|
||||
{colorInputJSX}
|
||||
</Picker>
|
||||
) : (
|
||||
colorInputJSX
|
||||
)}
|
||||
<Popover.Arrow
|
||||
width={20}
|
||||
height={10}
|
||||
style={{
|
||||
fill: "var(--popup-bg-color)",
|
||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
onEscape={(event) => {
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
>
|
||||
{colorInputJSX}
|
||||
</Picker>
|
||||
) : (
|
||||
colorInputJSX
|
||||
)}
|
||||
</PropertiesPopover>
|
||||
);
|
||||
};
|
||||
|
||||
@ -233,7 +186,7 @@ const ColorPickerTrigger = ({
|
||||
return (
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
className={clsx("color-picker__button active-color", {
|
||||
className={clsx("color-picker__button active-color properties-trigger", {
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
})}
|
||||
aria-label={label}
|
||||
@ -269,14 +222,7 @@ export const ColorPicker = ({
|
||||
type={type}
|
||||
topPicks={topPicks}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: "100%",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
<ButtonSeparator />
|
||||
<Popover.Root
|
||||
open={appState.openPopup === type}
|
||||
onOpenChange={(open) => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { ShadeList } from "./ShadeList";
|
||||
|
||||
import PickerColorList from "./PickerColorList";
|
||||
@ -9,15 +9,15 @@ import { useAtom } from "jotai";
|
||||
import { CustomColorList } from "./CustomColorList";
|
||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import {
|
||||
ColorPickerType,
|
||||
activeColorPickerSectionAtom,
|
||||
getColorNameAndShadeFromColor,
|
||||
getMostUsedCustomColors,
|
||||
isCustomColor,
|
||||
} from "./colorPickerUtils";
|
||||
import type { ColorPaletteCustom } from "../../colors";
|
||||
import {
|
||||
ColorPaletteCustom,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
} from "../../colors";
|
||||
@ -138,7 +138,7 @@ export const Picker = ({
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="color-picker-content"
|
||||
className="color-picker-content properties-content"
|
||||
// to allow focusing by clicking but not by tabbing
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user