Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c91cf93dd |
@@ -17,6 +17,8 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
|
|||||||
# put these in your .env.local, or make sure you don't commit!
|
# put these in your .env.local, or make sure you don't commit!
|
||||||
# must be lowercase `true` when turned on
|
# must be lowercase `true` when turned on
|
||||||
#
|
#
|
||||||
|
# whether to enable Service Workers in development
|
||||||
|
VITE_APP_DEV_ENABLE_SW=
|
||||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||||
# debugging Service Workers.
|
# debugging Service Workers.
|
||||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||||
|
|||||||
@@ -8,4 +8,3 @@ public/workbox
|
|||||||
packages/excalidraw/types
|
packages/excalidraw/types
|
||||||
examples/**/public
|
examples/**/public
|
||||||
dev-dist
|
dev-dist
|
||||||
coverage
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
|
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items.
|
||||||
|
|
||||||
### MainMenu.Group
|
### MainMenu.Group
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ exportToCanvas({<br/>
|
|||||||
getDimensions,<br/>
|
getDimensions,<br/>
|
||||||
files,<br/>
|
files,<br/>
|
||||||
exportPadding?: number;<br/>
|
exportPadding?: number;<br/>
|
||||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
|
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ This API receives the mermaid syntax as the input, and resolves to skeleton Exca
|
|||||||
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
|
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
|
||||||
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
|
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
|
||||||
try {
|
try {
|
||||||
const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
|
const { elements, files } = await parseMermaid(mermaidSyntax: string, {
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
});
|
});
|
||||||
const excalidrawElements = convertToExcalidrawElements(elements);
|
const excalidrawElements = convertToExcalidrawElements(elements);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
|
|||||||
|
|
||||||
// editor state (canvas config, preferences, ...)
|
// editor state (canvas config, preferences, ...)
|
||||||
"appState": {
|
"appState": {
|
||||||
"gridSize": 20,
|
"gridSize": null,
|
||||||
"viewBackgroundColor": "#ffffff"
|
"viewBackgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+22
-41
@@ -1547,7 +1547,7 @@
|
|||||||
"@docusaurus/theme-search-algolia" "2.2.0"
|
"@docusaurus/theme-search-algolia" "2.2.0"
|
||||||
"@docusaurus/types" "2.2.0"
|
"@docusaurus/types" "2.2.0"
|
||||||
|
|
||||||
"@docusaurus/react-loadable@5.5.2":
|
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||||
version "5.5.2"
|
version "5.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||||
@@ -2789,14 +2789,7 @@ brace-expansion@^1.1.7:
|
|||||||
balanced-match "^1.0.0"
|
balanced-match "^1.0.0"
|
||||||
concat-map "0.0.1"
|
concat-map "0.0.1"
|
||||||
|
|
||||||
braces@^3.0.3:
|
braces@^3.0.2, braces@~3.0.2:
|
||||||
version "3.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
|
|
||||||
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
|
|
||||||
dependencies:
|
|
||||||
fill-range "^7.1.1"
|
|
||||||
|
|
||||||
braces@~3.0.2:
|
|
||||||
version "3.0.2"
|
version "3.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||||
@@ -4018,13 +4011,6 @@ fill-range@^7.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range "^5.0.1"
|
to-regex-range "^5.0.1"
|
||||||
|
|
||||||
fill-range@^7.1.1:
|
|
||||||
version "7.1.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
|
|
||||||
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
|
|
||||||
dependencies:
|
|
||||||
to-regex-range "^5.0.1"
|
|
||||||
|
|
||||||
finalhandler@1.2.0:
|
finalhandler@1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
|
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
|
||||||
@@ -5221,11 +5207,11 @@ methods@~1.1.2:
|
|||||||
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
|
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
|
||||||
|
|
||||||
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
|
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
|
||||||
version "4.0.8"
|
version "4.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
|
||||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
|
||||||
dependencies:
|
dependencies:
|
||||||
braces "^3.0.3"
|
braces "^3.0.2"
|
||||||
picomatch "^2.3.1"
|
picomatch "^2.3.1"
|
||||||
|
|
||||||
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
|
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
|
||||||
@@ -6204,13 +6190,14 @@ react-dev-utils@^12.0.1:
|
|||||||
strip-ansi "^6.0.1"
|
strip-ansi "^6.0.1"
|
||||||
text-table "^0.2.0"
|
text-table "^0.2.0"
|
||||||
|
|
||||||
react-dom@18.2.0:
|
react-dom@^17.0.2:
|
||||||
version "18.2.0"
|
version "17.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||||
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
scheduler "^0.23.0"
|
object-assign "^4.1.1"
|
||||||
|
scheduler "^0.20.2"
|
||||||
|
|
||||||
react-error-overlay@^6.0.11:
|
react-error-overlay@^6.0.11:
|
||||||
version "6.0.11"
|
version "6.0.11"
|
||||||
@@ -6273,14 +6260,6 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.10.3"
|
"@babel/runtime" "^7.10.3"
|
||||||
|
|
||||||
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
|
||||||
version "5.5.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
|
||||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
|
||||||
dependencies:
|
|
||||||
"@types/react" "*"
|
|
||||||
prop-types "^15.6.2"
|
|
||||||
|
|
||||||
react-router-config@^5.1.1:
|
react-router-config@^5.1.1:
|
||||||
version "5.1.1"
|
version "5.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
||||||
@@ -6331,12 +6310,13 @@ react-textarea-autosize@^8.3.2:
|
|||||||
use-composed-ref "^1.3.0"
|
use-composed-ref "^1.3.0"
|
||||||
use-latest "^1.2.1"
|
use-latest "^1.2.1"
|
||||||
|
|
||||||
react@18.2.0:
|
react@^17.0.2:
|
||||||
version "18.2.0"
|
version "17.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||||
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
|
||||||
readable-stream@^2.0.1:
|
readable-stream@^2.0.1:
|
||||||
version "2.3.7"
|
version "2.3.7"
|
||||||
@@ -6684,12 +6664,13 @@ sax@^1.2.4:
|
|||||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||||
|
|
||||||
scheduler@^0.23.0:
|
scheduler@^0.20.2:
|
||||||
version "0.23.2"
|
version "0.20.2"
|
||||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
|
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
|
||||||
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
|
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
|
||||||
schema-utils@2.7.0:
|
schema-utils@2.7.0:
|
||||||
version "2.7.0"
|
version "2.7.0"
|
||||||
|
|||||||
+2
-2
@@ -40,7 +40,7 @@ import type {
|
|||||||
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||||
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
||||||
|
|
||||||
import "./ExampleApp.scss";
|
import "./App.scss";
|
||||||
|
|
||||||
type Comment = {
|
type Comment = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -73,7 +73,7 @@ export interface AppProps {
|
|||||||
excalidrawLib: typeof TExcalidraw;
|
excalidrawLib: typeof TExcalidraw;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExampleApp({
|
export default function App({
|
||||||
appTitle,
|
appTitle,
|
||||||
useCustom,
|
useCustom,
|
||||||
customArgs,
|
customArgs,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import * as excalidrawLib from "@excalidraw/excalidraw";
|
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
import App from "../../components/ExampleApp";
|
import App from "../../components/App";
|
||||||
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import App from "../components/ExampleApp";
|
import App from "../components/App";
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
|||||||
+58
-52
@@ -22,6 +22,7 @@ import { t } from "../packages/excalidraw/i18n";
|
|||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
|
TTDDialog,
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
StoreAction,
|
StoreAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
@@ -120,14 +121,6 @@ import {
|
|||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
import { useAppLangCode } from "./app-language/language-state";
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
import DebugCanvas, {
|
|
||||||
debugRenderer,
|
|
||||||
isVisualDebuggerEnabled,
|
|
||||||
loadSavedDebugState,
|
|
||||||
} from "./components/DebugCanvas";
|
|
||||||
import { AIComponents } from "./components/AI";
|
|
||||||
import type { SaveWarningRef } from "./components/SaveWarning";
|
|
||||||
import { SaveWarning } from "./components/SaveWarning";
|
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -333,8 +326,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
const activityRef = useRef<SaveWarningRef | null>(null);
|
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -346,8 +337,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||||
}
|
}
|
||||||
|
|
||||||
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackEvent("load", "frame", getFrame());
|
trackEvent("load", "frame", getFrame());
|
||||||
// Delayed so that the app has a time to load the latest SW
|
// Delayed so that the app has a time to load the latest SW
|
||||||
@@ -373,23 +362,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [, forceRefresh] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const debugState = loadSavedDebugState();
|
|
||||||
|
|
||||||
if (debugState.enabled && !window.visualDebug) {
|
|
||||||
window.visualDebug = {
|
|
||||||
data: [],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
delete window.visualDebug;
|
|
||||||
}
|
|
||||||
forceRefresh((prev) => !prev);
|
|
||||||
}
|
|
||||||
}, [excalidrawAPI]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||||
return;
|
return;
|
||||||
@@ -619,8 +591,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
collabAPI.syncElements(elements);
|
collabAPI.syncElements(elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
activityRef.current?.activity();
|
|
||||||
|
|
||||||
// this check is redundant, but since this is a hot path, it's best
|
// this check is redundant, but since this is a hot path, it's best
|
||||||
// not to evaludate the nested expression every time
|
// not to evaludate the nested expression every time
|
||||||
if (!LocalData.isSavePaused()) {
|
if (!LocalData.isSavePaused()) {
|
||||||
@@ -652,16 +622,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the debug scene if the debug canvas is available
|
|
||||||
if (debugCanvasRef.current && excalidrawAPI) {
|
|
||||||
debugRenderer(
|
|
||||||
debugCanvasRef.current,
|
|
||||||
appState,
|
|
||||||
window.devicePixelRatio,
|
|
||||||
() => forceRefresh((prev) => !prev),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||||
@@ -860,9 +820,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
theme={appTheme}
|
theme={appTheme}
|
||||||
setTheme={(theme) => setAppTheme(theme)}
|
setTheme={(theme) => setAppTheme(theme)}
|
||||||
refresh={() => forceRefresh((prev) => !prev)}
|
|
||||||
/>
|
/>
|
||||||
<SaveWarning ref={activityRef} />
|
|
||||||
<AppWelcomeScreen
|
<AppWelcomeScreen
|
||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
@@ -887,9 +845,64 @@ const ExcalidrawWrapper = () => {
|
|||||||
</OverwriteConfirmDialog.Action>
|
</OverwriteConfirmDialog.Action>
|
||||||
)}
|
)}
|
||||||
</OverwriteConfirmDialog>
|
</OverwriteConfirmDialog>
|
||||||
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
<AppFooter />
|
||||||
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
<TTDDialog
|
||||||
|
onTextSubmit={async (input) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_AI_BACKEND
|
||||||
|
}/v1/ai/text-to-diagram/generate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ prompt: input }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||||
|
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const rateLimitRemaining = response.headers.has(
|
||||||
|
"X-Ratelimit-Remaining",
|
||||||
|
)
|
||||||
|
? parseInt(
|
||||||
|
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
rateLimit,
|
||||||
|
rateLimitRemaining,
|
||||||
|
error: new Error(
|
||||||
|
"Too many requests today, please try again tomorrow!",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(json.message || "Generation failed...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedResponse = json.generatedResponse;
|
||||||
|
if (!generatedResponse) {
|
||||||
|
throw new Error("Generation failed...");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error("Request failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="collab-offline-warning">
|
<div className="collab-offline-warning">
|
||||||
@@ -1119,13 +1132,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{isVisualDebuggerEnabled() && excalidrawAPI && (
|
|
||||||
<DebugCanvas
|
|
||||||
appState={excalidrawAPI.getAppState()}
|
|
||||||
scale={window.devicePixelRatio}
|
|
||||||
ref={debugCanvasRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { t } from "../packages/excalidraw/i18n";
|
|||||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||||
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||||
import type { UIAppState } from "../packages/excalidraw/types";
|
import type { UIAppState } from "../packages/excalidraw/types";
|
||||||
import { Stats } from "../packages/excalidraw";
|
|
||||||
|
|
||||||
type StorageSizes = { scene: number; total: number };
|
type StorageSizes = { scene: number; total: number };
|
||||||
|
|
||||||
@@ -52,33 +51,39 @@ const CustomStats = (props: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stats.StatsRows order={-1}>
|
<>
|
||||||
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
|
<tr>
|
||||||
<Stats.StatsRow
|
<th colSpan={2}>{t("stats.storage")}</th>
|
||||||
style={{ textAlign: "center", cursor: "pointer" }}
|
</tr>
|
||||||
onClick={async () => {
|
<tr>
|
||||||
try {
|
<td>{t("stats.scene")}</td>
|
||||||
await copyTextToSystemClipboard(getVersion());
|
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
||||||
props.setToast(t("toast.copyToClipboard"));
|
</tr>
|
||||||
} catch {}
|
<tr>
|
||||||
}}
|
<td>{t("stats.total")}</td>
|
||||||
title={t("stats.versionCopy")}
|
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||||
>
|
</tr>
|
||||||
{timestamp}
|
<tr>
|
||||||
<br />
|
<th colSpan={2}>{t("stats.version")}</th>
|
||||||
{hash}
|
</tr>
|
||||||
</Stats.StatsRow>
|
<tr>
|
||||||
|
<td
|
||||||
<Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
|
colSpan={2}
|
||||||
<Stats.StatsRow columns={2}>
|
style={{ textAlign: "center", cursor: "pointer" }}
|
||||||
<div>{t("stats.scene")}</div>
|
onClick={async () => {
|
||||||
<div>{nFormatter(storageSizes.scene, 1)}</div>
|
try {
|
||||||
</Stats.StatsRow>
|
await copyTextToSystemClipboard(getVersion());
|
||||||
<Stats.StatsRow columns={2}>
|
props.setToast(t("toast.copyToClipboard"));
|
||||||
<div>{t("stats.total")}</div>
|
} catch {}
|
||||||
<div>{nFormatter(storageSizes.total, 1)}</div>
|
}}
|
||||||
</Stats.StatsRow>
|
title={t("stats.versionCopy")}
|
||||||
</Stats.StatsRows>
|
>
|
||||||
|
{timestamp}
|
||||||
|
<br />
|
||||||
|
{hash}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export const STORAGE_KEYS = {
|
|||||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||||
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
|
|
||||||
VERSION_DATA_STATE: "version-dataState",
|
VERSION_DATA_STATE: "version-dataState",
|
||||||
VERSION_FILES: "version-files",
|
VERSION_FILES: "version-files",
|
||||||
|
|
||||||
|
|||||||
@@ -116,26 +116,20 @@ class Portal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let isChanged = false;
|
this.collab.excalidrawAPI.updateScene({
|
||||||
const newElements = this.collab.excalidrawAPI
|
elements: this.collab.excalidrawAPI
|
||||||
.getSceneElementsIncludingDeleted()
|
.getSceneElementsIncludingDeleted()
|
||||||
.map((element) => {
|
.map((element) => {
|
||||||
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||||
isChanged = true;
|
// this will signal collaborators to pull image data from server
|
||||||
// this will signal collaborators to pull image data from server
|
// (using mutation instead of newElementWith otherwise it'd break
|
||||||
// (using mutation instead of newElementWith otherwise it'd break
|
// in-progress dragging)
|
||||||
// in-progress dragging)
|
return newElementWith(element, { status: "saved" });
|
||||||
return newElementWith(element, { status: "saved" });
|
}
|
||||||
}
|
return element;
|
||||||
return element;
|
}),
|
||||||
});
|
storeAction: StoreAction.UPDATE,
|
||||||
|
});
|
||||||
if (isChanged) {
|
|
||||||
this.collab.excalidrawAPI.updateScene({
|
|
||||||
elements: newElements,
|
|
||||||
storeAction: StoreAction.UPDATE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, FILE_UPLOAD_TIMEOUT);
|
}, FILE_UPLOAD_TIMEOUT);
|
||||||
|
|
||||||
broadcastScene = async (
|
broadcastScene = async (
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||||
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
|
import { getFrame } from "../../packages/excalidraw/utils";
|
||||||
|
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||||
|
import { KEYS } from "../../packages/excalidraw/keys";
|
||||||
|
|
||||||
|
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||||
|
import {
|
||||||
|
copyIcon,
|
||||||
|
playerPlayIcon,
|
||||||
|
playerStopFilledIcon,
|
||||||
|
share,
|
||||||
|
shareIOS,
|
||||||
|
shareWindows,
|
||||||
|
tablerCheckIcon,
|
||||||
|
} from "../../packages/excalidraw/components/icons";
|
||||||
|
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||||
|
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||||
|
|
||||||
|
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
|
||||||
|
import "./RoomDialog.scss";
|
||||||
|
|
||||||
|
const getShareIcon = () => {
|
||||||
|
const navigator = window.navigator as any;
|
||||||
|
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
||||||
|
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
||||||
|
|
||||||
|
if (isAppleBrowser) {
|
||||||
|
return shareIOS;
|
||||||
|
} else if (isWindowsBrowser) {
|
||||||
|
return shareWindows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return share;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoomModalProps = {
|
||||||
|
handleClose: () => void;
|
||||||
|
activeRoomLink: string;
|
||||||
|
username: string;
|
||||||
|
onUsernameChange: (username: string) => void;
|
||||||
|
onRoomCreate: () => void;
|
||||||
|
onRoomDestroy: () => void;
|
||||||
|
setErrorMessage: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RoomModal = ({
|
||||||
|
activeRoomLink,
|
||||||
|
onRoomCreate,
|
||||||
|
onRoomDestroy,
|
||||||
|
setErrorMessage,
|
||||||
|
username,
|
||||||
|
onUsernameChange,
|
||||||
|
handleClose,
|
||||||
|
}: RoomModalProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
|
const timerRef = useRef<number>(0);
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const isShareSupported = "share" in navigator;
|
||||||
|
|
||||||
|
const copyRoomLink = async () => {
|
||||||
|
try {
|
||||||
|
await copyTextToSystemClipboard(activeRoomLink);
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
}
|
||||||
|
setJustCopied(true);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setJustCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
ref.current?.select();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareRoomLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: t("roomDialog.shareTitle"),
|
||||||
|
text: t("roomDialog.shareTitle"),
|
||||||
|
url: activeRoomLink,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
// Just ignore.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeRoomLink) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="RoomDialog__active__header">
|
||||||
|
{t("labels.liveCollaboration")}
|
||||||
|
</h3>
|
||||||
|
<TextField
|
||||||
|
value={username}
|
||||||
|
placeholder="Your name"
|
||||||
|
label="Your name"
|
||||||
|
onChange={onUsernameChange}
|
||||||
|
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
||||||
|
/>
|
||||||
|
<div className="RoomDialog__active__linkRow">
|
||||||
|
<TextField
|
||||||
|
ref={ref}
|
||||||
|
label="Link"
|
||||||
|
readonly
|
||||||
|
fullWidth
|
||||||
|
value={activeRoomLink}
|
||||||
|
/>
|
||||||
|
{isShareSupported && (
|
||||||
|
<FilledButton
|
||||||
|
size="large"
|
||||||
|
variant="icon"
|
||||||
|
label="Share"
|
||||||
|
icon={getShareIcon()}
|
||||||
|
className="RoomDialog__active__share"
|
||||||
|
onClick={shareRoomLink}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Popover.Root open={justCopied}>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<FilledButton
|
||||||
|
size="large"
|
||||||
|
label="Copy link"
|
||||||
|
icon={copyIcon}
|
||||||
|
onClick={copyRoomLink}
|
||||||
|
/>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
className="RoomDialog__popover"
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
sideOffset={5.5}
|
||||||
|
>
|
||||||
|
{tablerCheckIcon} copied
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
<div className="RoomDialog__active__description">
|
||||||
|
<p>
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="RoomDialog__active__description__emoji"
|
||||||
|
>
|
||||||
|
🔒{" "}
|
||||||
|
</span>
|
||||||
|
{t("roomDialog.desc_privacy")}
|
||||||
|
</p>
|
||||||
|
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="RoomDialog__active__actions">
|
||||||
|
<FilledButton
|
||||||
|
size="large"
|
||||||
|
variant="outlined"
|
||||||
|
color="danger"
|
||||||
|
label={t("roomDialog.button_stopSession")}
|
||||||
|
icon={playerStopFilledIcon}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent("share", "room closed");
|
||||||
|
onRoomDestroy();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="RoomDialog__inactive__illustration">
|
||||||
|
<CollabImage />
|
||||||
|
</div>
|
||||||
|
<div className="RoomDialog__inactive__header">
|
||||||
|
{t("labels.liveCollaboration")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="RoomDialog__inactive__description">
|
||||||
|
<strong>{t("roomDialog.desc_intro")}</strong>
|
||||||
|
{t("roomDialog.desc_privacy")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="RoomDialog__inactive__start_session">
|
||||||
|
<FilledButton
|
||||||
|
size="large"
|
||||||
|
label={t("roomDialog.button_startSession")}
|
||||||
|
icon={playerPlayIcon}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||||
|
onRoomCreate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RoomDialog = (props: RoomModalProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
||||||
|
<div className="RoomDialog">
|
||||||
|
<RoomModal {...props} />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoomDialog;
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
|
|
||||||
import {
|
|
||||||
DiagramToCodePlugin,
|
|
||||||
exportToBlob,
|
|
||||||
getTextFromElements,
|
|
||||||
MIME_TYPES,
|
|
||||||
TTDDialog,
|
|
||||||
} from "../../packages/excalidraw";
|
|
||||||
import { getDataURL } from "../../packages/excalidraw/data/blob";
|
|
||||||
import { safelyParseJSON } from "../../packages/excalidraw/utils";
|
|
||||||
|
|
||||||
export const AIComponents = ({
|
|
||||||
excalidrawAPI,
|
|
||||||
}: {
|
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DiagramToCodePlugin
|
|
||||||
generate={async ({ frame, children }) => {
|
|
||||||
const appState = excalidrawAPI.getAppState();
|
|
||||||
|
|
||||||
const blob = await exportToBlob({
|
|
||||||
elements: children,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
exportBackground: true,
|
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
|
||||||
},
|
|
||||||
exportingFrame: frame,
|
|
||||||
files: excalidrawAPI.getFiles(),
|
|
||||||
mimeType: MIME_TYPES.jpg,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataURL = await getDataURL(blob);
|
|
||||||
|
|
||||||
const textFromFrameChildren = getTextFromElements(children);
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${
|
|
||||||
import.meta.env.VITE_APP_AI_BACKEND
|
|
||||||
}/v1/ai/diagram-to-code/generate`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
texts: textFromFrameChildren,
|
|
||||||
image: dataURL,
|
|
||||||
theme: appState.theme,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
const errorJSON = safelyParseJSON(text);
|
|
||||||
|
|
||||||
if (!errorJSON) {
|
|
||||||
throw new Error(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorJSON.statusCode === 429) {
|
|
||||||
return {
|
|
||||||
html: `<html>
|
|
||||||
<body style="margin: 0; text-align: center">
|
|
||||||
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
|
|
||||||
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
|
|
||||||
</br>
|
|
||||||
</br>
|
|
||||||
<div>You can also try <a href="${
|
|
||||||
import.meta.env.VITE_APP_PLUS_LP
|
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(errorJSON.message || text);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { html } = await response.json();
|
|
||||||
|
|
||||||
if (!html) {
|
|
||||||
throw new Error("Generation failed (invalid response)");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
html,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
throw new Error("Generation failed (invalid response)");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TTDDialog
|
|
||||||
onTextSubmit={async (input) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${
|
|
||||||
import.meta.env.VITE_APP_AI_BACKEND
|
|
||||||
}/v1/ai/text-to-diagram/generate`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ prompt: input }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
|
||||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rateLimitRemaining = response.headers.has(
|
|
||||||
"X-Ratelimit-Remaining",
|
|
||||||
)
|
|
||||||
? parseInt(
|
|
||||||
response.headers.get("X-Ratelimit-Remaining") || "0",
|
|
||||||
10,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 429) {
|
|
||||||
return {
|
|
||||||
rateLimit,
|
|
||||||
rateLimitRemaining,
|
|
||||||
error: new Error(
|
|
||||||
"Too many requests today, please try again tomorrow!",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(json.message || "Generation failed...");
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatedResponse = json.generatedResponse;
|
|
||||||
if (!generatedResponse) {
|
|
||||||
throw new Error("Generation failed...");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { generatedResponse, rateLimit, rateLimitRemaining };
|
|
||||||
} catch (err: any) {
|
|
||||||
throw new Error("Request failed");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,27 +3,23 @@ import { Footer } from "../../packages/excalidraw/index";
|
|||||||
import { EncryptedIcon } from "./EncryptedIcon";
|
import { EncryptedIcon } from "./EncryptedIcon";
|
||||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
|
||||||
|
|
||||||
export const AppFooter = React.memo(
|
export const AppFooter = React.memo(() => {
|
||||||
({ onChange }: { onChange: () => void }) => {
|
return (
|
||||||
return (
|
<Footer>
|
||||||
<Footer>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
display: "flex",
|
||||||
display: "flex",
|
gap: ".5rem",
|
||||||
gap: ".5rem",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{isExcalidrawPlusSignedUser ? (
|
||||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
<ExcalidrawPlusAppLink />
|
||||||
{isExcalidrawPlusSignedUser ? (
|
) : (
|
||||||
<ExcalidrawPlusAppLink />
|
<EncryptedIcon />
|
||||||
) : (
|
)}
|
||||||
<EncryptedIcon />
|
</div>
|
||||||
)}
|
</Footer>
|
||||||
</div>
|
);
|
||||||
</Footer>
|
});
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
loginIcon,
|
loginIcon,
|
||||||
ExcalLogo,
|
ExcalLogo,
|
||||||
eyeIcon,
|
|
||||||
} from "../../packages/excalidraw/components/icons";
|
} from "../../packages/excalidraw/components/icons";
|
||||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||||
import { MainMenu } from "../../packages/excalidraw/index";
|
import { MainMenu } from "../../packages/excalidraw/index";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { LanguageList } from "../app-language/LanguageList";
|
import { LanguageList } from "../app-language/LanguageList";
|
||||||
import { saveDebugState } from "./DebugCanvas";
|
|
||||||
|
|
||||||
export const AppMainMenu: React.FC<{
|
export const AppMainMenu: React.FC<{
|
||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
@@ -16,7 +14,6 @@ export const AppMainMenu: React.FC<{
|
|||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
theme: Theme | "system";
|
theme: Theme | "system";
|
||||||
setTheme: (theme: Theme | "system") => void;
|
setTheme: (theme: Theme | "system") => void;
|
||||||
refresh: () => void;
|
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<MainMenu>
|
||||||
@@ -31,7 +28,6 @@ export const AppMainMenu: React.FC<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||||
<MainMenu.DefaultItems.SearchMenu />
|
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
@@ -54,23 +50,6 @@ export const AppMainMenu: React.FC<{
|
|||||||
>
|
>
|
||||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||||
</MainMenu.ItemLink>
|
</MainMenu.ItemLink>
|
||||||
{import.meta.env.DEV && (
|
|
||||||
<MainMenu.Item
|
|
||||||
icon={eyeIcon}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.visualDebug) {
|
|
||||||
delete window.visualDebug;
|
|
||||||
saveDebugState({ enabled: false });
|
|
||||||
} else {
|
|
||||||
window.visualDebug = { data: [] };
|
|
||||||
saveDebugState({ enabled: true });
|
|
||||||
}
|
|
||||||
props?.refresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Visual Debug
|
|
||||||
</MainMenu.Item>
|
|
||||||
)}
|
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.DefaultItems.ToggleTheme
|
<MainMenu.DefaultItems.ToggleTheme
|
||||||
allowSystemTheme
|
allowSystemTheme
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
|
||||||
import { type AppState } from "../../packages/excalidraw/types";
|
|
||||||
import { throttleRAF } from "../../packages/excalidraw/utils";
|
|
||||||
import {
|
|
||||||
bootstrapCanvas,
|
|
||||||
getNormalizedCanvasDimensions,
|
|
||||||
} from "../../packages/excalidraw/renderer/helpers";
|
|
||||||
import type { DebugElement } from "../../packages/excalidraw/visualdebug";
|
|
||||||
import {
|
|
||||||
ArrowheadArrowIcon,
|
|
||||||
CloseIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from "../../packages/excalidraw/components/icons";
|
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
|
||||||
import {
|
|
||||||
isLineSegment,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LineSegment,
|
|
||||||
} from "../../packages/math";
|
|
||||||
|
|
||||||
const renderLine = (
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
zoom: number,
|
|
||||||
segment: LineSegment<GlobalPoint>,
|
|
||||||
color: string,
|
|
||||||
) => {
|
|
||||||
context.save();
|
|
||||||
context.strokeStyle = color;
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
|
|
||||||
context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
|
|
||||||
context.stroke();
|
|
||||||
context.restore();
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
|
||||||
context.strokeStyle = "#888";
|
|
||||||
context.save();
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(-10 * zoom, -10 * zoom);
|
|
||||||
context.lineTo(10 * zoom, 10 * zoom);
|
|
||||||
context.moveTo(10 * zoom, -10 * zoom);
|
|
||||||
context.lineTo(-10 * zoom, 10 * zoom);
|
|
||||||
context.stroke();
|
|
||||||
context.save();
|
|
||||||
};
|
|
||||||
|
|
||||||
const render = (
|
|
||||||
frame: DebugElement[],
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
frame.forEach((el: DebugElement) => {
|
|
||||||
switch (true) {
|
|
||||||
case isLineSegment(el.data):
|
|
||||||
renderLine(
|
|
||||||
context,
|
|
||||||
appState.zoom.value,
|
|
||||||
el.data as LineSegment<GlobalPoint>,
|
|
||||||
el.color,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const _debugRenderer = (
|
|
||||||
canvas: HTMLCanvasElement,
|
|
||||||
appState: AppState,
|
|
||||||
scale: number,
|
|
||||||
refresh: () => void,
|
|
||||||
) => {
|
|
||||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
|
||||||
canvas,
|
|
||||||
scale,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = bootstrapCanvas({
|
|
||||||
canvas,
|
|
||||||
scale,
|
|
||||||
normalizedWidth,
|
|
||||||
normalizedHeight,
|
|
||||||
viewBackgroundColor: "transparent",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply zoom
|
|
||||||
context.save();
|
|
||||||
context.translate(
|
|
||||||
appState.scrollX * appState.zoom.value,
|
|
||||||
appState.scrollY * appState.zoom.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
renderOrigin(context, appState.zoom.value);
|
|
||||||
|
|
||||||
if (
|
|
||||||
window.visualDebug?.currentFrame &&
|
|
||||||
window.visualDebug?.data &&
|
|
||||||
window.visualDebug.data.length > 0
|
|
||||||
) {
|
|
||||||
// Render only one frame
|
|
||||||
const [idx] = debugFrameData();
|
|
||||||
|
|
||||||
render(window.visualDebug.data[idx], context, appState);
|
|
||||||
} else {
|
|
||||||
// Render all debug frames
|
|
||||||
window.visualDebug?.data.forEach((frame) => {
|
|
||||||
render(frame, context, appState);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.visualDebug) {
|
|
||||||
window.visualDebug!.data =
|
|
||||||
window.visualDebug?.data.map((frame) =>
|
|
||||||
frame.filter((el) => el.permanent),
|
|
||||||
) ?? [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debugFrameData = (): [number, number] => {
|
|
||||||
const currentFrame = window.visualDebug?.currentFrame ?? 0;
|
|
||||||
const frameCount = window.visualDebug?.data.length ?? 0;
|
|
||||||
|
|
||||||
if (frameCount > 0) {
|
|
||||||
return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [0, 0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveDebugState = (debug: { enabled: boolean }) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
|
|
||||||
JSON.stringify(debug),
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const debugRenderer = throttleRAF(
|
|
||||||
(
|
|
||||||
canvas: HTMLCanvasElement,
|
|
||||||
appState: AppState,
|
|
||||||
scale: number,
|
|
||||||
refresh: () => void,
|
|
||||||
) => {
|
|
||||||
_debugRenderer(canvas, appState, scale, refresh);
|
|
||||||
},
|
|
||||||
{ trailing: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const loadSavedDebugState = () => {
|
|
||||||
let debug;
|
|
||||||
try {
|
|
||||||
const savedDebugState = localStorage.getItem(
|
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
|
|
||||||
);
|
|
||||||
if (savedDebugState) {
|
|
||||||
debug = JSON.parse(savedDebugState) as { enabled: boolean };
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return debug ?? { enabled: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isVisualDebuggerEnabled = () =>
|
|
||||||
Array.isArray(window.visualDebug?.data);
|
|
||||||
|
|
||||||
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
|
||||||
const moveForward = useCallback(() => {
|
|
||||||
if (
|
|
||||||
!window.visualDebug?.currentFrame ||
|
|
||||||
isNaN(window.visualDebug?.currentFrame ?? -1)
|
|
||||||
) {
|
|
||||||
window.visualDebug!.currentFrame = 0;
|
|
||||||
}
|
|
||||||
window.visualDebug!.currentFrame += 1;
|
|
||||||
onChange();
|
|
||||||
}, [onChange]);
|
|
||||||
const moveBackward = useCallback(() => {
|
|
||||||
if (
|
|
||||||
!window.visualDebug?.currentFrame ||
|
|
||||||
isNaN(window.visualDebug?.currentFrame ?? -1) ||
|
|
||||||
window.visualDebug?.currentFrame < 1
|
|
||||||
) {
|
|
||||||
window.visualDebug!.currentFrame = 1;
|
|
||||||
}
|
|
||||||
window.visualDebug!.currentFrame -= 1;
|
|
||||||
onChange();
|
|
||||||
}, [onChange]);
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
window.visualDebug!.currentFrame = undefined;
|
|
||||||
onChange();
|
|
||||||
}, [onChange]);
|
|
||||||
const trashFrames = useCallback(() => {
|
|
||||||
if (window.visualDebug) {
|
|
||||||
window.visualDebug.currentFrame = undefined;
|
|
||||||
window.visualDebug.data = [];
|
|
||||||
}
|
|
||||||
onChange();
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="ToolIcon_type_button"
|
|
||||||
data-testid="debug-forward"
|
|
||||||
aria-label="Move forward"
|
|
||||||
type="button"
|
|
||||||
onClick={trashFrames}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="ToolIcon__icon"
|
|
||||||
aria-hidden="true"
|
|
||||||
aria-disabled="false"
|
|
||||||
>
|
|
||||||
{TrashIcon}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ToolIcon_type_button"
|
|
||||||
data-testid="debug-forward"
|
|
||||||
aria-label="Move forward"
|
|
||||||
type="button"
|
|
||||||
onClick={moveBackward}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="ToolIcon__icon"
|
|
||||||
aria-hidden="true"
|
|
||||||
aria-disabled="false"
|
|
||||||
>
|
|
||||||
<ArrowheadArrowIcon flip />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ToolIcon_type_button"
|
|
||||||
data-testid="debug-forward"
|
|
||||||
aria-label="Move forward"
|
|
||||||
type="button"
|
|
||||||
onClick={reset}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="ToolIcon__icon"
|
|
||||||
aria-hidden="true"
|
|
||||||
aria-disabled="false"
|
|
||||||
>
|
|
||||||
{CloseIcon}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ToolIcon_type_button"
|
|
||||||
data-testid="debug-backward"
|
|
||||||
aria-label="Move backward"
|
|
||||||
type="button"
|
|
||||||
onClick={moveForward}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="ToolIcon__icon"
|
|
||||||
aria-hidden="true"
|
|
||||||
aria-disabled="false"
|
|
||||||
>
|
|
||||||
<ArrowheadArrowIcon />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DebugCanvasProps {
|
|
||||||
appState: AppState;
|
|
||||||
scale: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
|
||||||
({ appState, scale }, ref) => {
|
|
||||||
const { width, height } = appState;
|
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
|
||||||
ref,
|
|
||||||
() => canvasRef.current,
|
|
||||||
[canvasRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
position: "absolute",
|
|
||||||
zIndex: 2,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
width={width * scale}
|
|
||||||
height={height * scale}
|
|
||||||
ref={canvasRef}
|
|
||||||
>
|
|
||||||
Debug Canvas
|
|
||||||
</canvas>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default DebugCanvas;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
||||||
import { t } from "../../packages/excalidraw/i18n";
|
|
||||||
import { getShortcutKey } from "../../packages/excalidraw/utils";
|
|
||||||
|
|
||||||
export type SaveWarningRef = {
|
|
||||||
activity: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SaveWarning = forwardRef<SaveWarningRef, {}>((props, ref) => {
|
|
||||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
/**
|
|
||||||
* Call this API method via the ref to hide warning message
|
|
||||||
* and start an idle timer again.
|
|
||||||
*/
|
|
||||||
activity: async () => {
|
|
||||||
if (timerRef.current != null) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
dialogRef.current?.classList.remove("animate");
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
timerRef.current = null;
|
|
||||||
dialogRef.current?.classList.add("animate");
|
|
||||||
}, 5000);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={dialogRef} className="alert-save">
|
|
||||||
<div className="dialog">
|
|
||||||
{t("alerts.saveYourContent", {
|
|
||||||
shortcut: getShortcutKey("CtrlOrCmd + S"),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -20,10 +20,6 @@ import {
|
|||||||
get,
|
get,
|
||||||
} from "idb-keyval";
|
} from "idb-keyval";
|
||||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||||
import {
|
|
||||||
CANVAS_SEARCH_TAB,
|
|
||||||
DEFAULT_SIDEBAR,
|
|
||||||
} from "../../packages/excalidraw/constants";
|
|
||||||
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||||
@@ -70,22 +66,13 @@ const saveDataStateToLocalStorage = (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const _appState = clearAppStateForLocalStorage(appState);
|
|
||||||
|
|
||||||
if (
|
|
||||||
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
|
||||||
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
|
||||||
) {
|
|
||||||
_appState.openSidebar = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||||
);
|
);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||||
JSON.stringify(_appState),
|
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||||
);
|
);
|
||||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -128,6 +128,29 @@
|
|||||||
<script>
|
<script>
|
||||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||||
</script>
|
</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 -->
|
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
||||||
|
|||||||
@@ -18,43 +18,6 @@
|
|||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-save {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 10vh;
|
|
||||||
margin-inline: auto;
|
|
||||||
width: fit-content;
|
|
||||||
|
|
||||||
opacity: 0;
|
|
||||||
transition: all 0s;
|
|
||||||
|
|
||||||
&.animate {
|
|
||||||
opacity: 1;
|
|
||||||
transition: all 0.2s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog {
|
|
||||||
margin-inline: 10px;
|
|
||||||
padding: 1rem;
|
|
||||||
padding-inline: 1.25rem;
|
|
||||||
|
|
||||||
resize: none;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
background-color: var(--color-warning);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
border: 1px solid var(--dialog-border-color);
|
|
||||||
|
|
||||||
font-size: 0.875rem;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--color-text-warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.encrypted-icon {
|
.encrypted-icon {
|
||||||
border-radius: var(--space-factor);
|
border-radius: var(--space-factor);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
|||||||
@@ -58,8 +58,8 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 110%;
|
line-height: 110%;
|
||||||
|
|
||||||
background: var(--color-success);
|
background: var(--color-success-lighter);
|
||||||
color: var(--color-success-text);
|
color: var(--color-success);
|
||||||
|
|
||||||
& > svg {
|
& > svg {
|
||||||
width: 0.875rem;
|
width: 0.875rem;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import { getFrame } from "../../packages/excalidraw/utils";
|
import { getFrame } from "../../packages/excalidraw/utils";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
share,
|
share,
|
||||||
shareIOS,
|
shareIOS,
|
||||||
shareWindows,
|
shareWindows,
|
||||||
|
tablerCheckIcon,
|
||||||
} from "../../packages/excalidraw/components/icons";
|
} from "../../packages/excalidraw/components/icons";
|
||||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||||
@@ -22,7 +24,6 @@ import { atom, useAtom, useAtomValue } from "jotai";
|
|||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
@@ -62,11 +63,10 @@ const ActiveRoomDialog = ({
|
|||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [, setJustCopied] = useState(false);
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
const isShareSupported = "share" in navigator;
|
const isShareSupported = "share" in navigator;
|
||||||
const { onCopy, copyStatus } = useCopyStatus();
|
|
||||||
|
|
||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -130,16 +130,26 @@ const ActiveRoomDialog = ({
|
|||||||
onClick={shareRoomLink}
|
onClick={shareRoomLink}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<FilledButton
|
<Popover.Root open={justCopied}>
|
||||||
size="large"
|
<Popover.Trigger asChild>
|
||||||
label={t("buttons.copyLink")}
|
<FilledButton
|
||||||
icon={copyIcon}
|
size="large"
|
||||||
status={copyStatus}
|
label="Copy link"
|
||||||
onClick={() => {
|
icon={copyIcon}
|
||||||
copyRoomLink();
|
onClick={copyRoomLink}
|
||||||
onCopy();
|
/>
|
||||||
}}
|
</Popover.Trigger>
|
||||||
/>
|
<Popover.Content
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
className="ShareDialog__popover"
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
sideOffset={5.5}
|
||||||
|
>
|
||||||
|
{tablerCheckIcon} copied
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
</div>
|
</div>
|
||||||
<div className="ShareDialog__active__description">
|
<div className="ShareDialog__active__description">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
workbox: {
|
workbox: {
|
||||||
// Don't push fonts, locales and wasm to app precache
|
// Don't push fonts and locales to app precache
|
||||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
|
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||||
@@ -108,17 +108,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
urlPattern: new RegExp(".wasm-.+.js"),
|
|
||||||
handler: "CacheFirst",
|
|
||||||
options: {
|
|
||||||
cacheName: "wasm",
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 50,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
|
|||||||
+7
-9
@@ -6,7 +6,6 @@
|
|||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/excalidraw",
|
"packages/excalidraw",
|
||||||
"packages/utils",
|
"packages/utils",
|
||||||
"packages/math",
|
|
||||||
"examples/excalidraw",
|
"examples/excalidraw",
|
||||||
"examples/excalidraw/*"
|
"examples/excalidraw/*"
|
||||||
],
|
],
|
||||||
@@ -21,8 +20,8 @@
|
|||||||
"@types/react-dom": "18.2.0",
|
"@types/react-dom": "18.2.0",
|
||||||
"@types/socket.io-client": "3.0.0",
|
"@types/socket.io-client": "3.0.0",
|
||||||
"@vitejs/plugin-react": "3.1.0",
|
"@vitejs/plugin-react": "3.1.0",
|
||||||
"@vitest/coverage-v8": "2.0.5",
|
"@vitest/coverage-v8": "0.33.0",
|
||||||
"@vitest/ui": "2.0.5",
|
"@vitest/ui": "0.32.2",
|
||||||
"chai": "4.3.6",
|
"chai": "4.3.6",
|
||||||
"dotenv": "16.0.1",
|
"dotenv": "16.0.1",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
@@ -37,12 +36,12 @@
|
|||||||
"rewire": "6.0.0",
|
"rewire": "6.0.0",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.12",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-checker": "0.7.2",
|
"vite-plugin-checker": "0.6.1",
|
||||||
"vite-plugin-ejs": "1.7.0",
|
"vite-plugin-ejs": "1.7.0",
|
||||||
"vite-plugin-pwa": "0.17.4",
|
"vite-plugin-pwa": "0.17.4",
|
||||||
"vite-plugin-svgr": "4.2.0",
|
"vite-plugin-svgr": "2.4.0",
|
||||||
"vitest": "2.0.5",
|
"vitest": "1.6.0",
|
||||||
"vitest-canvas-mock": "0.3.3"
|
"vitest-canvas-mock": "0.3.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 20.x.x"
|
"node": "18.0.0 - 20.x.x"
|
||||||
@@ -83,7 +82,6 @@
|
|||||||
"clean-install": "yarn rm:node_modules && yarn install"
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "18.2.0",
|
"@types/react": "18.2.0"
|
||||||
"strip-ansi": "6.0.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
|
|
||||||
|
|
||||||
- `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)
|
- `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)
|
||||||
|
|
||||||
| | Before `commitToHistory` | After `storeAction` | Notes |
|
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import type { AppState, Offsets } from "../types";
|
import type { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
@@ -38,7 +38,6 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
|||||||
import type { SceneBounds } from "../element/bounds";
|
import type { SceneBounds } from "../element/bounds";
|
||||||
import { setCursor } from "../cursor";
|
import { setCursor } from "../cursor";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { clamp, roundToStep } from "../../math";
|
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@@ -105,8 +104,6 @@ export const actionClearCanvas = register({
|
|||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
gridStep: appState.gridStep,
|
|
||||||
gridModeEnabled: appState.gridModeEnabled,
|
|
||||||
stats: appState.stats,
|
stats: appState.stats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
@@ -247,7 +244,6 @@ export const actionResetZoom = register({
|
|||||||
const zoomValueToFitBoundsOnViewport = (
|
const zoomValueToFitBoundsOnViewport = (
|
||||||
bounds: SceneBounds,
|
bounds: SceneBounds,
|
||||||
viewportDimensions: { width: number; height: number },
|
viewportDimensions: { width: number; height: number },
|
||||||
viewportZoomFactor: number = 1, // default to 1 if not provided
|
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = bounds;
|
const [x1, y1, x2, y2] = bounds;
|
||||||
const commonBoundsWidth = x2 - x1;
|
const commonBoundsWidth = x2 - x1;
|
||||||
@@ -255,89 +251,92 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
const commonBoundsHeight = y2 - y1;
|
const commonBoundsHeight = y2 - y1;
|
||||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
||||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
||||||
|
const zoomAdjustedToSteps =
|
||||||
const adjustedZoomValue =
|
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||||
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
|
const clampedZoomValueToFitElements = Math.min(
|
||||||
|
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
|
||||||
return Math.min(adjustedZoomValue, 1);
|
1,
|
||||||
|
);
|
||||||
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zoomToFitBounds = ({
|
export const zoomToFitBounds = ({
|
||||||
bounds,
|
bounds,
|
||||||
appState,
|
appState,
|
||||||
canvasOffsets,
|
|
||||||
fitToViewport = false,
|
fitToViewport = false,
|
||||||
viewportZoomFactor = 1,
|
viewportZoomFactor = 0.7,
|
||||||
minZoom = -Infinity,
|
|
||||||
maxZoom = Infinity,
|
|
||||||
}: {
|
}: {
|
||||||
bounds: SceneBounds;
|
bounds: SceneBounds;
|
||||||
canvasOffsets?: Offsets;
|
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
/** whether to fit content to viewport (beyond >100%) */
|
/** whether to fit content to viewport (beyond >100%) */
|
||||||
fitToViewport: boolean;
|
fitToViewport: boolean;
|
||||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||||
viewportZoomFactor?: number;
|
viewportZoomFactor?: number;
|
||||||
minZoom?: number;
|
|
||||||
maxZoom?: number;
|
|
||||||
}) => {
|
}) => {
|
||||||
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = bounds;
|
const [x1, y1, x2, y2] = bounds;
|
||||||
const centerX = (x1 + x2) / 2;
|
const centerX = (x1 + x2) / 2;
|
||||||
const centerY = (y1 + y2) / 2;
|
const centerY = (y1 + y2) / 2;
|
||||||
|
|
||||||
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
|
let newZoomValue;
|
||||||
const canvasOffsetTop = canvasOffsets?.top ?? 0;
|
let scrollX;
|
||||||
const canvasOffsetRight = canvasOffsets?.right ?? 0;
|
let scrollY;
|
||||||
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
|
|
||||||
|
|
||||||
const effectiveCanvasWidth =
|
|
||||||
appState.width - canvasOffsetLeft - canvasOffsetRight;
|
|
||||||
const effectiveCanvasHeight =
|
|
||||||
appState.height - canvasOffsetTop - canvasOffsetBottom;
|
|
||||||
|
|
||||||
let adjustedZoomValue;
|
|
||||||
|
|
||||||
if (fitToViewport) {
|
if (fitToViewport) {
|
||||||
const commonBoundsWidth = x2 - x1;
|
const commonBoundsWidth = x2 - x1;
|
||||||
const commonBoundsHeight = y2 - y1;
|
const commonBoundsHeight = y2 - y1;
|
||||||
|
|
||||||
adjustedZoomValue =
|
newZoomValue =
|
||||||
Math.min(
|
Math.min(
|
||||||
effectiveCanvasWidth / commonBoundsWidth,
|
appState.width / commonBoundsWidth,
|
||||||
effectiveCanvasHeight / commonBoundsHeight,
|
appState.height / commonBoundsHeight,
|
||||||
) * viewportZoomFactor;
|
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
||||||
|
|
||||||
|
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||||
|
newZoomValue = Math.min(
|
||||||
|
Math.max(newZoomValue, MIN_ZOOM),
|
||||||
|
MAX_ZOOM,
|
||||||
|
) as NormalizedZoomValue;
|
||||||
|
|
||||||
|
let appStateWidth = appState.width;
|
||||||
|
|
||||||
|
if (appState.openSidebar) {
|
||||||
|
const sidebarDOMElem = document.querySelector(
|
||||||
|
".sidebar",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
|
||||||
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
|
appStateWidth = !isRTL
|
||||||
|
? appState.width - sidebarWidth
|
||||||
|
: appState.width + sidebarWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
|
||||||
|
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||||
} else {
|
} else {
|
||||||
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
|
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
||||||
bounds,
|
|
||||||
{
|
|
||||||
width: effectiveCanvasWidth,
|
|
||||||
height: effectiveCanvasHeight,
|
|
||||||
},
|
|
||||||
viewportZoomFactor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newZoomValue = getNormalizedZoom(
|
|
||||||
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
|
|
||||||
);
|
|
||||||
|
|
||||||
const centerScroll = centerScrollOn({
|
|
||||||
scenePoint: { x: centerX, y: centerY },
|
|
||||||
viewportDimensions: {
|
|
||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
},
|
});
|
||||||
offsets: canvasOffsets,
|
|
||||||
zoom: { value: newZoomValue },
|
const centerScroll = centerScrollOn({
|
||||||
});
|
scenePoint: { x: centerX, y: centerY },
|
||||||
|
viewportDimensions: {
|
||||||
|
width: appState.width,
|
||||||
|
height: appState.height,
|
||||||
|
},
|
||||||
|
zoom: { value: newZoomValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollX = centerScroll.scrollX;
|
||||||
|
scrollY = centerScroll.scrollY;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
scrollX: centerScroll.scrollX,
|
scrollX,
|
||||||
scrollY: centerScroll.scrollY,
|
scrollY,
|
||||||
zoom: { value: newZoomValue },
|
zoom: { value: newZoomValue },
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
@@ -345,34 +344,25 @@ export const zoomToFitBounds = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const zoomToFit = ({
|
export const zoomToFit = ({
|
||||||
canvasOffsets,
|
|
||||||
targetElements,
|
targetElements,
|
||||||
appState,
|
appState,
|
||||||
fitToViewport,
|
fitToViewport,
|
||||||
viewportZoomFactor,
|
viewportZoomFactor,
|
||||||
minZoom,
|
|
||||||
maxZoom,
|
|
||||||
}: {
|
}: {
|
||||||
canvasOffsets?: Offsets;
|
|
||||||
targetElements: readonly ExcalidrawElement[];
|
targetElements: readonly ExcalidrawElement[];
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
/** whether to fit content to viewport (beyond >100%) */
|
/** whether to fit content to viewport (beyond >100%) */
|
||||||
fitToViewport: boolean;
|
fitToViewport: boolean;
|
||||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||||
viewportZoomFactor?: number;
|
viewportZoomFactor?: number;
|
||||||
minZoom?: number;
|
|
||||||
maxZoom?: number;
|
|
||||||
}) => {
|
}) => {
|
||||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||||
|
|
||||||
return zoomToFitBounds({
|
return zoomToFitBounds({
|
||||||
canvasOffsets,
|
|
||||||
bounds: commonBounds,
|
bounds: commonBounds,
|
||||||
appState,
|
appState,
|
||||||
fitToViewport,
|
fitToViewport,
|
||||||
viewportZoomFactor,
|
viewportZoomFactor,
|
||||||
minZoom,
|
|
||||||
maxZoom,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -393,7 +383,6 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
fitToViewport: false,
|
fitToViewport: false,
|
||||||
canvasOffsets: app.getEditorUIOffsets(),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||||
@@ -419,7 +408,6 @@ export const actionZoomToFitSelection = register({
|
|||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
fitToViewport: true,
|
fitToViewport: true,
|
||||||
canvasOffsets: app.getEditorUIOffsets(),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// NOTE this action should use shift-2 per figma, alas
|
// NOTE this action should use shift-2 per figma, alas
|
||||||
@@ -436,7 +424,7 @@ export const actionZoomToFit = register({
|
|||||||
icon: zoomAreaIcon,
|
icon: zoomAreaIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState) =>
|
||||||
zoomToFit({
|
zoomToFit({
|
||||||
targetElements: elements,
|
targetElements: elements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -444,7 +432,6 @@ export const actionZoomToFit = register({
|
|||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
fitToViewport: false,
|
fitToViewport: false,
|
||||||
canvasOffsets: app.getEditorUIOffsets(),
|
|
||||||
}),
|
}),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||||
import { getTextFromElements, isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
@@ -239,8 +239,16 @@ export const copyText = register({
|
|||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const text = selectedElements
|
||||||
|
.reduce((acc: string[], element) => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
acc.push(element.text);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.join("\n\n");
|
||||||
try {
|
try {
|
||||||
copyTextToSystemClipboard(getTextFromElements(selectedElements));
|
copyTextToSystemClipboard(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import type { ActionResult } from "./types";
|
import type { ActionResult } from "./types";
|
||||||
import { DEFAULT_GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
@@ -42,21 +42,20 @@ export const actionDuplicateSelection = register({
|
|||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||||
try {
|
appState,
|
||||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
app.scene.getNonDeletedElementsMap(),
|
||||||
appState,
|
);
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
if (!ret) {
|
||||||
elements,
|
|
||||||
appState: newAppState,
|
|
||||||
storeAction: StoreAction.CAPTURE,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
appState: ret.appState,
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -100,8 +99,8 @@ const duplicateElements = (
|
|||||||
groupIdMap,
|
groupIdMap,
|
||||||
element,
|
element,
|
||||||
{
|
{
|
||||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
x: element.x + GRID_SIZE / 2,
|
||||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
y: element.y + GRID_SIZE / 2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
duplicatedElementsMap.set(newElement.id, newElement);
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { done } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
|
import { isPathALoop } from "../math";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
@@ -15,8 +16,6 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { point } from "../../math";
|
|
||||||
import { isPathALoop } from "../shapes";
|
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
@@ -51,6 +50,7 @@ export const actionFinalize = register({
|
|||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
|
selectedLinearElement: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
@@ -113,10 +113,10 @@ export const actionFinalize = register({
|
|||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
mutateElement(multiPointElement, {
|
mutateElement(multiPointElement, {
|
||||||
points: linePoints.map((p, index) =>
|
points: linePoints.map((point, index) =>
|
||||||
index === linePoints.length - 1
|
index === linePoints.length - 1
|
||||||
? point(firstPoint[0], firstPoint[1])
|
? ([firstPoint[0], firstPoint[1]] as const)
|
||||||
: p,
|
: point,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ export const actionFinalize = register({
|
|||||||
newElement: null,
|
newElement: null,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingTextElement: null,
|
editingElement: null,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
@@ -217,7 +217,6 @@ export const actionFinalize = register({
|
|||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
visible={appState.multiElement != null}
|
visible={appState.multiElement != null}
|
||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
style={{ pointerEvents: "all" }}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Excalidraw } from "../index";
|
|
||||||
import { render } from "../tests/test-utils";
|
|
||||||
import { API } from "../tests/helpers/api";
|
|
||||||
import { point } from "../../math";
|
|
||||||
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
describe("flipping re-centers selection", () => {
|
|
||||||
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
|
|
||||||
const elements = [
|
|
||||||
API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
id: "rec1",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
boundElements: [{ id: "arr", type: "arrow" }],
|
|
||||||
}),
|
|
||||||
API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
id: "rec2",
|
|
||||||
x: 220,
|
|
||||||
y: 250,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
boundElements: [{ id: "arr", type: "arrow" }],
|
|
||||||
}),
|
|
||||||
API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arr",
|
|
||||||
x: 149.9,
|
|
||||||
y: 95,
|
|
||||||
width: 156,
|
|
||||||
height: 239.9,
|
|
||||||
startBinding: {
|
|
||||||
elementId: "rec1",
|
|
||||||
focus: 0,
|
|
||||||
gap: 5,
|
|
||||||
fixedPoint: [0.49, -0.05],
|
|
||||||
},
|
|
||||||
endBinding: {
|
|
||||||
elementId: "rec2",
|
|
||||||
focus: 0,
|
|
||||||
gap: 5,
|
|
||||||
fixedPoint: [-0.05, 0.49],
|
|
||||||
},
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: "arrow",
|
|
||||||
points: [
|
|
||||||
point(0, 0),
|
|
||||||
point(0, -35),
|
|
||||||
point(-90.9, -35),
|
|
||||||
point(-90.9, 204.9),
|
|
||||||
point(65.1, 204.9),
|
|
||||||
],
|
|
||||||
elbowed: true,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
await render(<Excalidraw initialData={{ elements }} />);
|
|
||||||
|
|
||||||
API.setSelectedElements(elements);
|
|
||||||
|
|
||||||
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
|
|
||||||
const rec1 = h.elements.find((el) => el.id === "rec1");
|
|
||||||
expect(rec1?.x).toBeCloseTo(100);
|
|
||||||
expect(rec1?.y).toBeCloseTo(100);
|
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2");
|
|
||||||
expect(rec2?.x).toBeCloseTo(220);
|
|
||||||
expect(rec2?.y).toBeCloseTo(250);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("flipping arrowheads", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<Excalidraw />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flipping bound arrow should flip arrowheads only", () => {
|
|
||||||
const rect = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
|
||||||
});
|
|
||||||
const arrow = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startArrowhead: "arrow",
|
|
||||||
endArrowhead: null,
|
|
||||||
endBinding: {
|
|
||||||
elementId: rect.id,
|
|
||||||
focus: 0.5,
|
|
||||||
gap: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect, arrow]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe(null);
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
|
||||||
|
|
||||||
API.executeAction(actionFlipVertical);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe(null);
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flipping bound arrow should flip arrowheads only 2", () => {
|
|
||||||
const rect = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
|
||||||
});
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
|
||||||
});
|
|
||||||
const arrow = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startArrowhead: "arrow",
|
|
||||||
endArrowhead: "circle",
|
|
||||||
startBinding: {
|
|
||||||
elementId: rect.id,
|
|
||||||
focus: 0.5,
|
|
||||||
gap: 5,
|
|
||||||
},
|
|
||||||
endBinding: {
|
|
||||||
elementId: rect2.id,
|
|
||||||
focus: 0.5,
|
|
||||||
gap: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect, rect2, arrow]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("circle");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
|
||||||
|
|
||||||
API.executeAction(actionFlipVertical);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flipping unbound arrow shouldn't flip arrowheads", () => {
|
|
||||||
const arrow = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startArrowhead: "arrow",
|
|
||||||
endArrowhead: "circle",
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([arrow]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
|
|
||||||
const rect = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
|
||||||
});
|
|
||||||
const arrow = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startArrowhead: "arrow",
|
|
||||||
endArrowhead: null,
|
|
||||||
endBinding: {
|
|
||||||
elementId: rect.id,
|
|
||||||
focus: 0.5,
|
|
||||||
gap: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect, arrow]);
|
|
||||||
API.setSelectedElements([rect, arrow]);
|
|
||||||
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,8 +2,6 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
|
||||||
ExcalidrawElbowArrowElement,
|
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
@@ -20,13 +18,7 @@ import {
|
|||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import {
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
isArrowElement,
|
|
||||||
isElbowArrow,
|
|
||||||
isLinearElement,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
@@ -117,23 +109,7 @@ const flipElements = (
|
|||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
if (
|
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||||
selectedElements.every(
|
|
||||||
(element) =>
|
|
||||||
isArrowElement(element) && (element.startBinding || element.endBinding),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return selectedElements.map((element) => {
|
|
||||||
const _element = element as ExcalidrawArrowElement;
|
|
||||||
return newElementWith(_element, {
|
|
||||||
startArrowhead: _element.endArrowhead,
|
|
||||||
endArrowhead: _element.startArrowhead,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { minX, minY, maxX, maxY, midX, midY } =
|
|
||||||
getCommonBoundingBox(selectedElements);
|
|
||||||
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -155,48 +131,5 @@ const flipElements = (
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// flipping arrow elements (and potentially other) makes the selection group
|
|
||||||
// "move" across the canvas because of how arrows can bump against the "wall"
|
|
||||||
// of the selection, so we need to center the group back to the original
|
|
||||||
// position so that repeated flips don't accumulate the offset
|
|
||||||
|
|
||||||
const { elbowArrows, otherElements } = selectedElements.reduce(
|
|
||||||
(
|
|
||||||
acc: {
|
|
||||||
elbowArrows: ExcalidrawElbowArrowElement[];
|
|
||||||
otherElements: ExcalidrawElement[];
|
|
||||||
},
|
|
||||||
element,
|
|
||||||
) =>
|
|
||||||
isElbowArrow(element)
|
|
||||||
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
|
|
||||||
: { ...acc, otherElements: acc.otherElements.concat(element) },
|
|
||||||
{ elbowArrows: [], otherElements: [] },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { midX: newMidX, midY: newMidY } =
|
|
||||||
getCommonBoundingBox(selectedElements);
|
|
||||||
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
|
|
||||||
otherElements.forEach((element) =>
|
|
||||||
mutateElement(element, {
|
|
||||||
x: element.x + diffX,
|
|
||||||
y: element.y + diffY,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
elbowArrows.forEach((element) =>
|
|
||||||
mutateElbowArrow(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
element.points,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
informMutation: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
return selectedElements;
|
return selectedElements;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
@@ -13,19 +13,17 @@ import type { Store } from "../store";
|
|||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
|
|
||||||
const executeHistoryAction = (
|
const writeData = (
|
||||||
app: AppClassProperties,
|
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
updater: () => [SceneElementsMap, AppState] | void,
|
updater: () => [SceneElementsMap, AppState] | void,
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
if (
|
if (
|
||||||
!appState.multiElement &&
|
!appState.multiElement &&
|
||||||
!appState.resizingElement &&
|
!appState.resizingElement &&
|
||||||
!appState.editingTextElement &&
|
!appState.editingElement &&
|
||||||
!appState.newElement &&
|
!appState.newElement &&
|
||||||
!appState.selectedElementsAreBeingDragged &&
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
!appState.selectionElement &&
|
!appState.selectionElement
|
||||||
!app.flowChartCreator.isCreatingChart
|
|
||||||
) {
|
) {
|
||||||
const result = updater();
|
const result = updater();
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) =>
|
perform: (elements, appState, value, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
writeData(appState, () =>
|
||||||
history.undo(
|
history.undo(
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
appState,
|
appState,
|
||||||
@@ -96,7 +94,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState, _, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
writeData(appState, () =>
|
||||||
history.redo(
|
history.redo(
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
appState,
|
appState,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
|
||||||
import type { StoreActionType } from "../store";
|
import type { StoreActionType } from "../store";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||||
@@ -115,8 +115,6 @@ import {
|
|||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import type { LocalPoint } from "../../math";
|
|
||||||
import { point, vector } from "../../math";
|
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -135,7 +133,7 @@ export const changeProperty = (
|
|||||||
return elements.map((element) => {
|
return elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
selectedElementIds.get(element.id) ||
|
selectedElementIds.get(element.id) ||
|
||||||
element.id === appState.editingTextElement?.id
|
element.id === appState.editingElement?.id
|
||||||
) {
|
) {
|
||||||
return callback(element);
|
return callback(element);
|
||||||
}
|
}
|
||||||
@@ -150,13 +148,13 @@ export const getFormValue = function <T extends Primitive>(
|
|||||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||||
): T {
|
): T {
|
||||||
const editingTextElement = appState.editingTextElement;
|
const editingElement = appState.editingElement;
|
||||||
const nonDeletedElements = getNonDeletedElements(elements);
|
const nonDeletedElements = getNonDeletedElements(elements);
|
||||||
|
|
||||||
let ret: T | null = null;
|
let ret: T | null = null;
|
||||||
|
|
||||||
if (editingTextElement) {
|
if (editingElement) {
|
||||||
ret = getAttribute(editingTextElement);
|
ret = getAttribute(editingElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
@@ -1075,20 +1073,19 @@ export const actionChangeFontFamily = register({
|
|||||||
// open, populate the cache from scratch
|
// open, populate the cache from scratch
|
||||||
cachedElementsRef.current.clear();
|
cachedElementsRef.current.clear();
|
||||||
|
|
||||||
const { editingTextElement } = appState;
|
const { editingElement } = appState;
|
||||||
|
|
||||||
// still check type to be safe
|
if (editingElement?.type === "text") {
|
||||||
if (editingTextElement?.type === "text") {
|
// retrieve the latest version from the scene, as `editingElement` isn't mutated
|
||||||
// retrieve the latest version from the scene, as `editingTextElement` isn't mutated
|
const latestEditingElement = app.scene.getElement(
|
||||||
const latesteditingTextElement = app.scene.getElement(
|
editingElement.id,
|
||||||
editingTextElement.id,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// inside the wysiwyg editor
|
// inside the wysiwyg editor
|
||||||
cachedElementsRef.current.set(
|
cachedElementsRef.current.set(
|
||||||
editingTextElement.id,
|
editingElement.id,
|
||||||
newElementWith(
|
newElementWith(
|
||||||
latesteditingTextElement || editingTextElement,
|
latestEditingElement || editingElement,
|
||||||
{},
|
{},
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
@@ -1650,10 +1647,10 @@ export const actionChangeArrowType = register({
|
|||||||
newElement,
|
newElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
[finalStartPoint, finalEndPoint].map(
|
[finalStartPoint, finalEndPoint].map(
|
||||||
(p): LocalPoint =>
|
(point) =>
|
||||||
point(p[0] - newElement.x, p[1] - newElement.y),
|
[point[0] - newElement.x, point[1] - newElement.y] as Point,
|
||||||
),
|
),
|
||||||
vector(0, 0),
|
[0, 0],
|
||||||
{
|
{
|
||||||
...(startElement && newElement.startBinding
|
...(startElement && newElement.startBinding
|
||||||
? {
|
? {
|
||||||
@@ -1685,6 +1682,19 @@ export const actionChangeArrowType = register({
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
mutateElement(
|
||||||
|
newElement,
|
||||||
|
{
|
||||||
|
startBinding: newElement.startBinding
|
||||||
|
? { ...newElement.startBinding, fixedPoint: null }
|
||||||
|
: null,
|
||||||
|
endBinding: newElement.endBinding
|
||||||
|
? { ...newElement.endBinding, fixedPoint: null }
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElement;
|
return newElement;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { GRID_SIZE } from "../constants";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { gridIcon } from "../components/icons";
|
import { gridIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
@@ -12,21 +13,21 @@ export const actionToggleGridMode = register({
|
|||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
predicate: (appState) => appState.gridModeEnabled,
|
predicate: (appState) => !appState.gridSize,
|
||||||
},
|
},
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
gridModeEnabled: !this.checked!(appState),
|
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
checked: (appState: AppState) => appState.gridSize !== null,
|
||||||
predicate: (element, appState, props) => {
|
predicate: (element, appState, props) => {
|
||||||
return props.gridModeEnabled === undefined;
|
return typeof props.gridModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
objectsSnapModeEnabled: !this.checked!(appState),
|
objectsSnapModeEnabled: !this.checked!(appState),
|
||||||
gridModeEnabled: false,
|
gridSize: null,
|
||||||
},
|
},
|
||||||
storeAction: StoreAction.NONE,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { KEYS } from "../keys";
|
|
||||||
import { register } from "./register";
|
|
||||||
import type { AppState } from "../types";
|
|
||||||
import { searchIcon } from "../components/icons";
|
|
||||||
import { StoreAction } from "../store";
|
|
||||||
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
|
||||||
|
|
||||||
export const actionToggleSearchMenu = register({
|
|
||||||
name: "searchMenu",
|
|
||||||
icon: searchIcon,
|
|
||||||
keywords: ["search", "find"],
|
|
||||||
label: "search.title",
|
|
||||||
viewMode: true,
|
|
||||||
trackEvent: {
|
|
||||||
category: "search_menu",
|
|
||||||
action: "toggle",
|
|
||||||
predicate: (appState) => appState.gridModeEnabled,
|
|
||||||
},
|
|
||||||
perform(elements, appState, _, app) {
|
|
||||||
if (
|
|
||||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
|
||||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
|
||||||
) {
|
|
||||||
const searchInput =
|
|
||||||
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
|
||||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (searchInput?.matches(":focus")) {
|
|
||||||
return {
|
|
||||||
appState: { ...appState, openSidebar: null },
|
|
||||||
storeAction: StoreAction.NONE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
searchInput?.focus();
|
|
||||||
searchInput?.select();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
|
|
||||||
openDialog: null,
|
|
||||||
},
|
|
||||||
storeAction: StoreAction.NONE,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
|
||||||
predicate: (element, appState, props) => {
|
|
||||||
return props.gridModeEnabled === undefined;
|
|
||||||
},
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
|
|
||||||
});
|
|
||||||
@@ -86,5 +86,3 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
|
|||||||
export { actionLink } from "./actionLink";
|
export { actionLink } from "./actionLink";
|
||||||
export { actionToggleElementLock } from "./actionElementLock";
|
export { actionToggleElementLock } from "./actionElementLock";
|
||||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||||
|
|
||||||
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ export type ShortcutName =
|
|||||||
>
|
>
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport"
|
| "imageExport"
|
||||||
| "commandPalette"
|
| "commandPalette";
|
||||||
| "searchMenu";
|
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||||
@@ -113,7 +112,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
toggleShortcuts: [getShortcutKey("?")],
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
|
|||||||
@@ -137,8 +137,7 @@ export type ActionName =
|
|||||||
| "wrapTextInContainer"
|
| "wrapTextInContainer"
|
||||||
| "commandPalette"
|
| "commandPalette"
|
||||||
| "autoResize"
|
| "autoResize"
|
||||||
| "elementStats"
|
| "elementStats";
|
||||||
| "searchMenu";
|
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@@ -192,8 +191,7 @@ export interface Action {
|
|||||||
| "history"
|
| "history"
|
||||||
| "menu"
|
| "menu"
|
||||||
| "collab"
|
| "collab"
|
||||||
| "hyperlink"
|
| "hyperlink";
|
||||||
| "search_menu";
|
|
||||||
action?: string;
|
action?: string;
|
||||||
predicate?: (
|
predicate?: (
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// place here categories that you want to track. We want to track just a
|
// place here categories that you want to track. We want to track just a
|
||||||
// small subset of categories at a given time.
|
// small subset of categories at a given time.
|
||||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
|
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||||
|
|
||||||
export const trackEvent = (
|
export const trackEvent = (
|
||||||
category: string,
|
category: string,
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_GRID_SIZE,
|
|
||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
STATS_PANELS,
|
STATS_PANELS,
|
||||||
THEME,
|
THEME,
|
||||||
DEFAULT_GRID_STEP,
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import type { AppState, NormalizedZoomValue } from "./types";
|
import type { AppState, NormalizedZoomValue } from "./types";
|
||||||
|
|
||||||
@@ -44,7 +42,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
newElement: null,
|
newElement: null,
|
||||||
editingTextElement: null,
|
editingElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
activeTool: {
|
activeTool: {
|
||||||
@@ -61,9 +59,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
exportWithDarkMode: false,
|
exportWithDarkMode: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
gridSize: DEFAULT_GRID_SIZE,
|
gridSize: null,
|
||||||
gridStep: DEFAULT_GRID_STEP,
|
|
||||||
gridModeEnabled: false,
|
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
defaultSidebarDockedPreference: false,
|
defaultSidebarDockedPreference: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -116,7 +112,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
followedBy: new Set(),
|
followedBy: new Set(),
|
||||||
searchMatches: [],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,7 +161,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
cursorButton: { browser: true, export: false, server: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
activeEmbeddable: { browser: false, export: false, server: false },
|
activeEmbeddable: { browser: false, export: false, server: false },
|
||||||
newElement: { browser: false, export: false, server: false },
|
newElement: { browser: false, export: false, server: false },
|
||||||
editingTextElement: { browser: false, export: false, server: false },
|
editingElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
activeTool: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
@@ -179,8 +174,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
exportWithDarkMode: { browser: true, export: false, server: false },
|
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||||
fileHandle: { browser: false, export: false, server: false },
|
fileHandle: { browser: false, export: false, server: false },
|
||||||
gridSize: { browser: true, export: true, server: true },
|
gridSize: { browser: true, export: true, server: true },
|
||||||
gridStep: { browser: true, export: true, server: true },
|
|
||||||
gridModeEnabled: { browser: true, export: true, server: true },
|
|
||||||
height: { browser: false, export: false, server: false },
|
height: { browser: false, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false, server: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
defaultSidebarDockedPreference: {
|
defaultSidebarDockedPreference: {
|
||||||
@@ -237,7 +230,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||||
userToFollow: { browser: false, export: false, server: false },
|
userToFollow: { browser: false, export: false, server: false },
|
||||||
followedBy: { browser: false, export: false, server: false },
|
followedBy: { browser: false, export: false, server: false },
|
||||||
searchMatches: { browser: false, export: false, server: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Radians } from "../math";
|
|
||||||
import { point } from "../math";
|
|
||||||
import {
|
import {
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
DEFAULT_CHART_COLOR_INDEX,
|
DEFAULT_CHART_COLOR_INDEX,
|
||||||
@@ -205,7 +203,7 @@ const chartXLabels = (
|
|||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||||
y: y + BAR_GAP / 2,
|
y: y + BAR_GAP / 2,
|
||||||
width: BAR_WIDTH,
|
width: BAR_WIDTH,
|
||||||
angle: 5.87 as Radians,
|
angle: 5.87,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
verticalAlign: "top",
|
verticalAlign: "top",
|
||||||
@@ -260,7 +258,10 @@ const chartLines = (
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
points: [point(0, 0), point(chartWidth, 0)],
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[chartWidth, 0],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const yLine = newLinearElement({
|
const yLine = newLinearElement({
|
||||||
@@ -271,7 +272,10 @@ const chartLines = (
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
height: chartHeight,
|
height: chartHeight,
|
||||||
points: [point(0, 0), point(0, -chartHeight)],
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0, -chartHeight],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxLine = newLinearElement({
|
const maxLine = newLinearElement({
|
||||||
@@ -284,7 +288,10 @@ const chartLines = (
|
|||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
opacity: GRID_OPACITY,
|
opacity: GRID_OPACITY,
|
||||||
points: [point(0, 0), point(chartWidth, 0)],
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[chartWidth, 0],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return [xLine, yLine, maxLine];
|
return [xLine, yLine, maxLine];
|
||||||
@@ -441,7 +448,10 @@ const chartTypeLine = (
|
|||||||
height: cy,
|
height: cy,
|
||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
opacity: GRID_OPACITY,
|
opacity: GRID_OPACITY,
|
||||||
points: [point(0, 0), point(0, cy)],
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0, cy],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ import {
|
|||||||
frameToolIcon,
|
frameToolIcon,
|
||||||
mermaidLogoIcon,
|
mermaidLogoIcon,
|
||||||
laserPointerToolIcon,
|
laserPointerToolIcon,
|
||||||
|
OpenAIIcon,
|
||||||
MagicIcon,
|
MagicIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
import { CLASSES } from "../constants";
|
|
||||||
|
|
||||||
export const canChangeStrokeColor = (
|
export const canChangeStrokeColor = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -104,9 +104,7 @@ export const SelectedShapeActions = ({
|
|||||||
) {
|
) {
|
||||||
isSingleElementBoundContainer = true;
|
isSingleElementBoundContainer = true;
|
||||||
}
|
}
|
||||||
const isEditingTextOrNewElement = Boolean(
|
const isEditing = Boolean(appState.editingElement);
|
||||||
appState.editingTextElement || appState.newElement,
|
|
||||||
);
|
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
@@ -236,7 +234,7 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
{!isEditing && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -402,7 +400,7 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.mermaidToExcalidraw")}
|
{t("toolBar.mermaidToExcalidraw")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
|
{app.props.aiEnabled !== false && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => app.onMagicframeToolSelect()}
|
onSelect={() => app.onMagicframeToolSelect()}
|
||||||
@@ -412,6 +410,20 @@ export const ShapesSwitcher = ({
|
|||||||
{t("toolBar.magicframe")}
|
{t("toolBar.magicframe")}
|
||||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
trackEvent("ai", "open-settings", "d2c");
|
||||||
|
app.setOpenDialog({
|
||||||
|
name: "settings",
|
||||||
|
source: "settings",
|
||||||
|
tab: "diagram-to-code",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
icon={OpenAIIcon}
|
||||||
|
data-testid="toolbar-magicSettings"
|
||||||
|
>
|
||||||
|
{t("toolBar.magicSettings")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
@@ -427,7 +439,7 @@ export const ZoomActions = ({
|
|||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
zoom: Zoom;
|
zoom: Zoom;
|
||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
|
<Stack.Col gap={1} className="zoom-actions">
|
||||||
<Stack.Row align="center">
|
<Stack.Row align="center">
|
||||||
{renderAction("zoomOut")}
|
{renderAction("zoomOut")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
|
|||||||
return (
|
return (
|
||||||
<PropertiesPopover
|
<PropertiesPopover
|
||||||
container={container}
|
container={container}
|
||||||
style={{ maxWidth: "13rem" }}
|
style={{ maxWidth: "208px" }}
|
||||||
onFocusOutside={(event) => {
|
onFocusOutside={(event) => {
|
||||||
// refocus due to eye dropper
|
// refocus due to eye dropper
|
||||||
focusPickerContent();
|
focusPickerContent();
|
||||||
|
|||||||
@@ -43,11 +43,7 @@ import { InlineIcon } from "../InlineIcon";
|
|||||||
import { SHAPES } from "../../shapes";
|
import { SHAPES } from "../../shapes";
|
||||||
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
||||||
import { useStableCallback } from "../../hooks/useStableCallback";
|
import { useStableCallback } from "../../hooks/useStableCallback";
|
||||||
import {
|
import { actionClearCanvas, actionLink } from "../../actions";
|
||||||
actionClearCanvas,
|
|
||||||
actionLink,
|
|
||||||
actionToggleSearchMenu,
|
|
||||||
} from "../../actions";
|
|
||||||
import { jotaiStore } from "../../jotai";
|
import { jotaiStore } from "../../jotai";
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import type { CommandPaletteItem } from "./types";
|
import type { CommandPaletteItem } from "./types";
|
||||||
@@ -386,15 +382,6 @@ function CommandPaletteInner({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("search.title"),
|
|
||||||
category: DEFAULT_CATEGORIES.app,
|
|
||||||
icon: searchIcon,
|
|
||||||
viewMode: true,
|
|
||||||
perform: () => {
|
|
||||||
actionManager.executeAction(actionToggleSearchMenu);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: t("labels.changeStroke"),
|
label: t("labels.changeStroke"),
|
||||||
keywords: ["color", "outline"],
|
keywords: ["color", "outline"],
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
|
||||||
CANVAS_SEARCH_TAB,
|
|
||||||
DEFAULT_SIDEBAR,
|
|
||||||
LIBRARY_SIDEBAR_TAB,
|
|
||||||
} from "../constants";
|
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
import { useUIAppState } from "../context/ui-appState";
|
import { useUIAppState } from "../context/ui-appState";
|
||||||
|
import { t } from "../i18n";
|
||||||
import type { MarkOptional, Merge } from "../utility-types";
|
import type { MarkOptional, Merge } from "../utility-types";
|
||||||
import { composeEventHandlers } from "../utils";
|
import { composeEventHandlers } from "../utils";
|
||||||
import { useExcalidrawSetAppState } from "./App";
|
import { useExcalidrawSetAppState } from "./App";
|
||||||
@@ -13,9 +10,6 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
|
|||||||
import { LibraryMenu } from "./LibraryMenu";
|
import { LibraryMenu } from "./LibraryMenu";
|
||||||
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||||
import { Sidebar } from "./Sidebar/Sidebar";
|
import { Sidebar } from "./Sidebar/Sidebar";
|
||||||
import "../components/dropdownMenu/DropdownMenu.scss";
|
|
||||||
import { SearchMenu } from "./SearchMenu";
|
|
||||||
import { LibraryIcon, searchIcon } from "./icons";
|
|
||||||
|
|
||||||
const DefaultSidebarTrigger = withInternalFallback(
|
const DefaultSidebarTrigger = withInternalFallback(
|
||||||
"DefaultSidebarTrigger",
|
"DefaultSidebarTrigger",
|
||||||
@@ -37,11 +31,14 @@ const DefaultSidebarTrigger = withInternalFallback(
|
|||||||
);
|
);
|
||||||
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
||||||
|
|
||||||
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
|
const DefaultTabTriggers = ({
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
|
||||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||||
return (
|
return (
|
||||||
<DefaultSidebarTabTriggersTunnel.In>
|
<DefaultSidebarTabTriggersTunnel.In>
|
||||||
{children}
|
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
|
||||||
</DefaultSidebarTabTriggersTunnel.In>
|
</DefaultSidebarTabTriggersTunnel.In>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -68,21 +65,17 @@ export const DefaultSidebar = Object.assign(
|
|||||||
|
|
||||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||||
|
|
||||||
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{...rest}
|
{...rest}
|
||||||
name="default"
|
name="default"
|
||||||
key="default"
|
key="default"
|
||||||
className={clsx("default-sidebar", className)}
|
className={clsx("default-sidebar", className)}
|
||||||
docked={
|
docked={docked ?? appState.defaultSidebarDockedPreference}
|
||||||
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
|
|
||||||
}
|
|
||||||
onDock={
|
onDock={
|
||||||
// `onDock=false` disables docking.
|
// `onDock=false` disables docking.
|
||||||
// if `docked` passed, but no onDock passed, disable manual docking.
|
// if `docked` passed, but no onDock passed, disable manual docking.
|
||||||
isForceDocked || onDock === false || (!onDock && docked != null)
|
onDock === false || (!onDock && docked != null)
|
||||||
? undefined
|
? undefined
|
||||||
: // compose to allow the host app to listen on default behavior
|
: // compose to allow the host app to listen on default behavior
|
||||||
composeEventHandlers(onDock, (docked) => {
|
composeEventHandlers(onDock, (docked) => {
|
||||||
@@ -92,22 +85,26 @@ export const DefaultSidebar = Object.assign(
|
|||||||
>
|
>
|
||||||
<Sidebar.Tabs>
|
<Sidebar.Tabs>
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
<Sidebar.TabTriggers>
|
{rest.__fallback && (
|
||||||
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
|
<div
|
||||||
{searchIcon}
|
style={{
|
||||||
</Sidebar.TabTrigger>
|
color: "var(--color-primary)",
|
||||||
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
|
fontSize: "1.2em",
|
||||||
{LibraryIcon}
|
fontWeight: "bold",
|
||||||
</Sidebar.TabTrigger>
|
textOverflow: "ellipsis",
|
||||||
<DefaultSidebarTabTriggersTunnel.Out />
|
overflow: "hidden",
|
||||||
</Sidebar.TabTriggers>
|
whiteSpace: "nowrap",
|
||||||
|
paddingRight: "1em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("toolBar.library")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DefaultSidebarTabTriggersTunnel.Out />
|
||||||
</Sidebar.Header>
|
</Sidebar.Header>
|
||||||
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
||||||
<LibraryMenu />
|
<LibraryMenu />
|
||||||
</Sidebar.Tab>
|
</Sidebar.Tab>
|
||||||
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
|
|
||||||
<SearchMenu />
|
|
||||||
</Sidebar.Tab>
|
|
||||||
{children}
|
{children}
|
||||||
</Sidebar.Tabs>
|
</Sidebar.Tabs>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useLayoutEffect } from "react";
|
|
||||||
import { useApp } from "../App";
|
|
||||||
import type { GenerateDiagramToCode } from "../../types";
|
|
||||||
|
|
||||||
export const DiagramToCodePlugin = (props: {
|
|
||||||
generate: GenerateDiagramToCode;
|
|
||||||
}) => {
|
|
||||||
const app = useApp();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
app.setPlugins({
|
|
||||||
diagramToCode: { generate: props.generate },
|
|
||||||
});
|
|
||||||
}, [app, props.generate]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
@import "../css/variables.module.scss";
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
@keyframes successStatusAnimation {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: scale(1.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ExcButton {
|
.ExcButton {
|
||||||
--text-color: transparent;
|
--text-color: transparent;
|
||||||
@@ -30,20 +16,11 @@
|
|||||||
|
|
||||||
.Spinner {
|
.Spinner {
|
||||||
--spinner-color: var(--color-surface-lowest);
|
--spinner-color: var(--color-surface-lowest);
|
||||||
}
|
|
||||||
|
|
||||||
.ExcButton__statusIcon {
|
|
||||||
visibility: visible;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
visibility: visible;
|
||||||
width: 1.2rem;
|
|
||||||
height: 1.2rem;
|
|
||||||
|
|
||||||
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ExcButton--status-loading,
|
&[disabled] {
|
||||||
&.ExcButton--status-success {
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
.ExcButton__contents {
|
.ExcButton__contents {
|
||||||
@@ -51,10 +28,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[disabled] {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&,
|
&,
|
||||||
&__contents {
|
&__contents {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -146,46 +119,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--color-success {
|
|
||||||
&.ExcButton--variant-filled {
|
|
||||||
--text-color: var(--color-success-text);
|
|
||||||
--back-color: var(--color-success);
|
|
||||||
|
|
||||||
.Spinner {
|
|
||||||
--spinner-color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
--back-color: var(--color-success-darker);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
--back-color: var(--color-success-darkest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ExcButton--variant-outlined,
|
|
||||||
&.ExcButton--variant-icon {
|
|
||||||
--text-color: var(--color-success-contrast);
|
|
||||||
--border-color: var(--color-success-contrast);
|
|
||||||
--back-color: transparent;
|
|
||||||
|
|
||||||
.Spinner {
|
|
||||||
--spinner-color: var(--color-success-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
--text-color: var(--color-success-contrast-hover);
|
|
||||||
--border-color: var(--color-success-contrast-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
--text-color: var(--color-success-contrast-active);
|
|
||||||
--border-color: var(--color-success-contrast-active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--color-muted {
|
&--color-muted {
|
||||||
&.ExcButton--variant-filled {
|
&.ExcButton--variant-filled {
|
||||||
--text-color: var(--island-bg-color);
|
--text-color: var(--island-bg-color);
|
||||||
|
|||||||
@@ -5,15 +5,9 @@ import "./FilledButton.scss";
|
|||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import { isPromiseLike } from "../utils";
|
import { isPromiseLike } from "../utils";
|
||||||
import { tablerCheckIcon } from "./icons";
|
|
||||||
|
|
||||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||||
export type ButtonColor =
|
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
||||||
| "primary"
|
|
||||||
| "danger"
|
|
||||||
| "warning"
|
|
||||||
| "muted"
|
|
||||||
| "success";
|
|
||||||
export type ButtonSize = "medium" | "large";
|
export type ButtonSize = "medium" | "large";
|
||||||
|
|
||||||
export type FilledButtonProps = {
|
export type FilledButtonProps = {
|
||||||
@@ -21,7 +15,6 @@ export type FilledButtonProps = {
|
|||||||
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClick?: (event: React.MouseEvent) => void;
|
onClick?: (event: React.MouseEvent) => void;
|
||||||
status?: null | "loading" | "success";
|
|
||||||
|
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
color?: ButtonColor;
|
color?: ButtonColor;
|
||||||
@@ -44,7 +37,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
size = "medium",
|
size = "medium",
|
||||||
fullWidth,
|
fullWidth,
|
||||||
className,
|
className,
|
||||||
status,
|
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@@ -54,11 +46,8 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
const ret = onClick?.(event);
|
const ret = onClick?.(event);
|
||||||
|
|
||||||
if (isPromiseLike(ret)) {
|
if (isPromiseLike(ret)) {
|
||||||
// delay loading state to prevent flicker in case of quick response
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setIsLoading(true);
|
|
||||||
}, 50);
|
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
await ret;
|
await ret;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!(error instanceof AbortError)) {
|
if (!(error instanceof AbortError)) {
|
||||||
@@ -67,15 +56,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const _status = isLoading ? "loading" : status;
|
|
||||||
color = _status === "success" ? "success" : color;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -83,7 +68,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
`ExcButton--color-${color}`,
|
`ExcButton--color-${color}`,
|
||||||
`ExcButton--variant-${variant}`,
|
`ExcButton--variant-${variant}`,
|
||||||
`ExcButton--size-${size}`,
|
`ExcButton--size-${size}`,
|
||||||
`ExcButton--status-${_status}`,
|
|
||||||
{ "ExcButton--fullWidth": fullWidth },
|
{ "ExcButton--fullWidth": fullWidth },
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -91,16 +75,10 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabled={_status === "loading" || _status === "success"}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<div className="ExcButton__contents">
|
<div className="ExcButton__contents">
|
||||||
{_status === "loading" ? (
|
{isLoading && <Spinner />}
|
||||||
<Spinner className="ExcButton__statusIcon" />
|
|
||||||
) : (
|
|
||||||
_status === "success" && (
|
|
||||||
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{icon && (
|
{icon && (
|
||||||
<div className="ExcButton__icon" aria-hidden>
|
<div className="ExcButton__icon" aria-hidden>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -288,10 +288,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("stats.fullTitle")}
|
label={t("stats.fullTitle")}
|
||||||
shortcuts={[getShortcutKey("Alt+/")]}
|
shortcuts={[getShortcutKey("Alt+/")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
|
||||||
label={t("search.title")}
|
|
||||||
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("commandPalette.title")}
|
label={t("commandPalette.title")}
|
||||||
shortcuts={
|
shortcuts={
|
||||||
@@ -308,16 +304,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
className="HelpDialog__island--editor"
|
className="HelpDialog__island--editor"
|
||||||
caption={t("helpDialog.editor")}
|
caption={t("helpDialog.editor")}
|
||||||
>
|
>
|
||||||
<Shortcut
|
|
||||||
label={t("helpDialog.createFlowchart")}
|
|
||||||
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
|
|
||||||
isOr={true}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("helpDialog.navigateFlowchart")}
|
|
||||||
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
|
|
||||||
isOr={true}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.moveCanvas")}
|
label={t("labels.moveCanvas")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ $wide-viewport-width: 1000px;
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { AppClassProperties, Device, UIAppState } from "../types";
|
import type { AppClassProperties, Device, UIAppState } from "../types";
|
||||||
import {
|
import {
|
||||||
isFlowchartNodeElement,
|
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
@@ -11,9 +10,6 @@ import { getShortcutKey } from "../utils";
|
|||||||
import { isEraserActive } from "../appState";
|
import { isEraserActive } from "../appState";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { isNodeInFlowchart } from "../element/flowchart";
|
|
||||||
import { isGridModeEnabled } from "../snapping";
|
|
||||||
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
|
|
||||||
|
|
||||||
interface HintViewerProps {
|
interface HintViewerProps {
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
@@ -22,23 +18,10 @@ interface HintViewerProps {
|
|||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({
|
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||||
appState,
|
|
||||||
isMobile,
|
|
||||||
device,
|
|
||||||
app,
|
|
||||||
}: HintViewerProps): null | string | string[] => {
|
|
||||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (
|
|
||||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
|
||||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
|
||||||
appState.searchMatches?.length
|
|
||||||
) {
|
|
||||||
return t("hints.dismissSearch");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -96,7 +79,7 @@ const getHints = ({
|
|||||||
return t("hints.text_selected");
|
return t("hints.text_selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.editingTextElement) {
|
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
||||||
return t("hints.text_editing");
|
return t("hints.text_editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,13 +87,13 @@ const getHints = ({
|
|||||||
if (
|
if (
|
||||||
appState.selectionElement &&
|
appState.selectionElement &&
|
||||||
!selectedElements.length &&
|
!selectedElements.length &&
|
||||||
!appState.editingTextElement &&
|
!appState.editingElement &&
|
||||||
!appState.editingLinearElement
|
!appState.editingLinearElement
|
||||||
) {
|
) {
|
||||||
return t("hints.deepBoxSelect");
|
return t("hints.deepBoxSelect");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
if (appState.gridSize && appState.selectedElementsAreBeingDragged) {
|
||||||
return t("hints.disableSnapping");
|
return t("hints.disableSnapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,19 +115,6 @@ const getHints = ({
|
|||||||
!appState.selectedElementsAreBeingDragged &&
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
isTextBindableContainer(selectedElements[0])
|
isTextBindableContainer(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
if (isFlowchartNodeElement(selectedElements[0])) {
|
|
||||||
if (
|
|
||||||
isNodeInFlowchart(
|
|
||||||
selectedElements[0],
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
|
||||||
}
|
|
||||||
|
|
||||||
return t("hints.bindTextToElement");
|
return t("hints.bindTextToElement");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,24 +129,17 @@ export const HintViewer = ({
|
|||||||
device,
|
device,
|
||||||
app,
|
app,
|
||||||
}: HintViewerProps) => {
|
}: HintViewerProps) => {
|
||||||
const hints = getHints({
|
let hint = getHints({
|
||||||
appState,
|
appState,
|
||||||
isMobile,
|
isMobile,
|
||||||
device,
|
device,
|
||||||
app,
|
app,
|
||||||
});
|
});
|
||||||
|
if (!hint) {
|
||||||
if (!hints) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hint = Array.isArray(hints)
|
hint = getShortcutKey(hint);
|
||||||
? hints
|
|
||||||
.map((hint) => {
|
|
||||||
return getShortcutKey(hint).replace(/\. ?$/, "");
|
|
||||||
})
|
|
||||||
.join(". ")
|
|
||||||
: getShortcutKey(hints);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="HintViewer">
|
<div className="HintViewer">
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import "./ImageExportDialog.scss";
|
|||||||
import { FilledButton } from "./FilledButton";
|
import { FilledButton } from "./FilledButton";
|
||||||
import { cloneJSON } from "../utils";
|
import { cloneJSON } from "../utils";
|
||||||
import { prepareElementsForExport } from "../data";
|
import { prepareElementsForExport } from "../data";
|
||||||
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -90,21 +89,6 @@ const ImageExportModal = ({
|
|||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// if user changes setting right after export to clipboard, reset the status
|
|
||||||
// so they don't have to wait for the timeout to click the button again
|
|
||||||
resetCopyStatus();
|
|
||||||
}, [
|
|
||||||
projectName,
|
|
||||||
exportWithBackground,
|
|
||||||
exportDarkMode,
|
|
||||||
exportScale,
|
|
||||||
embedScene,
|
|
||||||
resetCopyStatus,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
elementsSnapshot,
|
elementsSnapshot,
|
||||||
appStateSnapshot,
|
appStateSnapshot,
|
||||||
@@ -121,7 +105,6 @@ const ImageExportModal = ({
|
|||||||
if (!maxWidth) {
|
if (!maxWidth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
exportToCanvas({
|
exportToCanvas({
|
||||||
elements: exportedElements,
|
elements: exportedElements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -311,17 +294,11 @@ const ImageExportModal = ({
|
|||||||
<FilledButton
|
<FilledButton
|
||||||
className="ImageExportModal__settings__buttons__button"
|
className="ImageExportModal__settings__buttons__button"
|
||||||
label={t("imageExportDialog.title.copyPngToClipboard")}
|
label={t("imageExportDialog.title.copyPngToClipboard")}
|
||||||
status={copyStatus}
|
onClick={() =>
|
||||||
onClick={async () => {
|
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
|
||||||
await onExportImage(
|
exportingFrame,
|
||||||
EXPORT_IMAGE_TYPES.clipboard,
|
})
|
||||||
exportedElements,
|
}
|
||||||
{
|
|
||||||
exportingFrame,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
onCopy();
|
|
||||||
}}
|
|
||||||
icon={copyIcon}
|
icon={copyIcon}
|
||||||
>
|
>
|
||||||
{t("imageExportDialog.button.copyPngToClipboard")}
|
{t("imageExportDialog.button.copyPngToClipboard")}
|
||||||
|
|||||||
@@ -27,6 +27,99 @@
|
|||||||
& > * {
|
& > * {
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > .Stats {
|
||||||
|
width: 204px;
|
||||||
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
pointer-events: var(--ui-pointerEvents);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elementType {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elementsCount {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsItem {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
th {
|
||||||
|
border-bottom: 1px solid var(--input-border-color);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td:nth-child(2) {
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--default-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 12px;
|
||||||
|
right: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
|
|||||||
@@ -53,17 +53,18 @@ import { LibraryIcon } from "./icons";
|
|||||||
import { UIAppStateContext } from "../context/ui-appState";
|
import { UIAppStateContext } from "../context/ui-appState";
|
||||||
import { DefaultSidebar } from "./DefaultSidebar";
|
import { DefaultSidebar } from "./DefaultSidebar";
|
||||||
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
||||||
|
|
||||||
|
import "./LayerUI.scss";
|
||||||
|
import "./Toolbar.scss";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { LaserPointerButton } from "./LaserPointerButton";
|
import { LaserPointerButton } from "./LaserPointerButton";
|
||||||
|
import { MagicSettings } from "./MagicSettings";
|
||||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { actionToggleStats } from "../actions";
|
import { actionToggleStats } from "../actions";
|
||||||
|
|
||||||
import "./LayerUI.scss";
|
|
||||||
import "./Toolbar.scss";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
@@ -84,6 +85,14 @@ interface LayerUIProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
|
openAIKey: string | null;
|
||||||
|
isOpenAIKeyPersisted: boolean;
|
||||||
|
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
|
||||||
|
onMagicSettingsConfirm: (
|
||||||
|
apiKey: string,
|
||||||
|
shouldPersist: boolean,
|
||||||
|
source: "tool" | "generation" | "settings",
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMainMenu: React.FC<{
|
const DefaultMainMenu: React.FC<{
|
||||||
@@ -99,7 +108,6 @@ const DefaultMainMenu: React.FC<{
|
|||||||
{UIOptions.canvasActions.saveAsImage && (
|
{UIOptions.canvasActions.saveAsImage && (
|
||||||
<MainMenu.DefaultItems.SaveAsImage />
|
<MainMenu.DefaultItems.SaveAsImage />
|
||||||
)}
|
)}
|
||||||
<MainMenu.DefaultItems.SearchMenu />
|
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
@@ -141,6 +149,10 @@ const LayerUI = ({
|
|||||||
children,
|
children,
|
||||||
app,
|
app,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
|
openAIKey,
|
||||||
|
isOpenAIKeyPersisted,
|
||||||
|
onOpenAIAPIKeyChange,
|
||||||
|
onMagicSettingsConfirm,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
@@ -348,7 +360,7 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
{shouldShowStats && (
|
{shouldShowStats && (
|
||||||
<Stats
|
<Stats
|
||||||
app={app}
|
scene={app.scene}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
actionManager.executeAction(actionToggleStats);
|
actionManager.executeAction(actionToggleStats);
|
||||||
}}
|
}}
|
||||||
@@ -470,6 +482,25 @@ const LayerUI = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{appState.openDialog?.name === "settings" && (
|
||||||
|
<MagicSettings
|
||||||
|
openAIKey={openAIKey}
|
||||||
|
isPersisted={isOpenAIKeyPersisted}
|
||||||
|
onChange={onOpenAIAPIKeyChange}
|
||||||
|
onConfirm={(apiKey, shouldPersist) => {
|
||||||
|
const source =
|
||||||
|
appState.openDialog?.name === "settings"
|
||||||
|
? appState.openDialog?.source
|
||||||
|
: "settings";
|
||||||
|
setAppState({ openDialog: null }, () => {
|
||||||
|
onMagicSettingsConfirm(apiKey, shouldPersist, source);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setAppState({ openDialog: null });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ActiveConfirmDialog />
|
<ActiveConfirmDialog />
|
||||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||||
{renderImageExportDialog()}
|
{renderImageExportDialog()}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.MagicSettings {
|
||||||
|
.Island {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.MagicSettings-confirm {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MagicSettings__confirm {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import { TextField } from "./TextField";
|
||||||
|
import { MagicIcon, OpenAIIcon } from "./icons";
|
||||||
|
import { FilledButton } from "./FilledButton";
|
||||||
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { useUIAppState } from "../context/ui-appState";
|
||||||
|
import { InlineIcon } from "./InlineIcon";
|
||||||
|
import { Paragraph } from "./Paragraph";
|
||||||
|
|
||||||
|
import "./MagicSettings.scss";
|
||||||
|
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
|
||||||
|
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
|
||||||
|
|
||||||
|
export const MagicSettings = (props: {
|
||||||
|
openAIKey: string | null;
|
||||||
|
isPersisted: boolean;
|
||||||
|
onChange: (key: string, shouldPersist: boolean) => void;
|
||||||
|
onConfirm: (key: string, shouldPersist: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
|
||||||
|
const [shouldPersist, setShouldPersist] = useState<boolean>(
|
||||||
|
props.isPersisted,
|
||||||
|
);
|
||||||
|
|
||||||
|
const appState = useUIAppState();
|
||||||
|
|
||||||
|
const onConfirm = () => {
|
||||||
|
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (appState.openDialog?.name !== "settings") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={() => {
|
||||||
|
props.onClose();
|
||||||
|
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
<div style={{ display: "flex" }}>
|
||||||
|
Wireframe to Code (AI){" "}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "0.1rem 0.5rem",
|
||||||
|
marginLeft: "1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "var(--color-promo)",
|
||||||
|
color: "var(--color-surface-lowest)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Experimental
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="MagicSettings"
|
||||||
|
autofocus={false}
|
||||||
|
>
|
||||||
|
{/* <h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
paddingLeft: "2.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AI Settings
|
||||||
|
</h2> */}
|
||||||
|
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
|
||||||
|
{/* <TTDDialogTabTriggers>
|
||||||
|
<TTDDialogTabTrigger tab="text-to-diagram">
|
||||||
|
<InlineIcon icon={brainIcon} /> Text to diagram
|
||||||
|
</TTDDialogTabTrigger>
|
||||||
|
<TTDDialogTabTrigger tab="diagram-to-code">
|
||||||
|
<InlineIcon icon={MagicIcon} /> Wireframe to code
|
||||||
|
</TTDDialogTabTrigger>
|
||||||
|
</TTDDialogTabTriggers> */}
|
||||||
|
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
|
||||||
|
TODO
|
||||||
|
</TTDDialogTab> */}
|
||||||
|
<TTDDialogTab
|
||||||
|
// className="ttd-dialog-content"
|
||||||
|
tab="diagram-to-code"
|
||||||
|
>
|
||||||
|
<Paragraph>
|
||||||
|
For the diagram-to-code feature we use{" "}
|
||||||
|
<InlineIcon icon={OpenAIIcon} />
|
||||||
|
OpenAI.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
While the OpenAI API is in beta, its use is strictly limited — as
|
||||||
|
such we require you use your own API key. You can create an{" "}
|
||||||
|
<a
|
||||||
|
href="https://platform.openai.com/login?launch"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
OpenAI account
|
||||||
|
</a>
|
||||||
|
, add a small credit (5 USD minimum), and{" "}
|
||||||
|
<a
|
||||||
|
href="https://platform.openai.com/api-keys"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
generate your own API key
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
Your OpenAI key does not leave the browser, and you can also set
|
||||||
|
your own limit in your OpenAI account dashboard if needed.
|
||||||
|
</Paragraph>
|
||||||
|
<TextField
|
||||||
|
isRedacted
|
||||||
|
value={keyInputValue}
|
||||||
|
placeholder="Paste your API key here"
|
||||||
|
label="OpenAI API key"
|
||||||
|
onChange={(value) => {
|
||||||
|
setKeyInputValue(value);
|
||||||
|
props.onChange(value.trim(), shouldPersist);
|
||||||
|
}}
|
||||||
|
selectOnRender
|
||||||
|
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
|
||||||
|
/>
|
||||||
|
<Paragraph>
|
||||||
|
By default, your API token is not persisted anywhere so you'll need
|
||||||
|
to insert it again after reload. But, you can persist locally in
|
||||||
|
your browser below.
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
|
||||||
|
Persist API key in browser storage
|
||||||
|
</CheckboxItem>
|
||||||
|
|
||||||
|
<Paragraph>
|
||||||
|
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
|
||||||
|
tool to wrap your elements in a frame that will then allow you to
|
||||||
|
turn it into code. This dialog can be accessed using the{" "}
|
||||||
|
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<FilledButton
|
||||||
|
className="MagicSettings__confirm"
|
||||||
|
size="large"
|
||||||
|
label="Confirm"
|
||||||
|
onClick={onConfirm}
|
||||||
|
/>
|
||||||
|
</TTDDialogTab>
|
||||||
|
</TTDDialogTabs>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -133,7 +133,6 @@ const SingleLibraryItem = ({
|
|||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
},
|
},
|
||||||
files: null,
|
files: null,
|
||||||
skipInliningFonts: true,
|
|
||||||
});
|
});
|
||||||
node.innerHTML = svg.outerHTML;
|
node.innerHTML = svg.outerHTML;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
@import "open-color/open-color";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.layer-ui__search {
|
|
||||||
flex: 1 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 8px 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__search-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
.ExcTextField {
|
|
||||||
flex: 1 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExcTextField__input {
|
|
||||||
background-color: #f5f5f9;
|
|
||||||
@at-root .excalidraw.theme--dark#{&} {
|
|
||||||
background-color: #31303b;
|
|
||||||
}
|
|
||||||
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__search-count {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 8px 0 8px;
|
|
||||||
margin: 0 0.75rem 0.25rem 0.75rem;
|
|
||||||
font-size: 0.8em;
|
|
||||||
|
|
||||||
.result-nav {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.result-nav-btn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
--button-border: transparent;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: var(--color-surface-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__search-result-container {
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1 1 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__result-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 2rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
margin: 0 0.75rem;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
|
|
||||||
.text-icon {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-text {
|
|
||||||
flex: 1;
|
|
||||||
max-height: 48px;
|
|
||||||
line-height: 24px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-surface-high);
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: var(--color-surface-high);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
import { Fragment, memo, useEffect, useRef, useState } from "react";
|
|
||||||
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
|
|
||||||
import { TextField } from "./TextField";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
|
||||||
import { debounce } from "lodash";
|
|
||||||
import type { AppClassProperties } from "../types";
|
|
||||||
import { isTextElement, newTextElement } from "../element";
|
|
||||||
import type { ExcalidrawTextElement } from "../element/types";
|
|
||||||
import { measureText } from "../element/textElement";
|
|
||||||
import { addEventListener, getFontString } from "../utils";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { jotaiScope } from "../jotai";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
|
||||||
import { randomInteger } from "../random";
|
|
||||||
import { CLASSES, EVENT } from "../constants";
|
|
||||||
import { useStable } from "../hooks/useStable";
|
|
||||||
|
|
||||||
import "./SearchMenu.scss";
|
|
||||||
import { round } from "../../math";
|
|
||||||
|
|
||||||
const searchQueryAtom = atom<string>("");
|
|
||||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE = 350;
|
|
||||||
|
|
||||||
type SearchMatchItem = {
|
|
||||||
textElement: ExcalidrawTextElement;
|
|
||||||
searchQuery: SearchQuery;
|
|
||||||
index: number;
|
|
||||||
preview: {
|
|
||||||
indexInSearchQuery: number;
|
|
||||||
previewText: string;
|
|
||||||
moreBefore: boolean;
|
|
||||||
moreAfter: boolean;
|
|
||||||
};
|
|
||||||
matchedLines: {
|
|
||||||
offsetX: number;
|
|
||||||
offsetY: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchMatches = {
|
|
||||||
nonce: number | null;
|
|
||||||
items: SearchMatchItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchQuery = string & { _brand: "SearchQuery" };
|
|
||||||
|
|
||||||
export const SearchMenu = () => {
|
|
||||||
const app = useApp();
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
|
||||||
const searchQuery = inputValue.trim() as SearchQuery;
|
|
||||||
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
|
||||||
|
|
||||||
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
|
|
||||||
nonce: null,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
|
||||||
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
|
||||||
|
|
||||||
const [focusIndex, setFocusIndex] = useAtom(
|
|
||||||
searchItemInFocusAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSearching) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
searchQuery !== searchedQueryRef.current ||
|
|
||||||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
|
|
||||||
) {
|
|
||||||
searchedQueryRef.current = null;
|
|
||||||
handleSearch(searchQuery, app, (matchItems, index) => {
|
|
||||||
setSearchMatches({
|
|
||||||
nonce: randomInteger(),
|
|
||||||
items: matchItems,
|
|
||||||
});
|
|
||||||
searchedQueryRef.current = searchQuery;
|
|
||||||
lastSceneNonceRef.current = app.scene.getSceneNonce();
|
|
||||||
setAppState({
|
|
||||||
searchMatches: matchItems.map((searchMatch) => ({
|
|
||||||
id: searchMatch.textElement.id,
|
|
||||||
focus: false,
|
|
||||||
matchedLines: searchMatch.matchedLines,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isSearching,
|
|
||||||
searchQuery,
|
|
||||||
elementsMap,
|
|
||||||
app,
|
|
||||||
setAppState,
|
|
||||||
setFocusIndex,
|
|
||||||
lastSceneNonceRef,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const goToNextItem = () => {
|
|
||||||
if (searchMatches.items.length > 0) {
|
|
||||||
setFocusIndex((focusIndex) => {
|
|
||||||
if (focusIndex === null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (focusIndex + 1) % searchMatches.items.length;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPreviousItem = () => {
|
|
||||||
if (searchMatches.items.length > 0) {
|
|
||||||
setFocusIndex((focusIndex) => {
|
|
||||||
if (focusIndex === null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return focusIndex - 1 < 0
|
|
||||||
? searchMatches.items.length - 1
|
|
||||||
: focusIndex - 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAppState((state) => {
|
|
||||||
return {
|
|
||||||
searchMatches: state.searchMatches.map((match, index) => {
|
|
||||||
if (index === focusIndex) {
|
|
||||||
return { ...match, focus: true };
|
|
||||||
}
|
|
||||||
return { ...match, focus: false };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [focusIndex, setAppState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchMatches.items.length > 0 && focusIndex !== null) {
|
|
||||||
const match = searchMatches.items[focusIndex];
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const zoomValue = app.state.zoom.value;
|
|
||||||
|
|
||||||
const matchAsElement = newTextElement({
|
|
||||||
text: match.searchQuery,
|
|
||||||
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
|
|
||||||
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
|
|
||||||
width: match.matchedLines[0]?.width,
|
|
||||||
height: match.matchedLines[0]?.height,
|
|
||||||
fontSize: match.textElement.fontSize,
|
|
||||||
fontFamily: match.textElement.fontFamily,
|
|
||||||
});
|
|
||||||
|
|
||||||
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
|
|
||||||
|
|
||||||
const fontSize = match.textElement.fontSize;
|
|
||||||
const isTextTiny =
|
|
||||||
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isElementCompletelyInViewport(
|
|
||||||
[matchAsElement],
|
|
||||||
app.canvas.width / window.devicePixelRatio,
|
|
||||||
app.canvas.height / window.devicePixelRatio,
|
|
||||||
{
|
|
||||||
offsetLeft: app.state.offsetLeft,
|
|
||||||
offsetTop: app.state.offsetTop,
|
|
||||||
scrollX: app.state.scrollX,
|
|
||||||
scrollY: app.state.scrollY,
|
|
||||||
zoom: app.state.zoom,
|
|
||||||
},
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
app.getEditorUIOffsets(),
|
|
||||||
) ||
|
|
||||||
isTextTiny
|
|
||||||
) {
|
|
||||||
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
|
|
||||||
|
|
||||||
if (isTextTiny) {
|
|
||||||
if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
|
|
||||||
zoomOptions = { fitToContent: true };
|
|
||||||
} else {
|
|
||||||
zoomOptions = {
|
|
||||||
fitToViewport: true,
|
|
||||||
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
|
|
||||||
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
zoomOptions = { fitToContent: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
app.scrollToContent(matchAsElement, {
|
|
||||||
animate: true,
|
|
||||||
duration: 300,
|
|
||||||
...zoomOptions,
|
|
||||||
canvasOffsets: app.getEditorUIOffsets(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [focusIndex, searchMatches, app]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
setFocusIndex(null);
|
|
||||||
searchedQueryRef.current = null;
|
|
||||||
lastSceneNonceRef.current = undefined;
|
|
||||||
setAppState({
|
|
||||||
searchMatches: [],
|
|
||||||
});
|
|
||||||
setIsSearching(false);
|
|
||||||
};
|
|
||||||
}, [setAppState, setFocusIndex]);
|
|
||||||
|
|
||||||
const stableState = useStable({
|
|
||||||
goToNextItem,
|
|
||||||
goToPreviousItem,
|
|
||||||
searchMatches,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const eventHandler = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
event.key === KEYS.ESCAPE &&
|
|
||||||
!app.state.openDialog &&
|
|
||||||
!app.state.openPopup
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
setAppState({
|
|
||||||
openSidebar: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
if (!searchInputRef.current?.matches(":focus")) {
|
|
||||||
if (app.state.openDialog) {
|
|
||||||
setAppState({
|
|
||||||
openDialog: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
searchInputRef.current?.select();
|
|
||||||
} else {
|
|
||||||
setAppState({
|
|
||||||
openSidebar: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.target instanceof HTMLElement &&
|
|
||||||
event.target.closest(".layer-ui__search")
|
|
||||||
) {
|
|
||||||
if (stableState.searchMatches.items.length) {
|
|
||||||
if (event.key === KEYS.ENTER) {
|
|
||||||
event.stopPropagation();
|
|
||||||
stableState.goToNextItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === KEYS.ARROW_UP) {
|
|
||||||
event.stopPropagation();
|
|
||||||
stableState.goToPreviousItem();
|
|
||||||
} else if (event.key === KEYS.ARROW_DOWN) {
|
|
||||||
event.stopPropagation();
|
|
||||||
stableState.goToNextItem();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// `capture` needed to prevent firing on initial open from App.tsx,
|
|
||||||
// as well as to handle events before App ones
|
|
||||||
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
|
|
||||||
capture: true,
|
|
||||||
});
|
|
||||||
}, [setAppState, stableState, app]);
|
|
||||||
|
|
||||||
const matchCount = `${searchMatches.items.length} ${
|
|
||||||
searchMatches.items.length === 1
|
|
||||||
? t("search.singleResult")
|
|
||||||
: t("search.multipleResults")
|
|
||||||
}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="layer-ui__search">
|
|
||||||
<div className="layer-ui__search-header">
|
|
||||||
<TextField
|
|
||||||
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
|
|
||||||
value={inputValue}
|
|
||||||
ref={searchInputRef}
|
|
||||||
placeholder={t("search.placeholder")}
|
|
||||||
icon={searchIcon}
|
|
||||||
onChange={(value) => {
|
|
||||||
setInputValue(value);
|
|
||||||
setIsSearching(true);
|
|
||||||
const searchQuery = value.trim() as SearchQuery;
|
|
||||||
handleSearch(searchQuery, app, (matchItems, index) => {
|
|
||||||
setSearchMatches({
|
|
||||||
nonce: randomInteger(),
|
|
||||||
items: matchItems,
|
|
||||||
});
|
|
||||||
setFocusIndex(index);
|
|
||||||
searchedQueryRef.current = searchQuery;
|
|
||||||
lastSceneNonceRef.current = app.scene.getSceneNonce();
|
|
||||||
setAppState({
|
|
||||||
searchMatches: matchItems.map((searchMatch) => ({
|
|
||||||
id: searchMatch.textElement.id,
|
|
||||||
focus: false,
|
|
||||||
matchedLines: searchMatch.matchedLines,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsSearching(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
selectOnRender
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="layer-ui__search-count">
|
|
||||||
{searchMatches.items.length > 0 && (
|
|
||||||
<>
|
|
||||||
{focusIndex !== null && focusIndex > -1 ? (
|
|
||||||
<div>
|
|
||||||
{focusIndex + 1} / {matchCount}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>{matchCount}</div>
|
|
||||||
)}
|
|
||||||
<div className="result-nav">
|
|
||||||
<Button
|
|
||||||
onSelect={() => {
|
|
||||||
goToNextItem();
|
|
||||||
}}
|
|
||||||
className="result-nav-btn"
|
|
||||||
>
|
|
||||||
{collapseDownIcon}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onSelect={() => {
|
|
||||||
goToPreviousItem();
|
|
||||||
}}
|
|
||||||
className="result-nav-btn"
|
|
||||||
>
|
|
||||||
{upIcon}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchMatches.items.length === 0 &&
|
|
||||||
searchQuery &&
|
|
||||||
searchedQueryRef.current && (
|
|
||||||
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MatchList
|
|
||||||
matches={searchMatches}
|
|
||||||
onItemClick={setFocusIndex}
|
|
||||||
focusIndex={focusIndex}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ListItem = (props: {
|
|
||||||
preview: SearchMatchItem["preview"];
|
|
||||||
searchQuery: SearchQuery;
|
|
||||||
highlighted: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) => {
|
|
||||||
const preview = [
|
|
||||||
props.preview.moreBefore ? "..." : "",
|
|
||||||
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
|
|
||||||
props.preview.previewText.slice(
|
|
||||||
props.preview.indexInSearchQuery,
|
|
||||||
props.preview.indexInSearchQuery + props.searchQuery.length,
|
|
||||||
),
|
|
||||||
props.preview.previewText.slice(
|
|
||||||
props.preview.indexInSearchQuery + props.searchQuery.length,
|
|
||||||
),
|
|
||||||
props.preview.moreAfter ? "..." : "",
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
tabIndex={-1}
|
|
||||||
className={clsx("layer-ui__result-item", {
|
|
||||||
active: props.highlighted,
|
|
||||||
})}
|
|
||||||
onClick={props.onClick}
|
|
||||||
ref={(ref) => {
|
|
||||||
if (props.highlighted) {
|
|
||||||
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="preview-text">
|
|
||||||
{preview.flatMap((text, idx) => (
|
|
||||||
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MatchListProps {
|
|
||||||
matches: SearchMatches;
|
|
||||||
onItemClick: (index: number) => void;
|
|
||||||
focusIndex: number | null;
|
|
||||||
searchQuery: SearchQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MatchListBase = (props: MatchListProps) => {
|
|
||||||
return (
|
|
||||||
<div className="layer-ui__search-result-container">
|
|
||||||
{props.matches.items.map((searchMatch, index) => (
|
|
||||||
<ListItem
|
|
||||||
key={searchMatch.textElement.id + searchMatch.index}
|
|
||||||
searchQuery={props.searchQuery}
|
|
||||||
preview={searchMatch.preview}
|
|
||||||
highlighted={index === props.focusIndex}
|
|
||||||
onClick={() => props.onItemClick(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
|
|
||||||
return (
|
|
||||||
prevProps.matches.nonce === nextProps.matches.nonce &&
|
|
||||||
prevProps.focusIndex === nextProps.focusIndex
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MatchList = memo(MatchListBase, areEqual);
|
|
||||||
|
|
||||||
const getMatchPreview = (
|
|
||||||
text: string,
|
|
||||||
index: number,
|
|
||||||
searchQuery: SearchQuery,
|
|
||||||
) => {
|
|
||||||
const WORDS_BEFORE = 2;
|
|
||||||
const WORDS_AFTER = 5;
|
|
||||||
|
|
||||||
const substrBeforeQuery = text.slice(0, index);
|
|
||||||
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
|
|
||||||
// text = "small", query = "mall", not complete before
|
|
||||||
// text = "small", query = "smal", complete before
|
|
||||||
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
|
|
||||||
const startWordIndex =
|
|
||||||
wordsBeforeQuery.length -
|
|
||||||
WORDS_BEFORE -
|
|
||||||
1 -
|
|
||||||
(isQueryCompleteBefore ? 0 : 1);
|
|
||||||
let wordsBeforeAsString =
|
|
||||||
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
|
|
||||||
(isQueryCompleteBefore ? " " : "");
|
|
||||||
|
|
||||||
const MAX_ALLOWED_CHARS = 20;
|
|
||||||
|
|
||||||
wordsBeforeAsString =
|
|
||||||
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
|
|
||||||
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
|
|
||||||
: wordsBeforeAsString;
|
|
||||||
|
|
||||||
const substrAfterQuery = text.slice(index + searchQuery.length);
|
|
||||||
const wordsAfter = substrAfterQuery.split(/\s+/);
|
|
||||||
// text = "small", query = "mall", complete after
|
|
||||||
// text = "small", query = "smal", not complete after
|
|
||||||
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
|
|
||||||
const numberOfWordsToTake = isQueryCompleteAfter
|
|
||||||
? WORDS_AFTER + 1
|
|
||||||
: WORDS_AFTER;
|
|
||||||
const wordsAfterAsString =
|
|
||||||
(isQueryCompleteAfter ? "" : " ") +
|
|
||||||
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
|
|
||||||
|
|
||||||
return {
|
|
||||||
indexInSearchQuery: wordsBeforeAsString.length,
|
|
||||||
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
|
|
||||||
moreBefore: startWordIndex > 0,
|
|
||||||
moreAfter: wordsAfter.length > numberOfWordsToTake,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeWrappedText = (
|
|
||||||
wrappedText: string,
|
|
||||||
originalText: string,
|
|
||||||
): string => {
|
|
||||||
const wrappedLines = wrappedText.split("\n");
|
|
||||||
const normalizedLines: string[] = [];
|
|
||||||
let originalIndex = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < wrappedLines.length; i++) {
|
|
||||||
let currentLine = wrappedLines[i];
|
|
||||||
const nextLine = wrappedLines[i + 1];
|
|
||||||
|
|
||||||
if (nextLine) {
|
|
||||||
const nextLineIndexInOriginal = originalText.indexOf(
|
|
||||||
nextLine,
|
|
||||||
originalIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
|
|
||||||
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
|
|
||||||
|
|
||||||
while (j > 0) {
|
|
||||||
currentLine += " ";
|
|
||||||
j--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedLines.push(currentLine);
|
|
||||||
originalIndex = originalIndex + currentLine.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedLines.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMatchedLines = (
|
|
||||||
textElement: ExcalidrawTextElement,
|
|
||||||
searchQuery: SearchQuery,
|
|
||||||
index: number,
|
|
||||||
) => {
|
|
||||||
const normalizedText = normalizeWrappedText(
|
|
||||||
textElement.text,
|
|
||||||
textElement.originalText,
|
|
||||||
);
|
|
||||||
|
|
||||||
const lines = normalizedText.split("\n");
|
|
||||||
|
|
||||||
const lineIndexRanges = [];
|
|
||||||
let currentIndex = 0;
|
|
||||||
let lineNumber = 0;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const startIndex = currentIndex;
|
|
||||||
const endIndex = startIndex + line.length - 1;
|
|
||||||
|
|
||||||
lineIndexRanges.push({
|
|
||||||
line,
|
|
||||||
startIndex,
|
|
||||||
endIndex,
|
|
||||||
lineNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move to the next line's start index
|
|
||||||
currentIndex = endIndex + 1;
|
|
||||||
lineNumber++;
|
|
||||||
}
|
|
||||||
|
|
||||||
let startIndex = index;
|
|
||||||
let remainingQuery = textElement.originalText.slice(
|
|
||||||
index,
|
|
||||||
index + searchQuery.length,
|
|
||||||
);
|
|
||||||
const matchedLines: {
|
|
||||||
offsetX: number;
|
|
||||||
offsetY: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
for (const lineIndexRange of lineIndexRanges) {
|
|
||||||
if (remainingQuery === "") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
startIndex >= lineIndexRange.startIndex &&
|
|
||||||
startIndex <= lineIndexRange.endIndex
|
|
||||||
) {
|
|
||||||
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
|
|
||||||
const textToStart = lineIndexRange.line.slice(
|
|
||||||
0,
|
|
||||||
startIndex - lineIndexRange.startIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
const matchedWord = remainingQuery.slice(0, matchCapacity);
|
|
||||||
remainingQuery = remainingQuery.slice(matchCapacity);
|
|
||||||
|
|
||||||
const offset = measureText(
|
|
||||||
textToStart,
|
|
||||||
getFontString(textElement),
|
|
||||||
textElement.lineHeight,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// measureText returns a non-zero width for the empty string
|
|
||||||
// which is not what we're after here, hence the check and the correction
|
|
||||||
if (textToStart === "") {
|
|
||||||
offset.width = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
|
|
||||||
const lineLength = measureText(
|
|
||||||
lineIndexRange.line,
|
|
||||||
getFontString(textElement),
|
|
||||||
textElement.lineHeight,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const spaceToStart =
|
|
||||||
textElement.textAlign === "center"
|
|
||||||
? (textElement.width - lineLength.width) / 2
|
|
||||||
: textElement.width - lineLength.width;
|
|
||||||
offset.width += spaceToStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width, height } = measureText(
|
|
||||||
matchedWord,
|
|
||||||
getFontString(textElement),
|
|
||||||
textElement.lineHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
const offsetX = offset.width;
|
|
||||||
const offsetY = lineIndexRange.lineNumber * offset.height;
|
|
||||||
|
|
||||||
matchedLines.push({
|
|
||||||
offsetX,
|
|
||||||
offsetY,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
|
|
||||||
startIndex += matchCapacity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchedLines;
|
|
||||||
};
|
|
||||||
|
|
||||||
const escapeSpecialCharacters = (string: string) => {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = debounce(
|
|
||||||
(
|
|
||||||
searchQuery: SearchQuery,
|
|
||||||
app: AppClassProperties,
|
|
||||||
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
|
|
||||||
) => {
|
|
||||||
if (!searchQuery || searchQuery === "") {
|
|
||||||
cb([], null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elements = app.scene.getNonDeletedElements();
|
|
||||||
const texts = elements.filter((el) =>
|
|
||||||
isTextElement(el),
|
|
||||||
) as ExcalidrawTextElement[];
|
|
||||||
|
|
||||||
texts.sort((a, b) => a.y - b.y);
|
|
||||||
|
|
||||||
const matchItems: SearchMatchItem[] = [];
|
|
||||||
|
|
||||||
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
|
|
||||||
|
|
||||||
for (const textEl of texts) {
|
|
||||||
let match = null;
|
|
||||||
const text = textEl.originalText;
|
|
||||||
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
|
||||||
const preview = getMatchPreview(text, match.index, searchQuery);
|
|
||||||
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
|
|
||||||
|
|
||||||
if (matchedLines.length > 0) {
|
|
||||||
matchItems.push({
|
|
||||||
textElement: textEl,
|
|
||||||
searchQuery,
|
|
||||||
preview,
|
|
||||||
index: match.index,
|
|
||||||
matchedLines,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleIds = new Set(
|
|
||||||
app.visibleElements.map((visibleElement) => visibleElement.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
const focusIndex =
|
|
||||||
matchItems.findIndex((matchItem) =>
|
|
||||||
visibleIds.has(matchItem.textElement.id),
|
|
||||||
) ?? null;
|
|
||||||
|
|
||||||
cb(matchItems, focusIndex);
|
|
||||||
},
|
|
||||||
SEARCH_DEBOUNCE,
|
|
||||||
);
|
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 110%;
|
line-height: 110%;
|
||||||
|
|
||||||
background: var(--color-success);
|
background: var(--color-success-lighter);
|
||||||
color: var(--color-success-text);
|
color: var(--color-success);
|
||||||
|
|
||||||
& > svg {
|
& > svg {
|
||||||
width: 0.875rem;
|
width: 0.875rem;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../clipboard";
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
@@ -6,8 +7,7 @@ import { useI18n } from "../i18n";
|
|||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { TextField } from "./TextField";
|
import { TextField } from "./TextField";
|
||||||
import { FilledButton } from "./FilledButton";
|
import { FilledButton } from "./FilledButton";
|
||||||
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
import { copyIcon, tablerCheckIcon } from "./icons";
|
||||||
import { copyIcon } from "./icons";
|
|
||||||
|
|
||||||
import "./ShareableLinkDialog.scss";
|
import "./ShareableLinkDialog.scss";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
|
|||||||
setErrorMessage,
|
setErrorMessage,
|
||||||
}: ShareableLinkDialogProps) => {
|
}: ShareableLinkDialogProps) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [, setJustCopied] = useState(false);
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
|
|||||||
|
|
||||||
ref.current?.select();
|
ref.current?.select();
|
||||||
};
|
};
|
||||||
const { onCopy, copyStatus } = useCopyStatus();
|
|
||||||
return (
|
return (
|
||||||
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
|
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
|
||||||
<div className="ShareableLinkDialog">
|
<div className="ShareableLinkDialog">
|
||||||
@@ -60,16 +60,26 @@ export const ShareableLinkDialog = ({
|
|||||||
value={link}
|
value={link}
|
||||||
selectOnRender
|
selectOnRender
|
||||||
/>
|
/>
|
||||||
<FilledButton
|
<Popover.Root open={justCopied}>
|
||||||
size="large"
|
<Popover.Trigger asChild>
|
||||||
label={t("buttons.copyLink")}
|
<FilledButton
|
||||||
icon={copyIcon}
|
size="large"
|
||||||
status={copyStatus}
|
label="Copy link"
|
||||||
onClick={() => {
|
icon={copyIcon}
|
||||||
onCopy();
|
onClick={copyRoomLink}
|
||||||
copyRoomLink();
|
/>
|
||||||
}}
|
</Popover.Trigger>
|
||||||
/>
|
<Popover.Content
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
className="ShareableLinkDialog__popover"
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
sideOffset={5.5}
|
||||||
|
>
|
||||||
|
{tablerCheckIcon} copied
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
</div>
|
</div>
|
||||||
<div className="ShareableLinkDialog__description">
|
<div className="ShareableLinkDialog__description">
|
||||||
🔒 {t("alerts.uploadedSecurly")}
|
🔒 {t("alerts.uploadedSecurly")}
|
||||||
|
|||||||
@@ -6,18 +6,16 @@ const Spinner = ({
|
|||||||
size = "1em",
|
size = "1em",
|
||||||
circleWidth = 8,
|
circleWidth = 8,
|
||||||
synchronized = false,
|
synchronized = false,
|
||||||
className = "",
|
|
||||||
}: {
|
}: {
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
circleWidth?: number;
|
circleWidth?: number;
|
||||||
synchronized?: boolean;
|
synchronized?: boolean;
|
||||||
className?: string;
|
|
||||||
}) => {
|
}) => {
|
||||||
const mountTime = React.useRef(Date.now());
|
const mountTime = React.useRef(Date.now());
|
||||||
const mountDelay = -(mountTime.current % 1600);
|
const mountDelay = -(mountTime.current % 1600);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`Spinner ${className}`}>
|
<div className="Spinner">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { mutateElement } from "../../element/mutateElement";
|
|||||||
import { getBoundTextElement } from "../../element/textElement";
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
|
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
|
||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
|
import { degreeToRadian, radianToDegree } from "../../math";
|
||||||
import { angleIcon } from "../icons";
|
import { angleIcon } from "../icons";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import type { Degrees } from "../../../math";
|
|
||||||
import { degreesToRadians, radiansToDegrees } from "../../../math";
|
|
||||||
|
|
||||||
interface AngleProps {
|
interface AngleProps {
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
@@ -37,7 +36,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextValue !== undefined) {
|
if (nextValue !== undefined) {
|
||||||
const nextAngle = degreesToRadians(nextValue as Degrees);
|
const nextAngle = degreeToRadian(nextValue);
|
||||||
mutateElement(latestElement, {
|
mutateElement(latestElement, {
|
||||||
angle: nextAngle,
|
angle: nextAngle,
|
||||||
});
|
});
|
||||||
@@ -52,7 +51,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const originalAngleInDegrees =
|
const originalAngleInDegrees =
|
||||||
Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
|
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
||||||
const changeInDegrees = Math.round(accumulatedChange);
|
const changeInDegrees = Math.round(accumulatedChange);
|
||||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||||
if (shouldChangeByStepSize) {
|
if (shouldChangeByStepSize) {
|
||||||
@@ -62,7 +61,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
|||||||
nextAngleInDegrees =
|
nextAngleInDegrees =
|
||||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||||
|
|
||||||
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
|
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||||
|
|
||||||
mutateElement(latestElement, {
|
mutateElement(latestElement, {
|
||||||
angle: nextAngle,
|
angle: nextAngle,
|
||||||
@@ -81,7 +80,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
|||||||
<DragInput
|
<DragInput
|
||||||
label="A"
|
label="A"
|
||||||
icon={angleIcon}
|
icon={angleIcon}
|
||||||
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
|
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
|
||||||
elements={[element]}
|
elements={[element]}
|
||||||
dragInputCallback={handleDegreeChange}
|
dragInputCallback={handleDegreeChange}
|
||||||
editable={isPropertyEditable(element, "angle")}
|
editable={isPropertyEditable(element, "angle")}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import StatsDragInput from "./DragInput";
|
|
||||||
import type Scene from "../../scene/Scene";
|
|
||||||
import type { AppState } from "../../types";
|
|
||||||
import { getStepSizedValue } from "./utils";
|
|
||||||
import { getNormalizedGridStep } from "../../scene";
|
|
||||||
|
|
||||||
interface PositionProps {
|
|
||||||
property: "gridStep";
|
|
||||||
scene: Scene;
|
|
||||||
appState: AppState;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEP_SIZE = 5;
|
|
||||||
|
|
||||||
const CanvasGrid = ({
|
|
||||||
property,
|
|
||||||
scene,
|
|
||||||
appState,
|
|
||||||
setAppState,
|
|
||||||
}: PositionProps) => {
|
|
||||||
return (
|
|
||||||
<StatsDragInput
|
|
||||||
label="Grid step"
|
|
||||||
sensitivity={8}
|
|
||||||
elements={[]}
|
|
||||||
dragInputCallback={({
|
|
||||||
nextValue,
|
|
||||||
instantChange,
|
|
||||||
shouldChangeByStepSize,
|
|
||||||
setInputValue,
|
|
||||||
}) => {
|
|
||||||
setAppState((state) => {
|
|
||||||
let nextGridStep;
|
|
||||||
|
|
||||||
if (nextValue) {
|
|
||||||
nextGridStep = nextValue;
|
|
||||||
} else if (instantChange) {
|
|
||||||
nextGridStep = shouldChangeByStepSize
|
|
||||||
? getStepSizedValue(
|
|
||||||
state.gridStep + STEP_SIZE * Math.sign(instantChange),
|
|
||||||
STEP_SIZE,
|
|
||||||
)
|
|
||||||
: state.gridStep + instantChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextGridStep) {
|
|
||||||
setInputValue(state.gridStep);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextGridStep = getNormalizedGridStep(nextGridStep);
|
|
||||||
setInputValue(nextGridStep);
|
|
||||||
return {
|
|
||||||
gridStep: nextGridStep,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
scene={scene}
|
|
||||||
value={appState.gridStep}
|
|
||||||
property={property}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CanvasGrid;
|
|
||||||
@@ -31,11 +31,7 @@ const Collapsible = ({
|
|||||||
{label}
|
{label}
|
||||||
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
|
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && <>{children}</>}
|
||||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
> = ({
|
> = ({
|
||||||
accumulatedChange,
|
accumulatedChange,
|
||||||
originalElements,
|
originalElements,
|
||||||
|
originalElementsMap,
|
||||||
shouldKeepAspectRatio,
|
shouldKeepAspectRatio,
|
||||||
shouldChangeByStepSize,
|
shouldChangeByStepSize,
|
||||||
nextValue,
|
nextValue,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-lg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,18 +18,17 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 1px solid var(--default-border-color);
|
border: 1px solid var(--default-border-color);
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
padding: 0 0.5rem 0 0.75rem;
|
width: 2rem;
|
||||||
min-width: 1rem;
|
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: var(--popup-text-color);
|
color: var(--popup-text-color);
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
|
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
|
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||||
border-right: 1px solid var(--default-border-color);
|
border-right: 1px solid var(--default-border-color);
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
@@ -56,11 +55,11 @@
|
|||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
|
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
|
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||||
border-left: 1px solid var(--default-border-color);
|
border-left: 1px solid var(--default-border-color);
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export type DragInputCallbackType<
|
|||||||
nextValue?: number;
|
nextValue?: number;
|
||||||
property: P;
|
property: P;
|
||||||
originalAppState: AppState;
|
originalAppState: AppState;
|
||||||
setInputValue: (value: number) => void;
|
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
interface StatsDragInputProps<
|
interface StatsDragInputProps<
|
||||||
@@ -46,8 +45,6 @@ interface StatsDragInputProps<
|
|||||||
property: T;
|
property: T;
|
||||||
scene: Scene;
|
scene: Scene;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
/** how many px you need to drag to get 1 unit change */
|
|
||||||
sensitivity?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatsDragInput = <
|
const StatsDragInput = <
|
||||||
@@ -64,7 +61,6 @@ const StatsDragInput = <
|
|||||||
property,
|
property,
|
||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
sensitivity = 1,
|
|
||||||
}: StatsDragInputProps<T, E>) => {
|
}: StatsDragInputProps<T, E>) => {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -130,49 +126,27 @@ const StatsDragInput = <
|
|||||||
nextValue: rounded,
|
nextValue: rounded,
|
||||||
property,
|
property,
|
||||||
originalAppState: appState,
|
originalAppState: appState,
|
||||||
setInputValue: (value) => setInputValue(String(value)),
|
|
||||||
});
|
});
|
||||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const callbacksRef = useRef<
|
const handleInputValueRef = useRef(handleInputValue);
|
||||||
Partial<{
|
handleInputValueRef.current = handleInputValue;
|
||||||
handleInputValue: typeof handleInputValue;
|
|
||||||
onPointerUp: (event: PointerEvent) => void;
|
|
||||||
onPointerMove: (event: PointerEvent) => void;
|
|
||||||
}>
|
|
||||||
>({});
|
|
||||||
callbacksRef.current.handleInputValue = handleInputValue;
|
|
||||||
|
|
||||||
// make sure that clicking on canvas (which umounts the component)
|
// make sure that clicking on canvas (which umounts the component)
|
||||||
// updates current input value (blur isn't triggered)
|
// updates current input value (blur isn't triggered)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
const callbacks = callbacksRef.current;
|
|
||||||
return () => {
|
return () => {
|
||||||
const nextValue = input?.value;
|
const nextValue = input?.value;
|
||||||
if (nextValue) {
|
if (nextValue) {
|
||||||
callbacks.handleInputValue?.(
|
handleInputValueRef.current(
|
||||||
nextValue,
|
nextValue,
|
||||||
stateRef.current.originalElements,
|
stateRef.current.originalElements,
|
||||||
stateRef.current.originalAppState,
|
stateRef.current.originalAppState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// generally not needed, but in case `pointerup` doesn't fire and
|
|
||||||
// we don't remove the listeners that way, we should at least remove
|
|
||||||
// on unmount
|
|
||||||
window.removeEventListener(
|
|
||||||
EVENT.POINTER_MOVE,
|
|
||||||
callbacks.onPointerMove!,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
window.removeEventListener(
|
|
||||||
EVENT.POINTER_UP,
|
|
||||||
callbacks.onPointerUp!,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
// we need to track change of `editable` state as mount/unmount
|
// we need to track change of `editable` state as mount/unmount
|
||||||
@@ -198,8 +172,6 @@ const StatsDragInput = <
|
|||||||
ref={labelRef}
|
ref={labelRef}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
if (inputRef.current && editable) {
|
if (inputRef.current && editable) {
|
||||||
document.body.classList.add("excalidraw-cursor-resize");
|
|
||||||
|
|
||||||
let startValue = Number(inputRef.current.value);
|
let startValue = Number(inputRef.current.value);
|
||||||
if (isNaN(startValue)) {
|
if (isNaN(startValue)) {
|
||||||
startValue = 0;
|
startValue = 0;
|
||||||
@@ -224,43 +196,35 @@ const StatsDragInput = <
|
|||||||
|
|
||||||
const originalAppState: AppState = cloneJSON(appState);
|
const originalAppState: AppState = cloneJSON(appState);
|
||||||
|
|
||||||
let accumulatedChange = 0;
|
let accumulatedChange: number | null = null;
|
||||||
let stepChange = 0;
|
|
||||||
|
document.body.classList.add("excalidraw-cursor-resize");
|
||||||
|
|
||||||
const onPointerMove = (event: PointerEvent) => {
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (!accumulatedChange) {
|
||||||
|
accumulatedChange = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
lastPointer &&
|
lastPointer &&
|
||||||
originalElementsMap !== null &&
|
originalElementsMap !== null &&
|
||||||
originalElements !== null
|
originalElements !== null &&
|
||||||
|
accumulatedChange !== null
|
||||||
) {
|
) {
|
||||||
const instantChange = event.clientX - lastPointer.x;
|
const instantChange = event.clientX - lastPointer.x;
|
||||||
|
accumulatedChange += instantChange;
|
||||||
|
|
||||||
if (instantChange !== 0) {
|
dragInputCallback({
|
||||||
stepChange += instantChange;
|
accumulatedChange,
|
||||||
|
instantChange,
|
||||||
if (Math.abs(stepChange) >= sensitivity) {
|
originalElements,
|
||||||
stepChange =
|
originalElementsMap,
|
||||||
Math.sign(stepChange) *
|
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||||
Math.floor(Math.abs(stepChange) / sensitivity);
|
shouldChangeByStepSize: event.shiftKey,
|
||||||
|
property,
|
||||||
accumulatedChange += stepChange;
|
scene,
|
||||||
|
originalAppState,
|
||||||
dragInputCallback({
|
});
|
||||||
accumulatedChange,
|
|
||||||
instantChange: stepChange,
|
|
||||||
originalElements,
|
|
||||||
originalElementsMap,
|
|
||||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
|
||||||
shouldChangeByStepSize: event.shiftKey,
|
|
||||||
property,
|
|
||||||
scene,
|
|
||||||
originalAppState,
|
|
||||||
setInputValue: (value) => setInputValue(String(value)),
|
|
||||||
});
|
|
||||||
|
|
||||||
stepChange = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPointer = {
|
lastPointer = {
|
||||||
@@ -269,31 +233,27 @@ const StatsDragInput = <
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerUp = () => {
|
|
||||||
window.removeEventListener(
|
|
||||||
EVENT.POINTER_MOVE,
|
|
||||||
onPointerMove,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
|
||||||
|
|
||||||
lastPointer = null;
|
|
||||||
accumulatedChange = 0;
|
|
||||||
stepChange = 0;
|
|
||||||
originalElements = null;
|
|
||||||
originalElementsMap = null;
|
|
||||||
|
|
||||||
document.body.classList.remove("excalidraw-cursor-resize");
|
|
||||||
|
|
||||||
window.removeEventListener(EVENT.POINTER_UP, onPointerUp, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
callbacksRef.current.onPointerMove = onPointerMove;
|
|
||||||
callbacksRef.current.onPointerUp = onPointerUp;
|
|
||||||
|
|
||||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
|
||||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
|
window.addEventListener(
|
||||||
|
EVENT.POINTER_UP,
|
||||||
|
() => {
|
||||||
|
window.removeEventListener(
|
||||||
|
EVENT.POINTER_MOVE,
|
||||||
|
onPointerMove,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||||
|
|
||||||
|
lastPointer = null;
|
||||||
|
accumulatedChange = null;
|
||||||
|
originalElements = null;
|
||||||
|
originalElementsMap = null;
|
||||||
|
|
||||||
|
document.body.classList.remove("excalidraw-cursor-resize");
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPointerEnter={() => {
|
onPointerEnter={() => {
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { getBoundTextElement } from "../../element/textElement";
|
|||||||
import { isArrowElement } from "../../element/typeChecks";
|
import { isArrowElement } from "../../element/typeChecks";
|
||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { isInGroup } from "../../groups";
|
import { isInGroup } from "../../groups";
|
||||||
|
import { degreeToRadian, radianToDegree } from "../../math";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import { angleIcon } from "../icons";
|
import { angleIcon } from "../icons";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import type { Degrees } from "../../../math";
|
|
||||||
import { degreesToRadians, radiansToDegrees } from "../../../math";
|
|
||||||
|
|
||||||
interface MultiAngleProps {
|
interface MultiAngleProps {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@@ -40,7 +39,7 @@ const handleDegreeChange: DragInputCallbackType<
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (nextValue !== undefined) {
|
if (nextValue !== undefined) {
|
||||||
const nextAngle = degreesToRadians(nextValue as Degrees);
|
const nextAngle = degreeToRadian(nextValue);
|
||||||
|
|
||||||
for (const element of editableLatestIndividualElements) {
|
for (const element of editableLatestIndividualElements) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -72,7 +71,7 @@ const handleDegreeChange: DragInputCallbackType<
|
|||||||
}
|
}
|
||||||
const originalElement = editableOriginalIndividualElements[i];
|
const originalElement = editableOriginalIndividualElements[i];
|
||||||
const originalAngleInDegrees =
|
const originalAngleInDegrees =
|
||||||
Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
|
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
|
||||||
const changeInDegrees = Math.round(accumulatedChange);
|
const changeInDegrees = Math.round(accumulatedChange);
|
||||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||||
if (shouldChangeByStepSize) {
|
if (shouldChangeByStepSize) {
|
||||||
@@ -82,7 +81,7 @@ const handleDegreeChange: DragInputCallbackType<
|
|||||||
nextAngleInDegrees =
|
nextAngleInDegrees =
|
||||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||||
|
|
||||||
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
|
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||||
|
|
||||||
mutateElement(
|
mutateElement(
|
||||||
latestElement,
|
latestElement,
|
||||||
@@ -110,7 +109,7 @@ const MultiAngle = ({
|
|||||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||||
);
|
);
|
||||||
const angles = editableLatestIndividualElements.map(
|
const angles = editableLatestIndividualElements.map(
|
||||||
(el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
|
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
|
||||||
);
|
);
|
||||||
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
|
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ import type {
|
|||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState, Point } from "../../types";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
import { point, type GlobalPoint } from "../../../math";
|
|
||||||
|
|
||||||
interface MultiDimensionProps {
|
interface MultiDimensionProps {
|
||||||
property: "width" | "height";
|
property: "width" | "height";
|
||||||
@@ -105,7 +104,7 @@ const resizeGroup = (
|
|||||||
nextHeight: number,
|
nextHeight: number,
|
||||||
initialHeight: number,
|
initialHeight: number,
|
||||||
aspectRatio: number,
|
aspectRatio: number,
|
||||||
anchor: GlobalPoint,
|
anchor: Point,
|
||||||
property: MultiDimensionProps["property"],
|
property: MultiDimensionProps["property"],
|
||||||
latestElements: ExcalidrawElement[],
|
latestElements: ExcalidrawElement[],
|
||||||
originalElements: ExcalidrawElement[],
|
originalElements: ExcalidrawElement[],
|
||||||
@@ -182,7 +181,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextHeight,
|
nextHeight,
|
||||||
initialHeight,
|
initialHeight,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
point(x1, y1),
|
[x1, y1],
|
||||||
property,
|
property,
|
||||||
latestElements,
|
latestElements,
|
||||||
originalElements,
|
originalElements,
|
||||||
@@ -287,7 +286,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextHeight,
|
nextHeight,
|
||||||
initialHeight,
|
initialHeight,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
point(x1, y1),
|
[x1, y1],
|
||||||
property,
|
property,
|
||||||
latestElements,
|
latestElements,
|
||||||
originalElements,
|
originalElements,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
|
import { rotate } from "../../math";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
@@ -13,7 +14,6 @@ import { useMemo } from "react";
|
|||||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { point, pointRotateRads } from "../../../math";
|
|
||||||
|
|
||||||
interface MultiPositionProps {
|
interface MultiPositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
@@ -43,9 +43,11 @@ const moveElements = (
|
|||||||
origElement.x + origElement.width / 2,
|
origElement.x + origElement.width / 2,
|
||||||
origElement.y + origElement.height / 2,
|
origElement.y + origElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
point(origElement.x, origElement.y),
|
origElement.x,
|
||||||
point(cx, cy),
|
origElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
origElement.angle,
|
origElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -96,9 +98,11 @@ const moveGroupTo = (
|
|||||||
latestElement.y + latestElement.height / 2,
|
latestElement.y + latestElement.height / 2,
|
||||||
];
|
];
|
||||||
|
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
point(latestElement.x, latestElement.y),
|
latestElement.x,
|
||||||
point(cx, cy),
|
latestElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
latestElement.angle,
|
latestElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,9 +174,11 @@ const handlePositionChange: DragInputCallbackType<
|
|||||||
origElement.x + origElement.width / 2,
|
origElement.x + origElement.width / 2,
|
||||||
origElement.y + origElement.height / 2,
|
origElement.y + origElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
point(origElement.x, origElement.y),
|
origElement.x,
|
||||||
point(cx, cy),
|
origElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
origElement.angle,
|
origElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -240,11 +246,7 @@ const MultiPosition = ({
|
|||||||
const [el] = elementsInUnit;
|
const [el] = elementsInUnit;
|
||||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||||
|
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
|
||||||
point(el.x, el.y),
|
|
||||||
point(cx, cy),
|
|
||||||
el.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||||
|
import { rotate } from "../../math";
|
||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, moveElement } from "./utils";
|
import { getStepSizedValue, moveElement } from "./utils";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { point, pointRotateRads } from "../../../math";
|
|
||||||
|
|
||||||
interface PositionProps {
|
interface PositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
@@ -32,9 +32,11 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
|||||||
origElement.x + origElement.width / 2,
|
origElement.x + origElement.width / 2,
|
||||||
origElement.y + origElement.height / 2,
|
origElement.y + origElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
point(origElement.x, origElement.y),
|
origElement.x,
|
||||||
point(cx, cy),
|
origElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
origElement.angle,
|
origElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -92,9 +94,11 @@ const Position = ({
|
|||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
}: PositionProps) => {
|
}: PositionProps) => {
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
point(element.x, element.y),
|
element.x,
|
||||||
point(element.x + element.width / 2, element.y + element.height / 2),
|
element.y,
|
||||||
|
element.x + element.width / 2,
|
||||||
|
element.y + element.height / 2,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const value =
|
const value =
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
.exc-stats {
|
|
||||||
width: 204px;
|
|
||||||
position: absolute;
|
|
||||||
top: 60px;
|
|
||||||
font-size: 12px;
|
|
||||||
z-index: var(--zIndex-layerUI);
|
|
||||||
pointer-events: var(--ui-pointerEvents);
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 12px;
|
|
||||||
right: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.5em;
|
|
||||||
margin-block-start: 0.83em;
|
|
||||||
margin-block-end: 0.83em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 1.17em;
|
|
||||||
margin: 0;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__rows {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.3125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
div + div {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__row--heading {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,13 @@ import { useEffect, useMemo, useState, memo } from "react";
|
|||||||
import { getCommonBounds } from "../../element/bounds";
|
import { getCommonBounds } from "../../element/bounds";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import type {
|
import type { AppState, ExcalidrawProps } from "../../types";
|
||||||
AppClassProperties,
|
|
||||||
AppState,
|
|
||||||
ExcalidrawProps,
|
|
||||||
} from "../../types";
|
|
||||||
import { CloseIcon } from "../icons";
|
import { CloseIcon } from "../icons";
|
||||||
import { Island } from "../Island";
|
import { Island } from "../Island";
|
||||||
import { throttle } from "lodash";
|
import { throttle } from "lodash";
|
||||||
import Dimension from "./Dimension";
|
import Dimension from "./Dimension";
|
||||||
import Angle from "./Angle";
|
import Angle from "./Angle";
|
||||||
|
|
||||||
import FontSize from "./FontSize";
|
import FontSize from "./FontSize";
|
||||||
import MultiDimension from "./MultiDimension";
|
import MultiDimension from "./MultiDimension";
|
||||||
import { elementsAreInSameGroup } from "../../groups";
|
import { elementsAreInSameGroup } from "../../groups";
|
||||||
@@ -20,18 +17,14 @@ import MultiFontSize from "./MultiFontSize";
|
|||||||
import Position from "./Position";
|
import Position from "./Position";
|
||||||
import MultiPosition from "./MultiPosition";
|
import MultiPosition from "./MultiPosition";
|
||||||
import Collapsible from "./Collapsible";
|
import Collapsible from "./Collapsible";
|
||||||
|
import type Scene from "../../scene/Scene";
|
||||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||||
import { getAtomicUnits } from "./utils";
|
import { getAtomicUnits } from "./utils";
|
||||||
import { STATS_PANELS } from "../../constants";
|
import { STATS_PANELS } from "../../constants";
|
||||||
import { isElbowArrow } from "../../element/typeChecks";
|
import { isElbowArrow } from "../../element/typeChecks";
|
||||||
import CanvasGrid from "./CanvasGrid";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
import "./Stats.scss";
|
|
||||||
import { isGridModeEnabled } from "../../snapping";
|
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
app: AppClassProperties;
|
scene: Scene;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
}
|
}
|
||||||
@@ -40,12 +33,11 @@ const STATS_TIMEOUT = 50;
|
|||||||
|
|
||||||
export const Stats = (props: StatsProps) => {
|
export const Stats = (props: StatsProps) => {
|
||||||
const appState = useExcalidrawAppState();
|
const appState = useExcalidrawAppState();
|
||||||
const sceneNonce = props.app.scene.getSceneNonce() || 1;
|
const sceneNonce = props.scene.getSceneNonce() || 1;
|
||||||
const selectedElements = props.app.scene.getSelectedElements({
|
const selectedElements = props.scene.getSelectedElements({
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
includeBoundTextElement: false,
|
includeBoundTextElement: false,
|
||||||
});
|
});
|
||||||
const gridModeEnabled = isGridModeEnabled(props.app);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatsInner
|
<StatsInner
|
||||||
@@ -53,71 +45,23 @@ export const Stats = (props: StatsProps) => {
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
sceneNonce={sceneNonce}
|
sceneNonce={sceneNonce}
|
||||||
selectedElements={selectedElements}
|
selectedElements={selectedElements}
|
||||||
gridModeEnabled={gridModeEnabled}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatsRow = ({
|
|
||||||
children,
|
|
||||||
columns = 1,
|
|
||||||
heading,
|
|
||||||
style,
|
|
||||||
...rest
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
columns?: number;
|
|
||||||
heading?: boolean;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
} & React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={clsx("exc-stats__row", { "exc-stats__row--heading": heading })}
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
StatsRow.displayName = "StatsRow";
|
|
||||||
|
|
||||||
const StatsRows = ({
|
|
||||||
children,
|
|
||||||
order,
|
|
||||||
style,
|
|
||||||
...rest
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
order?: number;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
} & React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div className="exc-stats__rows" style={{ order, ...style }} {...rest}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
StatsRows.displayName = "StatsRows";
|
|
||||||
|
|
||||||
Stats.StatsRow = StatsRow;
|
|
||||||
Stats.StatsRows = StatsRows;
|
|
||||||
|
|
||||||
export const StatsInner = memo(
|
export const StatsInner = memo(
|
||||||
({
|
({
|
||||||
app,
|
scene,
|
||||||
onClose,
|
onClose,
|
||||||
renderCustomStats,
|
renderCustomStats,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
appState,
|
appState,
|
||||||
sceneNonce,
|
sceneNonce,
|
||||||
gridModeEnabled,
|
|
||||||
}: StatsProps & {
|
}: StatsProps & {
|
||||||
sceneNonce: number;
|
sceneNonce: number;
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
gridModeEnabled: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const scene = app.scene;
|
|
||||||
const elements = scene.getNonDeletedElements();
|
const elements = scene.getNonDeletedElements();
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
@@ -162,7 +106,7 @@ export const StatsInner = memo(
|
|||||||
}, [selectedElements, appState]);
|
}, [selectedElements, appState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exc-stats">
|
<div className="Stats">
|
||||||
<Island padding={3}>
|
<Island padding={3}>
|
||||||
<div className="title">
|
<div className="title">
|
||||||
<h2>{t("stats.title")}</h2>
|
<h2>{t("stats.title")}</h2>
|
||||||
@@ -177,6 +121,7 @@ export const StatsInner = memo(
|
|||||||
openTrigger={() =>
|
openTrigger={() =>
|
||||||
setAppState((state) => {
|
setAppState((state) => {
|
||||||
return {
|
return {
|
||||||
|
...state,
|
||||||
stats: {
|
stats: {
|
||||||
open: true,
|
open: true,
|
||||||
panels: state.stats.panels ^ STATS_PANELS.generalStats,
|
panels: state.stats.panels ^ STATS_PANELS.generalStats,
|
||||||
@@ -185,36 +130,26 @@ export const StatsInner = memo(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StatsRows>
|
<table>
|
||||||
<StatsRow heading>{t("stats.scene")}</StatsRow>
|
<tbody>
|
||||||
<StatsRow columns={2}>
|
<tr>
|
||||||
<div>{t("stats.shapes")}</div>
|
<th colSpan={2}>{t("stats.scene")}</th>
|
||||||
<div>{elements.length}</div>
|
</tr>
|
||||||
</StatsRow>
|
<tr>
|
||||||
<StatsRow columns={2}>
|
<td>{t("stats.elements")}</td>
|
||||||
<div>{t("stats.width")}</div>
|
<td>{elements.length}</td>
|
||||||
<div>{sceneDimension.width}</div>
|
</tr>
|
||||||
</StatsRow>
|
<tr>
|
||||||
<StatsRow columns={2}>
|
<td>{t("stats.width")}</td>
|
||||||
<div>{t("stats.height")}</div>
|
<td>{sceneDimension.width}</td>
|
||||||
<div>{sceneDimension.height}</div>
|
</tr>
|
||||||
</StatsRow>
|
<tr>
|
||||||
{gridModeEnabled && (
|
<td>{t("stats.height")}</td>
|
||||||
<>
|
<td>{sceneDimension.height}</td>
|
||||||
<StatsRow heading>Canvas</StatsRow>
|
</tr>
|
||||||
<StatsRow>
|
{renderCustomStats?.(elements, appState)}
|
||||||
<CanvasGrid
|
</tbody>
|
||||||
property="gridStep"
|
</table>
|
||||||
scene={scene}
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
/>
|
|
||||||
</StatsRow>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</StatsRows>
|
|
||||||
|
|
||||||
{renderCustomStats?.(elements, appState)}
|
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{selectedElements.length > 0 && (
|
{selectedElements.length > 0 && (
|
||||||
@@ -232,6 +167,7 @@ export const StatsInner = memo(
|
|||||||
openTrigger={() =>
|
openTrigger={() =>
|
||||||
setAppState((state) => {
|
setAppState((state) => {
|
||||||
return {
|
return {
|
||||||
|
...state,
|
||||||
stats: {
|
stats: {
|
||||||
open: true,
|
open: true,
|
||||||
panels:
|
panels:
|
||||||
@@ -241,139 +177,117 @@ export const StatsInner = memo(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StatsRows>
|
{singleElement && (
|
||||||
{singleElement && (
|
<div className="sectionContent">
|
||||||
<>
|
<div className="elementType">
|
||||||
<StatsRow heading data-testid="stats-element-type">
|
{t(`element.${singleElement.type}`)}
|
||||||
{t(`element.${singleElement.type}`)}
|
</div>
|
||||||
</StatsRow>
|
|
||||||
|
|
||||||
<StatsRow>
|
<div className="statsItem">
|
||||||
<Position
|
<Position
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
property="x"
|
property="x"
|
||||||
elementsMap={elementsMap}
|
elementsMap={elementsMap}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
</StatsRow>
|
<Position
|
||||||
<StatsRow>
|
element={singleElement}
|
||||||
<Position
|
property="y"
|
||||||
element={singleElement}
|
elementsMap={elementsMap}
|
||||||
property="y"
|
scene={scene}
|
||||||
elementsMap={elementsMap}
|
appState={appState}
|
||||||
scene={scene}
|
/>
|
||||||
appState={appState}
|
<Dimension
|
||||||
/>
|
property="width"
|
||||||
</StatsRow>
|
element={singleElement}
|
||||||
<StatsRow>
|
scene={scene}
|
||||||
<Dimension
|
appState={appState}
|
||||||
property="width"
|
/>
|
||||||
element={singleElement}
|
<Dimension
|
||||||
scene={scene}
|
property="height"
|
||||||
appState={appState}
|
element={singleElement}
|
||||||
/>
|
scene={scene}
|
||||||
</StatsRow>
|
appState={appState}
|
||||||
<StatsRow>
|
/>
|
||||||
<Dimension
|
|
||||||
property="height"
|
|
||||||
element={singleElement}
|
|
||||||
scene={scene}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
|
||||||
</StatsRow>
|
|
||||||
{!isElbowArrow(singleElement) && (
|
{!isElbowArrow(singleElement) && (
|
||||||
<StatsRow>
|
<Angle
|
||||||
<Angle
|
property="angle"
|
||||||
property="angle"
|
|
||||||
element={singleElement}
|
|
||||||
scene={scene}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
|
||||||
</StatsRow>
|
|
||||||
)}
|
|
||||||
<StatsRow>
|
|
||||||
<FontSize
|
|
||||||
property="fontSize"
|
|
||||||
element={singleElement}
|
element={singleElement}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
</StatsRow>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{multipleElements && (
|
|
||||||
<>
|
|
||||||
{elementsAreInSameGroup(multipleElements) && (
|
|
||||||
<StatsRow heading>{t("element.group")}</StatsRow>
|
|
||||||
)}
|
)}
|
||||||
|
<FontSize
|
||||||
|
property="fontSize"
|
||||||
|
element={singleElement}
|
||||||
|
scene={scene}
|
||||||
|
appState={appState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
|
{multipleElements && (
|
||||||
<div>{t("stats.shapes")}</div>
|
<div className="sectionContent">
|
||||||
<div>{selectedElements.length}</div>
|
{elementsAreInSameGroup(multipleElements) && (
|
||||||
</StatsRow>
|
<div className="elementType">{t("element.group")}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<StatsRow>
|
<div className="elementsCount">
|
||||||
<MultiPosition
|
<div>{t("stats.elements")}</div>
|
||||||
property="x"
|
<div>{selectedElements.length}</div>
|
||||||
elements={multipleElements}
|
</div>
|
||||||
elementsMap={elementsMap}
|
|
||||||
atomicUnits={atomicUnits}
|
<div className="statsItem">
|
||||||
scene={scene}
|
<MultiPosition
|
||||||
appState={appState}
|
property="x"
|
||||||
/>
|
elements={multipleElements}
|
||||||
</StatsRow>
|
elementsMap={elementsMap}
|
||||||
<StatsRow>
|
atomicUnits={atomicUnits}
|
||||||
<MultiPosition
|
scene={scene}
|
||||||
property="y"
|
appState={appState}
|
||||||
elements={multipleElements}
|
/>
|
||||||
elementsMap={elementsMap}
|
<MultiPosition
|
||||||
atomicUnits={atomicUnits}
|
property="y"
|
||||||
scene={scene}
|
elements={multipleElements}
|
||||||
appState={appState}
|
elementsMap={elementsMap}
|
||||||
/>
|
atomicUnits={atomicUnits}
|
||||||
</StatsRow>
|
scene={scene}
|
||||||
<StatsRow>
|
appState={appState}
|
||||||
<MultiDimension
|
/>
|
||||||
property="width"
|
<MultiDimension
|
||||||
elements={multipleElements}
|
property="width"
|
||||||
elementsMap={elementsMap}
|
elements={multipleElements}
|
||||||
atomicUnits={atomicUnits}
|
elementsMap={elementsMap}
|
||||||
scene={scene}
|
atomicUnits={atomicUnits}
|
||||||
appState={appState}
|
scene={scene}
|
||||||
/>
|
appState={appState}
|
||||||
</StatsRow>
|
/>
|
||||||
<StatsRow>
|
<MultiDimension
|
||||||
<MultiDimension
|
property="height"
|
||||||
property="height"
|
elements={multipleElements}
|
||||||
elements={multipleElements}
|
elementsMap={elementsMap}
|
||||||
elementsMap={elementsMap}
|
atomicUnits={atomicUnits}
|
||||||
atomicUnits={atomicUnits}
|
scene={scene}
|
||||||
scene={scene}
|
appState={appState}
|
||||||
appState={appState}
|
/>
|
||||||
/>
|
<MultiAngle
|
||||||
</StatsRow>
|
property="angle"
|
||||||
<StatsRow>
|
elements={multipleElements}
|
||||||
<MultiAngle
|
scene={scene}
|
||||||
property="angle"
|
appState={appState}
|
||||||
elements={multipleElements}
|
/>
|
||||||
scene={scene}
|
<MultiFontSize
|
||||||
appState={appState}
|
property="fontSize"
|
||||||
/>
|
elements={multipleElements}
|
||||||
</StatsRow>
|
scene={scene}
|
||||||
<StatsRow>
|
appState={appState}
|
||||||
<MultiFontSize
|
elementsMap={elementsMap}
|
||||||
property="fontSize"
|
/>
|
||||||
elements={multipleElements}
|
</div>
|
||||||
scene={scene}
|
</div>
|
||||||
appState={appState}
|
)}
|
||||||
elementsMap={elementsMap}
|
|
||||||
/>
|
|
||||||
</StatsRow>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</StatsRows>
|
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -385,9 +299,7 @@ export const StatsInner = memo(
|
|||||||
return (
|
return (
|
||||||
prev.sceneNonce === next.sceneNonce &&
|
prev.sceneNonce === next.sceneNonce &&
|
||||||
prev.selectedElements === next.selectedElements &&
|
prev.selectedElements === next.selectedElements &&
|
||||||
prev.appState.stats.panels === next.appState.stats.panels &&
|
prev.appState.stats.panels === next.appState.stats.panels
|
||||||
prev.gridModeEnabled === next.gridModeEnabled &&
|
|
||||||
prev.appState.gridStep === next.appState.gridStep
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,13 +19,12 @@ import type {
|
|||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
|
import { degreeToRadian, rotate } from "../../math";
|
||||||
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
|
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
|
||||||
import { getCommonBounds, isTextElement } from "../../element";
|
import { getCommonBounds, isTextElement } from "../../element";
|
||||||
import { API } from "../../tests/helpers/api";
|
import { API } from "../../tests/helpers/api";
|
||||||
import { actionGroup } from "../../actions";
|
import { actionGroup } from "../../actions";
|
||||||
import { isInGroup } from "../../groups";
|
import { isInGroup } from "../../groups";
|
||||||
import type { Degrees } from "../../../math";
|
|
||||||
import { degreesToRadians, point, pointRotateRads } from "../../../math";
|
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@@ -33,6 +32,21 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
|||||||
let stats: HTMLElement | null = null;
|
let stats: HTMLElement | null = null;
|
||||||
let elementStats: HTMLElement | null | undefined = null;
|
let elementStats: HTMLElement | null | undefined = null;
|
||||||
|
|
||||||
|
const getStatsProperty = (label: string) => {
|
||||||
|
const elementStats = UI.queryStats()?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
if (elementStats) {
|
||||||
|
const properties = elementStats?.querySelector(".statsItem");
|
||||||
|
return (
|
||||||
|
properties?.querySelector?.(
|
||||||
|
`.drag-input-container[data-testid="${label}"]`,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const testInputProperty = (
|
const testInputProperty = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
|
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
|
||||||
@@ -40,16 +54,14 @@ const testInputProperty = (
|
|||||||
initialValue: number,
|
initialValue: number,
|
||||||
nextValue: number,
|
nextValue: number,
|
||||||
) => {
|
) => {
|
||||||
const input = UI.queryStatsProperty(label)?.querySelector(
|
const input = getStatsProperty(label)?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
expect(input.value).toBe(initialValue.toString());
|
expect(input.value).toBe(initialValue.toString());
|
||||||
UI.updateInput(input, String(nextValue));
|
UI.updateInput(input, String(nextValue));
|
||||||
if (property === "angle") {
|
if (property === "angle") {
|
||||||
expect(element[property]).toBe(
|
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
||||||
degreesToRadians(Number(nextValue) as Degrees),
|
|
||||||
);
|
|
||||||
} else if (property === "fontSize" && isTextElement(element)) {
|
} else if (property === "fontSize" && isTextElement(element)) {
|
||||||
expect(element[property]).toBe(Number(nextValue));
|
expect(element[property]).toBe(Number(nextValue));
|
||||||
} else if (property !== "fontSize") {
|
} else if (property !== "fontSize") {
|
||||||
@@ -124,7 +136,7 @@ describe("binding with linear elements", () => {
|
|||||||
|
|
||||||
it("should remain bound to linear element on small position change", async () => {
|
it("should remain bound to linear element on small position change", async () => {
|
||||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
const inputX = UI.queryStatsProperty("X")?.querySelector(
|
const inputX = getStatsProperty("X")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
@@ -136,7 +148,7 @@ describe("binding with linear elements", () => {
|
|||||||
|
|
||||||
it("should remain bound to linear element on small angle change", async () => {
|
it("should remain bound to linear element on small angle change", async () => {
|
||||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
const inputAngle = getStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
@@ -147,7 +159,7 @@ describe("binding with linear elements", () => {
|
|||||||
|
|
||||||
it("should unbind linear element on large position change", async () => {
|
it("should unbind linear element on large position change", async () => {
|
||||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
const inputX = UI.queryStatsProperty("X")?.querySelector(
|
const inputX = getStatsProperty("X")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
@@ -159,7 +171,7 @@ describe("binding with linear elements", () => {
|
|||||||
|
|
||||||
it("should remain bound to linear element on small angle change", async () => {
|
it("should remain bound to linear element on small angle change", async () => {
|
||||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||||
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
const inputAngle = getStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
@@ -213,14 +225,18 @@ describe("stats for a generic element", () => {
|
|||||||
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
|
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
|
||||||
|
|
||||||
// element type
|
// element type
|
||||||
const elementType = queryByTestId(elementStats!, "stats-element-type");
|
const elementType = elementStats?.querySelector(".elementType");
|
||||||
expect(elementType).toBeDefined();
|
expect(elementType).toBeDefined();
|
||||||
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
||||||
|
|
||||||
// properties
|
// properties
|
||||||
|
const properties = elementStats?.querySelector(".statsItem");
|
||||||
|
expect(properties?.childNodes).toBeDefined();
|
||||||
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
||||||
expect(
|
expect(
|
||||||
stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`),
|
properties?.querySelector?.(
|
||||||
|
`.drag-input-container[data-testid="${label}"]`,
|
||||||
|
),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -241,7 +257,7 @@ describe("stats for a generic element", () => {
|
|||||||
const rectangle = h.elements[0];
|
const rectangle = h.elements[0];
|
||||||
const rectangleId = rectangle.id;
|
const rectangleId = rectangle.id;
|
||||||
|
|
||||||
const input = UI.queryStatsProperty("W")?.querySelector(
|
const input = getStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
@@ -263,17 +279,19 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
point(rectangle.x, rectangle.y),
|
rectangle.x,
|
||||||
point(cx, cy),
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
const xInput = UI.queryStatsProperty("X")?.querySelector(
|
const xInput = getStatsProperty("X")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
const yInput = UI.queryStatsProperty("Y")?.querySelector(
|
const yInput = getStatsProperty("Y")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
@@ -282,9 +300,11 @@ describe("stats for a generic element", () => {
|
|||||||
|
|
||||||
testInputProperty(rectangle, "angle", "A", 0, 45);
|
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||||
|
|
||||||
let [newTopLeftX, newTopLeftY] = pointRotateRads(
|
let [newTopLeftX, newTopLeftY] = rotate(
|
||||||
point(rectangle.x, rectangle.y),
|
rectangle.x,
|
||||||
point(cx, cy),
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,9 +313,11 @@ describe("stats for a generic element", () => {
|
|||||||
|
|
||||||
testInputProperty(rectangle, "angle", "A", 45, 66);
|
testInputProperty(rectangle, "angle", "A", 45, 66);
|
||||||
|
|
||||||
[newTopLeftX, newTopLeftY] = pointRotateRads(
|
[newTopLeftX, newTopLeftY] = rotate(
|
||||||
point(rectangle.x, rectangle.y),
|
rectangle.x,
|
||||||
point(cx, cy),
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||||
@@ -310,9 +332,11 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
point(rectangle.x, rectangle.y),
|
rectangle.x,
|
||||||
point(cx, cy),
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
||||||
@@ -320,9 +344,11 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
];
|
];
|
||||||
let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
let [currentTopLeftX, currentTopLeftY] = rotate(
|
||||||
point(rectangle.x, rectangle.y),
|
rectangle.x,
|
||||||
point(cx, cy),
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||||
@@ -333,9 +359,11 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
];
|
];
|
||||||
[currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
[currentTopLeftX, currentTopLeftY] = rotate(
|
||||||
point(rectangle.x, rectangle.y),
|
rectangle.x,
|
||||||
point(cx, cy),
|
rectangle.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -389,7 +417,7 @@ describe("stats for a non-generic element", () => {
|
|||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
// can change font size
|
// can change font size
|
||||||
const input = UI.queryStatsProperty("F")?.querySelector(
|
const input = getStatsProperty("F")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(input).toBeDefined();
|
expect(input).toBeDefined();
|
||||||
@@ -398,9 +426,9 @@ describe("stats for a non-generic element", () => {
|
|||||||
expect(text.fontSize).toBe(36);
|
expect(text.fontSize).toBe(36);
|
||||||
|
|
||||||
// cannot change width or height
|
// cannot change width or height
|
||||||
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
|
const width = getStatsProperty("W")?.querySelector(".drag-input");
|
||||||
expect(width).toBeUndefined();
|
expect(width).toBeUndefined();
|
||||||
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
|
const height = getStatsProperty("H")?.querySelector(".drag-input");
|
||||||
expect(height).toBeUndefined();
|
expect(height).toBeUndefined();
|
||||||
|
|
||||||
// min font size is 4
|
// min font size is 4
|
||||||
@@ -428,7 +456,7 @@ describe("stats for a non-generic element", () => {
|
|||||||
expect(elementStats).toBeDefined();
|
expect(elementStats).toBeDefined();
|
||||||
|
|
||||||
// cannot change angle
|
// cannot change angle
|
||||||
const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input");
|
const angle = getStatsProperty("A")?.querySelector(".drag-input");
|
||||||
expect(angle).toBeUndefined();
|
expect(angle).toBeUndefined();
|
||||||
|
|
||||||
// can change width or height
|
// can change width or height
|
||||||
@@ -478,7 +506,7 @@ describe("stats for a non-generic element", () => {
|
|||||||
API.setElements([container, text]);
|
API.setElements([container, text]);
|
||||||
|
|
||||||
API.setSelectedElements([container]);
|
API.setSelectedElements([container]);
|
||||||
const fontSize = UI.queryStatsProperty("F")?.querySelector(
|
const fontSize = getStatsProperty("F")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(fontSize).toBeDefined();
|
expect(fontSize).toBeDefined();
|
||||||
@@ -542,15 +570,15 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
const width = UI.queryStatsProperty("W")?.querySelector(
|
const width = getStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(width?.value).toBe("Mixed");
|
expect(width?.value).toBe("Mixed");
|
||||||
const height = UI.queryStatsProperty("H")?.querySelector(
|
const height = getStatsProperty("H")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(height?.value).toBe("Mixed");
|
expect(height?.value).toBe("Mixed");
|
||||||
const angle = UI.queryStatsProperty("A")?.querySelector(
|
const angle = getStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(angle.value).toBe("0");
|
expect(angle.value).toBe("0");
|
||||||
@@ -601,25 +629,25 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
const width = UI.queryStatsProperty("W")?.querySelector(
|
const width = getStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(width).toBeDefined();
|
expect(width).toBeDefined();
|
||||||
expect(width.value).toBe("Mixed");
|
expect(width.value).toBe("Mixed");
|
||||||
|
|
||||||
const height = UI.queryStatsProperty("H")?.querySelector(
|
const height = getStatsProperty("H")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(height).toBeDefined();
|
expect(height).toBeDefined();
|
||||||
expect(height.value).toBe("Mixed");
|
expect(height.value).toBe("Mixed");
|
||||||
|
|
||||||
const angle = UI.queryStatsProperty("A")?.querySelector(
|
const angle = getStatsProperty("A")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(angle).toBeDefined();
|
expect(angle).toBeDefined();
|
||||||
expect(angle.value).toBe("0");
|
expect(angle.value).toBe("0");
|
||||||
|
|
||||||
const fontSize = UI.queryStatsProperty("F")?.querySelector(
|
const fontSize = getStatsProperty("F")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(fontSize).toBeDefined();
|
expect(fontSize).toBeDefined();
|
||||||
@@ -633,7 +661,7 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
UI.updateInput(angle, "40");
|
UI.updateInput(angle, "40");
|
||||||
|
|
||||||
const angleInRadian = degreesToRadians(40 as Degrees);
|
const angleInRadian = degreeToRadian(40);
|
||||||
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
||||||
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
|
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
|
||||||
expect(frame.angle).toBe(0);
|
expect(frame.angle).toBe(0);
|
||||||
@@ -664,7 +692,7 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
elementStats = stats?.querySelector("#elementStats");
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
const x = UI.queryStatsProperty("X")?.querySelector(
|
const x = getStatsProperty("X")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
@@ -677,7 +705,7 @@ describe("stats for multiple elements", () => {
|
|||||||
expect(h.elements[1].x).toBe(400);
|
expect(h.elements[1].x).toBe(400);
|
||||||
expect(x.value).toBe("300");
|
expect(x.value).toBe("300");
|
||||||
|
|
||||||
const y = UI.queryStatsProperty("Y")?.querySelector(
|
const y = getStatsProperty("Y")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
@@ -690,13 +718,13 @@ describe("stats for multiple elements", () => {
|
|||||||
expect(h.elements[1].y).toBe(300);
|
expect(h.elements[1].y).toBe(300);
|
||||||
expect(y.value).toBe("200");
|
expect(y.value).toBe("200");
|
||||||
|
|
||||||
const width = UI.queryStatsProperty("W")?.querySelector(
|
const width = getStatsProperty("W")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(width).toBeDefined();
|
expect(width).toBeDefined();
|
||||||
expect(Number(width.value)).toBe(200);
|
expect(Number(width.value)).toBe(200);
|
||||||
|
|
||||||
const height = UI.queryStatsProperty("H")?.querySelector(
|
const height = getStatsProperty("H")?.querySelector(
|
||||||
".drag-input",
|
".drag-input",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(height).toBeDefined();
|
expect(height).toBeDefined();
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Radians } from "../../../math";
|
|
||||||
import { point, pointRotateRads } from "../../../math";
|
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElements,
|
bindOrUnbindLinearElements,
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
@@ -32,6 +30,7 @@ import {
|
|||||||
getElementsInGroup,
|
getElementsInGroup,
|
||||||
isInGroup,
|
isInGroup,
|
||||||
} from "../../groups";
|
} from "../../groups";
|
||||||
|
import { rotate } from "../../math";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { getFontString } from "../../utils";
|
import { getFontString } from "../../utils";
|
||||||
@@ -42,8 +41,7 @@ export type StatsInputProperty =
|
|||||||
| "width"
|
| "width"
|
||||||
| "height"
|
| "height"
|
||||||
| "angle"
|
| "angle"
|
||||||
| "fontSize"
|
| "fontSize";
|
||||||
| "gridStep";
|
|
||||||
|
|
||||||
export const SMALLEST_DELTA = 0.01;
|
export const SMALLEST_DELTA = 0.01;
|
||||||
|
|
||||||
@@ -230,19 +228,23 @@ export const moveElement = (
|
|||||||
originalElement.x + originalElement.width / 2,
|
originalElement.x + originalElement.width / 2,
|
||||||
originalElement.y + originalElement.height / 2,
|
originalElement.y + originalElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = rotate(
|
||||||
point(originalElement.x, originalElement.y),
|
originalElement.x,
|
||||||
point(cx, cy),
|
originalElement.y,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
originalElement.angle,
|
originalElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeInX = newTopLeftX - topLeftX;
|
const changeInX = newTopLeftX - topLeftX;
|
||||||
const changeInY = newTopLeftY - topLeftY;
|
const changeInY = newTopLeftY - topLeftY;
|
||||||
|
|
||||||
const [x, y] = pointRotateRads(
|
const [x, y] = rotate(
|
||||||
point(newTopLeftX, newTopLeftY),
|
newTopLeftX,
|
||||||
point(cx + changeInX, cy + changeInY),
|
newTopLeftY,
|
||||||
-originalElement.angle as Radians,
|
cx + changeInX,
|
||||||
|
cy + changeInY,
|
||||||
|
-originalElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
mutateElement(
|
mutateElement(
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
|
|||||||
import { ArrowRightIcon } from "../icons";
|
import { ArrowRightIcon } from "../icons";
|
||||||
|
|
||||||
import "./TTDDialog.scss";
|
import "./TTDDialog.scss";
|
||||||
|
import { isFiniteNumber } from "../../utils";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||||
import { isFiniteNumber } from "../../../math";
|
|
||||||
|
|
||||||
const MIN_PROMPT_LENGTH = 3;
|
const MIN_PROMPT_LENGTH = 3;
|
||||||
const MAX_PROMPT_LENGTH = 1000;
|
const MAX_PROMPT_LENGTH = 1000;
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { isMemberOf } from "../../utils";
|
|||||||
const TTDDialogTabs = (
|
const TTDDialogTabs = (
|
||||||
props: {
|
props: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
} & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
|
} & (
|
||||||
|
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
|
||||||
|
| { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
|
||||||
|
),
|
||||||
) => {
|
) => {
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
@@ -36,6 +39,13 @@ const TTDDialogTabs = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
props.dialog === "settings" &&
|
||||||
|
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
|
||||||
|
) {
|
||||||
|
setAppState({
|
||||||
|
openDialog: { name: props.dialog, tab, source: "settings" },
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
props.dialog === "ttd" &&
|
props.dialog === "ttd" &&
|
||||||
isMemberOf(["text-to-diagram", "mermaid"], tab)
|
isMemberOf(["text-to-diagram", "mermaid"], tab)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -3,29 +3,16 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
--ExcTextField--color: var(--color-on-surface);
|
--ExcTextField--color: var(--color-on-surface);
|
||||||
--ExcTextField--label-color: var(--color-on-surface);
|
--ExcTextField--label-color: var(--color-on-surface);
|
||||||
--ExcTextField--background: var(--color-surface-low);
|
--ExcTextField--background: transparent;
|
||||||
--ExcTextField--readonly--background: var(--color-surface-high);
|
--ExcTextField--readonly--background: var(--color-surface-high);
|
||||||
--ExcTextField--readonly--color: var(--color-on-surface);
|
--ExcTextField--readonly--color: var(--color-on-surface);
|
||||||
--ExcTextField--border: var(--color-gray-20);
|
--ExcTextField--border: var(--color-border-outline);
|
||||||
--ExcTextField--readonly--border: var(--color-border-outline-variant);
|
--ExcTextField--readonly--border: var(--color-border-outline-variant);
|
||||||
--ExcTextField--border-hover: var(--color-brand-hover);
|
--ExcTextField--border-hover: var(--color-brand-hover);
|
||||||
--ExcTextField--border-active: var(--color-brand-active);
|
--ExcTextField--border-active: var(--color-brand-active);
|
||||||
--ExcTextField--placeholder: var(--color-border-outline-variant);
|
--ExcTextField--placeholder: var(--color-border-outline-variant);
|
||||||
|
|
||||||
.ExcTextField {
|
.ExcTextField {
|
||||||
position: relative;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%; // 50% is not exactly in the center of the input
|
|
||||||
transform: translateY(-50%);
|
|
||||||
left: 0.75rem;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
color: var(--color-gray-40);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--fullWidth {
|
&--fullWidth {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
@@ -50,6 +37,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
|
|
||||||
@@ -57,8 +45,6 @@
|
|||||||
border: 1px solid var(--ExcTextField--border);
|
border: 1px solid var(--ExcTextField--border);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
|
|
||||||
&:not(&--readonly) {
|
&:not(&--readonly) {
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--ExcTextField--border-hover);
|
border-color: var(--ExcTextField--border-hover);
|
||||||
@@ -94,6 +80,10 @@
|
|||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--ExcTextField--placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
&:not(:focus) {
|
&:not(:focus) {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: initial;
|
background-color: initial;
|
||||||
@@ -115,9 +105,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--hasIcon .ExcTextField__input {
|
|
||||||
padding-left: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ type TextFieldProps = {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
selectOnRender?: boolean;
|
selectOnRender?: boolean;
|
||||||
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
label?: string;
|
label?: string;
|
||||||
className?: string;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isRedacted?: boolean;
|
isRedacted?: boolean;
|
||||||
} & ({ value: string } | { defaultValue: string });
|
} & ({ value: string } | { defaultValue: string });
|
||||||
@@ -39,8 +37,6 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
|||||||
selectOnRender,
|
selectOnRender,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
isRedacted = false,
|
isRedacted = false,
|
||||||
icon,
|
|
||||||
className,
|
|
||||||
...rest
|
...rest
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -51,8 +47,6 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (selectOnRender) {
|
if (selectOnRender) {
|
||||||
// focusing first is needed because vitest/jsdom
|
|
||||||
innerRef.current?.focus();
|
|
||||||
innerRef.current?.select();
|
innerRef.current?.select();
|
||||||
}
|
}
|
||||||
}, [selectOnRender]);
|
}, [selectOnRender]);
|
||||||
@@ -62,16 +56,14 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("ExcTextField", className, {
|
className={clsx("ExcTextField", {
|
||||||
"ExcTextField--fullWidth": fullWidth,
|
"ExcTextField--fullWidth": fullWidth,
|
||||||
"ExcTextField--hasIcon": !!icon,
|
|
||||||
})}
|
})}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
innerRef.current?.focus();
|
innerRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
<div className="ExcTextField__label">{label}</div>
|
||||||
{label && <div className="ExcTextField__label">{label}</div>}
|
|
||||||
<div
|
<div
|
||||||
className={clsx("ExcTextField__input", {
|
className={clsx("ExcTextField__input", {
|
||||||
"ExcTextField__input--readonly": readonly,
|
"ExcTextField__input--readonly": readonly,
|
||||||
|
|||||||
@@ -202,8 +202,7 @@ const getRelevantAppStateProps = (
|
|||||||
activeEmbeddable: appState.activeEmbeddable,
|
activeEmbeddable: appState.activeEmbeddable,
|
||||||
snapLines: appState.snapLines,
|
snapLines: appState.snapLines,
|
||||||
zenModeEnabled: appState.zenModeEnabled,
|
zenModeEnabled: appState.zenModeEnabled,
|
||||||
editingTextElement: appState.editingTextElement,
|
editingElement: appState.editingElement,
|
||||||
searchMatches: appState.searchMatches,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const areEqual = (
|
const areEqual = (
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { NonDeletedSceneElementsMap } from "../../element/types";
|
|
||||||
import type { AppState } from "../../types";
|
|
||||||
import type {
|
|
||||||
RenderableElementsMap,
|
|
||||||
StaticCanvasRenderConfig,
|
|
||||||
} from "../../scene/types";
|
|
||||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
|
||||||
import { renderNewElementScene } from "../../renderer/renderNewElementScene";
|
|
||||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
|
||||||
|
|
||||||
interface NewElementCanvasProps {
|
|
||||||
appState: AppState;
|
|
||||||
elementsMap: RenderableElementsMap;
|
|
||||||
allElementsMap: NonDeletedSceneElementsMap;
|
|
||||||
scale: number;
|
|
||||||
rc: RoughCanvas;
|
|
||||||
renderConfig: StaticCanvasRenderConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NewElementCanvas = (props: NewElementCanvasProps) => {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canvasRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderNewElementScene(
|
|
||||||
{
|
|
||||||
canvas: canvasRef.current,
|
|
||||||
scale: props.scale,
|
|
||||||
newElement: props.appState.newElement,
|
|
||||||
elementsMap: props.elementsMap,
|
|
||||||
allElementsMap: props.allElementsMap,
|
|
||||||
rc: props.rc,
|
|
||||||
renderConfig: props.renderConfig,
|
|
||||||
appState: props.appState,
|
|
||||||
},
|
|
||||||
isRenderThrottlingEnabled(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
className="excalidraw__canvas"
|
|
||||||
style={{
|
|
||||||
width: props.appState.width,
|
|
||||||
height: props.appState.height,
|
|
||||||
}}
|
|
||||||
width={props.appState.width * props.scale}
|
|
||||||
height={props.appState.height * props.scale}
|
|
||||||
ref={canvasRef}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NewElementCanvas;
|
|
||||||
@@ -101,7 +101,6 @@ const getRelevantAppStateProps = (
|
|||||||
exportScale: appState.exportScale,
|
exportScale: appState.exportScale,
|
||||||
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
|
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
gridStep: appState.gridStep,
|
|
||||||
frameRendering: appState.frameRendering,
|
frameRendering: appState.frameRendering,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
frameToHighlight: appState.frameToHighlight,
|
frameToHighlight: appState.frameToHighlight,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const DropdownMenuItemLink = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
className = "",
|
className = "",
|
||||||
selected,
|
selected,
|
||||||
rel = "noreferrer",
|
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -23,7 +22,6 @@ const DropdownMenuItemLink = ({
|
|||||||
className?: string;
|
className?: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onSelect?: (event: Event) => void;
|
onSelect?: (event: Event) => void;
|
||||||
rel?: string;
|
|
||||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
|
import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
|
||||||
import {
|
import {
|
||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
@@ -36,7 +36,6 @@ import { trackEvent } from "../../analytics";
|
|||||||
import { useAppProps, useExcalidrawAppState } from "../App";
|
import { useAppProps, useExcalidrawAppState } from "../App";
|
||||||
import { isEmbeddableElement } from "../../element/typeChecks";
|
import { isEmbeddableElement } from "../../element/typeChecks";
|
||||||
import { getLinkHandleFromCoords } from "./helpers";
|
import { getLinkHandleFromCoords } from "./helpers";
|
||||||
import { point, type GlobalPoint } from "../../../math";
|
|
||||||
|
|
||||||
const CONTAINER_WIDTH = 320;
|
const CONTAINER_WIDTH = 320;
|
||||||
const SPACE_BOTTOM = 85;
|
const SPACE_BOTTOM = 85;
|
||||||
@@ -177,12 +176,10 @@ export const Hyperlink = ({
|
|||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
const shouldHide = shouldHideLinkPopup(
|
const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
|
||||||
element,
|
event.clientX,
|
||||||
elementsMap,
|
event.clientY,
|
||||||
appState,
|
]) as boolean;
|
||||||
point(event.clientX, event.clientY),
|
|
||||||
) as boolean;
|
|
||||||
if (shouldHide) {
|
if (shouldHide) {
|
||||||
timeoutId = window.setTimeout(() => {
|
timeoutId = window.setTimeout(() => {
|
||||||
setAppState({ showHyperlinkPopup: false });
|
setAppState({ showHyperlinkPopup: false });
|
||||||
@@ -419,7 +416,7 @@ const shouldHideLinkPopup = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
[clientX, clientY]: GlobalPoint,
|
[clientX, clientY]: Point,
|
||||||
): Boolean => {
|
): Boolean => {
|
||||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
{ clientX, clientY },
|
{ clientX, clientY },
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { GlobalPoint, Radians } from "../../../math";
|
|
||||||
import { point, pointRotateRads } from "../../../math";
|
|
||||||
import { MIME_TYPES } from "../../constants";
|
import { MIME_TYPES } from "../../constants";
|
||||||
import type { Bounds } from "../../element/bounds";
|
import type { Bounds } from "../../element/bounds";
|
||||||
import { getElementAbsoluteCoords } from "../../element/bounds";
|
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||||
@@ -8,8 +6,9 @@ import type {
|
|||||||
ElementsMap,
|
ElementsMap,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
|
import { rotate } from "../../math";
|
||||||
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
||||||
import type { AppState, UIAppState } from "../../types";
|
import type { AppState, Point, UIAppState } from "../../types";
|
||||||
|
|
||||||
export const EXTERNAL_LINK_IMG = document.createElement("img");
|
export const EXTERNAL_LINK_IMG = document.createElement("img");
|
||||||
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
||||||
@@ -18,7 +17,7 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
|||||||
|
|
||||||
export const getLinkHandleFromCoords = (
|
export const getLinkHandleFromCoords = (
|
||||||
[x1, y1, x2, y2]: Bounds,
|
[x1, y1, x2, y2]: Bounds,
|
||||||
angle: Radians,
|
angle: number,
|
||||||
appState: Pick<UIAppState, "zoom">,
|
appState: Pick<UIAppState, "zoom">,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
const size = DEFAULT_LINK_SIZE;
|
const size = DEFAULT_LINK_SIZE;
|
||||||
@@ -34,9 +33,11 @@ export const getLinkHandleFromCoords = (
|
|||||||
const x = x2 + dashedLineMargin - centeringOffset;
|
const x = x2 + dashedLineMargin - centeringOffset;
|
||||||
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
||||||
|
|
||||||
const [rotatedX, rotatedY] = pointRotateRads(
|
const [rotatedX, rotatedY] = rotate(
|
||||||
point(x + linkWidth / 2, y + linkHeight / 2),
|
x + linkWidth / 2,
|
||||||
point(centerX, centerY),
|
y + linkHeight / 2,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
@@ -51,7 +52,7 @@ export const isPointHittingLinkIcon = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
[x, y]: GlobalPoint,
|
[x, y]: Point,
|
||||||
) => {
|
) => {
|
||||||
const threshold = 4 / appState.zoom.value;
|
const threshold = 4 / appState.zoom.value;
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
@@ -72,7 +73,7 @@ export const isPointHittingLink = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
[x, y]: GlobalPoint,
|
[x, y]: Point,
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||||
@@ -85,5 +86,5 @@ export const isPointHittingLink = (
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
|
return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2139,11 +2139,3 @@ export const collapseUpIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const upIcon = createIcon(
|
|
||||||
<g>
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
|
||||||
<path d="M6 15l6 -6l6 6" />
|
|
||||||
</g>,
|
|
||||||
tablerIconProps,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
LoadIcon,
|
LoadIcon,
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
save,
|
save,
|
||||||
searchIcon,
|
|
||||||
SunIcon,
|
SunIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
usersIcon,
|
usersIcon,
|
||||||
@@ -28,7 +27,6 @@ import {
|
|||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
actionSaveToActiveFile,
|
actionSaveToActiveFile,
|
||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
actionToggleSearchMenu,
|
|
||||||
actionToggleTheme,
|
actionToggleTheme,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -42,6 +40,7 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
|
|||||||
import { THEME } from "../../constants";
|
import { THEME } from "../../constants";
|
||||||
import type { Theme } from "../../element/types";
|
import type { Theme } from "../../element/types";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
|
|
||||||
import "./DefaultItems.scss";
|
import "./DefaultItems.scss";
|
||||||
|
|
||||||
export const LoadScene = () => {
|
export const LoadScene = () => {
|
||||||
@@ -146,27 +145,6 @@ export const CommandPalette = (opts?: { className?: string }) => {
|
|||||||
};
|
};
|
||||||
CommandPalette.displayName = "CommandPalette";
|
CommandPalette.displayName = "CommandPalette";
|
||||||
|
|
||||||
export const SearchMenu = (opts?: { className?: string }) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const actionManager = useExcalidrawActionManager();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
icon={searchIcon}
|
|
||||||
data-testid="search-menu-button"
|
|
||||||
onSelect={() => {
|
|
||||||
actionManager.executeAction(actionToggleSearchMenu);
|
|
||||||
}}
|
|
||||||
shortcut={getShortcutFromShortcutName("searchMenu")}
|
|
||||||
aria-label={t("search.title")}
|
|
||||||
className={opts?.className}
|
|
||||||
>
|
|
||||||
{t("search.title")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
SearchMenu.displayName = "SearchMenu";
|
|
||||||
|
|
||||||
export const Help = () => {
|
export const Help = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -112,8 +112,6 @@ export const ENV = {
|
|||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||||
ZOOM_ACTIONS: "zoom-actions",
|
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,8 +179,7 @@ export const COLOR_VOICE_CALL = "#a2f1a6";
|
|||||||
|
|
||||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||||
|
|
||||||
export const DEFAULT_GRID_SIZE = 20;
|
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||||
export const DEFAULT_GRID_STEP = 5;
|
|
||||||
|
|
||||||
export const IMAGE_MIME_TYPES = {
|
export const IMAGE_MIME_TYPES = {
|
||||||
svg: "image/svg+xml",
|
svg: "image/svg+xml",
|
||||||
@@ -237,7 +234,7 @@ export const VERSION_TIMEOUT = 30000;
|
|||||||
export const SCROLL_TIMEOUT = 100;
|
export const SCROLL_TIMEOUT = 100;
|
||||||
export const ZOOM_STEP = 0.1;
|
export const ZOOM_STEP = 0.1;
|
||||||
export const MIN_ZOOM = 0.1;
|
export const MIN_ZOOM = 0.1;
|
||||||
export const MAX_ZOOM = 30;
|
export const MAX_ZOOM = 30.0;
|
||||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
||||||
|
|
||||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||||
@@ -377,7 +374,6 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const LIBRARY_SIDEBAR_TAB = "library";
|
export const LIBRARY_SIDEBAR_TAB = "library";
|
||||||
export const CANVAS_SEARCH_TAB = "search";
|
|
||||||
|
|
||||||
export const DEFAULT_SIDEBAR = {
|
export const DEFAULT_SIDEBAR = {
|
||||||
name: "default",
|
name: "default",
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ body.excalidraw-cursor-resize * {
|
|||||||
.App-menu__left {
|
.App-menu__left {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
width: 12.5rem;
|
width: 200px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,14 +129,8 @@
|
|||||||
--color-muted-background-darker: var(--color-gray-100);
|
--color-muted-background-darker: var(--color-gray-100);
|
||||||
|
|
||||||
--color-promo: var(--color-primary);
|
--color-promo: var(--color-primary);
|
||||||
|
--color-success: #268029;
|
||||||
--color-success: #cafccc;
|
--color-success-lighter: #cafccc;
|
||||||
--color-success-darker: #bafabc;
|
|
||||||
--color-success-darkest: #a5eba8;
|
|
||||||
--color-success-text: #268029;
|
|
||||||
--color-success-contrast: #65bb6a;
|
|
||||||
--color-success-contrast-hover: #6bcf70;
|
|
||||||
--color-success-contrast-active: #6edf74;
|
|
||||||
|
|
||||||
--color-logo-icon: var(--color-primary);
|
--color-logo-icon: var(--color-primary);
|
||||||
--color-logo-text: #190064;
|
--color-logo-text: #190064;
|
||||||
@@ -144,9 +138,9 @@
|
|||||||
--border-radius-md: 0.375rem;
|
--border-radius-md: 0.375rem;
|
||||||
--border-radius-lg: 0.5rem;
|
--border-radius-lg: 0.5rem;
|
||||||
|
|
||||||
--color-surface-high: #f1f0ff;
|
--color-surface-high: hsl(244, 100%, 97%);
|
||||||
--color-surface-mid: #f2f2f7;
|
--color-surface-mid: hsl(240 25% 96%);
|
||||||
--color-surface-low: #ececf4;
|
--color-surface-low: hsl(240 25% 94%);
|
||||||
--color-surface-lowest: #ffffff;
|
--color-surface-lowest: #ffffff;
|
||||||
--color-on-surface: #1b1b1f;
|
--color-on-surface: #1b1b1f;
|
||||||
--color-brand-hover: #5753d0;
|
--color-brand-hover: #5753d0;
|
||||||
|
|||||||
@@ -57,15 +57,6 @@ export const base64ToString = async (base64: string, isByteString = false) => {
|
|||||||
: byteStringToString(window.atob(base64));
|
: byteStringToString(window.atob(base64));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
|
|
||||||
if (typeof Buffer !== "undefined") {
|
|
||||||
// Node.js environment
|
|
||||||
return Buffer.from(base64, "base64").buffer;
|
|
||||||
}
|
|
||||||
// Browser environment
|
|
||||||
return byteStringToArrayBuffer(atob(base64));
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// text encoding
|
// text encoding
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { THEME } from "../constants";
|
||||||
|
import type { Theme } from "../element/types";
|
||||||
|
import type { DataURL } from "../types";
|
||||||
|
import type { OpenAIInput, OpenAIOutput } from "./ai/types";
|
||||||
|
|
||||||
|
export type MagicCacheData =
|
||||||
|
| {
|
||||||
|
status: "pending";
|
||||||
|
}
|
||||||
|
| { status: "done"; html: string }
|
||||||
|
| {
|
||||||
|
status: "error";
|
||||||
|
message?: string;
|
||||||
|
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
|
||||||
|
Your role is to transform low-fidelity wireframes into working front-end HTML code.
|
||||||
|
|
||||||
|
YOU MUST FOLLOW FOLLOWING RULES:
|
||||||
|
|
||||||
|
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
|
||||||
|
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
|
||||||
|
- Inline JavaScript when needed
|
||||||
|
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
|
||||||
|
- Source images from Unsplash or create applicable placeholders
|
||||||
|
- Interpret annotations as intended vs literal UI
|
||||||
|
- Fill gaps using your expertise in UX and business logic
|
||||||
|
- generate primarily for desktop UI, but make it responsive.
|
||||||
|
- Use grid and flexbox wherever applicable.
|
||||||
|
- Convert the wireframe in its entirety, don't omit elements if possible.
|
||||||
|
|
||||||
|
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
|
||||||
|
|
||||||
|
Your goal is a production-ready prototype that brings the wireframes to life.
|
||||||
|
|
||||||
|
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
|
||||||
|
|
||||||
|
export async function diagramToHTML({
|
||||||
|
image,
|
||||||
|
apiKey,
|
||||||
|
text,
|
||||||
|
theme = THEME.LIGHT,
|
||||||
|
}: {
|
||||||
|
image: DataURL;
|
||||||
|
apiKey: string;
|
||||||
|
text: string;
|
||||||
|
theme?: Theme;
|
||||||
|
}) {
|
||||||
|
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
|
||||||
|
model: "gpt-4-vision-preview",
|
||||||
|
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
|
||||||
|
max_tokens: 4096,
|
||||||
|
temperature: 0.1,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: SYSTEM_PROMPT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: image,
|
||||||
|
detail: "high",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result:
|
||||||
|
| ({ ok: true } & OpenAIOutput.ChatCompletion)
|
||||||
|
| ({ ok: false } & OpenAIOutput.APIError);
|
||||||
|
|
||||||
|
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const json: OpenAIOutput.ChatCompletion = await resp.json();
|
||||||
|
result = { ...json, ok: true };
|
||||||
|
} else {
|
||||||
|
const json: OpenAIOutput.APIError = await resp.json();
|
||||||
|
result = { ...json, ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const shouldDiscardRemoteElement = (
|
|||||||
if (
|
if (
|
||||||
local &&
|
local &&
|
||||||
// local element is being edited
|
// local element is being edited
|
||||||
(local.id === localAppState.editingTextElement?.id ||
|
(local.id === localAppState.editingElement?.id ||
|
||||||
local.id === localAppState.resizingElement?.id ||
|
local.id === localAppState.resizingElement?.id ||
|
||||||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
||||||
// local element is newer
|
// local element is newer
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user