Compare commits
416 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 198992f02b | |||
| a830820d45 | |||
| 211c2c5b4c | |||
| ebcd8535ed | |||
| 34fd9d60d7 | |||
| 60e831851b | |||
| 40a4146b67 | |||
| e466771826 | |||
| 06042fa7ee | |||
| abdf12bcd0 | |||
| 57a310dfb5 | |||
| 06fb8d3d7c | |||
| f46dd72595 | |||
| da36bc650d | |||
| 90d458c089 | |||
| 93cf69a588 | |||
| 7dc916e3dc | |||
| 460036e324 | |||
| c4a527ee17 | |||
| 45d11181c9 | |||
| bc9fccd61b | |||
| a83cfaae6f | |||
| 795274f9f0 | |||
| f51ac29fbe | |||
| e1698dd3aa | |||
| a39587c845 | |||
| 76f004bf99 | |||
| 5c0f4fdd42 | |||
| 9d157d7009 | |||
| 84e5373a9a | |||
| 107023393c | |||
| 19f2c741a9 | |||
| a3d000ee71 | |||
| 6ff96ef739 | |||
| e34139986c | |||
| 30801b718a | |||
| 606b05eb17 | |||
| 625fad6c1b | |||
| 9ebafb63a6 | |||
| 4d087cbbd9 | |||
| 539f6807e3 | |||
| 20b2604c68 | |||
| 0781ac6ad9 | |||
| 22b8282812 | |||
| 50500f57f8 | |||
| 9a0f8167eb | |||
| 9564931b69 | |||
| 83a3ed78b4 | |||
| 8d240c6f66 | |||
| 132a8d3cf8 | |||
| b2998238f7 | |||
| a72f4e1689 | |||
| dc68825d2b | |||
| c49cfc3bde | |||
| df1566a6ee | |||
| e89685b931 | |||
| b292d2ecbc | |||
| 4e8d015c6d | |||
| f520d6839c | |||
| 1dddcf1633 | |||
| db7d73c65e | |||
| 5371a13749 | |||
| 73ee1552b7 | |||
| aaa71dad68 | |||
| 365320fdc6 | |||
| ca05af6aee | |||
| 3de073dfa2 | |||
| 1f73461abd | |||
| b7bc95a216 | |||
| f11580cc71 | |||
| bb5c9439c7 | |||
| cec234157b | |||
| 69cd706970 | |||
| fa9ca4598d | |||
| 9c88cc2305 | |||
| 8817c4ed5e | |||
| f65bdb0a90 | |||
| 99ce84a9b8 | |||
| 5b15b45bec | |||
| 0573bc959b | |||
| b950747454 | |||
| 60609026c9 | |||
| fd5affb040 | |||
| 5f6dd3dc91 | |||
| 62a178758b | |||
| c9218fdb10 | |||
| fd13af7747 | |||
| c310a50fd5 | |||
| aba5b98285 | |||
| 53acd4e31b | |||
| 38837620e0 | |||
| ec1c4e8ce3 | |||
| 27a3d5a36d | |||
| 038a44b8ef | |||
| 56bfebb15c | |||
| 9e14480293 | |||
| 1b24d6def7 | |||
| 5cca65323f | |||
| 32d92bcef5 | |||
| c6c7040cac | |||
| c6419e54db | |||
| 1ce70c7022 | |||
| af3d94064a | |||
| 0750f61536 | |||
| 979830edf6 | |||
| 7eea5d5ec4 | |||
| eef72ca4fb | |||
| d0e25ccec2 | |||
| cb616e2957 | |||
| 80d466cf7a | |||
| d0383d0ce3 | |||
| d75222110b | |||
| e27e4e0e2d | |||
| 6057a40665 | |||
| e29c03554c | |||
| 3643bf82d0 | |||
| 50cfcc26cc | |||
| d5fb4305a3 | |||
| 970a1aa720 | |||
| e3d305ddc2 | |||
| 04389e8408 | |||
| 29dbc53a56 | |||
| 8acc174353 | |||
| 6985e75394 | |||
| 0b65b2cc63 | |||
| f6ea4bf135 | |||
| a5b37fabea | |||
| 43476864ad | |||
| e29436d8db | |||
| f0b6ace0fc | |||
| d0a5264b1b | |||
| 841ef74430 | |||
| 3f7d7f79de | |||
| a30d454e5f | |||
| d3471352ed | |||
| de9c8d9fc3 | |||
| 1ab1db08a8 | |||
| 22a72427ec | |||
| 74428d8091 | |||
| 657929ea6f | |||
| 3a0e6aa70c | |||
| 743fd2d38a | |||
| 3b89c89a52 | |||
| f0a80e9f78 | |||
| 5e3dd21a10 | |||
| a96134fa81 | |||
| 8dd6dd666c | |||
| 3a1fc51e44 | |||
| d83742c5eb | |||
| 83da4bd876 | |||
| c08a3a4d65 | |||
| 65e20609d7 | |||
| 5d62d50e4f | |||
| 218acae143 | |||
| 35e618cdb6 | |||
| e1f14c081c | |||
| cd3165b180 | |||
| 94da04d9f9 | |||
| a112d5ea41 | |||
| 77ba23407c | |||
| 4ea2007557 | |||
| 42d2692b2f | |||
| f7c7632ca6 | |||
| 61737f9c1b | |||
| 24d176ba2c | |||
| f363fc071b | |||
| 7221028010 | |||
| fc1762d0fd | |||
| 0581fc1eaf | |||
| 284ac616e1 | |||
| 631ff71965 | |||
| fb95a77055 | |||
| 0d57e9f628 | |||
| 7a24f13e81 | |||
| 8f6e1d942d | |||
| b21c6abbe4 | |||
| 8757a9ddc4 | |||
| f7b3497dee | |||
| 0e17d69535 | |||
| ff6ec195bd | |||
| dd8637e125 | |||
| 6d9257310c | |||
| 3d3f96880d | |||
| 715399b558 | |||
| b818b61d8b | |||
| 99757b1a06 | |||
| fe547a6ada | |||
| b6cb835cd4 | |||
| 917079900a | |||
| a3304507a1 | |||
| 453d934cd9 | |||
| cf73a91567 | |||
| 368ea9dd7f | |||
| 0bdca3493b | |||
| f69095d2bd | |||
| f5df6b9e4c | |||
| 0d285b3eec | |||
| be98b5c7cf | |||
| 013cc37a47 | |||
| 82f3fa6c57 | |||
| 669f8a2b59 | |||
| 0518fa5beb | |||
| e48b742ee1 | |||
| d4c45dc9be | |||
| d728887146 | |||
| 240e1b0492 | |||
| 4cb28e838f | |||
| 5823570530 | |||
| b832b44413 | |||
| 5c7123e77b | |||
| 36b220a067 | |||
| d03c2eb963 | |||
| e01199e6b8 | |||
| 9c516f1e0a | |||
| 6566456832 | |||
| 60936c48ca | |||
| 43be14dc93 | |||
| 526d2c52f3 | |||
| fcb69c36e6 | |||
| 53a4d76e3b | |||
| 0710d40919 | |||
| a07cd1b0e8 | |||
| 4a55ab6bab | |||
| 6257bdbfb6 | |||
| 3f377c9d69 | |||
| 176e11bac8 | |||
| 84c89018d9 | |||
| 6cac1c8a04 | |||
| 1e996e4d94 | |||
| d028157a5c | |||
| 2934b8c308 | |||
| c6f08724eb | |||
| 976da91fe7 | |||
| 0b42b49d9e | |||
| 3a472aa6fc | |||
| 6a716adea6 | |||
| abb33bf686 | |||
| 0463c18f02 | |||
| fd83e44c6f | |||
| 2d0d6d6c1b | |||
| ab39228fc9 | |||
| 6e9085eee7 | |||
| eac4fa5b57 | |||
| ef40b72eb0 | |||
| e1f5b9f138 | |||
| 8bf7fae439 | |||
| 503fd4c598 | |||
| e99df11729 | |||
| 39b9224c3c | |||
| 38cf2fb51e | |||
| 333a2ee6fc | |||
| 728bb66eb7 | |||
| 49ea45aa5d | |||
| cf5418b128 | |||
| 9e0eb0f541 | |||
| d506a822ff | |||
| 0fb50e07c0 | |||
| c2edd7c6f4 | |||
| bf7aafb77d | |||
| a3b9763dcd | |||
| baf93453c3 | |||
| d9b9dc783e | |||
| dd4343d48c | |||
| fdd021574c | |||
| 652b0f60f5 | |||
| 902c8eaf56 | |||
| 7e9a7cbd26 | |||
| 9d04681dc5 | |||
| b0bfc7af20 | |||
| ba2828914d | |||
| 791b63148b | |||
| 9ff150bdc2 | |||
| 527b2658c1 | |||
| 7b8983c75a | |||
| c4067bfe2f | |||
| 46abd7bc96 | |||
| 3ba91352ed | |||
| 0e57d55451 | |||
| d4e6e3cae3 | |||
| c6395ae166 | |||
| 2d1aeee971 | |||
| c9455a0de4 | |||
| 9f944db928 | |||
| 170bf35513 | |||
| e6e8349a45 | |||
| d632694c9b | |||
| cff4ca670d | |||
| eaabc26428 | |||
| 0bcc2360a9 | |||
| f3da2a95ed | |||
| bec4653dcb | |||
| a2e948d080 | |||
| 6b7da271b5 | |||
| 65c7c1815f | |||
| 8c019fe3fd | |||
| 64466783de | |||
| ce3774e8f2 | |||
| 5dd19a04c2 | |||
| 44769fe876 | |||
| 4947ec612c | |||
| 24b78a1332 | |||
| ee4f0a43fc | |||
| cfd9306c57 | |||
| 8a3048d4a7 | |||
| d7138d86ea | |||
| b0644a6d3f | |||
| 430adfe6f4 | |||
| ceb6b47c17 | |||
| 54973ba281 | |||
| 157b1911e1 | |||
| 482beddd67 | |||
| 904828940d | |||
| d0990c63db | |||
| c86d22be7f | |||
| 7c03c458a1 | |||
| 8029e68e17 | |||
| c3e9430577 | |||
| 9d34d4fcd4 | |||
| fe310acebd | |||
| d7c7236ee8 | |||
| eefc3b9408 | |||
| cc67f9d544 | |||
| e2e13bba0b | |||
| 041f012f72 | |||
| dbd8cc8d6f | |||
| 9fbac79d11 | |||
| ef915b7427 | |||
| c5b913ad9f | |||
| f66c943720 | |||
| 9c0edf1cb9 | |||
| 9112c61edb | |||
| f58a84a857 | |||
| 626e846d10 | |||
| feacdcd156 | |||
| e1fbf09310 | |||
| dc521a62c1 | |||
| f0bbb7614e | |||
| 62b35ab86c | |||
| 2ea8c308e4 | |||
| 0a13e3a344 | |||
| 540809f2a4 | |||
| 467d9bd08c | |||
| 13e8374bea | |||
| b96dd6d332 | |||
| 32d77172cc | |||
| 9e23421d53 | |||
| c3d91aac44 | |||
| 99088550be | |||
| b9a171a2ef | |||
| 521d727b5b | |||
| b7d610fbbe | |||
| 1f33be6403 | |||
| 7ca28ae504 | |||
| d648d1147e | |||
| 869739b170 | |||
| ca09cc2830 | |||
| 5d1e8448bc | |||
| 2076b3643a | |||
| cbe5581782 | |||
| be9fcec967 | |||
| 41f8203889 | |||
| 60be58033f | |||
| 7010f1af88 | |||
| edd1510183 | |||
| 5926023ab1 | |||
| 7adc3d163f | |||
| 124ecebca6 | |||
| 8d8fdc2985 | |||
| 0691617028 | |||
| 3f181f7fa7 | |||
| 00f2600cbb | |||
| 4274935c62 | |||
| 6209445644 | |||
| d9636c4101 | |||
| fc1c3a3985 | |||
| 2c7737ed9b | |||
| c4dcd3a5d2 | |||
| f0a37029bc | |||
| 2567526103 | |||
| a10f7c10ae | |||
| 60dc788254 | |||
| 34ab5746b4 | |||
| 6c7b8f4bf4 | |||
| 7a79d0306c | |||
| d70108dcfa | |||
| c2fc95116a | |||
| 947a11bac2 | |||
| 87336c115e | |||
| 6041c34aeb | |||
| 184d9ca6e8 | |||
| 3a844186a7 | |||
| cc262ce5d5 | |||
| 802b01562c | |||
| 6f2b43ff0c | |||
| 461f327887 | |||
| a33a643a2f | |||
| 91f7b38a39 | |||
| 998a296ca2 | |||
| 0afec1bf5c | |||
| 2436e8498a | |||
| 521362a532 | |||
| ae5a2dee54 | |||
| bf62bce4cb | |||
| 92c5acb960 | |||
| d4ce7b067a | |||
| 214e68ce03 | |||
| 19cb9ab30e | |||
| 3d097669f8 | |||
| ede77bd6ac | |||
| 05dde8f0d1 | |||
| d11453ae15 | |||
| 2d3577bb43 | |||
| 4258aeac53 | |||
| 4f772d77a4 | |||
| 6f57935419 | |||
| 61c69915da |
+1
-22
@@ -39,26 +39,5 @@
|
||||
"allowReferrer": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
|
||||
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@excalidraw/excalidraw"],
|
||||
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
|
||||
"allowTypeImports": true
|
||||
}
|
||||
],
|
||||
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "circle",
|
||||
startArrowhead: "dot",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
|
||||
import type { FileId } from "@excalidraw/excalidraw/element/types";
|
||||
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"browser-fs-access": "0.38.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"react-dom": "19.0.0",
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"browser-fs-access": "0.29.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"vite": "5.0.12"
|
||||
"vite": "5.0.12",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { unstable_batchedUpdates } from "react-dom";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
@@ -52,6 +54,40 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions,
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener("keyup", scheduleRejection);
|
||||
document.addEventListener("pointerup", scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener("focus", focusHandler);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
window.removeEventListener("focus", focusHandler);
|
||||
document.removeEventListener("keyup", scheduleRejection);
|
||||
document.removeEventListener("pointerup", scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new Error("Request Aborted"));
|
||||
}
|
||||
};
|
||||
},
|
||||
}) as Promise<RetType>;
|
||||
};
|
||||
|
||||
|
||||
+20
-92
@@ -5,8 +5,6 @@ import {
|
||||
CaptureUpdateAction,
|
||||
reconcileElements,
|
||||
useEditorInterface,
|
||||
ExcalidrawAPIProvider,
|
||||
useExcalidrawAPI,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
@@ -36,6 +34,7 @@ import {
|
||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
||||
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import {
|
||||
@@ -75,7 +74,6 @@ import type {
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
ExcalidrawProps,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||
@@ -116,7 +114,6 @@ import {
|
||||
} from "./data";
|
||||
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { FileStatusStore } from "./data/fileStatusStore";
|
||||
import {
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
@@ -372,8 +369,6 @@ const initializeScene = async (opts: {
|
||||
};
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const excalidrawAPI = useExcalidrawAPI();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
@@ -404,6 +399,9 @@ const ExcalidrawWrapper = () => {
|
||||
}, VERSION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
@@ -435,15 +433,18 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted loadImages
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadImages = useCallback(
|
||||
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
|
||||
if (!data.scene || !excalidrawAPI) {
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImages = (
|
||||
data: ResolutionType<typeof initializeScene>,
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
if (!data.scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
@@ -470,12 +471,6 @@ const ExcalidrawWrapper = () => {
|
||||
}, [] as FileId[]) || [];
|
||||
|
||||
if (data.isExternalScene) {
|
||||
if (fileIds.length) {
|
||||
// Direct Firebase call (not through FileManager), so track manually
|
||||
FileStatusStore.updateStatuses(
|
||||
fileIds.map((id) => [id, "loading"]),
|
||||
);
|
||||
}
|
||||
loadFilesFromFirebase(
|
||||
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
||||
data.key,
|
||||
@@ -487,18 +482,12 @@ const ExcalidrawWrapper = () => {
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
FileStatusStore.updateStatuses([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
});
|
||||
} else if (isInitialLoad) {
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(async ({ loadedFiles, erroredFiles }) => {
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
@@ -511,19 +500,10 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
// on fresh load, clear unused files from IDB (from previous
|
||||
// session)
|
||||
LocalData.fileStorage.clearObsoleteFiles({
|
||||
currentFileIds: fileIds,
|
||||
});
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
}
|
||||
}
|
||||
},
|
||||
[collabAPI, excalidrawAPI],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
@@ -648,7 +628,7 @@ const ExcalidrawWrapper = () => {
|
||||
false,
|
||||
);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
@@ -793,56 +773,6 @@ const ExcalidrawWrapper = () => {
|
||||
[setShareDialogState],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport — intercepts file save to wait for pending image loads
|
||||
// ---------------------------------------------------------------------------
|
||||
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
|
||||
async function* () {
|
||||
let snapshot = FileStatusStore.getSnapshot();
|
||||
const { pending, total } = FileStatusStore.getPendingCount(
|
||||
snapshot.value,
|
||||
);
|
||||
if (pending === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Yield initial progress
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (total - pending) / total,
|
||||
message: `Loading images (${total - pending}/${total})...`,
|
||||
};
|
||||
|
||||
// Wait for all pending images to finish
|
||||
while (true) {
|
||||
snapshot = await FileStatusStore.pull(snapshot.version);
|
||||
const { pending: nowPending, total: nowTotal } =
|
||||
FileStatusStore.getPendingCount(snapshot.value);
|
||||
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (nowTotal - nowPending) / nowTotal,
|
||||
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
|
||||
};
|
||||
|
||||
if (nowPending === 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
yield {
|
||||
type: "progress",
|
||||
message: `Preparing export...`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// const onExport = () => {
|
||||
// return new Promise((r) => setTimeout(r, 2500));
|
||||
// // console.log("onExport");
|
||||
// };
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
@@ -909,8 +839,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
onExport={onExport}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
@@ -1276,9 +1206,7 @@ const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider store={appJotaiStore}>
|
||||
<ExcalidrawAPIProvider>
|
||||
<ExcalidrawWrapper />
|
||||
</ExcalidrawAPIProvider>
|
||||
<ExcalidrawWrapper />
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -72,7 +72,6 @@ import {
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { FileStatusStore } from "../data/fileStatusStore";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@@ -150,7 +149,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles: async (fileIds) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
|
||||
@@ -414,6 +414,7 @@ export const debugRenderer = throttleRAF(
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, elements, scale);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
export const loadSavedDebugState = () => {
|
||||
|
||||
@@ -40,12 +40,10 @@ export class FileManager {
|
||||
|
||||
private _getFiles;
|
||||
private _saveFiles;
|
||||
private _onFileStatusChange;
|
||||
|
||||
constructor({
|
||||
getFiles,
|
||||
saveFiles,
|
||||
onFileStatusChange,
|
||||
}: {
|
||||
getFiles: (fileIds: FileId[]) => Promise<{
|
||||
loadedFiles: BinaryFileData[];
|
||||
@@ -55,13 +53,9 @@ export class FileManager {
|
||||
savedFiles: Map<FileId, BinaryFileData>;
|
||||
erroredFiles: Map<FileId, BinaryFileData>;
|
||||
}>;
|
||||
onFileStatusChange?: (
|
||||
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
|
||||
) => void;
|
||||
}) {
|
||||
this._getFiles = getFiles;
|
||||
this._saveFiles = saveFiles;
|
||||
this._onFileStatusChange = onFileStatusChange;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,8 +146,6 @@ export class FileManager {
|
||||
this.fetchingFiles.set(id, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
|
||||
|
||||
try {
|
||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||
|
||||
@@ -164,13 +156,6 @@ export class FileManager {
|
||||
this.erroredFiles_fetch.set(fileId, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
} finally {
|
||||
for (const id of ids) {
|
||||
@@ -210,13 +195,6 @@ export class FileManager {
|
||||
};
|
||||
|
||||
reset() {
|
||||
if (this._onFileStatusChange && this.fetchingFiles.size) {
|
||||
this._onFileStatusChange(
|
||||
[...this.fetchingFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
);
|
||||
}
|
||||
this.fetchingFiles.clear();
|
||||
this.savingFiles.clear();
|
||||
this.savedFiles.clear();
|
||||
|
||||
@@ -42,7 +42,6 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
import { FileManager } from "./FileManager";
|
||||
import { FileStatusStore } from "./fileStatusStore";
|
||||
import { Locker } from "./Locker";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
@@ -167,7 +166,6 @@ export class LocalData {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static fileStorage = new LocalFileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles(ids) {
|
||||
return getMany(ids, filesStore).then(
|
||||
async (filesData: (BinaryFileData | undefined)[]) => {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { VersionedSnapshotStore } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
export type FileLoadingStatus = "loading" | "loaded" | "error";
|
||||
|
||||
export class FileStatusStore {
|
||||
private static store = new VersionedSnapshotStore<
|
||||
Map<FileId, FileLoadingStatus>
|
||||
>(new Map());
|
||||
|
||||
static getSnapshot() {
|
||||
return this.store.getSnapshot();
|
||||
}
|
||||
|
||||
static pull(sinceVersion?: number) {
|
||||
return this.store.pull(sinceVersion);
|
||||
}
|
||||
|
||||
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
|
||||
if (!updates.length) {
|
||||
return;
|
||||
}
|
||||
this.store.update((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [id, status] of updates) {
|
||||
if (next.get(id) !== status) {
|
||||
next.set(id, status);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
|
||||
let pending = 0;
|
||||
let total = 0;
|
||||
for (const status of statuses.values()) {
|
||||
total++;
|
||||
if (status === "loading") {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return { pending, total };
|
||||
}
|
||||
}
|
||||
@@ -106,10 +106,6 @@ export default defineConfig(({ mode }) => {
|
||||
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
|
||||
return "mermaid-to-excalidraw";
|
||||
}
|
||||
|
||||
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
|
||||
return "codemirror.chunk";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -154,11 +150,6 @@ export default defineConfig(({ mode }) => {
|
||||
"**/locales/**",
|
||||
"service-worker.js",
|
||||
"**/*.chunk-*.js",
|
||||
// CodeMirrorEditor can't be assigned a `.chunk` name via
|
||||
// manualChunks because Rollup would hoist shared deps (React)
|
||||
// via a static import from the main bundle, defeating lazy
|
||||
// loading. So we exclude it by name instead.
|
||||
"**/CodeMirrorEditor-*.js",
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
@@ -198,7 +189,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
|
||||
urlPattern: new RegExp(".chunk-.+.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "chunk",
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { AppEventBus } from "./appEventBus";
|
||||
|
||||
type TestEvents = {
|
||||
initialize: [api: number];
|
||||
pointerUp: [pointerId: string];
|
||||
viewState: [zoom: number];
|
||||
};
|
||||
|
||||
const behavior = {
|
||||
initialize: { cardinality: "once", replay: "last" },
|
||||
pointerUp: { cardinality: "many", replay: "none" },
|
||||
viewState: { cardinality: "many", replay: "last" },
|
||||
} as const;
|
||||
|
||||
const flushMicrotasks = async () => Promise.resolve();
|
||||
|
||||
describe("AppEventBus", () => {
|
||||
it("replays once events to late callback and Promise subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 42);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("initialize", (value) => {
|
||||
calls.push(value);
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([42]);
|
||||
|
||||
await expect(bus.on("initialize")).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it("does not replay stream events to late subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("pointerUp", "first");
|
||||
|
||||
const calls: string[] = [];
|
||||
bus.on("pointerUp", (pointerId) => {
|
||||
calls.push(pointerId);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
bus.emit("pointerUp", "second");
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("replays replay-last stream events and stays subscribed", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("viewState", 1);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("viewState", (zoom) => {
|
||||
calls.push(zoom);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([1]);
|
||||
|
||||
bus.emit("viewState", 2);
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("throws when emitting a once event twice", () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 1);
|
||||
|
||||
expect(() => {
|
||||
bus.emit("initialize", 2);
|
||||
}).toThrow('Event "initialize" can only be emitted once');
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Emitter } from "./emitter";
|
||||
import { isProdEnv } from "./utils";
|
||||
|
||||
export type AppEventPayloadMap = Record<string, unknown[]>;
|
||||
|
||||
export type AppEventBehavior = {
|
||||
cardinality: "once" | "many";
|
||||
replay: "none" | "last";
|
||||
};
|
||||
|
||||
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
|
||||
[K in keyof Events]: AppEventBehavior;
|
||||
};
|
||||
|
||||
type AwaitableAppEventKeys<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> = {
|
||||
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
|
||||
? Behavior[K]["replay"] extends "last"
|
||||
? K
|
||||
: never
|
||||
: never;
|
||||
}[keyof Events];
|
||||
|
||||
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
|
||||
? Only
|
||||
: Args;
|
||||
|
||||
export class AppEventBus<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> {
|
||||
private readonly emitters = new Map<keyof Events, Emitter<any>>();
|
||||
private readonly lastPayload = new Map<keyof Events, any[]>();
|
||||
private readonly emittedOnce = new Set<keyof Events>();
|
||||
|
||||
constructor(private readonly behavior: Behavior) {}
|
||||
|
||||
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
|
||||
let emitter = this.emitters.get(name);
|
||||
if (!emitter) {
|
||||
emitter = new Emitter<any>();
|
||||
this.emitters.set(name, emitter);
|
||||
}
|
||||
return emitter as Emitter<Events[K]>;
|
||||
}
|
||||
|
||||
private toPromiseValue<Args extends any[]>(
|
||||
args: Args,
|
||||
): AppEventPromiseValue<Args> {
|
||||
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
|
||||
}
|
||||
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback;
|
||||
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
|
||||
name: K,
|
||||
): Promise<AppEventPromiseValue<Events[K]>>;
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback?: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
|
||||
const eventBehavior = this.behavior[name];
|
||||
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
|
||||
|
||||
if (callback) {
|
||||
if (eventBehavior.replay === "last" && cachedPayload) {
|
||||
queueMicrotask(() => callback(...cachedPayload));
|
||||
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
return this.getEmitter(name).on(callback);
|
||||
}
|
||||
|
||||
if (
|
||||
eventBehavior.cardinality !== "once" ||
|
||||
eventBehavior.replay !== "last"
|
||||
) {
|
||||
throw new Error(`Event "${String(name)}" requires a callback`);
|
||||
}
|
||||
|
||||
if (cachedPayload) {
|
||||
return Promise.resolve(this.toPromiseValue(cachedPayload));
|
||||
}
|
||||
|
||||
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
|
||||
this.getEmitter(name).once((...args: Events[K]) => {
|
||||
resolve(this.toPromiseValue(args));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
|
||||
const eventBehavior = this.behavior[name];
|
||||
|
||||
if (!isProdEnv()) {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
if (this.emittedOnce.has(name)) {
|
||||
throw new Error(`Event "${String(name)}" can only be emitted once`);
|
||||
}
|
||||
this.emittedOnce.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventBehavior.replay === "last") {
|
||||
this.lastPayload.set(name, args);
|
||||
}
|
||||
|
||||
try {
|
||||
this.getEmitter(name).trigger(...args);
|
||||
} finally {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
this.getEmitter(name).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.lastPayload.clear();
|
||||
this.emittedOnce.clear();
|
||||
|
||||
for (const emitter of this.emitters.values()) {
|
||||
emitter.clear();
|
||||
}
|
||||
|
||||
this.emitters.clear();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,5 @@ export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
export * from "./appEventBus";
|
||||
export * from "./editorInterface";
|
||||
export * from "./versionedSnapshotStore";
|
||||
export { Debug } from "../debug";
|
||||
|
||||
@@ -3,12 +3,6 @@ import {
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Import directly to avoid the @excalidraw/common throttleRAF mock from setupTests.ts.
|
||||
import { throttleRAF } from "./utils";
|
||||
|
||||
type RafCallback = FrameRequestCallback;
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("isTransparent()", () => {
|
||||
@@ -85,87 +79,4 @@ describe("@excalidraw/common/utils", () => {
|
||||
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("throttleRAF()", () => {
|
||||
let frameCallbacks: Map<number, RafCallback>;
|
||||
let nextFrameId: number;
|
||||
|
||||
const runScheduledFrame = (timestamp = 16) => {
|
||||
const callbacks = [...frameCallbacks.values()];
|
||||
frameCallbacks.clear();
|
||||
callbacks.forEach((callback) => callback(timestamp));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
frameCallbacks = new Map();
|
||||
nextFrameId = 0;
|
||||
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
|
||||
(callback) => {
|
||||
const frameId = ++nextFrameId;
|
||||
frameCallbacks.set(frameId, callback);
|
||||
return frameId;
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frameId) => {
|
||||
frameCallbacks.delete(frameId);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should invoke the callback with the last args from the same frame", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first", 1);
|
||||
throttled("second", 2);
|
||||
throttled("last", 3);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("last", 3);
|
||||
});
|
||||
|
||||
it("should flush the pending callback immediately", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first");
|
||||
throttled("last");
|
||||
|
||||
throttled.flush();
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("last");
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should cancel the pending callback", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first");
|
||||
throttled("last");
|
||||
|
||||
throttled.cancel();
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,8 +88,7 @@ export const isWritableElement = (
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
|
||||
target.type === "search"));
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -151,27 +150,38 @@ export const debounce = <T extends any[]>(
|
||||
return ret;
|
||||
};
|
||||
|
||||
// throttle callback to execute once per animation frame using the latest args
|
||||
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
|
||||
// throttle callback to execute once per animation frame
|
||||
export const throttleRAF = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
opts?: { trailing?: boolean },
|
||||
) => {
|
||||
let timerId: number | null = null;
|
||||
let lastArgs: T | null = null;
|
||||
let lastArgsTrailing: T | null = null;
|
||||
|
||||
const scheduleFunc = () => {
|
||||
const scheduleFunc = (args: T) => {
|
||||
timerId = window.requestAnimationFrame(() => {
|
||||
timerId = null;
|
||||
const args = lastArgs;
|
||||
fn(...args);
|
||||
lastArgs = null;
|
||||
|
||||
if (args) {
|
||||
fn(...args);
|
||||
if (lastArgsTrailing) {
|
||||
lastArgs = lastArgsTrailing;
|
||||
lastArgsTrailing = null;
|
||||
scheduleFunc(lastArgs);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ret = (...args: T) => {
|
||||
if (isTestEnv()) {
|
||||
fn(...args);
|
||||
return;
|
||||
}
|
||||
lastArgs = args;
|
||||
if (timerId === null) {
|
||||
scheduleFunc();
|
||||
scheduleFunc(lastArgs);
|
||||
} else if (opts?.trailing) {
|
||||
lastArgsTrailing = args;
|
||||
}
|
||||
};
|
||||
ret.flush = () => {
|
||||
@@ -180,12 +190,12 @@ export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
|
||||
timerId = null;
|
||||
}
|
||||
if (lastArgs) {
|
||||
fn(...lastArgs);
|
||||
lastArgs = null;
|
||||
fn(...(lastArgsTrailing || lastArgs));
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
}
|
||||
};
|
||||
ret.cancel = () => {
|
||||
lastArgs = null;
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
if (timerId !== null) {
|
||||
cancelAnimationFrame(timerId);
|
||||
timerId = null;
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
export type VersionedSnapshot<T> = Readonly<{
|
||||
version: number;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export class VersionedSnapshotStore<T> {
|
||||
private version = 0;
|
||||
private value: T;
|
||||
private readonly waiters = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
private readonly subscribers = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
|
||||
constructor(
|
||||
initialValue: T,
|
||||
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
|
||||
) {
|
||||
this.value = initialValue;
|
||||
}
|
||||
|
||||
public getSnapshot(): VersionedSnapshot<T> {
|
||||
return { version: this.version, value: this.value };
|
||||
}
|
||||
|
||||
public set(nextValue: T): boolean {
|
||||
if (this.isEqual(this.value, nextValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.value = nextValue;
|
||||
this.version += 1;
|
||||
|
||||
const snapshot = this.getSnapshot();
|
||||
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(snapshot);
|
||||
}
|
||||
for (const waiter of this.waiters) {
|
||||
waiter(snapshot);
|
||||
}
|
||||
this.waiters.clear();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public update(updater: (prev: T) => T): boolean {
|
||||
return this.set(updater(this.value));
|
||||
}
|
||||
|
||||
public subscribe(
|
||||
subscriber: (snapshot: VersionedSnapshot<T>) => void,
|
||||
): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
|
||||
if (this.version !== sinceVersion) {
|
||||
return Promise.resolve(this.getSnapshot());
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.add(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Arrowhead, AnyArrowhead } from "./types";
|
||||
|
||||
export const normalizeArrowhead = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
switch (arrowhead) {
|
||||
case undefined:
|
||||
case null:
|
||||
return null;
|
||||
case "dot":
|
||||
return "circle";
|
||||
case "crowfoot_one":
|
||||
return "cardinality_one";
|
||||
case "crowfoot_many":
|
||||
return "cardinality_many";
|
||||
case "crowfoot_one_or_many":
|
||||
return "cardinality_one_or_many";
|
||||
default:
|
||||
return arrowhead;
|
||||
}
|
||||
};
|
||||
|
||||
export const getArrowheadForPicker = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
const normalizedArrowhead = normalizeArrowhead(arrowhead);
|
||||
if (normalizedArrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedArrowhead;
|
||||
};
|
||||
@@ -709,9 +709,6 @@ const getFreeDrawElementAbsoluteCoords = (
|
||||
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
|
||||
};
|
||||
|
||||
const CARDINALITY_MARKER_SIZE = 20;
|
||||
const CROWFOOT_ARROWHEAD_SIZE = 15;
|
||||
|
||||
/** @returns number in pixels */
|
||||
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
switch (arrowhead) {
|
||||
@@ -720,14 +717,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
case "diamond":
|
||||
case "diamond_outline":
|
||||
return 12;
|
||||
case "cardinality_many":
|
||||
case "cardinality_one_or_many":
|
||||
case "cardinality_zero_or_many":
|
||||
return CROWFOOT_ARROWHEAD_SIZE;
|
||||
case "cardinality_one":
|
||||
case "cardinality_exactly_one":
|
||||
case "cardinality_zero_or_one":
|
||||
return CARDINALITY_MARKER_SIZE;
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one":
|
||||
case "crowfoot_one_or_many":
|
||||
return 20;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
@@ -750,12 +743,7 @@ export const getArrowheadPoints = (
|
||||
shape: Drawable[],
|
||||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
offsetMultiplier = 0,
|
||||
) => {
|
||||
if (arrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shape.length < 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -836,30 +824,29 @@ export const getArrowheadPoints = (
|
||||
const lengthMultiplier =
|
||||
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
|
||||
const minSize = Math.min(size, length * lengthMultiplier);
|
||||
const tx = x2 - nx * minSize * offsetMultiplier;
|
||||
const ty = y2 - ny * minSize * offsetMultiplier;
|
||||
const xs = tx - nx * minSize;
|
||||
const ys = ty - ny * minSize;
|
||||
const xs = x2 - nx * minSize;
|
||||
const ys = y2 - ny * minSize;
|
||||
|
||||
if (arrowhead === "circle" || arrowhead === "circle_outline") {
|
||||
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
|
||||
return [tx, ty, diameter];
|
||||
if (
|
||||
arrowhead === "dot" ||
|
||||
arrowhead === "circle" ||
|
||||
arrowhead === "circle_outline"
|
||||
) {
|
||||
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
|
||||
return [x2, y2, diameter];
|
||||
}
|
||||
|
||||
const angle = getArrowheadAngle(arrowhead);
|
||||
|
||||
if (
|
||||
arrowhead === "cardinality_many" ||
|
||||
arrowhead === "cardinality_one_or_many"
|
||||
) {
|
||||
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
|
||||
// swap (xs, ys) with (x2, y2)
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(-angle as Degrees),
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
@@ -869,12 +856,12 @@ export const getArrowheadPoints = (
|
||||
// Return points
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(x2, y2),
|
||||
((-angle * Math.PI) / 180) as Radians,
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(x2, y2),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
|
||||
@@ -887,9 +874,9 @@ export const getArrowheadPoints = (
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(tx + minSize * 2, ty),
|
||||
pointFrom(tx, ty),
|
||||
Math.atan2(py - ty, px - tx) as Radians,
|
||||
pointFrom(x2 + minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
);
|
||||
} else {
|
||||
const [px, py] =
|
||||
@@ -898,16 +885,16 @@ export const getArrowheadPoints = (
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(tx - minSize * 2, ty),
|
||||
pointFrom(tx, ty),
|
||||
Math.atan2(ty - py, tx - px) as Radians,
|
||||
pointFrom(x2 - minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
);
|
||||
}
|
||||
|
||||
return [tx, ty, x3, y3, ox, oy, x4, y4];
|
||||
return [x2, y2, x3, y3, ox, oy, x4, y4];
|
||||
}
|
||||
|
||||
return [tx, ty, x3, y3, x4, y4];
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
};
|
||||
|
||||
// TODO reuse shape.ts
|
||||
|
||||
@@ -872,19 +872,6 @@ export const shouldApplyFrameClip = (
|
||||
return true;
|
||||
}
|
||||
|
||||
// Elements that belong to a frame should still render through that frame's
|
||||
// clip, even when fully outside the frame bounds (e.g. generated content).
|
||||
if (
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
element.frameId === frame.id
|
||||
) {
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// if an element is outside the frame, but is part of a group that has some elements
|
||||
// "in" the frame, we should clip the element
|
||||
if (
|
||||
|
||||
@@ -99,4 +99,3 @@ export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
export * from "./arrows/helpers";
|
||||
export * from "./arrowheads";
|
||||
|
||||
+86
-223
@@ -69,10 +69,10 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
Arrowhead,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
@@ -296,82 +296,6 @@ const modifyIframeLikeForRoughOptions = (
|
||||
return element;
|
||||
};
|
||||
|
||||
const generateArrowheadCardinalityOne = (
|
||||
generator: RoughGenerator,
|
||||
arrowheadPoints: number[] | null,
|
||||
lineOptions: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, lineOptions)];
|
||||
};
|
||||
|
||||
const generateArrowheadLinesToTip = (
|
||||
generator: RoughGenerator,
|
||||
arrowheadPoints: number[] | null,
|
||||
lineOptions: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, lineOptions),
|
||||
generator.line(x4, y4, x2, y2, lineOptions),
|
||||
];
|
||||
};
|
||||
|
||||
const getArrowheadLineOptions = (
|
||||
element: ExcalidrawLinearElement,
|
||||
options: Options,
|
||||
) => {
|
||||
const lineOptions = { ...options };
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete lineOptions.strokeLineDash;
|
||||
}
|
||||
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
|
||||
|
||||
return lineOptions;
|
||||
};
|
||||
|
||||
const generateArrowheadOutlineCircle = (
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
strokeColor: string,
|
||||
arrowheadPoints: number[] | null,
|
||||
fill: string,
|
||||
diameterScale = 1,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
const circleOptions = {
|
||||
...options,
|
||||
fill,
|
||||
fillStyle: "solid" as const,
|
||||
stroke: strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
};
|
||||
|
||||
delete circleOptions.strokeLineDash;
|
||||
|
||||
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
|
||||
};
|
||||
|
||||
const getArrowheadShapes = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
@@ -382,54 +306,63 @@ const getArrowheadShapes = (
|
||||
canvasBackgroundColor: string,
|
||||
isDarkMode: boolean,
|
||||
) => {
|
||||
if (arrowhead === null) {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const generateCrowfootOne = (
|
||||
arrowheadPoints: number[] | null,
|
||||
options: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, options)];
|
||||
};
|
||||
|
||||
const strokeColor = isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
const backgroundFillColor = isDarkMode
|
||||
? applyDarkModeFilter(canvasBackgroundColor)
|
||||
: canvasBackgroundColor;
|
||||
const cardinalityOneOrManyOffset = -0.25;
|
||||
const cardinalityZeroCircleScale = 0.8;
|
||||
|
||||
switch (arrowhead) {
|
||||
case "dot":
|
||||
case "circle":
|
||||
case "circle_outline": {
|
||||
return generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
|
||||
);
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.circle(x, y, diameter, {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "circle_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
|
||||
fillStyle: "solid",
|
||||
stroke: strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "triangle":
|
||||
case "triangle_outline": {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
|
||||
const triangleOptions = {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
|
||||
fillStyle: "solid" as const,
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
};
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete triangleOptions.strokeLineDash;
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
@@ -439,34 +372,24 @@ const getArrowheadShapes = (
|
||||
[x3, y3],
|
||||
[x, y],
|
||||
],
|
||||
triangleOptions,
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
case "diamond":
|
||||
case "diamond_outline": {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
const diamondOptions = {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
|
||||
fillStyle: "solid" as const,
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
};
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete diamondOptions.strokeLineDash;
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
@@ -477,106 +400,46 @@ const getArrowheadShapes = (
|
||||
[x4, y4],
|
||||
[x, y],
|
||||
],
|
||||
diamondOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_one":
|
||||
return generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
case "cardinality_many":
|
||||
return generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
case "cardinality_one_or_many": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_many"),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
"cardinality_one",
|
||||
cardinalityOneOrManyOffset,
|
||||
),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_exactly_one": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one"),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_zero_or_one": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
|
||||
backgroundFillColor,
|
||||
cardinalityZeroCircleScale,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_zero_or_many": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_many"),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
|
||||
backgroundFillColor,
|
||||
cardinalityZeroCircleScale,
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
case "crowfoot_one":
|
||||
return generateCrowfootOne(arrowheadPoints, options);
|
||||
case "bar":
|
||||
case "arrow":
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one_or_many":
|
||||
default: {
|
||||
return generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
options.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete options.strokeLineDash;
|
||||
}
|
||||
options.roughness = Math.min(1, options.roughness || 0);
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
...(arrowhead === "crowfoot_one_or_many"
|
||||
? generateCrowfootOne(
|
||||
getArrowheadPoints(element, shape, position, "crowfoot_one"),
|
||||
options,
|
||||
)
|
||||
: []),
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -303,32 +303,19 @@ export type PointsPositionUpdates = Map<
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
export type CardinalityArrowhead =
|
||||
| "cardinality_one"
|
||||
| "cardinality_many"
|
||||
| "cardinality_one_or_many"
|
||||
| "cardinality_exactly_one"
|
||||
| "cardinality_zero_or_one"
|
||||
| "cardinality_zero_or_many";
|
||||
|
||||
export type ArrowheadLegacy =
|
||||
| "dot"
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
| "bar"
|
||||
| "dot" // legacy. Do not use for new elements.
|
||||
| "circle"
|
||||
| "circle_outline"
|
||||
| "triangle"
|
||||
| "triangle_outline"
|
||||
| "diamond"
|
||||
| "diamond_outline"
|
||||
| CardinalityArrowhead;
|
||||
|
||||
export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
convertToExcalidrawElements,
|
||||
Excalidraw,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { shouldApplyFrameClip } from "../src/frame";
|
||||
|
||||
import type { ExcalidrawElement } from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
@@ -564,78 +561,3 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame clipping", () => {
|
||||
const getAppStateForFrameClip = () =>
|
||||
({
|
||||
frameRendering: {
|
||||
enabled: true,
|
||||
clip: true,
|
||||
},
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectedElementIds: {},
|
||||
frameToHighlight: null,
|
||||
editingGroupId: null,
|
||||
} as any);
|
||||
|
||||
it("clips a frame child even when fully outside the frame bounds", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
id: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const outsideChild = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "outside-child",
|
||||
x: 250,
|
||||
y: 250,
|
||||
width: 50,
|
||||
height: 50,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
const elementsMap = arrayToMap([outsideChild, frame]);
|
||||
|
||||
expect(
|
||||
shouldApplyFrameClip(
|
||||
outsideChild,
|
||||
frame,
|
||||
getAppStateForFrameClip(),
|
||||
elementsMap,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clip an outside element that does not belong to the frame", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
id: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const outsideElement = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "outside",
|
||||
x: 250,
|
||||
y: 250,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
const elementsMap = arrayToMap([outsideElement, frame]);
|
||||
|
||||
expect(
|
||||
shouldApplyFrameClip(
|
||||
outsideElement,
|
||||
frame,
|
||||
getAppStateForFrameClip(),
|
||||
elementsMap,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,87 +11,6 @@ The change should be grouped under one of the below section and must contain PR
|
||||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
## Excalidraw API
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
|
||||
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
|
||||
|
||||
### Features
|
||||
|
||||
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
|
||||
|
||||
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
|
||||
|
||||
- Same events are also accessible imperatively through `api.onEvent(...)`.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) => {
|
||||
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
|
||||
console.log(container);
|
||||
});
|
||||
|
||||
api.onEvent("editor:initialize").then((readyApi) => {
|
||||
readyApi.scrollToContent();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
|
||||
|
||||
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
|
||||
|
||||
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
|
||||
|
||||
```tsx
|
||||
<ExcalidrawAPIProvider>
|
||||
<Excalidraw />
|
||||
<Logger />
|
||||
</ExcalidrawAPIProvider>;
|
||||
|
||||
function Logger() {
|
||||
// initially null before the ExcalidrawAPIProvider initializes ater
|
||||
// <Excalidraw/> renders
|
||||
// When <Excalidraw/> unmounts, is reset back to null
|
||||
const api = useExcalidrawAPI();
|
||||
|
||||
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
|
||||
console.log("view mode changed:", viewModeEnabled);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (api) {
|
||||
console.log("editor instance id:", api.id);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onExport={async function* (_type, { files }, { signal }) {
|
||||
yield { type: "progress", message: "Waiting for images..." };
|
||||
|
||||
await waitForImagesToLoad(files, signal);
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield { type: "progress", message: "Export ready", progress: 1 };
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Excalidraw Library
|
||||
|
||||
## 0.18.0 (2025-03-11)
|
||||
|
||||
+14
-111
@@ -1,10 +1,10 @@
|
||||
# Excalidraw
|
||||
|
||||
**Excalidraw** is exported as a React component that you can embed directly in your app.
|
||||
**Excalidraw** is exported as a component to be directly embedded in your project.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the package together with its React peer dependencies.
|
||||
Use `npm` or `yarn` to install the package.
|
||||
|
||||
```bash
|
||||
npm install react react-dom @excalidraw/excalidraw
|
||||
@@ -12,131 +12,34 @@ npm install react react-dom @excalidraw/excalidraw
|
||||
yarn add react react-dom @excalidraw/excalidraw
|
||||
```
|
||||
|
||||
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
|
||||
## Quick start
|
||||
#### Self-hosting fonts
|
||||
|
||||
The minimum working setup has two easy-to-miss requirements:
|
||||
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
|
||||
1. Import the package CSS:
|
||||
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
|
||||
|
||||
```ts
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
```js
|
||||
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
|
||||
```
|
||||
|
||||
2. Render Excalidraw inside a container with a non-zero height.
|
||||
### Dimensions of Excalidraw
|
||||
|
||||
```tsx
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
|
||||
|
||||
## Next.js / SSR frameworks
|
||||
|
||||
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
|
||||
|
||||
```tsx
|
||||
// app/components/ExcalidrawClient.tsx
|
||||
"use client";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
export default function ExcalidrawClient() {
|
||||
return (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/page.tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ExcalidrawClient = dynamic(
|
||||
() => import("./components/ExcalidrawClient"),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
return <ExcalidrawClient />;
|
||||
}
|
||||
```
|
||||
|
||||
See the local examples for complete setups:
|
||||
|
||||
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
|
||||
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
|
||||
|
||||
## LLM / agent tips
|
||||
|
||||
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
|
||||
|
||||
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
|
||||
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
|
||||
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
|
||||
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
|
||||
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
|
||||
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
|
||||
|
||||
## Migrating to `@excalidraw/excalidraw@0.18.x`
|
||||
|
||||
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
|
||||
|
||||
| Old path | New path |
|
||||
| --- | --- |
|
||||
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
|
||||
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
|
||||
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
|
||||
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
|
||||
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
|
||||
|
||||
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
|
||||
|
||||
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
|
||||
|
||||
For example:
|
||||
|
||||
```ts
|
||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
```
|
||||
|
||||
## Self-hosting fonts
|
||||
|
||||
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
|
||||
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
</script>
|
||||
```
|
||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||
|
||||
## Demo
|
||||
|
||||
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
|
||||
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
|
||||
|
||||
## Integration
|
||||
|
||||
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
|
||||
## API
|
||||
|
||||
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
|
||||
## Contributing
|
||||
|
||||
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
|
||||
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
import { useStylesPanelMode } from "..";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
import { useStylesPanelMode } from "..";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -9,20 +9,18 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useEditorInterface } from "../components/App";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { Toast } from "../components/Toast";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
|
||||
import { t } from "../i18n";
|
||||
@@ -33,15 +31,7 @@ import "../components/ToolIcon.scss";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { JSONExportData } from "../data/json";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
OnExportProgress,
|
||||
} from "../types";
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export const actionChangeProjectName = register<AppState["name"]>({
|
||||
name: "changeProjectName",
|
||||
@@ -160,143 +150,6 @@ export const actionChangeExportEmbedScene = register<
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport interception helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let onExportInProgress = false;
|
||||
|
||||
const onProgressToast = (
|
||||
app: AppClassProperties,
|
||||
progress: {
|
||||
message?: OnExportProgress["message"];
|
||||
progress?: number | null;
|
||||
},
|
||||
) => {
|
||||
const message = progress.message ?? t("progressDialog.defaultMessage");
|
||||
app.setAppState({
|
||||
toast: {
|
||||
message:
|
||||
progress.progress != null ? (
|
||||
<>
|
||||
{message}
|
||||
<Toast.ProgressBar progress={progress.progress} />
|
||||
</>
|
||||
) : (
|
||||
message
|
||||
),
|
||||
duration: Infinity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** awaits host app's onExport result, and renders progress to the UI */
|
||||
async function handleOnExportResult(
|
||||
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
|
||||
opts: {
|
||||
signal: AbortSignal;
|
||||
app: AppClassProperties;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.app.state.isLoading) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
|
||||
}
|
||||
|
||||
if (
|
||||
onExportResult != null &&
|
||||
typeof onExportResult === "object" &&
|
||||
Symbol.asyncIterator in onExportResult
|
||||
) {
|
||||
for await (const value of onExportResult) {
|
||||
if (opts.signal.aborted) {
|
||||
onExportResult.return();
|
||||
return;
|
||||
}
|
||||
if (value.type === "progress") {
|
||||
onProgressToast(opts.app, {
|
||||
message: value.message,
|
||||
progress: value.progress ?? null,
|
||||
});
|
||||
} else if (value.type === "done") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generator completed without explicit "done" message
|
||||
return;
|
||||
}
|
||||
|
||||
if (onExportResult instanceof Promise) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await onExportResult;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDataForJSONExport(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
app: AppClassProperties,
|
||||
): { abortController: AbortController; data: Promise<JSONExportData> } {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
|
||||
try {
|
||||
if (app.props.onExport) {
|
||||
await handleOnExportResult(
|
||||
app.props.onExport(
|
||||
"json",
|
||||
{
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
},
|
||||
),
|
||||
{
|
||||
app,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
// if abort error, assume it's a reaction on the signal being aborted
|
||||
console.warn(
|
||||
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
|
||||
);
|
||||
} else {
|
||||
// non-abort error
|
||||
//
|
||||
console.error("Error during props.onExport() handling", error);
|
||||
}
|
||||
|
||||
// either way, we currently don't allow host apps to cancel save actions
|
||||
// so we resolve to orig data
|
||||
}
|
||||
|
||||
resolve({
|
||||
elements,
|
||||
appState,
|
||||
// return latest files in case they finished loading during onExport
|
||||
files: app.files,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
abortController,
|
||||
data: dataPromise,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
label: "buttons.save",
|
||||
@@ -310,62 +163,42 @@ export const actionSaveToActiveFile = register({
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, value, app) => {
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const previousFileHandle = appState.fileHandle;
|
||||
const filename = app.getName();
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(previousFileHandle)
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(
|
||||
exportedDataPromise,
|
||||
previousFileHandle,
|
||||
filename,
|
||||
elements,
|
||||
appState,
|
||||
app.files,
|
||||
app.getName(),
|
||||
)
|
||||
: await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename,
|
||||
fileHandle: previousFileHandle,
|
||||
});
|
||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toast: {
|
||||
message:
|
||||
previousFileHandle && fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
duration: 1500,
|
||||
},
|
||||
toast: fileHandleExists
|
||||
? {
|
||||
message: fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@@ -379,50 +212,36 @@ export const actionSaveFileToDisk = register({
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle: savedFileHandle } = await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename: app.getName(),
|
||||
fileHandle: null,
|
||||
});
|
||||
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
app.getName(),
|
||||
);
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
fileHandle: savedFileHandle,
|
||||
toast: { message: t("toast.fileSaved"), duration: 3000 },
|
||||
fileHandle,
|
||||
toast: { message: t("toast.fileSaved") },
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key.toLowerCase() === KEYS.S &&
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD],
|
||||
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@@ -481,8 +300,7 @@ export const actionExportWithDarkMode = register<
|
||||
name: "exportWithDarkMode",
|
||||
label: "imageExportDialog.label.darkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value, app) => {
|
||||
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
import { useStylesPanelMode } from "..";
|
||||
|
||||
import type { History } from "../history";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getArrowheadForPicker } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
@@ -125,12 +124,9 @@ import {
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
ArrowheadCardinalityExactlyOneIcon,
|
||||
ArrowheadCardinalityManyIcon,
|
||||
ArrowheadCardinalityOneIcon,
|
||||
ArrowheadCardinalityOneOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrOneIcon,
|
||||
ArrowheadCrowfootIcon,
|
||||
ArrowheadCrowfootOneIcon,
|
||||
ArrowheadCrowfootOneOrManyIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import { Fonts } from "../fonts";
|
||||
@@ -1554,117 +1550,80 @@ export const actionChangeRoundness = register<"sharp" | "round">({
|
||||
});
|
||||
|
||||
const getArrowheadOptions = (flip: boolean) => {
|
||||
return {
|
||||
visibleSections: [
|
||||
{
|
||||
name: "default",
|
||||
options: [
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hiddenSections: [
|
||||
{
|
||||
name: "default",
|
||||
options: [
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("labels.cardinality"),
|
||||
options: [
|
||||
{
|
||||
value: "cardinality_one",
|
||||
text: t("labels.arrowhead_cardinality_one"),
|
||||
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "cardinality_many",
|
||||
text: t("labels.arrowhead_cardinality_many"),
|
||||
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "cardinality_one_or_many",
|
||||
text: t("labels.arrowhead_cardinality_one_or_many"),
|
||||
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
{
|
||||
value: "cardinality_exactly_one",
|
||||
text: t("labels.arrowhead_cardinality_exactly_one"),
|
||||
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
{
|
||||
value: "cardinality_zero_or_one",
|
||||
text: t("labels.arrowhead_cardinality_zero_or_one"),
|
||||
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
{
|
||||
value: "cardinality_zero_or_many",
|
||||
text: t("labels.arrowhead_cardinality_zero_or_many"),
|
||||
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one",
|
||||
text: t("labels.arrowhead_crowfoot_one"),
|
||||
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_many",
|
||||
text: t("labels.arrowhead_crowfoot_many"),
|
||||
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one_or_many",
|
||||
text: t("labels.arrowhead_crowfoot_one_or_many"),
|
||||
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const actionChangeArrowhead = register<{
|
||||
@@ -1708,52 +1667,45 @@ export const actionChangeArrowhead = register<{
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const isRTL = getLanguage().rtl;
|
||||
const startArrowheadOptions = useMemo(
|
||||
() => getArrowheadOptions(!isRTL),
|
||||
[isRTL],
|
||||
);
|
||||
const endArrowheadOptions = useMemo(
|
||||
() => getArrowheadOptions(!!isRTL),
|
||||
[isRTL],
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.arrowheads")}</legend>
|
||||
<div className="iconSelectList buttonList">
|
||||
<IconPicker
|
||||
visibleSections={startArrowheadOptions.visibleSections}
|
||||
hiddenSections={startArrowheadOptions.hiddenSections}
|
||||
label="arrowhead_start"
|
||||
options={getArrowheadOptions(!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? getArrowheadForPicker(element.startArrowhead)
|
||||
? element.startArrowhead
|
||||
: appState.currentItemStartArrowhead,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemStartArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "start", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
<IconPicker
|
||||
visibleSections={endArrowheadOptions.visibleSections}
|
||||
hiddenSections={endArrowheadOptions.hiddenSections}
|
||||
label="arrowhead_end"
|
||||
group="arrowheads"
|
||||
options={getArrowheadOptions(!!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? getArrowheadForPicker(element.endArrowhead)
|
||||
? element.endArrowhead
|
||||
: appState.currentItemEndArrowhead,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemEndArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "end", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
normalizeFile,
|
||||
} from "./data/blob";
|
||||
|
||||
import type { FileSystemHandle } from "./data/filesystem";
|
||||
|
||||
import type { BinaryFiles } from "./types";
|
||||
|
||||
type ElementsClipboard = {
|
||||
@@ -367,7 +369,7 @@ type AllowedParsedDataTransferItem =
|
||||
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||
kind: "file";
|
||||
file: File;
|
||||
fileHandle: FileSystemFileHandle | null;
|
||||
fileHandle: FileSystemHandle | null;
|
||||
}
|
||||
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||
|
||||
@@ -376,7 +378,7 @@ type ParsedDataTransferItem =
|
||||
type: string;
|
||||
kind: "file";
|
||||
file: File;
|
||||
fileHandle: FileSystemFileHandle | null;
|
||||
fileHandle: FileSystemHandle | null;
|
||||
}
|
||||
| { type: string; kind: "string"; value: string };
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ import {
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
applyDarkModeFilter,
|
||||
AppEventBus,
|
||||
type EXPORT_IMAGE_TYPES,
|
||||
randomInteger,
|
||||
CLASSES,
|
||||
@@ -449,7 +448,7 @@ import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||
import { isPointHittingLink } from "./hyperlink/helpers";
|
||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||
import { AppStateObserver, type OnStateChange } from "./AppStateObserver";
|
||||
import { Toast } from "./Toast";
|
||||
|
||||
import { findShapeByKey } from "./shapes";
|
||||
|
||||
@@ -465,6 +464,7 @@ import type {
|
||||
import type { ClipboardData, PastedMixedContent } from "../clipboard";
|
||||
import type { ExportedElements } from "../data";
|
||||
import type { ContextMenuItems } from "./ContextMenu";
|
||||
import type { FileSystemHandle } from "../data/filesystem";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
@@ -488,7 +488,6 @@ import type {
|
||||
UnsubscribeCallback,
|
||||
EmbedsValidationStatus,
|
||||
ElementsPendingErasure,
|
||||
ExcalidrawImperativeAPIEventMap,
|
||||
GenerateDiagramToCode,
|
||||
NullableGridSize,
|
||||
Offsets,
|
||||
@@ -514,12 +513,6 @@ const EditorInterfaceContext = React.createContext<EditorInterface>(
|
||||
);
|
||||
EditorInterfaceContext.displayName = "EditorInterfaceContext";
|
||||
|
||||
const editorLifecycleEventBehavior = {
|
||||
"editor:mount": { cardinality: "once", replay: "last" },
|
||||
"editor:initialize": { cardinality: "once", replay: "last" },
|
||||
"editor:unmount": { cardinality: "once", replay: "last" },
|
||||
} as const;
|
||||
|
||||
export const ExcalidrawContainerContext = React.createContext<{
|
||||
container: HTMLDivElement | null;
|
||||
id: string | null;
|
||||
@@ -552,15 +545,6 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
|
||||
);
|
||||
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
|
||||
|
||||
export const ExcalidrawAPIContext =
|
||||
React.createContext<ExcalidrawImperativeAPI | null>(null);
|
||||
ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext";
|
||||
|
||||
export const ExcalidrawAPISetContext = React.createContext<
|
||||
((api: ExcalidrawImperativeAPI | null) => void) | null
|
||||
>(null);
|
||||
ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext";
|
||||
|
||||
export const useApp = () => useContext(AppContext);
|
||||
export const useAppProps = () => useContext(AppPropsContext);
|
||||
export const useEditorInterface = () =>
|
||||
@@ -577,10 +561,6 @@ export const useExcalidrawSetAppState = () =>
|
||||
useContext(ExcalidrawSetAppStateContext);
|
||||
export const useExcalidrawActionManager = () =>
|
||||
useContext(ExcalidrawActionManagerContext);
|
||||
/**
|
||||
* Requires wrapping your component in <ExcalidrawAPIContext.Provider>
|
||||
*/
|
||||
export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext);
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
@@ -615,7 +595,6 @@ const gesture: Gesture = {
|
||||
class App extends React.Component<AppProps, AppState> {
|
||||
canvas: AppClassProperties["canvas"];
|
||||
interactiveCanvas: AppClassProperties["interactiveCanvas"] = null;
|
||||
public sessionExportThemeOverride: AppState["theme"] | undefined;
|
||||
rc: RoughCanvas;
|
||||
unmounted: boolean = false;
|
||||
actionManager: ActionManager;
|
||||
@@ -655,26 +634,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
* insert to DOM before user initially scrolls to them) */
|
||||
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
|
||||
|
||||
private handleToastClose = () => {
|
||||
this.setToast(null);
|
||||
};
|
||||
|
||||
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
||||
|
||||
private _initialized = false;
|
||||
|
||||
private readonly editorLifecycleEvents = new AppEventBus<
|
||||
ExcalidrawImperativeAPIEventMap,
|
||||
typeof editorLifecycleEventBehavior
|
||||
>(editorLifecycleEventBehavior);
|
||||
|
||||
public onEvent = this.editorLifecycleEvents.on.bind(
|
||||
this.editorLifecycleEvents,
|
||||
) as AppEventBus<
|
||||
ExcalidrawImperativeAPIEventMap,
|
||||
typeof editorLifecycleEventBehavior
|
||||
>["on"];
|
||||
|
||||
private appStateObserver = new AppStateObserver(() => this.state);
|
||||
|
||||
public onStateChange: OnStateChange = this.appStateObserver.onStateChange;
|
||||
|
||||
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
||||
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
|
||||
|
||||
@@ -730,56 +695,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
>();
|
||||
onRemoveEventListenersEmitter = new Emitter<[]>();
|
||||
|
||||
api: ExcalidrawImperativeAPI;
|
||||
|
||||
private createExcalidrawAPI(): ExcalidrawImperativeAPI {
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
isDestroyed: false,
|
||||
updateScene: this.updateScene,
|
||||
applyDeltas: this.applyDeltas,
|
||||
mutateElement: this.mutateElement,
|
||||
updateLibrary: this.library.updateLibrary,
|
||||
addFiles: this.addFiles,
|
||||
resetScene: this.resetScene,
|
||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||
getSceneElementsMapIncludingDeleted:
|
||||
this.getSceneElementsMapIncludingDeleted,
|
||||
history: {
|
||||
clear: this.resetHistory,
|
||||
},
|
||||
scrollToContent: this.scrollToContent,
|
||||
getSceneElements: this.getSceneElements,
|
||||
getAppState: () => this.state,
|
||||
getFiles: () => this.files,
|
||||
getName: this.getName,
|
||||
registerAction: (action: Action) => {
|
||||
this.actionManager.registerAction(action);
|
||||
},
|
||||
refresh: this.refresh,
|
||||
setToast: this.setToast,
|
||||
id: this.id,
|
||||
setActiveTool: this.setActiveTool,
|
||||
setCursor: this.setCursor,
|
||||
resetCursor: this.resetCursor,
|
||||
getEditorInterface: () => this.editorInterface,
|
||||
updateFrameRendering: this.updateFrameRendering,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
|
||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
||||
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
|
||||
onStateChange: this.onStateChange,
|
||||
onEvent: this.onEvent,
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const {
|
||||
excalidrawAPI,
|
||||
viewModeEnabled = false,
|
||||
zenModeEnabled = false,
|
||||
gridModeEnabled = false,
|
||||
@@ -787,11 +707,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
theme = defaultAppState.theme,
|
||||
name = `${t("labels.untitled")}-${getDateTime()}`,
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
...defaultAppState,
|
||||
theme,
|
||||
exportWithDarkMode: theme === THEME.DARK,
|
||||
isLoading: true,
|
||||
...this.getCanvasOffsets(),
|
||||
viewModeEnabled,
|
||||
@@ -824,6 +742,51 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.store = new Store(this);
|
||||
this.history = new History(this.store);
|
||||
|
||||
if (excalidrawAPI) {
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
updateScene: this.updateScene,
|
||||
applyDeltas: this.applyDeltas,
|
||||
mutateElement: this.mutateElement,
|
||||
updateLibrary: this.library.updateLibrary,
|
||||
addFiles: this.addFiles,
|
||||
resetScene: this.resetScene,
|
||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||
getSceneElementsMapIncludingDeleted:
|
||||
this.getSceneElementsMapIncludingDeleted,
|
||||
history: {
|
||||
clear: this.resetHistory,
|
||||
},
|
||||
scrollToContent: this.scrollToContent,
|
||||
getSceneElements: this.getSceneElements,
|
||||
getAppState: () => this.state,
|
||||
getFiles: () => this.files,
|
||||
getName: this.getName,
|
||||
registerAction: (action: Action) => {
|
||||
this.actionManager.registerAction(action);
|
||||
},
|
||||
refresh: this.refresh,
|
||||
setToast: this.setToast,
|
||||
id: this.id,
|
||||
setActiveTool: this.setActiveTool,
|
||||
setCursor: this.setCursor,
|
||||
resetCursor: this.resetCursor,
|
||||
getEditorInterface: () => this.editorInterface,
|
||||
updateFrameRendering: this.updateFrameRendering,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
|
||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
||||
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
|
||||
} as const;
|
||||
if (typeof excalidrawAPI === "function") {
|
||||
excalidrawAPI(api);
|
||||
} else {
|
||||
console.error("excalidrawAPI should be a function!");
|
||||
}
|
||||
}
|
||||
|
||||
this.excalidrawContainerValue = {
|
||||
container: this.excalidrawContainerRef.current,
|
||||
id: this.id,
|
||||
@@ -835,12 +798,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.actionManager.registerAll(actions);
|
||||
this.actionManager.registerAction(createUndoAction(this.history));
|
||||
this.actionManager.registerAction(createRedoAction(this.history));
|
||||
|
||||
// in case internal editor APIs call this early, otherwise we need
|
||||
// to construct this in componentDidMount because componentWillUnmount
|
||||
// will invalidate it (so in StrictMode, doing this in constructor alone
|
||||
// would be a problem)
|
||||
this.api = this.createExcalidrawAPI();
|
||||
}
|
||||
|
||||
updateEditorAtom = <Value, Args extends unknown[], Result>(
|
||||
@@ -2083,279 +2040,282 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onPointerEnter={this.toggleOverscrollBehavior}
|
||||
onPointerLeave={this.toggleOverscrollBehavior}
|
||||
>
|
||||
<ExcalidrawAPIContext.Provider value={this.api}>
|
||||
<AppContext.Provider value={this}>
|
||||
<AppPropsContext.Provider value={this.props}>
|
||||
<ExcalidrawContainerContext.Provider
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<EditorInterfaceContext.Provider value={this.editorInterface}>
|
||||
<ExcalidrawSetAppStateContext.Provider
|
||||
value={this.setAppState}
|
||||
>
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
<AppContext.Provider value={this}>
|
||||
<AppPropsContext.Provider value={this.props}>
|
||||
<ExcalidrawContainerContext.Provider
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<EditorInterfaceContext.Provider value={this.editorInterface}>
|
||||
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
<ExcalidrawActionManagerContext.Provider
|
||||
value={this.actionManager}
|
||||
>
|
||||
<ExcalidrawActionManagerContext.Provider
|
||||
value={this.actionManager}
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onHandToolToggle={this.onHandToolToggle}
|
||||
langCode={getLanguage().code}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
UIOptions={this.props.UIOptions}
|
||||
onExportImage={this.onExportImage}
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type ===
|
||||
this.state.preferredSelectionTool.type &&
|
||||
!this.state.zenModeEnabled &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
app={this}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
generateLinkForSelection={
|
||||
this.props.generateLinkForSelection
|
||||
}
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onHandToolToggle={this.onHandToolToggle}
|
||||
langCode={getLanguage().code}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled ===
|
||||
"undefined" && this.state.zenModeEnabled
|
||||
}
|
||||
UIOptions={this.props.UIOptions}
|
||||
onExportImage={this.onExportImage}
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type ===
|
||||
this.state.preferredSelectionTool.type &&
|
||||
!this.state.zenModeEnabled &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
app={this}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
generateLinkForSelection={
|
||||
this.props.generateLinkForSelection
|
||||
}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<SVGLayer
|
||||
trails={[
|
||||
this.laserTrails,
|
||||
this.lassoTrail,
|
||||
this.eraserTrail,
|
||||
]}
|
||||
/>
|
||||
{selectedElements.length === 1 &&
|
||||
this.state.openDialog?.name !==
|
||||
"elementLinkSelector" &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={firstSelectedElement.id}
|
||||
element={firstSelectedElement}
|
||||
scene={this.scene}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
setToast={this.setToast}
|
||||
updateEmbedValidationStatus={
|
||||
this.updateEmbedValidationStatus
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{this.props.aiEnabled !== false &&
|
||||
selectedElements.length === 1 &&
|
||||
isMagicFrameElement(firstSelectedElement) && (
|
||||
<ElementCanvasButtons
|
||||
element={firstSelectedElement}
|
||||
elementsMap={elementsMap}
|
||||
>
|
||||
<ElementCanvasButton
|
||||
title={t("labels.convertToCode")}
|
||||
icon={MagicIcon}
|
||||
checked={false}
|
||||
onChange={() =>
|
||||
this.onMagicFrameGenerate(
|
||||
firstSelectedElement,
|
||||
"button",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ElementCanvasButtons>
|
||||
)}
|
||||
{selectedElements.length === 1 &&
|
||||
isIframeElement(firstSelectedElement) &&
|
||||
firstSelectedElement.customData?.generationData
|
||||
?.status === "done" && (
|
||||
<ElementCanvasButtons
|
||||
element={firstSelectedElement}
|
||||
elementsMap={elementsMap}
|
||||
>
|
||||
<ElementCanvasButton
|
||||
title={t("labels.copySource")}
|
||||
icon={copyIcon}
|
||||
checked={false}
|
||||
onChange={() =>
|
||||
this.onIframeSrcCopy(firstSelectedElement)
|
||||
}
|
||||
/>
|
||||
<ElementCanvasButton
|
||||
title="Enter fullscreen"
|
||||
icon={fullscreenIcon}
|
||||
checked={false}
|
||||
onChange={() => {
|
||||
const iframe =
|
||||
this.getHTMLIFrameElement(
|
||||
firstSelectedElement,
|
||||
);
|
||||
if (iframe) {
|
||||
try {
|
||||
iframe.requestFullscreen();
|
||||
this.setState({
|
||||
activeEmbeddable: {
|
||||
element: firstSelectedElement,
|
||||
state: "active",
|
||||
},
|
||||
selectedElementIds: {
|
||||
[firstSelectedElement.id]: true,
|
||||
},
|
||||
newElement: null,
|
||||
selectionElement: null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(err);
|
||||
this.setState({
|
||||
errorMessage:
|
||||
"Couldn't enter fullscreen",
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ElementCanvasButtons>
|
||||
)}
|
||||
|
||||
{this.state.contextMenu && (
|
||||
<ContextMenu
|
||||
items={this.state.contextMenu.items}
|
||||
top={this.state.contextMenu.top}
|
||||
left={this.state.contextMenu.left}
|
||||
actionManager={this.actionManager}
|
||||
onClose={(callback) => {
|
||||
this.setState({ contextMenu: null }, () => {
|
||||
this.focusContainer();
|
||||
callback?.();
|
||||
});
|
||||
}}
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<SVGLayer
|
||||
trails={[
|
||||
this.laserTrails,
|
||||
this.lassoTrail,
|
||||
this.eraserTrail,
|
||||
]}
|
||||
/>
|
||||
{selectedElements.length === 1 &&
|
||||
this.state.openDialog?.name !==
|
||||
"elementLinkSelector" &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={firstSelectedElement.id}
|
||||
element={firstSelectedElement}
|
||||
scene={this.scene}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
setToast={this.setToast}
|
||||
updateEmbedValidationStatus={
|
||||
this.updateEmbedValidationStatus
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StaticCanvas
|
||||
canvas={this.canvas}
|
||||
{this.props.aiEnabled !== false &&
|
||||
selectedElements.length === 1 &&
|
||||
isMagicFrameElement(firstSelectedElement) && (
|
||||
<ElementCanvasButtons
|
||||
element={firstSelectedElement}
|
||||
elementsMap={elementsMap}
|
||||
>
|
||||
<ElementCanvasButton
|
||||
title={t("labels.convertToCode")}
|
||||
icon={MagicIcon}
|
||||
checked={false}
|
||||
onChange={() =>
|
||||
this.onMagicFrameGenerate(
|
||||
firstSelectedElement,
|
||||
"button",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ElementCanvasButtons>
|
||||
)}
|
||||
{selectedElements.length === 1 &&
|
||||
isIframeElement(firstSelectedElement) &&
|
||||
firstSelectedElement.customData?.generationData
|
||||
?.status === "done" && (
|
||||
<ElementCanvasButtons
|
||||
element={firstSelectedElement}
|
||||
elementsMap={elementsMap}
|
||||
>
|
||||
<ElementCanvasButton
|
||||
title={t("labels.copySource")}
|
||||
icon={copyIcon}
|
||||
checked={false}
|
||||
onChange={() =>
|
||||
this.onIframeSrcCopy(firstSelectedElement)
|
||||
}
|
||||
/>
|
||||
<ElementCanvasButton
|
||||
title="Enter fullscreen"
|
||||
icon={fullscreenIcon}
|
||||
checked={false}
|
||||
onChange={() => {
|
||||
const iframe =
|
||||
this.getHTMLIFrameElement(
|
||||
firstSelectedElement,
|
||||
);
|
||||
if (iframe) {
|
||||
try {
|
||||
iframe.requestFullscreen();
|
||||
this.setState({
|
||||
activeEmbeddable: {
|
||||
element: firstSelectedElement,
|
||||
state: "active",
|
||||
},
|
||||
selectedElementIds: {
|
||||
[firstSelectedElement.id]: true,
|
||||
},
|
||||
newElement: null,
|
||||
selectionElement: null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(err);
|
||||
this.setState({
|
||||
errorMessage:
|
||||
"Couldn't enter fullscreen",
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ElementCanvasButtons>
|
||||
)}
|
||||
|
||||
{this.state.toast !== null && (
|
||||
<Toast
|
||||
message={this.state.toast.message}
|
||||
onClose={this.handleToastClose}
|
||||
duration={this.state.toast.duration}
|
||||
closable={this.state.toast.closable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.state.contextMenu && (
|
||||
<ContextMenu
|
||||
items={this.state.contextMenu.items}
|
||||
top={this.state.contextMenu.top}
|
||||
left={this.state.contextMenu.left}
|
||||
actionManager={this.actionManager}
|
||||
onClose={(callback) => {
|
||||
this.setState({ contextMenu: null }, () => {
|
||||
this.focusContainer();
|
||||
callback?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StaticCanvas
|
||||
canvas={this.canvas}
|
||||
rc={this.rc}
|
||||
elementsMap={elementsMap}
|
||||
allElementsMap={allElementsMap}
|
||||
visibleElements={visibleElements}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
scale={window.devicePixelRatio}
|
||||
appState={this.state}
|
||||
renderConfig={{
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderGrid: isGridModeEnabled(this),
|
||||
canvasBackgroundColor:
|
||||
this.state.viewBackgroundColor,
|
||||
embedsValidationStatus: this.embedsValidationStatus,
|
||||
elementsPendingErasure: this.elementsPendingErasure,
|
||||
pendingFlowchartNodes:
|
||||
this.flowChartCreator.pendingNodes,
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
{this.state.newElement && (
|
||||
<NewElementCanvas
|
||||
appState={this.state}
|
||||
scale={window.devicePixelRatio}
|
||||
rc={this.rc}
|
||||
elementsMap={elementsMap}
|
||||
allElementsMap={allElementsMap}
|
||||
visibleElements={visibleElements}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
scale={window.devicePixelRatio}
|
||||
appState={this.state}
|
||||
renderConfig={{
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderGrid: isGridModeEnabled(this),
|
||||
renderGrid: false,
|
||||
canvasBackgroundColor:
|
||||
this.state.viewBackgroundColor,
|
||||
embedsValidationStatus:
|
||||
this.embedsValidationStatus,
|
||||
elementsPendingErasure:
|
||||
this.elementsPendingErasure,
|
||||
pendingFlowchartNodes:
|
||||
this.flowChartCreator.pendingNodes,
|
||||
pendingFlowchartNodes: null,
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
{this.state.newElement && (
|
||||
<NewElementCanvas
|
||||
appState={this.state}
|
||||
scale={window.devicePixelRatio}
|
||||
rc={this.rc}
|
||||
elementsMap={elementsMap}
|
||||
allElementsMap={allElementsMap}
|
||||
renderConfig={{
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderGrid: false,
|
||||
canvasBackgroundColor:
|
||||
this.state.viewBackgroundColor,
|
||||
embedsValidationStatus:
|
||||
this.embedsValidationStatus,
|
||||
elementsPendingErasure:
|
||||
this.elementsPendingErasure,
|
||||
pendingFlowchartNodes: null,
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<InteractiveCanvas
|
||||
app={this}
|
||||
containerRef={this.excalidrawContainerRef}
|
||||
canvas={this.interactiveCanvas}
|
||||
elementsMap={elementsMap}
|
||||
visibleElements={visibleElements}
|
||||
allElementsMap={allElementsMap}
|
||||
selectedElements={selectedElements}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
scale={window.devicePixelRatio}
|
||||
appState={this.state}
|
||||
renderScrollbars={
|
||||
this.props.renderScrollbars === true
|
||||
}
|
||||
editorInterface={this.editorInterface}
|
||||
renderInteractiveSceneCallback={
|
||||
this.renderInteractiveSceneCallback
|
||||
}
|
||||
handleCanvasRef={this.handleInteractiveCanvasRef}
|
||||
onContextMenu={this.handleCanvasContextMenu}
|
||||
onPointerMove={this.handleCanvasPointerMove}
|
||||
onPointerUp={this.handleCanvasPointerUp}
|
||||
onPointerCancel={this.removePointer}
|
||||
onTouchMove={this.handleTouchMove}
|
||||
onPointerDown={this.handleCanvasPointerDown}
|
||||
onDoubleClick={this.handleCanvasDoubleClick}
|
||||
)}
|
||||
<InteractiveCanvas
|
||||
app={this}
|
||||
containerRef={this.excalidrawContainerRef}
|
||||
canvas={this.interactiveCanvas}
|
||||
elementsMap={elementsMap}
|
||||
visibleElements={visibleElements}
|
||||
allElementsMap={allElementsMap}
|
||||
selectedElements={selectedElements}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
scale={window.devicePixelRatio}
|
||||
appState={this.state}
|
||||
renderScrollbars={
|
||||
this.props.renderScrollbars === true
|
||||
}
|
||||
editorInterface={this.editorInterface}
|
||||
renderInteractiveSceneCallback={
|
||||
this.renderInteractiveSceneCallback
|
||||
}
|
||||
handleCanvasRef={this.handleInteractiveCanvasRef}
|
||||
onContextMenu={this.handleCanvasContextMenu}
|
||||
onPointerMove={this.handleCanvasPointerMove}
|
||||
onPointerUp={this.handleCanvasPointerUp}
|
||||
onPointerCancel={this.removePointer}
|
||||
onTouchMove={this.handleTouchMove}
|
||||
onPointerDown={this.handleCanvasPointerDown}
|
||||
onDoubleClick={this.handleCanvasDoubleClick}
|
||||
/>
|
||||
{this.state.userToFollow && (
|
||||
<FollowMode
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
userToFollow={this.state.userToFollow}
|
||||
onDisconnect={this.maybeUnfollowRemoteUser}
|
||||
/>
|
||||
{this.state.userToFollow && (
|
||||
<FollowMode
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
userToFollow={this.state.userToFollow}
|
||||
onDisconnect={this.maybeUnfollowRemoteUser}
|
||||
/>
|
||||
)}
|
||||
{this.renderFrameNames()}
|
||||
{this.state.activeLockedId && (
|
||||
<UnlockPopup
|
||||
app={this}
|
||||
activeLockedId={this.state.activeLockedId}
|
||||
/>
|
||||
)}
|
||||
{showShapeSwitchPanel && (
|
||||
<ConvertElementTypePopup app={this} />
|
||||
)}
|
||||
</ExcalidrawActionManagerContext.Provider>
|
||||
{this.renderEmbeddables()}
|
||||
</ExcalidrawElementsContext.Provider>
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</ExcalidrawSetAppStateContext.Provider>
|
||||
</EditorInterfaceContext.Provider>
|
||||
</ExcalidrawContainerContext.Provider>
|
||||
</AppPropsContext.Provider>
|
||||
</AppContext.Provider>
|
||||
</ExcalidrawAPIContext.Provider>
|
||||
)}
|
||||
{this.renderFrameNames()}
|
||||
{this.state.activeLockedId && (
|
||||
<UnlockPopup
|
||||
app={this}
|
||||
activeLockedId={this.state.activeLockedId}
|
||||
/>
|
||||
)}
|
||||
{showShapeSwitchPanel && (
|
||||
<ConvertElementTypePopup app={this} />
|
||||
)}
|
||||
</ExcalidrawActionManagerContext.Provider>
|
||||
{this.renderEmbeddables()}
|
||||
</ExcalidrawElementsContext.Provider>
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</ExcalidrawSetAppStateContext.Provider>
|
||||
</EditorInterfaceContext.Provider>
|
||||
</ExcalidrawContainerContext.Provider>
|
||||
</AppPropsContext.Provider>
|
||||
</AppContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3012,8 +2972,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
public async componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.api = this.createExcalidrawAPI();
|
||||
|
||||
this.excalidrawContainerValue.container =
|
||||
this.excalidrawContainerRef.current;
|
||||
|
||||
@@ -3055,10 +3013,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.history.record(increment.delta);
|
||||
});
|
||||
|
||||
const { onIncrement } = this.props;
|
||||
|
||||
// per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
|
||||
if (this.props.onIncrement) {
|
||||
if (onIncrement) {
|
||||
this.store.onStoreIncrementEmitter.on((increment) => {
|
||||
this.props.onIncrement?.(increment);
|
||||
onIncrement(increment);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3092,43 +3052,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
errorMessage: <BraveMeasureTextError />,
|
||||
});
|
||||
}
|
||||
|
||||
const mountPayload = {
|
||||
excalidrawAPI: this.api,
|
||||
container: this.excalidrawContainerRef.current,
|
||||
};
|
||||
|
||||
this.editorLifecycleEvents.emit("editor:mount", mountPayload);
|
||||
this.props.onMount?.(mountPayload);
|
||||
this.props.onExcalidrawAPI?.(this.api);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
// we're recreating the api object reference so that the
|
||||
// <ExcalidrawAPIContext.Provider/> picks up on it
|
||||
this.api = { ...this.api, isDestroyed: true };
|
||||
|
||||
for (const key of Object.keys(this.api) as (keyof typeof this.api)[]) {
|
||||
if (
|
||||
(key.startsWith("get") ||
|
||||
key === "onStateChange" ||
|
||||
key === "onEvent") &&
|
||||
typeof this.api[key] === "function"
|
||||
) {
|
||||
(this.api as any)[key] = () => {
|
||||
throw new Error(
|
||||
"ExcalidrawAPI is no longer usable after the editor has been unmounted and will return invalid/empty data. You should check for `ExcalidrawAPI.isDestroyed` before calling get* methods on subscribing to state/event changes.",
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.editorLifecycleEvents.emit("editor:unmount");
|
||||
this.props.onUnmount?.();
|
||||
this.props.onExcalidrawAPI?.(null);
|
||||
|
||||
(window as any).launchQueue?.setConsumer(() => {});
|
||||
|
||||
this.renderer.destroy();
|
||||
this.scene.destroy();
|
||||
this.scene = new Scene();
|
||||
@@ -3145,8 +3072,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.onChangeEmitter.clear();
|
||||
this.store.onStoreIncrementEmitter.clear();
|
||||
this.store.onDurableIncrementEmitter.clear();
|
||||
this.appStateObserver.clear();
|
||||
this.editorLifecycleEvents.clear();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
@@ -3312,26 +3237,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
|
||||
// must be updated *before* state change listeners are triggered below
|
||||
if (!this._initialized && !this.state.isLoading) {
|
||||
this._initialized = true;
|
||||
this.editorLifecycleEvents.emit("editor:initialize", this.api);
|
||||
this.props.onInitialize?.(this.api);
|
||||
}
|
||||
|
||||
this.appStateObserver.flush(prevState);
|
||||
|
||||
this.updateEmbeddables();
|
||||
const elements = this.scene.getElementsIncludingDeleted();
|
||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||
|
||||
const shouldExportWithDarkMode =
|
||||
(this.sessionExportThemeOverride ?? this.state.theme) === THEME.DARK;
|
||||
|
||||
if (this.state.exportWithDarkMode !== shouldExportWithDarkMode) {
|
||||
this.setState({ exportWithDarkMode: shouldExportWithDarkMode });
|
||||
}
|
||||
|
||||
if (!this.state.showWelcomeScreen && !elements.length) {
|
||||
this.setState({ showWelcomeScreen: true });
|
||||
}
|
||||
@@ -3703,7 +3612,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
|
||||
const api = await import("@excalidraw/mermaid-to-excalidraw");
|
||||
try {
|
||||
const { elements: skeletonElements, files = {} } =
|
||||
const { elements: skeletonElements, files } =
|
||||
await api.parseMermaidToExcalidraw(data.text);
|
||||
|
||||
const elements = convertToExcalidrawElements(skeletonElements, {
|
||||
@@ -4406,7 +4315,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState(state);
|
||||
};
|
||||
|
||||
setToast = (toast: AppState["toast"]) => {
|
||||
setToast = (
|
||||
toast: {
|
||||
message: string;
|
||||
closable?: boolean;
|
||||
duration?: number;
|
||||
} | null,
|
||||
) => {
|
||||
this.setState({ toast });
|
||||
};
|
||||
|
||||
@@ -5231,8 +5146,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// eye dropper
|
||||
// -----------------------------------------------------------------------
|
||||
const lowerCased = event.key.toLocaleLowerCase();
|
||||
const isPickingStroke =
|
||||
lowerCased === KEYS.S && event.shiftKey && !event[KEYS.CTRL_OR_CMD];
|
||||
const isPickingStroke = lowerCased === KEYS.S && event.shiftKey;
|
||||
const isPickingBackground =
|
||||
event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
|
||||
|
||||
@@ -11679,7 +11593,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
loadFileToCanvas = async (
|
||||
file: File,
|
||||
fileHandle: FileSystemFileHandle | null,
|
||||
fileHandle: FileSystemHandle | null,
|
||||
) => {
|
||||
file = await normalizeFile(file);
|
||||
try {
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import type { AppState, UnsubscribeCallback } from "../types";
|
||||
|
||||
type StateChangeSelector =
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown);
|
||||
|
||||
type StateChangePredicateOptions = {
|
||||
predicate: (appState: AppState) => boolean;
|
||||
callback?: (appState: AppState) => void;
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
type StateChangeArg = StateChangeSelector | StateChangePredicateOptions;
|
||||
|
||||
type StateChangeListener = {
|
||||
predicate: (appState: AppState, prevState: AppState) => boolean;
|
||||
getValue: (appState: AppState) => unknown;
|
||||
callback: (value: any, appState: AppState) => void;
|
||||
once: boolean;
|
||||
};
|
||||
|
||||
type NormalizedStateChange = {
|
||||
predicate: StateChangeListener["predicate"];
|
||||
getValue: StateChangeListener["getValue"];
|
||||
callback?: StateChangeListener["callback"];
|
||||
once: boolean;
|
||||
matchesImmediately: boolean;
|
||||
};
|
||||
|
||||
export type OnStateChange = {
|
||||
<K extends keyof AppState>(
|
||||
prop: K,
|
||||
callback: (value: AppState[K], appState: AppState) => void,
|
||||
opts?: { once: boolean },
|
||||
): UnsubscribeCallback;
|
||||
<K extends keyof AppState>(prop: K): Promise<AppState[K]>;
|
||||
(
|
||||
prop: (keyof AppState)[],
|
||||
callback: (appState: AppState, appState2: AppState) => void,
|
||||
opts?: { once: boolean },
|
||||
): UnsubscribeCallback;
|
||||
(prop: (keyof AppState)[]): Promise<AppState>;
|
||||
<T>(
|
||||
prop: (appState: AppState) => T,
|
||||
callback: (value: T, appState: AppState) => void,
|
||||
opts?: { once: boolean },
|
||||
): UnsubscribeCallback;
|
||||
<T>(prop: (appState: AppState) => T): Promise<T>;
|
||||
(opts: {
|
||||
predicate: (appState: AppState) => boolean;
|
||||
callback: (appState: AppState) => void;
|
||||
once?: boolean;
|
||||
}): UnsubscribeCallback;
|
||||
(opts: { predicate: (appState: AppState) => boolean }): Promise<AppState>;
|
||||
(
|
||||
selector: StateChangeSelector,
|
||||
callback: (value: any, appState: AppState) => void,
|
||||
): any;
|
||||
};
|
||||
|
||||
export class AppStateObserver {
|
||||
private listeners: StateChangeListener[] = [];
|
||||
|
||||
constructor(private readonly getState: () => AppState) {}
|
||||
|
||||
private isStateChangePredicateOptions(
|
||||
propOrOpts: StateChangeArg,
|
||||
): propOrOpts is StateChangePredicateOptions {
|
||||
return (
|
||||
typeof propOrOpts === "object" &&
|
||||
!Array.isArray(propOrOpts) &&
|
||||
"predicate" in propOrOpts
|
||||
);
|
||||
}
|
||||
|
||||
private subscribe(listener: StateChangeListener): UnsubscribeCallback {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(
|
||||
(existingListener) => existingListener !== listener,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private normalize(
|
||||
propOrOpts: StateChangeArg,
|
||||
callback?: (value: any, appState: AppState) => void,
|
||||
opts?: { once: boolean },
|
||||
): NormalizedStateChange {
|
||||
let predicate: StateChangeListener["predicate"];
|
||||
let getValue: StateChangeListener["getValue"];
|
||||
let normalizedCallback = callback;
|
||||
let once = opts?.once ?? false;
|
||||
let matchesImmediately = false;
|
||||
|
||||
if (this.isStateChangePredicateOptions(propOrOpts)) {
|
||||
const {
|
||||
predicate: predicateFn,
|
||||
callback: callbackFromOpts,
|
||||
once: onceFromOpts,
|
||||
} = propOrOpts;
|
||||
|
||||
predicate = predicateFn;
|
||||
getValue = (appState: AppState) => appState;
|
||||
normalizedCallback = callbackFromOpts
|
||||
? (_value: AppState, appState: AppState) => callbackFromOpts(appState)
|
||||
: undefined;
|
||||
once = onceFromOpts ?? false;
|
||||
matchesImmediately = predicateFn(this.getState());
|
||||
} else if (typeof propOrOpts === "function") {
|
||||
const selector = propOrOpts;
|
||||
predicate = (appState: AppState, prevState: AppState) =>
|
||||
selector(appState) !== selector(prevState);
|
||||
getValue = (appState: AppState) => selector(appState);
|
||||
} else if (Array.isArray(propOrOpts)) {
|
||||
const keys = propOrOpts;
|
||||
predicate = (appState: AppState, prevState: AppState) =>
|
||||
keys.some((key) => appState[key] !== prevState[key]);
|
||||
getValue = (appState: AppState) => appState;
|
||||
} else {
|
||||
const key = propOrOpts;
|
||||
predicate = (appState: AppState, prevState: AppState) =>
|
||||
appState[key] !== prevState[key];
|
||||
getValue = (appState: AppState) => appState[key];
|
||||
}
|
||||
|
||||
return {
|
||||
predicate,
|
||||
getValue,
|
||||
callback: normalizedCallback,
|
||||
once,
|
||||
matchesImmediately,
|
||||
};
|
||||
}
|
||||
|
||||
public onStateChange: OnStateChange = ((
|
||||
propOrOpts: StateChangeArg,
|
||||
callback?: any,
|
||||
opts?: { once: boolean },
|
||||
) => {
|
||||
const {
|
||||
predicate,
|
||||
getValue,
|
||||
callback: stateChangeCallback,
|
||||
once,
|
||||
matchesImmediately,
|
||||
} = this.normalize(propOrOpts, callback, opts);
|
||||
|
||||
if (stateChangeCallback) {
|
||||
if (matchesImmediately) {
|
||||
queueMicrotask(() => {
|
||||
const state = this.getState();
|
||||
stateChangeCallback(getValue(state), state);
|
||||
});
|
||||
if (once) {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
return this.subscribe({
|
||||
predicate,
|
||||
getValue,
|
||||
callback: stateChangeCallback,
|
||||
once,
|
||||
});
|
||||
}
|
||||
|
||||
if (matchesImmediately) {
|
||||
return Promise.resolve(getValue(this.getState()));
|
||||
}
|
||||
|
||||
return new Promise<any>((resolve) => {
|
||||
this.subscribe({
|
||||
predicate,
|
||||
getValue,
|
||||
callback: (value) => resolve(value),
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}) as OnStateChange;
|
||||
|
||||
public flush(prevState: AppState) {
|
||||
if (!this.listeners.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.getState();
|
||||
const listenersToKeep: StateChangeListener[] = [];
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
if (listener.predicate(state, prevState)) {
|
||||
listener.callback(listener.getValue(state), state);
|
||||
if (!listener.once) {
|
||||
listenersToKeep.push(listener);
|
||||
}
|
||||
} else {
|
||||
listenersToKeep.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
this.listeners = listenersToKeep;
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.listeners = [];
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
isWritableElement,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
|
||||
|
||||
import { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch";
|
||||
import { getShortcutKey } from "../../shortcut";
|
||||
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
||||
|
||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
actionClearCanvas,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
bumpVersion,
|
||||
getLinearElementSubType,
|
||||
mutateElement,
|
||||
updateElbowArrowPoints,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -39,8 +37,6 @@ import {
|
||||
isProdEnv,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
ROUNDNESS,
|
||||
sceneCoordsToViewportCoords,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -75,6 +71,12 @@ import type {
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bumpVersion,
|
||||
mutateElement,
|
||||
ROUNDNESS,
|
||||
sceneCoordsToViewportCoords,
|
||||
} from "..";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { atom } from "../editor-jotai";
|
||||
|
||||
|
||||
@@ -6,20 +6,14 @@
|
||||
padding: 0.5rem;
|
||||
background: var(--popup-bg-color);
|
||||
border: 0 solid color.adjust(#fff, $alpha: -0.75);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
:root[dir="rtl"] & {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-sections,
|
||||
.picker-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.picker-container button,
|
||||
.picker button {
|
||||
position: relative;
|
||||
@@ -68,13 +62,7 @@
|
||||
|
||||
.picker-collapsible {
|
||||
font-size: 0.75rem;
|
||||
padding: 0;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.picker-section-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary-color);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.picker-keybinding {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Popover } from "radix-ui";
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { isArrowKey, KEYS } from "@excalidraw/common";
|
||||
|
||||
@@ -8,15 +8,13 @@ import { atom, useAtom } from "../editor-jotai";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
|
||||
import Collapsible from "./Stats/Collapsible";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { useEditorInterface, useExcalidrawContainer } from "./App";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
const moreOptionsAtom = atom(false);
|
||||
const PICKER_COLUMNS = 4;
|
||||
const DEFAULT_SECTION_NAME = "default";
|
||||
|
||||
type Option<T> = {
|
||||
value: T;
|
||||
@@ -25,73 +23,28 @@ type Option<T> = {
|
||||
keyBinding: string | null;
|
||||
};
|
||||
|
||||
type PickerSection<T> = {
|
||||
name: string;
|
||||
options: readonly Option<T>[];
|
||||
};
|
||||
|
||||
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
|
||||
sections.flatMap((section) => section.options);
|
||||
|
||||
const findOption = <T,>(
|
||||
sections: readonly PickerSection<T>[],
|
||||
predicate: (option: Option<T>) => boolean,
|
||||
) => {
|
||||
for (const section of sections) {
|
||||
const option = section.options.find(predicate);
|
||||
if (option) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasOption = <T,>(
|
||||
sections: readonly PickerSection<T>[],
|
||||
predicate: (option: Option<T>) => boolean,
|
||||
) => sections.some((section) => section.options.some(predicate));
|
||||
|
||||
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
|
||||
sections.flatMap((section) =>
|
||||
Array.from(
|
||||
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
|
||||
(_, index) =>
|
||||
section.options.slice(
|
||||
index * PICKER_COLUMNS,
|
||||
index * PICKER_COLUMNS + PICKER_COLUMNS,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
function Picker<T>({
|
||||
visibleSections,
|
||||
hiddenSections = [],
|
||||
options,
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
onClose,
|
||||
numberOfOptionsToAlwaysShow = options.length,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
visibleSections: readonly PickerSection<T>[];
|
||||
hiddenSections?: readonly PickerSection<T>[];
|
||||
options: readonly Option<T>[];
|
||||
onChange: (value: T) => void;
|
||||
onClose: () => void;
|
||||
numberOfOptionsToAlwaysShow?: number;
|
||||
}) {
|
||||
const editorInterface = useEditorInterface();
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
||||
const allSections = [...visibleSections, ...hiddenSections];
|
||||
const allOptions = flattenOptions(allSections);
|
||||
const navigationRows = getNavigationRows([
|
||||
...visibleSections,
|
||||
...(showMoreOptions ? hiddenSections : []),
|
||||
]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const pressedOption = allOptions.find(
|
||||
const pressedOption = options.find(
|
||||
(option) => option.keyBinding === event.key.toLowerCase(),
|
||||
);
|
||||
)!;
|
||||
|
||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||
// Keybinding navigation
|
||||
@@ -99,17 +52,17 @@ function Picker<T>({
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.TAB) {
|
||||
const index = allOptions.findIndex((option) => option.value === value);
|
||||
const index = options.findIndex((option) => option.value === value);
|
||||
const nextIndex = event.shiftKey
|
||||
? (allOptions.length + index - 1) % allOptions.length
|
||||
: (index + 1) % allOptions.length;
|
||||
onChange(allOptions[nextIndex].value);
|
||||
? (options.length + index - 1) % options.length
|
||||
: (index + 1) % options.length;
|
||||
onChange(options[nextIndex].value);
|
||||
} else if (isArrowKey(event.key)) {
|
||||
// Arrow navigation
|
||||
const isRTL = getLanguage().rtl;
|
||||
const index = allOptions.findIndex((option) => option.value === value);
|
||||
const index = options.findIndex((option) => option.value === value);
|
||||
if (index !== -1) {
|
||||
const length = allOptions.length;
|
||||
const length = options.length;
|
||||
let nextIndex = index;
|
||||
|
||||
switch (event.key) {
|
||||
@@ -123,60 +76,18 @@ function Picker<T>({
|
||||
break;
|
||||
// Go the next row
|
||||
case KEYS.ARROW_DOWN: {
|
||||
const currentRowIndex = navigationRows.findIndex((row) =>
|
||||
row.some((option) => option.value === value),
|
||||
);
|
||||
const currentRow = navigationRows[currentRowIndex];
|
||||
|
||||
if (currentRowIndex !== -1 && currentRow) {
|
||||
const column = currentRow.findIndex(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
const nextRow =
|
||||
navigationRows[(currentRowIndex + 1) % navigationRows.length];
|
||||
const nextOption =
|
||||
nextRow[Math.min(column, nextRow.length - 1)] ??
|
||||
allOptions[index];
|
||||
|
||||
onChange(nextOption.value);
|
||||
event.preventDefault();
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||
break;
|
||||
}
|
||||
// Go the previous row
|
||||
case KEYS.ARROW_UP: {
|
||||
const currentRowIndex = navigationRows.findIndex((row) =>
|
||||
row.some((option) => option.value === value),
|
||||
);
|
||||
const currentRow = navigationRows[currentRowIndex];
|
||||
|
||||
if (currentRowIndex !== -1 && currentRow) {
|
||||
const column = currentRow.findIndex(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
const previousRow =
|
||||
navigationRows[
|
||||
(navigationRows.length + currentRowIndex - 1) %
|
||||
navigationRows.length
|
||||
];
|
||||
const previousOption =
|
||||
previousRow[Math.min(column, previousRow.length - 1)] ??
|
||||
allOptions[index];
|
||||
|
||||
onChange(previousOption.value);
|
||||
event.preventDefault();
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
nextIndex =
|
||||
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onChange(allOptions[nextIndex].value);
|
||||
onChange(options[nextIndex].value);
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
@@ -188,29 +99,38 @@ function Picker<T>({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
||||
|
||||
const alwaysVisibleOptions = React.useMemo(
|
||||
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
||||
[options, numberOfOptionsToAlwaysShow],
|
||||
);
|
||||
const moreOptions = React.useMemo(
|
||||
() => options.slice(numberOfOptionsToAlwaysShow),
|
||||
[options, numberOfOptionsToAlwaysShow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOption(hiddenSections, (option) => option.value === value)) {
|
||||
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
|
||||
setShowMoreOptions(true);
|
||||
}
|
||||
}, [value, hiddenSections, setShowMoreOptions]);
|
||||
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
|
||||
|
||||
const renderOptions = (options: readonly Option<T>[]) => {
|
||||
const renderOptions = (options: Option<T>[]) => {
|
||||
return (
|
||||
<div className="picker-content">
|
||||
{options.map((option) => (
|
||||
{options.map((option, i) => (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("picker-option", {
|
||||
active: value === option.value,
|
||||
})}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
onChange(option.value);
|
||||
}}
|
||||
title={
|
||||
option.keyBinding
|
||||
? `${option.text} — ${option.keyBinding.toUpperCase()}`
|
||||
: option.text
|
||||
}
|
||||
title={`${option.text} ${
|
||||
option.keyBinding && `— ${option.keyBinding.toUpperCase()}`
|
||||
}`}
|
||||
aria-label={option.text || "none"}
|
||||
aria-keyshortcuts={option.keyBinding || undefined}
|
||||
key={option.text}
|
||||
@@ -233,38 +153,26 @@ function Picker<T>({
|
||||
);
|
||||
};
|
||||
|
||||
const renderSections = (sections: readonly PickerSection<T>[]) =>
|
||||
sections.map((section, index) =>
|
||||
section.name === DEFAULT_SECTION_NAME ? (
|
||||
<React.Fragment key={`${section.name}-${index}`}>
|
||||
{renderOptions(section.options)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div className="picker-section" key={`${section.name}-${index}`}>
|
||||
<div className="picker-section-label">{section.name}</div>
|
||||
{renderOptions(section.options)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
const isMobile = editorInterface.formFactor === "phone";
|
||||
|
||||
return (
|
||||
<Popover.Content
|
||||
className="picker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
side={"bottom"}
|
||||
side={isMobile ? "right" : "bottom"}
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
alignOffset={12}
|
||||
sideOffset={isMobile ? 8 : 12}
|
||||
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
|
||||
onKeyDown={handleKeyDown}
|
||||
collisionBoundary={container ?? undefined}
|
||||
>
|
||||
<div className="picker-sections">
|
||||
{renderSections(visibleSections)}
|
||||
<div
|
||||
className={`picker`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
>
|
||||
{renderOptions(alwaysVisibleOptions)}
|
||||
|
||||
{hiddenSections.length > 0 && (
|
||||
{moreOptions.length > 0 && (
|
||||
<Collapsible
|
||||
label={t("labels.more_options")}
|
||||
open={showMoreOptions}
|
||||
@@ -273,9 +181,7 @@ function Picker<T>({
|
||||
}}
|
||||
className="picker-collapsible"
|
||||
>
|
||||
<div className="picker-sections">
|
||||
{renderSections(hiddenSections)}
|
||||
</div>
|
||||
{renderOptions(moreOptions)}
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
@@ -286,45 +192,49 @@ function Picker<T>({
|
||||
export function IconPicker<T>({
|
||||
value,
|
||||
label,
|
||||
visibleSections,
|
||||
hiddenSections,
|
||||
options,
|
||||
onChange,
|
||||
group = "",
|
||||
numberOfOptionsToAlwaysShow,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
visibleSections: readonly PickerSection<T>[];
|
||||
hiddenSections?: readonly PickerSection<T>[];
|
||||
options: readonly {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
keyBinding: string | null;
|
||||
}[];
|
||||
onChange: (value: T) => void;
|
||||
numberOfOptionsToAlwaysShow?: number;
|
||||
group?: string;
|
||||
}) {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const selectedOption = useMemo(
|
||||
() =>
|
||||
findOption(visibleSections, (option) => option.value === value) ??
|
||||
findOption(hiddenSections ?? [], (option) => option.value === value),
|
||||
[visibleSections, hiddenSections, value],
|
||||
);
|
||||
const rPickerButton = React.useRef<any>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||
<Popover.Trigger
|
||||
name={group}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => setActive(!isActive)}
|
||||
ref={rPickerButton}
|
||||
className={isActive ? "active" : ""}
|
||||
>
|
||||
{selectedOption?.icon}
|
||||
{options.find((option) => option.value === value)?.icon}
|
||||
</Popover.Trigger>
|
||||
{isActive && (
|
||||
<Picker
|
||||
visibleSections={visibleSections}
|
||||
hiddenSections={hiddenSections}
|
||||
options={options}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
}}
|
||||
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
|
||||
@@ -59,7 +59,6 @@ type ImageExportModalProps = {
|
||||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
name: string;
|
||||
exportWithDarkMode: boolean;
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
@@ -69,7 +68,6 @@ const ImageExportModal = ({
|
||||
actionManager,
|
||||
onExportImage,
|
||||
name,
|
||||
exportWithDarkMode,
|
||||
}: ImageExportModalProps) => {
|
||||
const hasSelection = isSomeElementSelected(
|
||||
elementsSnapshot,
|
||||
@@ -81,13 +79,15 @@ const ImageExportModal = ({
|
||||
const [exportWithBackground, setExportWithBackground] = useState(
|
||||
appStateSnapshot.exportBackground,
|
||||
);
|
||||
const [exportDarkMode, setExportDarkMode] = useState(
|
||||
appStateSnapshot.exportWithDarkMode,
|
||||
);
|
||||
const [embedScene, setEmbedScene] = useState(
|
||||
appStateSnapshot.exportEmbedScene,
|
||||
);
|
||||
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const previewRenderRequestIdRef = useRef(0);
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
|
||||
@@ -99,7 +99,7 @@ const ImageExportModal = ({
|
||||
}, [
|
||||
projectName,
|
||||
exportWithBackground,
|
||||
exportWithDarkMode,
|
||||
exportDarkMode,
|
||||
exportScale,
|
||||
embedScene,
|
||||
resetCopyStatus,
|
||||
@@ -122,18 +122,13 @@ const ImageExportModal = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++previewRenderRequestIdRef.current;
|
||||
const isStaleRequest = () => {
|
||||
return requestId !== previewRenderRequestIdRef.current;
|
||||
};
|
||||
|
||||
exportToCanvas({
|
||||
elements: exportedElements,
|
||||
appState: {
|
||||
...appStateSnapshot,
|
||||
name: projectName,
|
||||
exportBackground: exportWithBackground,
|
||||
exportWithDarkMode,
|
||||
exportWithDarkMode: exportDarkMode,
|
||||
exportScale,
|
||||
exportEmbedScene: embedScene,
|
||||
},
|
||||
@@ -142,41 +137,25 @@ const ImageExportModal = ({
|
||||
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
||||
exportingFrame,
|
||||
})
|
||||
.then(async (canvas) => {
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If converting to blob fails, there's some problem that will likely
|
||||
// prevent preview and export (e.g. canvas too big).
|
||||
try {
|
||||
await canvasToBlob(canvas);
|
||||
} catch (error: any) {
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw new Error(t("canvasError.canvasTooBig"));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
.then((canvas) => {
|
||||
setRenderError(null);
|
||||
previewNode.replaceChildren(canvas);
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
return canvasToBlob(canvas)
|
||||
.then(() => {
|
||||
previewNode.replaceChildren(canvas);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw new Error(t("canvasError.canvasTooBig"));
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
setRenderError(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
previewRenderRequestIdRef.current += 1;
|
||||
};
|
||||
}, [
|
||||
appStateSnapshot,
|
||||
files,
|
||||
@@ -184,7 +163,7 @@ const ImageExportModal = ({
|
||||
exportingFrame,
|
||||
projectName,
|
||||
exportWithBackground,
|
||||
exportWithDarkMode,
|
||||
exportDarkMode,
|
||||
exportScale,
|
||||
embedScene,
|
||||
]);
|
||||
@@ -254,8 +233,9 @@ const ImageExportModal = ({
|
||||
>
|
||||
<Switch
|
||||
name="exportDarkModeSwitch"
|
||||
checked={exportWithDarkMode}
|
||||
checked={exportDarkMode}
|
||||
onChange={(checked) => {
|
||||
setExportDarkMode(checked);
|
||||
actionManager.executeAction(
|
||||
actionExportWithDarkMode,
|
||||
"ui",
|
||||
@@ -419,7 +399,6 @@ export const ImageExportDialog = ({
|
||||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
name={name}
|
||||
exportWithDarkMode={appState.exportWithDarkMode}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,6 @@ import { ImageExportDialog } from "./ImageExportDialog";
|
||||
import { Island } from "./Island";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LaserPointerButton } from "./LaserPointerButton";
|
||||
import { Toast } from "./Toast";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
@@ -606,30 +605,18 @@ const LayerUI = ({
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
{(appState.toast || appState.scrolledOutside) && (
|
||||
<div className="floating-status-stack">
|
||||
{appState.toast && (
|
||||
<Toast
|
||||
message={appState.toast.message}
|
||||
onClose={() => setAppState({ toast: null })}
|
||||
duration={appState.toast.duration}
|
||||
closable={appState.toast.closable}
|
||||
/>
|
||||
)}
|
||||
{!appState.toast && appState.scrolledOutside && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{renderSidebars()}
|
||||
|
||||
@@ -472,9 +472,9 @@ export const MobileToolBar = ({
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
icon={MagicIcon}
|
||||
data-testid="toolbar-magicframe"
|
||||
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext";
|
||||
|
||||
import { ChatHistoryMenu } from "./ChatHistoryMenu";
|
||||
|
||||
import { ChatInterface } from "./ChatInterface";
|
||||
import { ChatInterface } from ".";
|
||||
|
||||
import type { TTDPanelAction } from "../TTDDialogPanel";
|
||||
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
placeholder as cmPlaceholder,
|
||||
drawSelection,
|
||||
} from "@codemirror/view";
|
||||
import { Compartment, EditorState, type Extension } from "@codemirror/state";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
redo,
|
||||
} from "@codemirror/commands";
|
||||
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { mermaidLite } from "./mermaid-lang-lite";
|
||||
|
||||
export interface CodeMirrorEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onKeyboardSubmit?: () => void;
|
||||
placeholder?: string;
|
||||
theme: Theme;
|
||||
errorLine?: number | null;
|
||||
}
|
||||
|
||||
// ---- Dark theme ----
|
||||
|
||||
const darkTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#d4d4d4",
|
||||
},
|
||||
".cm-content": { caretColor: "#fff" },
|
||||
".cm-cursor": { borderLeftColor: "#fff" },
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#858585",
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLineGutter": { backgroundColor: "#2a2a2a" },
|
||||
".cm-activeLine": { backgroundColor: "#2a2a2a" },
|
||||
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.15)" },
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
|
||||
const darkHighlight = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#569cd6" },
|
||||
{ tag: tags.string, color: "#ce9178" },
|
||||
{ tag: tags.comment, color: "#6a9955" },
|
||||
{ tag: tags.number, color: "#b5cea8" },
|
||||
{ tag: tags.operator, color: "#d4d4d4" },
|
||||
{ tag: tags.punctuation, color: "#d4d4d4" },
|
||||
{ tag: tags.variableName, color: "#9cdcfe" },
|
||||
{ tag: tags.bracket, color: "#ffd700" },
|
||||
]);
|
||||
|
||||
// ---- Light theme ----
|
||||
|
||||
const lightTheme = EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#1e1e1e",
|
||||
},
|
||||
".cm-content": { caretColor: "#000" },
|
||||
".cm-cursor": { borderLeftColor: "#000" },
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#fff",
|
||||
color: "#999",
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLineGutter": { backgroundColor: "#e8e8e8" },
|
||||
".cm-activeLine": { backgroundColor: "#e8e8e8" },
|
||||
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.1)" },
|
||||
});
|
||||
|
||||
const lightHighlight = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#0000ff" },
|
||||
{ tag: tags.string, color: "#a31515" },
|
||||
{ tag: tags.comment, color: "#008000" },
|
||||
{ tag: tags.number, color: "#098658" },
|
||||
{ tag: tags.operator, color: "#1e1e1e" },
|
||||
{ tag: tags.punctuation, color: "#1e1e1e" },
|
||||
{ tag: tags.variableName, color: "#001080" },
|
||||
{ tag: tags.bracket, color: "#af00db" },
|
||||
]);
|
||||
|
||||
// ---- Error line decoration ----
|
||||
|
||||
const errorLineDeco = Decoration.line({ class: "cm-errorLine" });
|
||||
|
||||
const getErrorLineExtension = (
|
||||
errorLine: number | null | undefined,
|
||||
doc: { line(n: number): { from: number }; lines: number },
|
||||
): Extension => {
|
||||
if (!errorLine || errorLine < 1 || errorLine > doc.lines) {
|
||||
return EditorView.decorations.of(Decoration.none);
|
||||
}
|
||||
const line = doc.line(errorLine);
|
||||
return EditorView.decorations.of(
|
||||
Decoration.set([errorLineDeco.range(line.from)]),
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
const getThemeExtensions = (theme: Theme) => {
|
||||
if (theme === "dark") {
|
||||
return [darkTheme, syntaxHighlighting(darkHighlight)];
|
||||
}
|
||||
return [lightTheme, syntaxHighlighting(lightHighlight)];
|
||||
};
|
||||
|
||||
const CodeMirrorEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyboardSubmit,
|
||||
placeholder,
|
||||
theme,
|
||||
errorLine,
|
||||
}: CodeMirrorEditorProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onKeyboardSubmitRef = useRef(onKeyboardSubmit);
|
||||
const themeCompartmentRef = useRef(new Compartment());
|
||||
const errorLineCompartmentRef = useRef(new Compartment());
|
||||
|
||||
onChangeRef.current = onChange;
|
||||
onKeyboardSubmitRef.current = onKeyboardSubmit;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const themeCompartment = themeCompartmentRef.current;
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () => {
|
||||
onKeyboardSubmitRef.current?.();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
// historyKeymap binds Mod-Shift-z only on Mac; add it for all platforms
|
||||
{ key: "Mod-Shift-z", run: redo, preventDefault: true },
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
lineNumbers(),
|
||||
EditorView.lineWrapping,
|
||||
themeCompartment.of(getThemeExtensions(theme)),
|
||||
errorLineCompartmentRef.current.of([]),
|
||||
mermaidLite(),
|
||||
drawSelection({ drawRangeCursor: true }),
|
||||
...(placeholder ? [cmPlaceholder(placeholder)] : []),
|
||||
],
|
||||
}),
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
view.focus();
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Swap theme dynamically via compartment
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
view.dispatch({
|
||||
effects: themeCompartmentRef.current.reconfigure(
|
||||
getThemeExtensions(theme),
|
||||
),
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
// Update error line highlight
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
view.dispatch({
|
||||
effects: errorLineCompartmentRef.current.reconfigure(
|
||||
getErrorLineExtension(errorLine, view.state.doc),
|
||||
),
|
||||
});
|
||||
}, [errorLine]);
|
||||
|
||||
// Sync external value changes into EditorView
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
const currentDoc = view.state.doc.toString();
|
||||
if (value !== currentDoc) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: currentDoc.length, insert: value },
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="ttd-dialog-input ttd-dialog-input--codemirror"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeMirrorEditor;
|
||||
@@ -17,11 +17,6 @@ import { TTDDialogOutput } from "./TTDDialogOutput";
|
||||
import { TTDDialogPanel } from "./TTDDialogPanel";
|
||||
import { TTDDialogPanels } from "./TTDDialogPanels";
|
||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||
import {
|
||||
getMermaidErrorLineNumber,
|
||||
isMermaidAutoFixableError,
|
||||
} from "./utils/mermaidError";
|
||||
import { getMermaidAutoFixCandidates } from "./utils/mermaidAutoFix";
|
||||
import {
|
||||
convertMermaidToExcalidraw,
|
||||
insertToEditor,
|
||||
@@ -38,27 +33,6 @@ const MERMAID_EXAMPLE =
|
||||
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
|
||||
|
||||
const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300);
|
||||
const AUTO_FIX_DEBOUNCE_MS = 500;
|
||||
const AUTO_FIX_MAX_DEPTH = 4;
|
||||
const AUTO_FIX_MAX_CANDIDATES = 30;
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"message" in error &&
|
||||
typeof (error as { message?: unknown }).message === "string"
|
||||
) {
|
||||
return (error as { message: string }).message;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const MermaidToExcalidraw = ({
|
||||
mermaidToExcalidrawLib,
|
||||
@@ -72,16 +46,8 @@ const MermaidToExcalidraw = ({
|
||||
EditorLocalStorage.get<string>(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) ||
|
||||
MERMAID_EXAMPLE,
|
||||
);
|
||||
const deferredText = useDeferredValue(text);
|
||||
const deferredText = useDeferredValue(text.trim());
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [autoFixCandidate, setAutoFixCandidate] = useState<string | null>(null);
|
||||
|
||||
const errorLine = (() => {
|
||||
if (!error?.message) {
|
||||
return null;
|
||||
}
|
||||
return getMermaidErrorLineNumber(error.message, deferredText);
|
||||
})();
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const data = useRef<{
|
||||
@@ -95,7 +61,7 @@ const MermaidToExcalidraw = ({
|
||||
useEffect(() => {
|
||||
const doRender = async () => {
|
||||
try {
|
||||
if (!deferredText.trim()) {
|
||||
if (!deferredText) {
|
||||
resetPreview({ canvasRef, setError });
|
||||
return;
|
||||
}
|
||||
@@ -132,88 +98,6 @@ const MermaidToExcalidraw = ({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const errorMessage = error?.message ?? "";
|
||||
const sourceText = deferredText;
|
||||
const shouldTryAutoFix =
|
||||
isActive &&
|
||||
isMermaidAutoFixableError(errorMessage) &&
|
||||
!!sourceText.trim() &&
|
||||
mermaidToExcalidrawLib.loaded;
|
||||
|
||||
if (!shouldTryAutoFix) {
|
||||
setAutoFixCandidate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
if (!candidates.length) {
|
||||
setAutoFixCandidate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const api = await mermaidToExcalidrawLib.api;
|
||||
const seen = new Set<string>([sourceText]);
|
||||
const queue = candidates.map((candidate) => ({
|
||||
text: candidate,
|
||||
depth: 1,
|
||||
}));
|
||||
|
||||
let triedCandidates = 0;
|
||||
|
||||
while (queue.length > 0 && triedCandidates < AUTO_FIX_MAX_CANDIDATES) {
|
||||
const current = queue.shift();
|
||||
if (!current || seen.has(current.text)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current.text);
|
||||
triedCandidates += 1;
|
||||
|
||||
try {
|
||||
await api.parseMermaidToExcalidraw(current.text);
|
||||
if (!cancelled) {
|
||||
setAutoFixCandidate(current.text);
|
||||
}
|
||||
return;
|
||||
} catch (candidateError) {
|
||||
if (current.depth >= AUTO_FIX_MAX_DEPTH) {
|
||||
continue;
|
||||
}
|
||||
const nextErrorMessage = getErrorMessage(candidateError);
|
||||
if (!nextErrorMessage) {
|
||||
continue;
|
||||
}
|
||||
const nextCandidates = getMermaidAutoFixCandidates(
|
||||
current.text,
|
||||
nextErrorMessage,
|
||||
);
|
||||
for (const nextCandidate of nextCandidates) {
|
||||
if (!seen.has(nextCandidate)) {
|
||||
queue.push({
|
||||
text: nextCandidate,
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore auto-fix probe errors
|
||||
}
|
||||
if (!cancelled) {
|
||||
setAutoFixCandidate(null);
|
||||
}
|
||||
}, AUTO_FIX_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [deferredText, error?.message, isActive, mermaidToExcalidrawLib]);
|
||||
|
||||
const onInsertToEditor = () => {
|
||||
insertToEditor({
|
||||
app,
|
||||
@@ -223,53 +107,21 @@ const MermaidToExcalidraw = ({
|
||||
});
|
||||
};
|
||||
|
||||
const onApplyAutoFix = () => {
|
||||
if (!autoFixCandidate) {
|
||||
return;
|
||||
}
|
||||
setText(autoFixCandidate);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ttd-dialog-desc">
|
||||
<Trans
|
||||
i18nKey="mermaid.description"
|
||||
flowchartLink={(el) => (
|
||||
<a
|
||||
href="https://mermaid.js.org/syntax/flowchart.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
|
||||
)}
|
||||
sequenceLink={(el) => (
|
||||
<a
|
||||
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
classLink={(el) => (
|
||||
<a
|
||||
href="https://mermaid.js.org/syntax/classDiagram.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
erdLink={(el) => (
|
||||
<a
|
||||
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -278,8 +130,7 @@ const MermaidToExcalidraw = ({
|
||||
<TTDDialogInput
|
||||
input={text}
|
||||
placeholder={t("mermaid.inputPlaceholder")}
|
||||
onChange={(value) => setText(value)}
|
||||
errorLine={errorLine}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyboardSubmit={() => {
|
||||
onInsertToEditor();
|
||||
}}
|
||||
@@ -302,9 +153,6 @@ const MermaidToExcalidraw = ({
|
||||
canvasRef={canvasRef}
|
||||
loaded={mermaidToExcalidrawLib.loaded}
|
||||
error={error}
|
||||
sourceText={text}
|
||||
autoFixAvailable={!!autoFixCandidate}
|
||||
onApplyAutoFix={onApplyAutoFix}
|
||||
/>
|
||||
</TTDDialogPanel>
|
||||
</TTDDialogPanels>
|
||||
|
||||
@@ -219,49 +219,6 @@ $fullScreenModalBreakpoint: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-input--loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ttd-dialog-input--codemirror {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
// Override height:100% from .ttd-dialog-input — use flex sizing
|
||||
// so the editor fills remaining space without overflowing the panel
|
||||
height: 0;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
font-family: monospace;
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
padding: 0.85rem 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
color: var(--color-gray-40);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-output-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -374,55 +331,14 @@ $fullScreenModalBreakpoint: 600px;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ttd-dialog-output-error-summary {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
color: var(--color-gray-50);
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
|
||||
&__headline {
|
||||
font-weight: 600;
|
||||
color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-top: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__causes {
|
||||
margin: 0.35rem 0 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-output-error-message {
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
color: var(--color-gray-50);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
font-family: monospace;
|
||||
|
||||
&__caret {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-output-error-autofix-slot {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.35rem;
|
||||
min-height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ttd-dialog-output-error-autofix {
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +1,28 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { EVENT, KEYS } from "@excalidraw/common";
|
||||
|
||||
import Spinner from "../Spinner";
|
||||
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import type { CodeMirrorEditorProps } from "./CodeMirrorEditor";
|
||||
import type { ChangeEventHandler } from "react";
|
||||
|
||||
interface TTDDialogInputProps {
|
||||
input: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
onChange: ChangeEventHandler<HTMLTextAreaElement>;
|
||||
onKeyboardSubmit?: () => void;
|
||||
errorLine?: number | null;
|
||||
}
|
||||
|
||||
type EditorState =
|
||||
| { type: "loading" }
|
||||
| { type: "ready"; component: ComponentType<CodeMirrorEditorProps> }
|
||||
| { type: "fallback" };
|
||||
|
||||
const SPINNER_DELAY_MS = 300;
|
||||
|
||||
export const TTDDialogInput = ({
|
||||
input,
|
||||
placeholder,
|
||||
onChange,
|
||||
onKeyboardSubmit,
|
||||
errorLine,
|
||||
}: TTDDialogInputProps) => {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const callbackRef = useRef(onKeyboardSubmit);
|
||||
callbackRef.current = onKeyboardSubmit;
|
||||
|
||||
const [editorState, setEditorState] = useState<EditorState>({
|
||||
type: "loading",
|
||||
});
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
|
||||
const { theme } = useUIAppState();
|
||||
|
||||
// Lazy-load CodeMirror editor
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const spinnerTimer = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
setShowSpinner(true);
|
||||
}
|
||||
}, SPINNER_DELAY_MS);
|
||||
|
||||
import("./CodeMirrorEditor")
|
||||
.then((mod) => {
|
||||
if (!cancelled) {
|
||||
setEditorState({ type: "ready", component: mod.default });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setEditorState({ type: "fallback" });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(spinnerTimer);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(spinnerTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut + focus for textarea fallback
|
||||
useEffect(() => {
|
||||
if (editorState.type !== "fallback") {
|
||||
return;
|
||||
}
|
||||
if (!callbackRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -96,42 +40,15 @@ export const TTDDialogInput = ({
|
||||
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [editorState.type]);
|
||||
}, []);
|
||||
|
||||
if (editorState.type === "ready") {
|
||||
const CodeMirrorEditor = editorState.component;
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onKeyboardSubmit={onKeyboardSubmit}
|
||||
placeholder={placeholder}
|
||||
theme={theme}
|
||||
errorLine={errorLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (editorState.type === "fallback") {
|
||||
return (
|
||||
<textarea
|
||||
className="ttd-dialog-input"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
value={input}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (showSpinner) {
|
||||
return (
|
||||
<div className="ttd-dialog-input ttd-dialog-input--loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<textarea
|
||||
className="ttd-dialog-input"
|
||||
onChange={onChange}
|
||||
value={input}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { Button } from "../Button";
|
||||
import Spinner from "../Spinner";
|
||||
import { t } from "../../i18n";
|
||||
import { alertTriangleIcon } from "../icons";
|
||||
|
||||
import {
|
||||
formatMermaidParseErrorMessage,
|
||||
getMermaidSyntaxErrorGuidance,
|
||||
isMermaidCaretLine,
|
||||
} from "./utils/mermaidError";
|
||||
|
||||
interface TTDDialogOutputProps {
|
||||
error: Error | null;
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
loaded: boolean;
|
||||
hideErrorDetails?: boolean;
|
||||
sourceText?: string;
|
||||
autoFixAvailable?: boolean;
|
||||
onApplyAutoFix?: () => void;
|
||||
}
|
||||
|
||||
export const TTDDialogOutput = ({
|
||||
@@ -26,24 +16,7 @@ export const TTDDialogOutput = ({
|
||||
canvasRef,
|
||||
loaded,
|
||||
hideErrorDetails,
|
||||
sourceText,
|
||||
autoFixAvailable,
|
||||
onApplyAutoFix,
|
||||
}: TTDDialogOutputProps) => {
|
||||
const errorMessage = error
|
||||
? hideErrorDetails
|
||||
? t("chat.errors.mermaidParseError")
|
||||
: formatMermaidParseErrorMessage(error.message)
|
||||
: null;
|
||||
const syntaxGuidance =
|
||||
error && !hideErrorDetails
|
||||
? getMermaidSyntaxErrorGuidance(error.message, sourceText)
|
||||
: null;
|
||||
const showAutoFixButton =
|
||||
!!autoFixAvailable && !!onApplyAutoFix && !hideErrorDetails;
|
||||
|
||||
const errorMessageLines = errorMessage?.split(/\r?\n/) ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ttd-dialog-output-wrapper ${
|
||||
@@ -60,48 +33,14 @@ export const TTDDialogOutput = ({
|
||||
<div className="ttd-dialog-output-error-icon">
|
||||
{alertTriangleIcon}
|
||||
</div>
|
||||
{syntaxGuidance && (
|
||||
<div className="ttd-dialog-output-error-summary">
|
||||
<div className="ttd-dialog-output-error-summary__headline">
|
||||
{syntaxGuidance.summary}
|
||||
</div>
|
||||
<div className="ttd-dialog-output-error-summary__label">
|
||||
Likely causes:
|
||||
</div>
|
||||
<ul className="ttd-dialog-output-error-summary__causes">
|
||||
{syntaxGuidance.likelyCauses.map((cause) => (
|
||||
<li key={cause}>{cause}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="ttd-dialog-output-error-message">
|
||||
{errorMessageLines.map((line, index) => (
|
||||
<span
|
||||
key={`error-line-${index}`}
|
||||
className={
|
||||
isMermaidCaretLine(line)
|
||||
? "ttd-dialog-output-error-message__caret"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{line}
|
||||
{index < errorMessageLines.length - 1 ? "\n" : ""}
|
||||
</span>
|
||||
))}
|
||||
<div className="ttd-dialog-output-error-title">
|
||||
{t("ttd.error")}
|
||||
</div>
|
||||
<div className="ttd-dialog-output-error-message">
|
||||
{hideErrorDetails
|
||||
? t("chat.errors.mermaidParseError")
|
||||
: error.message}
|
||||
</div>
|
||||
{!hideErrorDetails && (
|
||||
<div className="ttd-dialog-output-error-autofix-slot">
|
||||
{showAutoFixButton ? (
|
||||
<Button
|
||||
className="ttd-dialog-panel-button ttd-dialog-output-error-autofix"
|
||||
onSelect={onApplyAutoFix}
|
||||
>
|
||||
{t("mermaid.autoFixAvailable")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getShortcutKey } from "../../shortcut";
|
||||
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
||||
|
||||
export const TTDDialogSubmitShortcut = () => {
|
||||
return (
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { convertMermaidToExcalidraw } from "./common";
|
||||
|
||||
type ConvertMermaidArgs = Parameters<typeof convertMermaidToExcalidraw>[0];
|
||||
type ParseMermaidToExcalidraw = Awaited<
|
||||
ConvertMermaidArgs["mermaidToExcalidrawLib"]["api"]
|
||||
>["parseMermaidToExcalidraw"];
|
||||
|
||||
const createConvertArgs = (
|
||||
mermaidDefinition: string,
|
||||
parseMermaidToExcalidraw: ParseMermaidToExcalidraw,
|
||||
): ConvertMermaidArgs => {
|
||||
const parent = document.createElement("div");
|
||||
const canvas = document.createElement("div");
|
||||
parent.appendChild(canvas);
|
||||
|
||||
return {
|
||||
canvasRef: { current: canvas },
|
||||
mermaidToExcalidrawLib: {
|
||||
loaded: true,
|
||||
api: Promise.resolve({ parseMermaidToExcalidraw }),
|
||||
},
|
||||
mermaidDefinition,
|
||||
setError: vi.fn(),
|
||||
data: {
|
||||
current: {
|
||||
elements: [],
|
||||
files: null,
|
||||
},
|
||||
},
|
||||
theme: "light",
|
||||
};
|
||||
};
|
||||
|
||||
describe("convertMermaidToExcalidraw", () => {
|
||||
it("returns the original parse error when quote-normalized fallback also fails", async () => {
|
||||
const originalError = new Error("Parse error on line 9: ...");
|
||||
const fallbackError = new Error("Parse error on line 6: ...");
|
||||
|
||||
const parseMermaidToExcalidraw = vi
|
||||
.fn<ParseMermaidToExcalidraw>()
|
||||
.mockRejectedValueOnce(originalError)
|
||||
.mockRejectedValueOnce(fallbackError);
|
||||
|
||||
const mermaidDefinition =
|
||||
'graph TD\nA["One"]\nB["Two"]x\nC["Three"]\nD["Four"]';
|
||||
|
||||
const result = await convertMermaidToExcalidraw(
|
||||
createConvertArgs(mermaidDefinition, parseMermaidToExcalidraw),
|
||||
);
|
||||
|
||||
expect(parseMermaidToExcalidraw).toHaveBeenCalledTimes(2);
|
||||
expect(parseMermaidToExcalidraw).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mermaidDefinition,
|
||||
);
|
||||
expect(parseMermaidToExcalidraw).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mermaidDefinition.replace(/"/g, "'"),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toBe(originalError);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry quote normalization when the input has no double quotes", async () => {
|
||||
const originalError = new Error("Parse error on line 9: ...");
|
||||
const parseMermaidToExcalidraw = vi
|
||||
.fn<ParseMermaidToExcalidraw>()
|
||||
.mockRejectedValueOnce(originalError);
|
||||
|
||||
const mermaidDefinition = "graph TD\nA[One]\nB[Two]x";
|
||||
|
||||
const result = await convertMermaidToExcalidraw(
|
||||
createConvertArgs(mermaidDefinition, parseMermaidToExcalidraw),
|
||||
);
|
||||
|
||||
expect(parseMermaidToExcalidraw).toHaveBeenCalledTimes(1);
|
||||
expect(parseMermaidToExcalidraw).toHaveBeenCalledWith(mermaidDefinition);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toBe(originalError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
EDITOR_LS_KEYS,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { convertToExcalidrawElements } from "@excalidraw/element";
|
||||
|
||||
import { exportToCanvas } from "@excalidraw/utils";
|
||||
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
@@ -14,6 +6,11 @@ import type {
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
exportToCanvas,
|
||||
THEME,
|
||||
} from "../../index";
|
||||
|
||||
import type { MermaidToExcalidrawLibProps } from "./types";
|
||||
|
||||
@@ -75,26 +72,18 @@ export const convertMermaidToExcalidraw = async ({
|
||||
const api = await mermaidToExcalidrawLib.api;
|
||||
|
||||
try {
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
|
||||
} catch (err: unknown) {
|
||||
const originalParseError = err as Error;
|
||||
|
||||
if (!mermaidDefinition.includes('"')) {
|
||||
return { success: false, error: originalParseError };
|
||||
}
|
||||
|
||||
try {
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
|
||||
} catch (err: unknown) {
|
||||
ret = await api.parseMermaidToExcalidraw(
|
||||
mermaidDefinition.replace(/"/g, "'"),
|
||||
);
|
||||
} catch {
|
||||
// Keep the original error so line/column references stay aligned with
|
||||
// the user's unmodified input.
|
||||
return { success: false, error: originalParseError };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err as Error };
|
||||
}
|
||||
|
||||
const { elements, files = {} } = ret;
|
||||
const { elements, files } = ret;
|
||||
setError(null);
|
||||
|
||||
data.current = {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
|
||||
const mermaidStreamParser = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Comments: %%...
|
||||
if (stream.match(/^%%.*$/)) {
|
||||
return "comment";
|
||||
}
|
||||
|
||||
// Strings
|
||||
if (stream.match(/^"(?:[^"\\]|\\.)*"/)) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
// Diagram type keywords (at start of line or after whitespace)
|
||||
if (
|
||||
stream.match(
|
||||
/^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap|journey|gitGraph|timeline|quadrantChart|sankey|xychart)\b/i,
|
||||
)
|
||||
) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
// Direction keywords
|
||||
if (stream.match(/^(TB|TD|BT|RL|LR)\b/)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
// Keywords
|
||||
if (
|
||||
stream.match(
|
||||
/^(subgraph|end|participant|actor|loop|alt|else|opt|par|critical|break|rect|note|over|activate|deactivate|title|section|class|style|linkStyle|classDef|click)\b/i,
|
||||
)
|
||||
) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
// Arrows: -->, ---, -.->, ===>, etc.
|
||||
if (stream.match(/^[-.=<>|ox]+>/)) {
|
||||
return "operator";
|
||||
}
|
||||
if (stream.match(/^<[-.=<>|ox]+/)) {
|
||||
return "operator";
|
||||
}
|
||||
if (stream.match(/^--+|\.\.+|==+/)) {
|
||||
return "operator";
|
||||
}
|
||||
|
||||
// Labels in brackets/parens: [text], (text), {text}, ((text)), etc.
|
||||
if (stream.match(/^[[\](){}|<>]/)) {
|
||||
return "bracket";
|
||||
}
|
||||
|
||||
// Node IDs (alphanumeric)
|
||||
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
|
||||
return "variableName";
|
||||
}
|
||||
|
||||
// Numbers
|
||||
if (stream.match(/^\d+(\.\d+)?/)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
// Punctuation
|
||||
if (stream.match(/^[,:;]/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip any other character
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export function mermaidLite() {
|
||||
return mermaidStreamParser;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { RequestError } from "../../../errors";
|
||||
import { RequestError } from "@excalidraw/excalidraw/errors";
|
||||
|
||||
import type { LLMMessage, TTTDDialog } from "../types";
|
||||
import type {
|
||||
LLMMessage,
|
||||
TTTDDialog,
|
||||
} from "@excalidraw/excalidraw/components/TTDDialog/types";
|
||||
|
||||
interface RateLimitInfo {
|
||||
rateLimit?: number;
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getMermaidAutoFixCandidates } from "./mermaidAutoFix";
|
||||
|
||||
describe("getMermaidAutoFixCandidates", () => {
|
||||
it("suggests removing trailing token after a closed label shape", () => {
|
||||
const sourceText = `graph TD
|
||||
L3_TCP["TCP (Transmission Control Protocol)"]x
|
||||
L3_UDP["UDP (User Datagram Protocol)"]`;
|
||||
|
||||
const errorMessage = `Parse error on line 2:
|
||||
...ission Control Protocol)"]x
|
||||
-----------------------------^
|
||||
Expecting 'SEMI', got 'NODE_STRING'`;
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`graph TD
|
||||
L3_TCP["TCP (Transmission Control Protocol)"]
|
||||
L3_UDP["UDP (User Datagram Protocol)"]`);
|
||||
});
|
||||
|
||||
it("suggests appending missing end statements", () => {
|
||||
const sourceText = `graph TD
|
||||
subgraph A
|
||||
A1[Start]`;
|
||||
|
||||
const errorMessage = `Parse error on line 3:
|
||||
... A1[Start]
|
||||
-------------^
|
||||
Expecting 'end'`;
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`graph TD
|
||||
subgraph A
|
||||
A1[Start]
|
||||
end`);
|
||||
});
|
||||
|
||||
it("returns empty list for non-parse errors", () => {
|
||||
expect(
|
||||
getMermaidAutoFixCandidates("graph TD\nA-->B", "Network error"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts line index from lexical error format too", () => {
|
||||
const sourceText = `graph TD
|
||||
subgraph Layers["X"]x
|
||||
direction TB`;
|
||||
|
||||
const errorMessage = `Lexical error on line 2. Unrecognized text.
|
||||
... subgraph Layers["X"]x direction
|
||||
-----------------------^`;
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`graph TD
|
||||
subgraph Layers["X"]
|
||||
direction TB`);
|
||||
});
|
||||
|
||||
it("removes extra > after edge label", () => {
|
||||
const sourceText = `flowchart TD
|
||||
A["User Input"] -->|text|> B["Tokenization"]
|
||||
A["User Input"] -->|text|> B["Tokenization"]`;
|
||||
|
||||
const errorMessage = `Parse error on line 2:
|
||||
...A["User Input"] -->|text|> B["Tokenization"]
|
||||
---------------------------^
|
||||
Expecting 'NODE_STRING', got 'GT'`;
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`flowchart TD
|
||||
A["User Input"] -->|text| B["Tokenization"]
|
||||
A["User Input"] -->|text| B["Tokenization"]`);
|
||||
});
|
||||
|
||||
it("suggests removing the last invalid deactivate for participant errors", () => {
|
||||
const sourceText = `sequenceDiagram
|
||||
participant QAEngineer as QA
|
||||
activate QA
|
||||
QA->>QA: Verifies Fix
|
||||
deactivate QA
|
||||
QA->>QA: Verifies Again
|
||||
deactivate QA`;
|
||||
|
||||
const errorMessage = "Trying to inactivate an inactive participant (QA)";
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`sequenceDiagram
|
||||
participant QAEngineer as QA
|
||||
activate QA
|
||||
QA->>QA: Verifies Fix
|
||||
deactivate QA
|
||||
QA->>QA: Verifies Again`);
|
||||
});
|
||||
|
||||
it("adds a fallback candidate that removes all invalid deactivations", () => {
|
||||
const sourceText = `sequenceDiagram
|
||||
participant QAEngineer as QA
|
||||
deactivate QA
|
||||
QA->>QA: Verifies Fix
|
||||
deactivate QA`;
|
||||
|
||||
const errorMessage = "Trying to inactivate an inactive participant (QA)";
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`sequenceDiagram
|
||||
participant QAEngineer as QA
|
||||
QA->>QA: Verifies Fix`);
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import {
|
||||
getMermaidErrorLineNumber,
|
||||
getMermaidInactiveParticipant,
|
||||
isMermaidAutoFixableError,
|
||||
isMermaidParseSyntaxError,
|
||||
} from "./mermaidError";
|
||||
|
||||
const getErrorLineIndex = (message: string, sourceText: string) => {
|
||||
const lineNumber = getMermaidErrorLineNumber(message, sourceText);
|
||||
if (lineNumber == null) {
|
||||
return null;
|
||||
}
|
||||
return lineNumber - 1;
|
||||
};
|
||||
|
||||
const replaceLineAt = (
|
||||
lines: string[],
|
||||
index: number,
|
||||
transform: (line: string) => string,
|
||||
) => {
|
||||
if (index < 0 || index >= lines.length) {
|
||||
return null;
|
||||
}
|
||||
const nextLine = transform(lines[index]);
|
||||
if (nextLine === lines[index]) {
|
||||
return null;
|
||||
}
|
||||
const nextLines = [...lines];
|
||||
nextLines[index] = nextLine;
|
||||
return nextLines.join("\n");
|
||||
};
|
||||
|
||||
const stripTrailingTokenAfterShape = (line: string) => {
|
||||
const alphaTailMatch = line.match(
|
||||
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([A-Za-z]+)\s*$/,
|
||||
);
|
||||
if (alphaTailMatch) {
|
||||
return alphaTailMatch[1];
|
||||
}
|
||||
|
||||
const punctuationTailMatch = line.match(
|
||||
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([,;:])\s*$/,
|
||||
);
|
||||
if (punctuationTailMatch) {
|
||||
return punctuationTailMatch[1];
|
||||
}
|
||||
|
||||
return line;
|
||||
};
|
||||
|
||||
const removeExtraArrowheadAfterEdgeLabel = (line: string) => {
|
||||
// Common typo in generated Mermaid: `-->|label|> Target` (extra `>`).
|
||||
// Convert it to `-->|label| Target`.
|
||||
return line.replace(/(\|[^|\n]+\|)\s*>\s*(?=[A-Za-z0-9_("[{'`])/g, "$1 ");
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const removeLastDeactivateForParticipant = (
|
||||
sourceText: string,
|
||||
participant: string,
|
||||
) => {
|
||||
const pattern = new RegExp(
|
||||
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
|
||||
);
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
if (pattern.test(lines[index])) {
|
||||
return lines.filter((_, lineIndex) => lineIndex !== index).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeAllDeactivateForParticipant = (
|
||||
sourceText: string,
|
||||
participant: string,
|
||||
) => {
|
||||
const pattern = new RegExp(
|
||||
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
|
||||
);
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
let removedAny = false;
|
||||
const remainingLines = lines.filter((line) => {
|
||||
if (!pattern.test(line)) {
|
||||
return true;
|
||||
}
|
||||
removedAny = true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return removedAny ? remainingLines.join("\n") : null;
|
||||
};
|
||||
|
||||
const appendMissingEnds = (sourceText: string) => {
|
||||
const subgraphCount = (sourceText.match(/^\s*subgraph\b/gm) || []).length;
|
||||
const endCount = (sourceText.match(/^\s*end\s*$/gm) || []).length;
|
||||
const missingCount = subgraphCount - endCount;
|
||||
|
||||
if (missingCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endings = Array.from({ length: missingCount }, () => "end").join("\n");
|
||||
return `${sourceText.trimEnd()}\n${endings}`;
|
||||
};
|
||||
|
||||
const normalizeSmartQuotes = (sourceText: string) =>
|
||||
sourceText.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
|
||||
|
||||
export const getMermaidAutoFixCandidates = (
|
||||
sourceText: string,
|
||||
errorMessage: string,
|
||||
) => {
|
||||
if (!isMermaidAutoFixableError(errorMessage) || !sourceText.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addCandidate = (candidate: string | null) => {
|
||||
if (!candidate || candidate === sourceText || seen.has(candidate)) {
|
||||
return;
|
||||
}
|
||||
seen.add(candidate);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
const inactiveParticipant = getMermaidInactiveParticipant(errorMessage);
|
||||
if (inactiveParticipant) {
|
||||
addCandidate(
|
||||
removeLastDeactivateForParticipant(sourceText, inactiveParticipant),
|
||||
);
|
||||
// Fallback for repeated invalid inactivations in one diagram.
|
||||
addCandidate(
|
||||
removeAllDeactivateForParticipant(sourceText, inactiveParticipant),
|
||||
);
|
||||
}
|
||||
|
||||
if (isMermaidParseSyntaxError(errorMessage)) {
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
const errorLineIndex = getErrorLineIndex(errorMessage, sourceText);
|
||||
const lineIndexesToTry =
|
||||
errorLineIndex == null
|
||||
? []
|
||||
: [errorLineIndex, errorLineIndex - 1, errorLineIndex + 1];
|
||||
|
||||
for (const lineIndex of lineIndexesToTry) {
|
||||
addCandidate(
|
||||
replaceLineAt(lines, lineIndex, (line) =>
|
||||
stripTrailingTokenAfterShape(line),
|
||||
),
|
||||
);
|
||||
addCandidate(
|
||||
replaceLineAt(lines, lineIndex, (line) =>
|
||||
removeExtraArrowheadAfterEdgeLabel(line),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Also try full-text replacement so repeated occurrences on other lines
|
||||
// are fixed together in a single candidate.
|
||||
addCandidate(removeExtraArrowheadAfterEdgeLabel(sourceText));
|
||||
|
||||
addCandidate(appendMissingEnds(sourceText));
|
||||
|
||||
const normalizedQuotes = normalizeSmartQuotes(sourceText);
|
||||
addCandidate(normalizedQuotes === sourceText ? null : normalizedQuotes);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatMermaidParseErrorMessage,
|
||||
getMermaidErrorLineNumber,
|
||||
getMermaidInactiveParticipant,
|
||||
getMermaidSyntaxErrorGuidance,
|
||||
isMermaidAutoFixableError,
|
||||
isMermaidParseSyntaxError,
|
||||
isMermaidCaretLine,
|
||||
} from "./mermaidError";
|
||||
|
||||
describe("formatMermaidParseErrorMessage", () => {
|
||||
it("strips the noisy Expecting clause from Mermaid parse errors", () => {
|
||||
const message = `Parse error on line 6:
|
||||
... Control Protocol)"]x L3_UDP
|
||||
----------------------^
|
||||
Expecting 'SEMI', 'NEWLINE', 'SPACE', got 'NODE_STRING'`;
|
||||
|
||||
expect(formatMermaidParseErrorMessage(message)).toBe(`Parse error on line 6:
|
||||
... Control Protocol)"]x L3_UDP
|
||||
----------------------^`);
|
||||
});
|
||||
|
||||
it("keeps Mermaid parse errors unchanged when no Expecting clause exists", () => {
|
||||
const message = `Parse error on line 3:
|
||||
... some snippet
|
||||
----^`;
|
||||
|
||||
expect(formatMermaidParseErrorMessage(message)).toBe(message);
|
||||
});
|
||||
|
||||
it("does not modify non-Mermaid parse messages", () => {
|
||||
const message =
|
||||
"Unexpected token while parsing JSON. Expecting value at position 10.";
|
||||
|
||||
expect(formatMermaidParseErrorMessage(message)).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMermaidCaretLine", () => {
|
||||
it("returns true for Mermaid caret lines", () => {
|
||||
expect(isMermaidCaretLine("-----------------------^")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular lines", () => {
|
||||
expect(isMermaidCaretLine(`... Control Protocol)"]x`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMermaidParseSyntaxError", () => {
|
||||
it("returns true for Mermaid parser syntax errors", () => {
|
||||
expect(isMermaidParseSyntaxError("Parse error on line 6: ...")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Mermaid lexical syntax errors", () => {
|
||||
expect(
|
||||
isMermaidParseSyntaxError("Lexical error on line 2. Unrecognized text."),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-parse errors", () => {
|
||||
expect(isMermaidParseSyntaxError("Network error")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMermaidAutoFixableError", () => {
|
||||
it("returns true for Mermaid parser syntax errors", () => {
|
||||
expect(isMermaidAutoFixableError("Parse error on line 6: ...")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for inactive participant runtime errors", () => {
|
||||
expect(
|
||||
isMermaidAutoFixableError(
|
||||
"Trying to inactivate an inactive participant (QA)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-fixable errors", () => {
|
||||
expect(isMermaidAutoFixableError("Network error")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMermaidInactiveParticipant", () => {
|
||||
it("extracts the participant id from inactive participant errors", () => {
|
||||
expect(
|
||||
getMermaidInactiveParticipant(
|
||||
"Trying to inactivate an inactive participant (QA)",
|
||||
),
|
||||
).toBe("QA");
|
||||
});
|
||||
|
||||
it("returns null for unrelated errors", () => {
|
||||
expect(
|
||||
getMermaidInactiveParticipant("Parse error on line 3: ..."),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMermaidErrorLineNumber", () => {
|
||||
it("extracts line number from parse error format", () => {
|
||||
expect(getMermaidErrorLineNumber("Parse error on line 6: ...")).toBe(6);
|
||||
});
|
||||
|
||||
it("extracts line number from lexical error format", () => {
|
||||
expect(
|
||||
getMermaidErrorLineNumber("Lexical error on line 2. Unrecognized text."),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null for messages without Mermaid line details", () => {
|
||||
expect(getMermaidErrorLineNumber("Network error")).toBeNull();
|
||||
});
|
||||
|
||||
it("infers line from inactive participant errors when source text is provided", () => {
|
||||
const sourceText = `sequenceDiagram
|
||||
participant QA
|
||||
deactivate QA
|
||||
QA->>QA: Verifies Fix
|
||||
deactivate QA`;
|
||||
|
||||
expect(
|
||||
getMermaidErrorLineNumber(
|
||||
"Trying to inactivate an inactive participant (QA)",
|
||||
sourceText,
|
||||
),
|
||||
).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMermaidSyntaxErrorGuidance", () => {
|
||||
it("returns summary and likely causes for Mermaid parse errors", () => {
|
||||
const message = `Parse error on line 6:
|
||||
... Control Protocol)"]x
|
||||
----------------------^`;
|
||||
|
||||
const source = `graph TD
|
||||
subgraph Layers["X"]
|
||||
L3_TCP["TCP (Transmission Control Protocol)"]x`;
|
||||
|
||||
expect(getMermaidSyntaxErrorGuidance(message, source)).toEqual({
|
||||
summary: "Syntax error near line 6.",
|
||||
likelyCauses: expect.arrayContaining([
|
||||
"A block is missing an `end` statement.",
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-parse errors", () => {
|
||||
expect(
|
||||
getMermaidSyntaxErrorGuidance("Network error", "graph TD"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
const MERMAID_SYNTAX_ERROR_LINE = /(?:Parse|Lexical) error on line (\d+)[.:]/i;
|
||||
const MERMAID_INACTIVE_PARTICIPANT_ERROR =
|
||||
/Trying to inactivate an inactive participant \((.+)\)/i;
|
||||
const MERMAID_CARET_LINE = /^\s*-+\^\s*$/;
|
||||
|
||||
export const isMermaidParseSyntaxError = (message: string) =>
|
||||
MERMAID_SYNTAX_ERROR_LINE.test(message);
|
||||
|
||||
export const isMermaidAutoFixableError = (message: string) =>
|
||||
isMermaidParseSyntaxError(message) ||
|
||||
MERMAID_INACTIVE_PARTICIPANT_ERROR.test(message);
|
||||
|
||||
export const isMermaidCaretLine = (line: string) =>
|
||||
MERMAID_CARET_LINE.test(line);
|
||||
|
||||
export const getMermaidInactiveParticipant = (
|
||||
message: string,
|
||||
): string | null => {
|
||||
const match = message.match(MERMAID_INACTIVE_PARTICIPANT_ERROR);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
return match[1].trim();
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const getInactiveParticipantLineNumber = (
|
||||
message: string,
|
||||
sourceText: string,
|
||||
): number | null => {
|
||||
const participant = getMermaidInactiveParticipant(message);
|
||||
if (!participant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deactivatePattern = new RegExp(
|
||||
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
|
||||
);
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
if (deactivatePattern.test(lines[index])) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getMermaidErrorLineNumber = (
|
||||
message: string,
|
||||
sourceText?: string,
|
||||
): number | null => {
|
||||
const match = message.match(MERMAID_SYNTAX_ERROR_LINE);
|
||||
if (!match) {
|
||||
if (!sourceText) {
|
||||
return null;
|
||||
}
|
||||
return getInactiveParticipantLineNumber(message, sourceText);
|
||||
}
|
||||
return Number.parseInt(match[1], 10);
|
||||
};
|
||||
|
||||
const countMatches = (text: string, re: RegExp) =>
|
||||
(text.match(re) || []).length;
|
||||
|
||||
export const getMermaidSyntaxErrorGuidance = (
|
||||
message: string,
|
||||
sourceText?: string,
|
||||
): { summary: string; likelyCauses: string[] } | null => {
|
||||
if (!isMermaidParseSyntaxError(message)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorLine = getMermaidErrorLineNumber(message, sourceText);
|
||||
const summary = errorLine
|
||||
? `Syntax error near line ${errorLine}.`
|
||||
: "Syntax error in Mermaid diagram.";
|
||||
|
||||
const likelyCauses: string[] = [];
|
||||
|
||||
if (sourceText) {
|
||||
const openBrackets = countMatches(sourceText, /\[/g);
|
||||
const closeBrackets = countMatches(sourceText, /\]/g);
|
||||
if (openBrackets !== closeBrackets) {
|
||||
likelyCauses.push("Unbalanced square brackets in a node label.");
|
||||
}
|
||||
|
||||
const openParens = countMatches(sourceText, /\(/g);
|
||||
const closeParens = countMatches(sourceText, /\)/g);
|
||||
if (openParens !== closeParens) {
|
||||
likelyCauses.push("Unbalanced parentheses in a node shape.");
|
||||
}
|
||||
|
||||
const openBraces = countMatches(sourceText, /\{/g);
|
||||
const closeBraces = countMatches(sourceText, /\}/g);
|
||||
if (openBraces !== closeBraces) {
|
||||
likelyCauses.push("Unbalanced braces in a decision node.");
|
||||
}
|
||||
|
||||
const subgraphCount = countMatches(sourceText, /^\s*subgraph\b/gm);
|
||||
const endCount = countMatches(sourceText, /^\s*end\s*$/gm);
|
||||
if (subgraphCount > endCount) {
|
||||
likelyCauses.push("A block is missing an `end` statement.");
|
||||
}
|
||||
}
|
||||
|
||||
if (/got 'NODE_STRING'/.test(message) || /got 'PS'/.test(message)) {
|
||||
likelyCauses.push(
|
||||
"An extra character/token may appear after a node or label definition.",
|
||||
);
|
||||
}
|
||||
|
||||
if (likelyCauses.length === 0) {
|
||||
likelyCauses.push(
|
||||
"A node or edge line is malformed (missing/extra delimiters).",
|
||||
);
|
||||
likelyCauses.push("A block (`subgraph`, `class`, etc.) may be incomplete.");
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
likelyCauses: [...new Set(likelyCauses)],
|
||||
};
|
||||
};
|
||||
|
||||
export const formatMermaidParseErrorMessage = (message: string) => {
|
||||
if (!isMermaidParseSyntaxError(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return message.replace(/\n\s*Expecting[\s\S]*$/, "").trimEnd();
|
||||
};
|
||||
@@ -1,51 +1,35 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.Toast {
|
||||
$closeButtonSize: 1.2rem;
|
||||
$closeButtonPadding: 0.4rem;
|
||||
|
||||
animation: Toast-fade-in 0.5s;
|
||||
min-width: 220px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--text-primary-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
animation: fade-in 0.5s;
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 4px;
|
||||
bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
left: 50%;
|
||||
margin-left: -150px;
|
||||
padding: 4px 0;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
z-index: 999999;
|
||||
|
||||
.Toast__message {
|
||||
font-family: var(--ui-font);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
padding: 0 $closeButtonSize + ($closeButtonPadding);
|
||||
color: var(--popup-text-color);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.Toast__progress-bar {
|
||||
margin-top: 0.35rem;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--button-gray-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Toast__progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: $closeButtonPadding;
|
||||
pointer-events: auto;
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: $closeButtonSize;
|
||||
@@ -54,7 +38,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes Toast-fade-in {
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -5,22 +5,11 @@ import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./Toast.scss";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
const DEFAULT_TOAST_TIMEOUT = 5000;
|
||||
|
||||
const ProgressBar = ({ progress }: { progress: number }) => (
|
||||
<div className="Toast__progress-bar">
|
||||
<div
|
||||
className="Toast__progress-bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(5, Math.round(progress * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ToastComponent = ({
|
||||
export const Toast = ({
|
||||
message,
|
||||
onClose,
|
||||
closable = false,
|
||||
@@ -28,7 +17,7 @@ const ToastComponent = ({
|
||||
duration = DEFAULT_TOAST_TIMEOUT,
|
||||
style,
|
||||
}: {
|
||||
message: ReactNode;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
closable?: boolean;
|
||||
duration?: number;
|
||||
@@ -58,12 +47,11 @@ const ToastComponent = ({
|
||||
return (
|
||||
<div
|
||||
className="Toast"
|
||||
role="status"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={style}
|
||||
>
|
||||
<div className="Toast__message">{message}</div>
|
||||
<p className="Toast__message">{message}</p>
|
||||
{closable && (
|
||||
<ToolButton
|
||||
icon={CloseIcon}
|
||||
@@ -76,5 +64,3 @@ const ToastComponent = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Toast = Object.assign(ToastComponent, { ProgressBar });
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
sceneCoordsToViewportCoords,
|
||||
type EditorInterface,
|
||||
} from "@excalidraw/common";
|
||||
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
|
||||
|
||||
import type {
|
||||
InteractiveCanvasRenderConfig,
|
||||
@@ -23,8 +24,6 @@ import type {
|
||||
import { t } from "../../i18n";
|
||||
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||
|
||||
import { AnimationController } from "../../renderer/animation";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
|
||||
@@ -69,11 +69,6 @@ const modifiedTablerIconProps: Opts = {
|
||||
strokeLinejoin: "round",
|
||||
} as const;
|
||||
|
||||
const arrowheadPreviewIconProps: Opts = {
|
||||
width: 40,
|
||||
height: 20,
|
||||
} as const;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// tabler-icons: present
|
||||
@@ -1296,17 +1291,16 @@ export const ArrowheadNoneIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
transform={flip ? "translate(24, 0) scale(-1, 1)" : ""}
|
||||
stroke="currentColor"
|
||||
opacity={0.3}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M7,11 H19" />
|
||||
<path d="M25,6 L33,16 M33,6 L25,16" />
|
||||
<path d="M12 12l-9 0" />
|
||||
<path d="M21 9l-6 6" />
|
||||
<path d="M21 15l-6 -6" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
tablerIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1318,46 +1312,11 @@ export const ArrowheadArrowIcon = React.memo(
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M7,11 H33 M23,5 L33,11 L23,17" />
|
||||
<path d="M34 10H6M34 10L27 5M34 10L27 15" />
|
||||
<path d="M27.5 5L34.5 10L27.5 15" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadTriangleIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
|
||||
<path d="M23,5 L35,11 L23,17 Z" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadTriangleOutlineIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M7,11 H23" />
|
||||
<path d="M23,5 L35,11 L23,17 Z" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1369,10 +1328,10 @@ export const ArrowheadCircleIcon = React.memo(
|
||||
fill="currentColor"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
>
|
||||
<path d="M7,11 H25" strokeWidth={2} strokeLinecap="round" />
|
||||
<circle cx="29" cy="11" r="4" />
|
||||
<path d="M32 10L6 10" strokeWidth={2} />
|
||||
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1385,10 +1344,58 @@ export const ArrowheadCircleOutlineIcon = React.memo(
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M7,11 H25" strokeLinecap="round" />
|
||||
<circle cx="29" cy="11" r="4" />
|
||||
<path d="M26 10L6 10" />
|
||||
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadBarIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
|
||||
<path
|
||||
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadTriangleIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
>
|
||||
<path d="M32 10L6 10" strokeWidth={2} />
|
||||
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadTriangleOutlineIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6,9.5H27" />
|
||||
<path d="M27,5L34,10L27,14Z" fill="none" />
|
||||
</g>,
|
||||
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1400,11 +1407,12 @@ export const ArrowheadDiamondIcon = React.memo(
|
||||
fill="currentColor"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" />
|
||||
<path d="M21,11 L28,5 L35,11 L28,17 Z" />
|
||||
<path d="M6,9.5H20" />
|
||||
<path d="M27,5L34,10L27,14L20,9.5Z" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1417,32 +1425,15 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M7,11 H21" />
|
||||
<path d="M21,11 L28,5 L35,11 L28,17 Z" />
|
||||
<path d="M6,9.5H20" />
|
||||
<path d="M27,5L34,10L27,14L20,9.5Z" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadBarIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M11,11 H31 M31,5 V17" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityOneIcon = React.memo(
|
||||
export const ArrowheadCrowfootIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
@@ -1452,13 +1443,13 @@ export const ArrowheadCardinalityOneIcon = React.memo(
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H7 M15,5 V17" />
|
||||
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityManyIcon = React.memo(
|
||||
export const ArrowheadCrowfootOneIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
@@ -1468,13 +1459,13 @@ export const ArrowheadCardinalityManyIcon = React.memo(
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" />
|
||||
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityOneOrManyIcon = React.memo(
|
||||
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
@@ -1484,59 +1475,9 @@ export const ArrowheadCardinalityOneOrManyIcon = React.memo(
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" />
|
||||
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityExactlyOneIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H7 M15,5 V17 M7,5 V17" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityZeroOrOneIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H19 M11,11 H7 M7,5 V17" />
|
||||
<circle cx="15" cy="11" r="4" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityZeroOrManyIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H27 M19,11 H7 M15,11 L7,5 M15,11 L7,17" />
|
||||
<circle cx="23" cy="11" r="4" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -469,6 +469,7 @@ const PreferencesToggleMidpointSnappingItem = () => {
|
||||
return (
|
||||
<DropdownMenuItemCheckbox
|
||||
checked={appState.isMidpointSnappingEnabled}
|
||||
disabled={appState.bindingPreference === "disabled"}
|
||||
onSelect={(event) => {
|
||||
actionManager.executeAction(actionToggleMidpointSnapping);
|
||||
event.preventDefault();
|
||||
|
||||
@@ -500,26 +500,6 @@ body.excalidraw-cursor-resize * {
|
||||
}
|
||||
}
|
||||
|
||||
.floating-status-stack {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 30px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
|
||||
.scroll-back-to-content {
|
||||
position: static;
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
transform: none;
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
@include outlineButtonStyles;
|
||||
@include filledButtonOnCanvas;
|
||||
|
||||
@@ -34,10 +34,9 @@
|
||||
--popup-text-color: #000;
|
||||
--popup-text-inverted-color: #fff;
|
||||
--select-highlight-color: #{$color-blue-5};
|
||||
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgb(0 0 0 / 18%);
|
||||
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--button-hover-bg: var(--color-surface-high);
|
||||
--button-active-bg: var(--color-surface-high);
|
||||
@@ -211,6 +210,9 @@
|
||||
--popup-text-color: #{$color-gray-4};
|
||||
--popup-text-inverted-color: #2c2c2c;
|
||||
--select-highlight-color: #{$color-blue-4};
|
||||
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
|
||||
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
|
||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||
|
||||
import type { FileSystemHandle } from "browser-fs-access";
|
||||
import type { ImportedLibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||
@@ -103,7 +104,7 @@ export const getMimeType = (blob: Blob | string): string => {
|
||||
return "";
|
||||
};
|
||||
|
||||
export const getFileHandleType = (handle: FileSystemFileHandle | null) => {
|
||||
export const getFileHandleType = (handle: FileSystemHandle | null) => {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
@@ -117,9 +118,7 @@ export const isImageFileHandleType = (
|
||||
return type === "png" || type === "svg";
|
||||
};
|
||||
|
||||
export const isImageFileHandle = (
|
||||
handle: FileSystemFileHandle | null,
|
||||
): handle is FileSystemFileHandle => {
|
||||
export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
||||
const type = getFileHandleType(handle);
|
||||
return type === "png" || type === "svg";
|
||||
};
|
||||
@@ -140,8 +139,8 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemFileHandle | null,
|
||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
let data;
|
||||
@@ -199,8 +198,8 @@ export const loadFromBlob = async (
|
||||
/** @see restore.localAppState */
|
||||
localAppState: AppState | null,
|
||||
localElements: readonly ExcalidrawElement[] | null,
|
||||
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemFileHandle | null,
|
||||
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
) => {
|
||||
const ret = await loadSceneOrLibraryFromBlob(
|
||||
blob,
|
||||
@@ -393,7 +392,7 @@ export const ImageURLToFile = async (
|
||||
|
||||
export const getFileHandle = async (
|
||||
event: DragEvent | React.DragEvent | DataTransferItem,
|
||||
): Promise<FileSystemFileHandle | null> => {
|
||||
): Promise<FileSystemHandle | null> => {
|
||||
if (nativeFileSystemSupported) {
|
||||
try {
|
||||
const dataTransferItem =
|
||||
@@ -401,7 +400,7 @@ export const getFileHandle = async (
|
||||
? event
|
||||
: (event as DragEvent).dataTransfer?.items?.[0];
|
||||
|
||||
const handle: FileSystemFileHandle | null =
|
||||
const handle: FileSystemHandle | null =
|
||||
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
|
||||
|
||||
return handle;
|
||||
|
||||
@@ -4,12 +4,18 @@ import {
|
||||
supported as nativeFileSystemSupported,
|
||||
} from "browser-fs-access";
|
||||
|
||||
import { MIME_TYPES } from "@excalidraw/common";
|
||||
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
||||
|
||||
import { AbortError } from "../errors";
|
||||
|
||||
import { normalizeFile } from "./blob";
|
||||
|
||||
import type { FileSystemHandle } from "browser-fs-access";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 5000;
|
||||
|
||||
export const fileOpen = async <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
description: string;
|
||||
@@ -36,6 +42,40 @@ export const fileOpen = async <M extends boolean | undefined = false>(opts: {
|
||||
extensions,
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
window.removeEventListener(EVENT.FOCUS, focusHandler);
|
||||
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
|
||||
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new AbortError());
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(files)) {
|
||||
@@ -55,8 +95,8 @@ export const fileSave = (
|
||||
extension: FILE_EXTENSION;
|
||||
mimeTypes?: string[];
|
||||
description: string;
|
||||
/** existing FileSystemFileHandle */
|
||||
fileHandle?: FileSystemFileHandle | null;
|
||||
/** existing FileSystemHandle */
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
},
|
||||
) => {
|
||||
return _fileSave(
|
||||
@@ -68,8 +108,8 @@ export const fileSave = (
|
||||
mimeTypes: opts.mimeTypes,
|
||||
},
|
||||
opts.fileHandle,
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
export { nativeFileSystemSupported };
|
||||
export type { FileSystemHandle };
|
||||
|
||||
@@ -33,6 +33,8 @@ import { canvasToBlob } from "./blob";
|
||||
import { fileSave } from "./filesystem";
|
||||
import { serializeAsJSON } from "./json";
|
||||
|
||||
import type { FileSystemHandle } from "./filesystem";
|
||||
|
||||
import type { ExportType } from "../scene/types";
|
||||
import type { AppState, BinaryFiles } from "../types";
|
||||
|
||||
@@ -108,7 +110,7 @@ export const exportCanvas = async (
|
||||
viewBackgroundColor: string;
|
||||
/** filename, if applicable */
|
||||
name?: string;
|
||||
fileHandle?: FileSystemFileHandle | null;
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
exportingFrame: ExcalidrawFrameLikeElement | null;
|
||||
},
|
||||
) => {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {
|
||||
DEFAULT_FILENAME,
|
||||
EXPORT_DATA_TYPES,
|
||||
getExportSource,
|
||||
MIME_TYPES,
|
||||
VERSIONS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement, NonDeleted } from "@excalidraw/element/types";
|
||||
|
||||
import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||
|
||||
@@ -22,12 +21,6 @@ import type {
|
||||
ImportedLibraryData,
|
||||
} from "./types";
|
||||
|
||||
export type JSONExportData = {
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips out files which are only referenced by deleted elements
|
||||
*/
|
||||
@@ -74,29 +67,27 @@ export const serializeAsJSON = (
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
export const saveAsJSON = async ({
|
||||
data,
|
||||
filename,
|
||||
fileHandle,
|
||||
}: {
|
||||
data: MaybePromise<JSONExportData>;
|
||||
filename: string;
|
||||
fileHandle: AppState["fileHandle"];
|
||||
}) => {
|
||||
const blob = Promise.resolve(data).then(({ elements, appState, files }) => {
|
||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||
return new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidraw,
|
||||
});
|
||||
export const saveAsJSON = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
/** filename */
|
||||
name: string = appState.name || DEFAULT_FILENAME,
|
||||
) => {
|
||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||
const blob = new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidraw,
|
||||
});
|
||||
|
||||
const savedFileHandle = await fileSave(blob, {
|
||||
name: filename,
|
||||
const fileHandle = await fileSave(blob, {
|
||||
name,
|
||||
extension: "excalidraw",
|
||||
description: "Excalidraw file",
|
||||
fileHandle: isImageFileHandle(fileHandle) ? null : fileHandle,
|
||||
fileHandle: isImageFileHandle(appState.fileHandle)
|
||||
? null
|
||||
: appState.fileHandle,
|
||||
});
|
||||
return { fileHandle: savedFileHandle };
|
||||
return { fileHandle };
|
||||
};
|
||||
|
||||
export const loadFromJSON = async (
|
||||
|
||||
@@ -1,35 +1,26 @@
|
||||
import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { exportCanvas, prepareElementsForExport } from ".";
|
||||
|
||||
import type { AppState, BinaryFiles } from "../types";
|
||||
|
||||
export const resaveAsImageWithScene = async (
|
||||
data: MaybePromise<{
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
}>,
|
||||
fileHandle: FileSystemFileHandle,
|
||||
filename: string,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
name: string,
|
||||
) => {
|
||||
const { exportBackground, viewBackgroundColor, fileHandle } = appState;
|
||||
|
||||
const fileHandleType = getFileHandleType(fileHandle);
|
||||
|
||||
if (!isImageFileHandleType(fileHandleType)) {
|
||||
if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
|
||||
throw new Error(
|
||||
"fileHandle should exist and should be of type svg or png when resaving",
|
||||
);
|
||||
}
|
||||
|
||||
let { elements, appState, files } = await data;
|
||||
|
||||
const { exportBackground, viewBackgroundColor } = appState;
|
||||
|
||||
appState = {
|
||||
...appState,
|
||||
exportEmbedScene: true,
|
||||
@@ -44,7 +35,7 @@ export const resaveAsImageWithScene = async (
|
||||
await exportCanvas(fileHandleType, exportedElements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name: filename,
|
||||
name,
|
||||
fileHandle,
|
||||
exportingFrame,
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import {
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
getNonDeletedElements,
|
||||
normalizeArrowhead,
|
||||
isPointInElement,
|
||||
isValidPolygon,
|
||||
projectFixedPointOntoDiagonal,
|
||||
@@ -427,8 +426,7 @@ export const restoreElement = (
|
||||
// @ts-ignore LEGACY type
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case "draw":
|
||||
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
||||
const endArrowhead = normalizeArrowhead(element.endArrowhead);
|
||||
const { startArrowhead = null, endArrowhead = null } = element;
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
@@ -460,11 +458,7 @@ export const restoreElement = (
|
||||
...getSizeFromPoints(points),
|
||||
});
|
||||
case "arrow": {
|
||||
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
||||
const endArrowhead =
|
||||
element.endArrowhead === undefined
|
||||
? "arrow"
|
||||
: normalizeArrowhead(element.endArrowhead);
|
||||
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
||||
const x: number | undefined = element.x;
|
||||
const y: number | undefined = element.y;
|
||||
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
|
||||
|
||||
Vendored
+1
-1
@@ -52,7 +52,7 @@ declare module "png-chunks-extract" {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface Blob {
|
||||
handle?: FileSystemFileHandle;
|
||||
handle?: import("browser-fs-acces").FileSystemHandle;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useExcalidrawAPI } from "../components/App";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
type AppStateSelector =
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown);
|
||||
|
||||
const getSelectedValue = (appState: AppState, selector: AppStateSelector) => {
|
||||
if (typeof selector === "function") {
|
||||
return selector(appState);
|
||||
}
|
||||
if (Array.isArray(selector)) {
|
||||
return appState;
|
||||
}
|
||||
return appState[selector];
|
||||
};
|
||||
|
||||
const getLatestValue = (
|
||||
api: ReturnType<typeof useExcalidrawAPI>,
|
||||
selector: AppStateSelector,
|
||||
_internal: boolean,
|
||||
) => {
|
||||
if (api?.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let appState = api?.getAppState();
|
||||
if (!appState) {
|
||||
if (!_internal) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"useAppStateValue: excalidrawAPI not defined yet for internal component while it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
|
||||
);
|
||||
// fall back in case there's a bug so we don't break the app
|
||||
// (internal components using this internal useAppStateValue expect
|
||||
// non-undefined values on init)
|
||||
appState = Object.assign(
|
||||
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
|
||||
getDefaultAppState(),
|
||||
);
|
||||
}
|
||||
|
||||
return getSelectedValue(appState, selector);
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes to specific appState changes. The component re-renders
|
||||
* only when the specified prop(s) change — not on every appState update.
|
||||
*
|
||||
* Works both inside and outside the <Excalidraw> tree, as long as
|
||||
* ExcalidrawAPIContext.Provider is an ancestor (automatically provided
|
||||
* inside <Excalidraw>, or manually by the host app).
|
||||
*
|
||||
* Returns the narrowed value depending on prop form:
|
||||
* - `keyof AppState` → `AppState[K]`
|
||||
* - `(keyof AppState)[]` → whole `AppState`
|
||||
* - selector function → selector's return type `T`
|
||||
*
|
||||
* If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest
|
||||
* value once available.
|
||||
*/
|
||||
export function useAppStateValue<K extends keyof AppState>(
|
||||
prop: K,
|
||||
_internal?: boolean,
|
||||
): AppState[K];
|
||||
export function useAppStateValue(
|
||||
props: (keyof AppState)[],
|
||||
_internal?: boolean,
|
||||
): AppState;
|
||||
export function useAppStateValue<T>(
|
||||
selector: (appState: AppState) => T,
|
||||
_internal?: boolean,
|
||||
): T;
|
||||
export function useAppStateValue(
|
||||
selector: AppStateSelector,
|
||||
_internal: boolean = true,
|
||||
): unknown {
|
||||
const api = useExcalidrawAPI();
|
||||
const [, rerender] = useState(0);
|
||||
|
||||
const stateRef = useRef<{
|
||||
selector: AppStateSelector;
|
||||
isInitialized: boolean;
|
||||
latestValue: unknown;
|
||||
} | null>(null);
|
||||
if (!stateRef.current) {
|
||||
stateRef.current = {
|
||||
selector,
|
||||
isInitialized: !!api,
|
||||
latestValue: getLatestValue(api, selector, _internal),
|
||||
};
|
||||
}
|
||||
stateRef.current.selector = selector;
|
||||
if (!stateRef.current.isInitialized && api && !api.isDestroyed) {
|
||||
stateRef.current.isInitialized = true;
|
||||
stateRef.current.latestValue = getLatestValue(api, selector, _internal);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentStateRef = stateRef.current;
|
||||
if (!api || api.isDestroyed || !currentStateRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
return api.onStateChange(currentStateRef.selector, (newValue: any) => {
|
||||
currentStateRef.latestValue = newValue;
|
||||
rerender((value) => value + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return stateRef.current.latestValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to specific appState changes without causing component rerenders.
|
||||
*
|
||||
* The callback is called on every matching change, but also on initial render
|
||||
* so you can initialize your state.
|
||||
*/
|
||||
export function useOnAppStateChange<K extends keyof AppState>(
|
||||
prop: K,
|
||||
callback: (value: AppState[K], appState: AppState) => void,
|
||||
): undefined;
|
||||
export function useOnAppStateChange(
|
||||
props: (keyof AppState)[],
|
||||
callback: (props: AppState, appState: AppState) => void,
|
||||
): undefined;
|
||||
export function useOnAppStateChange<T>(
|
||||
selector: (appState: AppState) => T,
|
||||
callback: (value: T, appState: AppState) => void,
|
||||
): undefined;
|
||||
export function useOnAppStateChange(
|
||||
selector: AppStateSelector,
|
||||
callback: (value: any, appState: AppState) => void,
|
||||
): undefined {
|
||||
const api = useExcalidrawAPI();
|
||||
|
||||
const stateRef = useRef({
|
||||
selector,
|
||||
callback,
|
||||
});
|
||||
stateRef.current.selector = selector;
|
||||
stateRef.current.callback = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || api.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
stateRef.current.callback(
|
||||
getLatestValue(api, stateRef.current.selector, true),
|
||||
api.getAppState(),
|
||||
);
|
||||
|
||||
return api.onStateChange(
|
||||
stateRef.current.selector,
|
||||
(newValue: any, state: AppState) => {
|
||||
stateRef.current.callback(newValue, state);
|
||||
},
|
||||
);
|
||||
}, [api]);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,27 +1,14 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
|
||||
|
||||
import App, {
|
||||
ExcalidrawAPIContext,
|
||||
ExcalidrawAPISetContext,
|
||||
} from "./components/App";
|
||||
import App from "./components/App";
|
||||
import { InitializeApp } from "./components/InitializeApp";
|
||||
import Footer from "./components/footer/FooterCenter";
|
||||
import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger";
|
||||
import MainMenu from "./components/main-menu/MainMenu";
|
||||
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
|
||||
import { defaultLang } from "./i18n";
|
||||
import {
|
||||
useAppStateValue as _useAppStateValue,
|
||||
useOnAppStateChange as _useOnAppStateChange,
|
||||
} from "./hooks/useAppStateValue";
|
||||
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
|
||||
import polyfill from "./polyfill";
|
||||
|
||||
@@ -29,45 +16,16 @@ import "./css/app.scss";
|
||||
import "./css/styles.scss";
|
||||
import "./fonts/fonts.css";
|
||||
|
||||
import type {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawImperativeAPI,
|
||||
ExcalidrawProps,
|
||||
} from "./types";
|
||||
import type { AppProps, ExcalidrawProps } from "./types";
|
||||
|
||||
polyfill();
|
||||
|
||||
/**
|
||||
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built
|
||||
* on it, such as `useAppStateValue()` and `useOnAppStateChange()`) to work
|
||||
* outside the <Excalidraw> component tree.
|
||||
*/
|
||||
export const ExcalidrawAPIProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||
return (
|
||||
<ExcalidrawAPIContext.Provider value={api}>
|
||||
<ExcalidrawAPISetContext.Provider value={setApi}>
|
||||
{children}
|
||||
</ExcalidrawAPISetContext.Provider>
|
||||
</ExcalidrawAPIContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
const {
|
||||
onExport,
|
||||
onChange,
|
||||
onIncrement,
|
||||
initialData,
|
||||
onExcalidrawAPI,
|
||||
onMount,
|
||||
onUnmount,
|
||||
onInitialize,
|
||||
excalidrawAPI,
|
||||
isCollaborating = false,
|
||||
onPointerUpdate,
|
||||
renderTopLeftUI,
|
||||
@@ -128,19 +86,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
UIOptions.canvasActions.toggleTheme = true;
|
||||
}
|
||||
|
||||
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
|
||||
|
||||
const onExcalidrawAPIRef = useRef(onExcalidrawAPI);
|
||||
onExcalidrawAPIRef.current = onExcalidrawAPI;
|
||||
|
||||
const handleExcalidrawAPI = useCallback(
|
||||
(api: ExcalidrawImperativeAPI | null) => {
|
||||
setExcalidrawAPI?.(api);
|
||||
onExcalidrawAPIRef.current?.(api);
|
||||
},
|
||||
[setExcalidrawAPI],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const importPolyfill = async () => {
|
||||
//@ts-ignore
|
||||
@@ -170,14 +115,10 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
<EditorJotaiProvider store={editorJotaiStore}>
|
||||
<InitializeApp langCode={langCode} theme={theme}>
|
||||
<App
|
||||
onExport={onExport}
|
||||
onChange={onChange}
|
||||
onIncrement={onIncrement}
|
||||
initialData={initialData}
|
||||
onExcalidrawAPI={handleExcalidrawAPI}
|
||||
onMount={onMount}
|
||||
onUnmount={onUnmount}
|
||||
onInitialize={onInitialize}
|
||||
excalidrawAPI={excalidrawAPI}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={onPointerUpdate}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
@@ -326,7 +267,6 @@ export {
|
||||
sceneCoordsToViewportCoords,
|
||||
viewportCoordsToSceneCoords,
|
||||
getFormFactor,
|
||||
throttleRAF,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
export {
|
||||
@@ -344,13 +284,7 @@ export { Button } from "./components/Button";
|
||||
export { Footer };
|
||||
export { MainMenu };
|
||||
export { Ellipsify } from "./components/Ellipsify";
|
||||
export {
|
||||
useEditorInterface,
|
||||
useStylesPanelMode,
|
||||
useExcalidrawAPI,
|
||||
ExcalidrawAPIContext,
|
||||
} from "./components/App";
|
||||
|
||||
export { useEditorInterface, useStylesPanelMode } from "./components/App";
|
||||
export { WelcomeScreen };
|
||||
export { LiveCollaborationTrigger };
|
||||
export { Stats } from "./components/Stats";
|
||||
@@ -391,35 +325,3 @@ export {
|
||||
tryParseSpreadsheet,
|
||||
isSpreadsheetValidForChartType,
|
||||
} from "./charts";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// useExcalidrawStateValue() wrapper for host apps for the return type to reflect the
|
||||
// the potentially `undefined` value for initial render before the excalidrawAPI
|
||||
// is ready.
|
||||
//
|
||||
/**
|
||||
* hook that subscribes to specific appState prop(s)
|
||||
*
|
||||
* @param prop - appState prop(s) to subscribe to, or a selector function.
|
||||
* NOTE `prop/selector` is memoized and will not change after initial render
|
||||
*/
|
||||
export function useExcalidrawStateValue<K extends keyof AppState>(
|
||||
prop: K,
|
||||
): AppState[K] | undefined;
|
||||
export function useExcalidrawStateValue<T extends keyof AppState>(
|
||||
props: T[],
|
||||
): AppState | undefined;
|
||||
export function useExcalidrawStateValue<T>(
|
||||
selector: (appState: AppState) => T,
|
||||
): T | undefined;
|
||||
export function useExcalidrawStateValue(
|
||||
selector:
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown),
|
||||
) {
|
||||
return _useAppStateValue(selector as any, false);
|
||||
}
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export { _useOnAppStateChange as useOnExcalidrawStateChange };
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "لصق",
|
||||
"pasteAsPlaintext": "اللصق كنص عادي",
|
||||
"pasteCharts": "لصق الرسوم البيانية",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "تحديد الكل",
|
||||
"multiSelect": "إضافة عنصر للتحديد",
|
||||
"moveCanvas": "نقل لوح الرسم",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "Crow's foot (many)",
|
||||
"arrowhead_crowfoot_one": "Crow's foot (one)",
|
||||
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "خيارات إضافية",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "نوع السهم",
|
||||
"arrowtype_sharp": "سهم حاد",
|
||||
"arrowtype_round": "سهم منحني",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "رابط إلى الكائن",
|
||||
"wrapSelectionInFrame": "تغليف التحديد في إطار",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "رابط إلى الكائن",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "خطأ"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "حفظ الملف على الجهاز",
|
||||
"disk_details": "تصدير بيانات المشهد إلى ملف يمكنك الاستيراد منه لاحقًا.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid إلى Excalidraw",
|
||||
"button": "إدراج",
|
||||
"description": "حاليًا، يتم دعم <flowchartLink>مخططات التدفق</flowchartLink>، <sequenceLink>التسلسلات</sequenceLink>، و<classLink>الفئات</classLink> فقط. سيتم عرض الأنواع الأخرى كصورة في Excalidraw.",
|
||||
"description": "",
|
||||
"syntax": "صيغة Mermaid",
|
||||
"preview": "معاينة",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Yapışdır",
|
||||
"pasteAsPlaintext": "Düz mətn kimi yapışdırın",
|
||||
"pasteCharts": "Diaqramları yapışdırın",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Hamısını seç",
|
||||
"multiSelect": "Seçimə element əlavə edin",
|
||||
"moveCanvas": "Kanvası köçürün",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": ""
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Постави",
|
||||
"pasteAsPlaintext": "Постави като обикновен текст",
|
||||
"pasteCharts": "Постави графики",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Маркирай всичко",
|
||||
"multiSelect": "Добави елемент към селекция",
|
||||
"moveCanvas": "Премести платно",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Вид стрелка",
|
||||
"arrowtype_sharp": "Остра стрелка",
|
||||
"arrowtype_round": "Извита стрелка",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Грешка"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Запази към диск",
|
||||
"disk_details": "",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "Mermaid Синтаксис",
|
||||
"preview": "Преглед",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "পেস্ট করুন",
|
||||
"pasteAsPlaintext": "প্লেইনটেক্সট হিসাবে পেস্ট করুন",
|
||||
"pasteCharts": "চার্ট পেস্ট করুন",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "সবটা সিলেক্ট করুন",
|
||||
"multiSelect": "একাধিক সিলেক্ট করুন",
|
||||
"moveCanvas": "ক্যানভাস সরান",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "ত্রুটি"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "পেস্ট করুন",
|
||||
"pasteAsPlaintext": "প্লেইনটেক্সট হিসাবে পেস্ট করুন",
|
||||
"pasteCharts": "চার্ট পেস্ট করুন",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "সবটা সিলেক্ট করুন",
|
||||
"multiSelect": "একাধিক সিলেক্ট করুন",
|
||||
"moveCanvas": "ক্যানভাস সরান",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "ত্রুটি"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Enganxa",
|
||||
"pasteAsPlaintext": "Enganxar com a text pla",
|
||||
"pasteCharts": "Enganxa els diagrames",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Selecciona-ho tot",
|
||||
"multiSelect": "Afegeix un element a la selecció",
|
||||
"moveCanvas": "Mou el llenç",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "Potes de gall (moltes)",
|
||||
"arrowhead_crowfoot_one": "Potes de gall (una)",
|
||||
"arrowhead_crowfoot_one_or_many": "Potes de gall (una o moltes)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "Més opcions",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Tipus de fletxa",
|
||||
"arrowtype_sharp": "Fletxa Esmolada",
|
||||
"arrowtype_round": "Fletxa corba",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Enllaç a l'objecte",
|
||||
"wrapSelectionInFrame": "Embolica la selecció en un marc",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Enllaç a l'objecte",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Error"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Desa al disc",
|
||||
"disk_details": "Exporta les dades de l'escena a un fitxer que després podreu importar.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid a Excalidraw",
|
||||
"button": "Inseriu",
|
||||
"description": "Actualment només s'admeten els diagrames <flowchartLink>Flowchart</flowchartLink>, <sequenceLink> Sequence, </sequenceLink> i <classLink> Class </classLink>. Els altres tipus es representaran com a imatge a Excalidraw.",
|
||||
"description": "",
|
||||
"syntax": "Sintaxi de Mermaid",
|
||||
"preview": "Previsualització",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Vložit",
|
||||
"pasteAsPlaintext": "Vložit jako prostý text",
|
||||
"pasteCharts": "Vložit grafy",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Vybrat vše",
|
||||
"multiSelect": "Přidat prvek do výběru",
|
||||
"moveCanvas": "Posunout plátno",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Typ šipky",
|
||||
"arrowtype_sharp": "Ostrá šipka",
|
||||
"arrowtype_round": "Zakřivená šipka",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Odkaz na objekt",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Odkaz na objekt",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Chyba"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Uložit na disk",
|
||||
"disk_details": "Exportovat data scény do souboru, ze kterého můžete importovat později.",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "Mermaid syntaxe",
|
||||
"preview": "Náhled",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Indsæt",
|
||||
"pasteAsPlaintext": "Indsæt som klartekst",
|
||||
"pasteCharts": "Indsæt diagrammer",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Marker alle",
|
||||
"multiSelect": "Tilføj element til markering",
|
||||
"moveCanvas": "Flyt lærred",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "Kragefod (mange)",
|
||||
"arrowhead_crowfoot_one": "Kragefod (én)",
|
||||
"arrowhead_crowfoot_one_or_many": "Kragefod (én eller mange)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "Flere muligheder",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Pile type",
|
||||
"arrowtype_sharp": "Skarp pil",
|
||||
"arrowtype_round": "Buet pil",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Fejl"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Gem til disk",
|
||||
"disk_details": "",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Einfügen",
|
||||
"pasteAsPlaintext": "Als unformatierten Text einfügen",
|
||||
"pasteCharts": "Diagramme einfügen",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Alle auswählen",
|
||||
"multiSelect": "Element zur Auswahl hinzufügen",
|
||||
"moveCanvas": "Leinwand verschieben",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "Krähenfuß (viele)",
|
||||
"arrowhead_crowfoot_one": "Krähenfuß (einer)",
|
||||
"arrowhead_crowfoot_one_or_many": "Krähenfuß (einer oder viele)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "Weitere Optionen",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Pfeiltyp",
|
||||
"arrowtype_sharp": "Scharfer Pfeil",
|
||||
"arrowtype_round": "Gebogener Pfeil",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Link zum Objekt",
|
||||
"wrapSelectionInFrame": "Auswahl in Rahmen einbetten",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Link zum Objekt",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Fehler"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Auf Festplatte speichern",
|
||||
"disk_details": "Exportiere die Zeichnungsdaten in eine Datei, die Du später importieren kannst.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid zu Excalidraw",
|
||||
"button": "Einfügen",
|
||||
"description": "Derzeit werden nur <flowchartLink>Flussdiagramme</flowchartLink>, <sequenceLink>Sequenzdiagramme</sequenceLink> und <classLink>Klassendiagramme</classLink> unterstützt. Die anderen Typen werden als Bild in Excalidraw dargestellt.",
|
||||
"description": "",
|
||||
"syntax": "Mermaid-Syntax",
|
||||
"preview": "Vorschau",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Einfügen",
|
||||
"pasteAsPlaintext": "Als unformatierten Text einfügen",
|
||||
"pasteCharts": "Diagramme einfügen",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Alle auswählen",
|
||||
"multiSelect": "Element zur Auswahl hinzufügen",
|
||||
"moveCanvas": "Leinwand verschieben",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "Krähenfuß (viele)",
|
||||
"arrowhead_crowfoot_one": "Krähenfuß (einer)",
|
||||
"arrowhead_crowfoot_one_or_many": "Krähenfuß (einer oder viele)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "Weitere Optionen",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Pfeiltyp",
|
||||
"arrowtype_sharp": "Scharfer Pfeil",
|
||||
"arrowtype_round": "Gebogener Pfeil",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Link zum Objekt",
|
||||
"wrapSelectionInFrame": "Auswahl in Rahmen einbetten",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Link zum Objekt",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Fehler"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Auf Festplatte speichern",
|
||||
"disk_details": "Exportiere die Zeichnungsdaten in eine Datei, die Du später importieren kannst.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid zu Excalidraw",
|
||||
"button": "Einfügen",
|
||||
"description": "Derzeit werden nur <flowchartLink>Flussdiagramme</flowchartLink>, <sequenceLink>Sequenzdiagramme</sequenceLink> und <classLink>Klassendiagramme</classLink> unterstützt. Die anderen Typen werden als Bild in Excalidraw dargestellt.",
|
||||
"description": "",
|
||||
"syntax": "Mermaid-Syntax",
|
||||
"preview": "Vorschau",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Επικόλληση",
|
||||
"pasteAsPlaintext": "Επικόλληση ως απλό κείμενο",
|
||||
"pasteCharts": "Επικόλληση γραφημάτων",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Επιλογή όλων",
|
||||
"multiSelect": "Προσθέστε το στοιχείο στην επιλογή",
|
||||
"moveCanvas": "Μετακίνηση καμβά",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Τύπος βέλους",
|
||||
"arrowtype_sharp": "Αιχμηρό βέλος",
|
||||
"arrowtype_round": "Κυρτό βέλος",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Σφάλμα"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Αποθήκευση στο δίσκο",
|
||||
"disk_details": "Εξαγωγή δεδομένων σκηνής σε ένα αρχείο από το οποίο μπορείτε να εισάγετε αργότερα.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid σε Excalidraw",
|
||||
"button": "Εισαγωγή",
|
||||
"description": "Επί του παρόντος υποστηρίζονται μόνο Διαγράμματα <flowchartLink>Ροής</flowchartLink>,<sequenceLink> Ακολουθίας, </sequenceLink> και <classLink>Κλάσεων</classLink>. Οι άλλοι τύποι θα αποδοθούν ως εικόνα στο Excalidraw.",
|
||||
"description": "",
|
||||
"syntax": "Σύνταξη Mermaid",
|
||||
"preview": "Προεπισκόπηση",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -53,14 +53,7 @@
|
||||
"arrowhead_crowfoot_many": "Crow's foot (many)",
|
||||
"arrowhead_crowfoot_one": "Crow's foot (one)",
|
||||
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
|
||||
"arrowhead_cardinality_one": "Cardinality (one)",
|
||||
"arrowhead_cardinality_many": "Cardinality (many)",
|
||||
"arrowhead_cardinality_one_or_many": "Cardinality (one or many)",
|
||||
"arrowhead_cardinality_exactly_one": "Cardinality (exactly one)",
|
||||
"arrowhead_cardinality_zero_or_one": "Cardinality (zero or one)",
|
||||
"arrowhead_cardinality_zero_or_many": "Cardinality (zero or many)",
|
||||
"more_options": "More options",
|
||||
"cardinality": "Cardinality",
|
||||
"arrowtypes": "Arrow type",
|
||||
"arrowtype_sharp": "Sharp arrow",
|
||||
"arrowtype_round": "Curved arrow",
|
||||
@@ -410,10 +403,6 @@
|
||||
"errorDialog": {
|
||||
"title": "Error"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "Saving",
|
||||
"defaultMessage": "Preparing to save..."
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Save to disk",
|
||||
"disk_details": "Export the scene data to a file from which you can import later.",
|
||||
@@ -631,12 +620,11 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid to Excalidraw",
|
||||
"button": "Insert",
|
||||
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>, <sequenceLink>Sequence</sequenceLink>, <classLink>Class</classLink>, and <erdLink>Entity Relationship</erdLink> Diagrams are supported. The other types will be rendered as image in Excalidraw.",
|
||||
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.",
|
||||
"syntax": "Mermaid Syntax",
|
||||
"preview": "Preview",
|
||||
"label": "Mermaid",
|
||||
"inputPlaceholder": "Write Mermaid diagram defintion here...",
|
||||
"autoFixAvailable": "Auto-fix is available"
|
||||
"inputPlaceholder": "Write Mermaid diagram defintion here..."
|
||||
},
|
||||
"ttd": {
|
||||
"error": "Error!"
|
||||
@@ -661,7 +649,7 @@
|
||||
"placeholder": {
|
||||
"title": "Let's design your diagram",
|
||||
"description": "Describe the diagram you want to create, and we'll generate it for you.",
|
||||
"hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams."
|
||||
"hint": "At the moment we know Flowchart, Sequence, and Class diagrams."
|
||||
},
|
||||
"preview": "Preview",
|
||||
"insert": "Insert",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Pegar",
|
||||
"pasteAsPlaintext": "Pegar como texto sin formato",
|
||||
"pasteCharts": "Pegar gráficos",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"multiSelect": "Añadir elemento a la selección",
|
||||
"moveCanvas": "Mover el lienzo",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "Pie de la corona (varios)",
|
||||
"arrowhead_crowfoot_one": "Pie de la corona (uno)",
|
||||
"arrowhead_crowfoot_one_or_many": "Pie de la corona (uno o varios)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "Más opciones",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Tipo de flecha",
|
||||
"arrowtype_sharp": "Flecha Afilada",
|
||||
"arrowtype_round": "Flecha Curva",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Enlace al objeto",
|
||||
"wrapSelectionInFrame": "Ajustar la selección en el marco",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Enlace al objeto",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Error"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Guardar en disco",
|
||||
"disk_details": "Exportar los datos de la escena a un archivo desde el cual pueda importar más tarde.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid a Excalidraw",
|
||||
"button": "Insertar",
|
||||
"description": "Actualmente sólo estos tipos de <flowchartLink>diagrama de flujo</flowchartLink>,<sequenceLink> Secuencia, </sequenceLink> y <classLink>Clase </classLink> son soportados. Los otros tipos de diagramas se renderizarán como imagen en Excalidraw.",
|
||||
"description": "",
|
||||
"syntax": "Sintaxis Mermaid",
|
||||
"preview": "Vista previa",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Itsatsi",
|
||||
"pasteAsPlaintext": "Itsatsi testu arrunt gisa",
|
||||
"pasteCharts": "Itsatsi grafikoak",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Hautatu dena",
|
||||
"multiSelect": "Gehitu elementua hautapenera",
|
||||
"moveCanvas": "Mugitu oihala",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Errorea"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Gorde diskoan",
|
||||
"disk_details": "Esportatu eszenaren datuak geroago inportatu ahal izango duzun fitxategi batan.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid-etik Excalidraw-ra",
|
||||
"button": "Txertatu",
|
||||
"description": "Momentu honetan <flowchartLink>Flowchart</flowchartLink>, <sequenceLink> Sequence, </sequenceLink> eta <classLink>Class </classLink>Diagramak onartzen dira. Beste motak irudi gisa errendatuko dira Excalidrawn.",
|
||||
"description": "",
|
||||
"syntax": "Mermaid sintaxia",
|
||||
"preview": "Aurrebista",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "جایگذاری",
|
||||
"pasteAsPlaintext": "جایگذاری به عنوان متن ساده",
|
||||
"pasteCharts": "جایگذاری نمودارها",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "انتخاب همه",
|
||||
"multiSelect": "یک ایتم به انتخاب شده ها اضافه کنید",
|
||||
"moveCanvas": "جابجایی بوم",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "پای کلاغی (بسیار)",
|
||||
"arrowhead_crowfoot_one": "پای کلاغی (یک)",
|
||||
"arrowhead_crowfoot_one_or_many": "پای کلاغی (یک یا بسیار)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "امکانات بیشتر",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "نوع پیکان",
|
||||
"arrowtype_sharp": "پیکان تیز",
|
||||
"arrowtype_round": "پیکان منحنی",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "لینک به آیتم",
|
||||
"wrapSelectionInFrame": "انتخاب را در قاب قرار دهید",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "لینک به آیتم",
|
||||
@@ -196,7 +211,7 @@
|
||||
"multipleResults": "نتایج",
|
||||
"placeholder": "جستجوی متن در بوم...",
|
||||
"frames": "",
|
||||
"texts": ""
|
||||
"texts": "متن"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "پاکسازی بوم نقاشی",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "خطا"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "ذخیره در دیسک",
|
||||
"disk_details": "داده های صحنه را به فایلی که بعداً می توانید از آن وارد کنید صادر کنید.",
|
||||
@@ -571,7 +590,7 @@
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"color": "",
|
||||
"color": "رنگ",
|
||||
"mostUsedCustomColors": "رنگ های بهتازگی بهکار گرفته شده",
|
||||
"colors": "رنگها",
|
||||
"shades": "جلوهها",
|
||||
@@ -612,14 +631,15 @@
|
||||
"mermaid": {
|
||||
"title": "مرمید به excalidraw",
|
||||
"button": "درج",
|
||||
"description": "فعلا فقط <flowchartLink> فلوچارت </flowchartLink> ، <sequenceLink> توالی </sequenceLink> و <classLink> کلاس </classLink> نمودارها پشتیبانی می شوند. انواع دیگر به صورت تصویر در Excalidraw ارائه خواهند شد.",
|
||||
"description": "",
|
||||
"syntax": "مرمید syntax",
|
||||
"preview": "پیشنمایش",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
"error": "خطا!"
|
||||
},
|
||||
"chat": {
|
||||
"inputPlaceholder": "",
|
||||
@@ -627,8 +647,8 @@
|
||||
"generating": "",
|
||||
"rateLimitRemaining": "",
|
||||
"role": {
|
||||
"user": "",
|
||||
"assistant": "",
|
||||
"user": "شما",
|
||||
"assistant": "دستیار هوش مصنوعی",
|
||||
"system": ""
|
||||
},
|
||||
"aiBeta": "",
|
||||
@@ -707,9 +727,9 @@
|
||||
"alt": "",
|
||||
"escape": "",
|
||||
"enter": "",
|
||||
"shift": "",
|
||||
"spacebar": "",
|
||||
"delete": "",
|
||||
"shift": "Shift",
|
||||
"spacebar": "فاصله",
|
||||
"delete": "حذف",
|
||||
"mmb": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Liitä",
|
||||
"pasteAsPlaintext": "Liitä pelkkänä tekstinä",
|
||||
"pasteCharts": "Liitä kaaviot",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Valitse kaikki",
|
||||
"multiSelect": "Lisää kohde valintaan",
|
||||
"moveCanvas": "Siirrä piirtoaluetta",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Virhe"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Tallenna levylle",
|
||||
"disk_details": "Vie työn tiedot tiedostoon, josta sen voi tuoda myöhemmin.",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "Esikatsele",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Coller",
|
||||
"pasteAsPlaintext": "Coller comme texte brut",
|
||||
"pasteCharts": "Coller les graphiques",
|
||||
"chartType_bar": "Graphique à barres",
|
||||
"chartType_line": "Courbes",
|
||||
"chartType_radar": "Diagramme en radar",
|
||||
"chartType_plaintext": "Texte brut",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"multiSelect": "Ajouter l'élément à la sélection",
|
||||
"moveCanvas": "Déplacer le canevas",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "Pied de Corde (un)",
|
||||
"arrowhead_crowfoot_one_or_many": "Pied du corbeau (un ou plusieurs)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "Plus d'options",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Type de flèche",
|
||||
"arrowtype_sharp": "Flèche pointue",
|
||||
"arrowtype_round": "Flèche incurvée",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Lien vers un objet",
|
||||
"wrapSelectionInFrame": "Mettre la sélection dans un cadre",
|
||||
"tab": "Onglet",
|
||||
"shapeSwitch": "Changer de forme"
|
||||
"shapeSwitch": "Changer de forme",
|
||||
"preferences": "Préférences",
|
||||
"preferences_toolLock": "Verrouillage de l'outil",
|
||||
"arrowBinding": "Liaison de flèches",
|
||||
"midpointSnapping": "S'accrocher aux points intermédiaires"
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Lien vers un objet",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Erreur"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Enregistrer sur le disque",
|
||||
"disk_details": "Exporter les données de la scène comme un fichier que vous pourrez importer ultérieurement.",
|
||||
@@ -557,9 +576,9 @@
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_line2": "",
|
||||
"center_heading_line3": "",
|
||||
"center_heading": "Vos dessins sont enregistrés dans le stockage de votre navigateur.",
|
||||
"center_heading_line2": "Le stockage du navigateur peut être effacé de manière inattendue.",
|
||||
"center_heading_line3": "Enregistrez régulièrement votre travail dans un fichier pour éviter de le perdre.",
|
||||
"center_heading_plus": "Vouliez-vous plutôt aller à Excalidraw+ à la place ?",
|
||||
"menuHint": "Exportation, préférences, langues, ..."
|
||||
},
|
||||
@@ -612,57 +631,58 @@
|
||||
"mermaid": {
|
||||
"title": "De Mermaid à Excalidraw",
|
||||
"button": "Insérer",
|
||||
"description": "Actuellement, seuls les diagrammes <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> et <classLink>de classe </classLink>sont pris en charge. Les autres types seront rendus en tant qu'image dans Excalidraw.",
|
||||
"description": "",
|
||||
"syntax": "Syntaxe Mermaid",
|
||||
"preview": "Prévisualisation",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"label": "Mermaid",
|
||||
"inputPlaceholder": "Écrivez la définition du diagramme de Mermaid ici...",
|
||||
"autoFixAvailable": "Correction automatique disponible"
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
"error": "Erreur!"
|
||||
},
|
||||
"chat": {
|
||||
"inputPlaceholder": "",
|
||||
"inputPlaceholderWithMessages": "",
|
||||
"generating": "",
|
||||
"rateLimitRemaining": "",
|
||||
"inputPlaceholder": "Commencez à taper votre idée de diagramme ici... ({{shortcut}} pour une nouvelle ligne)",
|
||||
"inputPlaceholderWithMessages": "Continuer à affiner votre diagramme...",
|
||||
"generating": "Génération...",
|
||||
"rateLimitRemaining": "{{count}} demandes restantes aujourd'hui",
|
||||
"role": {
|
||||
"user": "",
|
||||
"assistant": "",
|
||||
"system": ""
|
||||
"user": "Vous",
|
||||
"assistant": "Assistant IA",
|
||||
"system": "Système"
|
||||
},
|
||||
"aiBeta": "",
|
||||
"label": "",
|
||||
"menu": "",
|
||||
"newChat": "",
|
||||
"deleteChat": "",
|
||||
"deleteMessage": "",
|
||||
"viewAsMermaid": "",
|
||||
"aiBeta": "Bêta IA",
|
||||
"label": "Chat",
|
||||
"menu": "Menu",
|
||||
"newChat": "Nouveau chat",
|
||||
"deleteChat": "Supprimer le chat",
|
||||
"deleteMessage": "Supprimer le message",
|
||||
"viewAsMermaid": "Voir en tant que Mermaid",
|
||||
"placeholder": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"title": "Créons votre diagramme",
|
||||
"description": "Décrivez le diagramme que vous voulez créer, et nous le générerons pour vous.",
|
||||
"hint": ""
|
||||
},
|
||||
"preview": "",
|
||||
"insert": "",
|
||||
"retry": "",
|
||||
"preview": "Prévisualisation",
|
||||
"insert": "Insérer",
|
||||
"retry": "Recommencer",
|
||||
"errors": {
|
||||
"promptTooShort": "",
|
||||
"promptTooLong": "",
|
||||
"generationFailed": "",
|
||||
"invalidDiagram": "",
|
||||
"fixInMermaid": "",
|
||||
"aiRepair": "",
|
||||
"requestAborted": "",
|
||||
"requestFailed": "",
|
||||
"mermaidParseError": ""
|
||||
"promptTooShort": "La requête est trop courte (min {{min}} caractères)",
|
||||
"promptTooLong": "La requête est trop longue (max {{max}} caractères)",
|
||||
"generationFailed": "Échec de la génération",
|
||||
"invalidDiagram": "A généré un diagramme non valide :(. Vous pouvez modifier manuellement, réessayer avec la correction automatique, ou essayer une invite différente.",
|
||||
"fixInMermaid": "Modifier Mermaid manuellement →",
|
||||
"aiRepair": "Régénérer (correction automatique) →",
|
||||
"requestAborted": "Demande abandonnée",
|
||||
"requestFailed": "La requête a échoué",
|
||||
"mermaidParseError": "Erreur de syntaxe Mermaid"
|
||||
},
|
||||
"rateLimit": {
|
||||
"messageLimit": "",
|
||||
"generalRateLimit": "",
|
||||
"messageLimitInputPlaceholder": ""
|
||||
"messageLimit": "Vous avez atteint votre limite d'IA sur le plan gratuit. Essayez Excalidraw+ pour plus ou revenez demain.",
|
||||
"generalRateLimit": "Tenez vos chevaux, vous êtes trop rapide pour nous! Veuillez attendre un instant avant de réessayer.",
|
||||
"messageLimitInputPlaceholder": "Vous avez atteint votre limite de messages"
|
||||
},
|
||||
"upsellBtnLabel": ""
|
||||
"upsellBtnLabel": "Devenez Premium"
|
||||
},
|
||||
"quickSearch": {
|
||||
"placeholder": "Recherche rapide"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Pegar",
|
||||
"pasteAsPlaintext": "Pegar coma texto sen formato",
|
||||
"pasteCharts": "Pegar gráficos",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"multiSelect": "Engadir elemento á selección",
|
||||
"moveCanvas": "Mover o lenzo",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Erro"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Gardar no disco",
|
||||
"disk_details": "Exporte os datos da escena a un ficheiro que poderás importar máis tarde.",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "Vista previa",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "הדבקה",
|
||||
"pasteAsPlaintext": "הדבקה ללא עיצוב",
|
||||
"pasteCharts": "הדבקת תרשימים",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "בחירה בהכול",
|
||||
"multiSelect": "הוספת רכיב לבחירה",
|
||||
"moveCanvas": "הזזת הקנבס",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "רגל עורב (הרבה)",
|
||||
"arrowhead_crowfoot_one": "רגל עורב (אחת)",
|
||||
"arrowhead_crowfoot_one_or_many": "רגל עורב (אחת או הרבה)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "אפשרויות נוספות",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "סוג החץ",
|
||||
"arrowtype_sharp": "חץ מחודד",
|
||||
"arrowtype_round": "חץ מעוקל",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "קישור לפריט",
|
||||
"wrapSelectionInFrame": "לעטוף את הבחירה במסגרת",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "קישור לפריט",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "שגיאה"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "שמור לכונן",
|
||||
"disk_details": "ייצא מידע של הקנבאס לקובץ שתוכל לייבא אחר כך.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid ל־Excalidraw",
|
||||
"button": "הוספה",
|
||||
"description": "לעת עתה נתמכים רק <flowchartLink>תרשימי זרימה</flowchartLink>, <sequenceLink>תהליכים</sequenceLink>, <classLink>ודיאגרמת מחלקה</classLink>. שאר הסוגים ייוצרו כתמונות ב-Excalidraw.",
|
||||
"description": "",
|
||||
"syntax": "תחביר Mermaid",
|
||||
"preview": "תצוגה מקדימה",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "पेस्ट",
|
||||
"pasteAsPlaintext": "सादे पाठ के रूप में चिपकाएं",
|
||||
"pasteCharts": "चार्ट चिपकाएँ",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "सेलेक्ट ऑल",
|
||||
"multiSelect": "अवयव को चयन में सम्मिलित करें",
|
||||
"moveCanvas": "चित्रपटल को स्थानांतरित करें",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "चिड़िया पैर (अनेक)",
|
||||
"arrowhead_crowfoot_one": "चिड़िया पैर (एक)",
|
||||
"arrowhead_crowfoot_one_or_many": "चिड़िया पैर (एक या अनेक)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "और विकल्प",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "तीर प्रकार",
|
||||
"arrowtype_sharp": "तीक्ष्ण तीर",
|
||||
"arrowtype_round": "गोलाकार तीर",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "वस्तु की कड़ी",
|
||||
"wrapSelectionInFrame": "चौकट में चुने हुवे को लपेटे",
|
||||
"tab": "",
|
||||
"shapeSwitch": "आकार बदलें"
|
||||
"shapeSwitch": "आकार बदलें",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "वस्तु की कड़ी",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "त्रुटि"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "डिस्क पर सुरक्षित करे",
|
||||
"disk_details": "दृश्य डेटा एक फ़ाइल में निर्यात करे, जहांसे आप उसे पुनः आयात कर सके",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "मर्मेड से एक्सकाली में",
|
||||
"button": "सन्निवेश करे",
|
||||
"description": "वर्तमान में केवल <flowchartLink>बहाव चित्र</flowchartLink>, <sequenceLink> अनुक्रम चित्र </sequenceLink> और <classLink>वर्ग चित्र</classLink> का चित्रिकरण संभव हैं. अन्य चित्र प्रकार एक्सकाली प्रतिमा जैसे चित्रित किए जायेंगे.",
|
||||
"description": "",
|
||||
"syntax": "मर्मेड विन्यास",
|
||||
"preview": "पूर्वावलोकन",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Beillesztés",
|
||||
"pasteAsPlaintext": "Beillesztés formázatlan szövegként",
|
||||
"pasteCharts": "Grafikon beillesztése",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Összes kijelölése",
|
||||
"multiSelect": "Elem hozzáadása a kijelöléshez",
|
||||
"moveCanvas": "Vászon mozgatása",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Nyíl típus",
|
||||
"arrowtype_sharp": "Hegyes nyíl",
|
||||
"arrowtype_round": "Ívelt nyíl",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Hivatkozás az objektumhoz",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "Tab",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Hivatkozás az objektumhoz",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Hiba"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Mentés lemezre",
|
||||
"disk_details": "Exportálja a jelenetadatokat egy fájlba, amelyből később importálhatja.",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Tempel",
|
||||
"pasteAsPlaintext": "Tempel sebagai teks biasa",
|
||||
"pasteCharts": "Tempel diagram",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Pilih semua",
|
||||
"multiSelect": "Tambah unsur ke seleksi",
|
||||
"moveCanvas": "Pindahkan kanvas",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "Pilihan lain",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Jenis panah",
|
||||
"arrowtype_sharp": "Panah runcing",
|
||||
"arrowtype_round": "Panah melengkung",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Taut ke objek",
|
||||
"wrapSelectionInFrame": "Bungkus pilihan dalam bingkai",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Taut ke objek",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Kesalahan"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Simpan ke disk",
|
||||
"disk_details": "Ekspor data pemandangan ke file yang mana Anda dapat impor nanti.",
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid menjadi Excalidraw",
|
||||
"button": "Sisipkan",
|
||||
"description": "Saat ini hanya <flowchartLink>Flowchart</flowchartLink>, <sequenceLink>Sekuen, </sequenceLink>, dan <classLink>Kelas</classLink>Diagram yang didukung. Jenis lainnya akan dirender sebagai gambar di Excalidraw.",
|
||||
"description": "",
|
||||
"syntax": "Syntax Mermaid",
|
||||
"preview": "Pratinjau",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Incolla",
|
||||
"pasteAsPlaintext": "Incolla come testo normale",
|
||||
"pasteCharts": "Incolla grafici",
|
||||
"chartType_bar": "Grafico a barre",
|
||||
"chartType_line": "Grafico a linee",
|
||||
"chartType_radar": "Grafico radar",
|
||||
"chartType_plaintext": "Testo semplice",
|
||||
"selectAll": "Seleziona tutto",
|
||||
"multiSelect": "Aggiungi elemento alla selezione",
|
||||
"moveCanvas": "Sposta tela",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "Zampe di gallina (molti)",
|
||||
"arrowhead_crowfoot_one": "Zampa di gallina (una)",
|
||||
"arrowhead_crowfoot_one_or_many": "Zampe di gallina (una o molte)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "Altre opzioni",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "Tipo di freccia",
|
||||
"arrowtype_sharp": "Freccia affilata",
|
||||
"arrowtype_round": "Freccia curva",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "Link all'oggetto",
|
||||
"wrapSelectionInFrame": "Avvolgi la selezione nella cornice",
|
||||
"tab": "Scheda",
|
||||
"shapeSwitch": "Cambia forma"
|
||||
"shapeSwitch": "Cambia forma",
|
||||
"preferences": "Preferenze",
|
||||
"preferences_toolLock": "Blocco strumento",
|
||||
"arrowBinding": "Legatura a freccia",
|
||||
"midpointSnapping": "Aggancia ai punti medi"
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Link all'oggetto",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Errore"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Salva su disco",
|
||||
"disk_details": "Esporta i dati della scena su file, dal quale potrai importare in seguito.",
|
||||
@@ -557,9 +576,9 @@
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_line2": "",
|
||||
"center_heading_line3": "",
|
||||
"center_heading": "I tuoi disegni vengono salvati nella memoria del tuo browser.",
|
||||
"center_heading_line2": "La memoria del browser potrebbe essere cancellata inaspettatamente.",
|
||||
"center_heading_line3": "Salva regolarmente il tuo lavoro in un file per evitare di perderlo.",
|
||||
"center_heading_plus": "Volevi invece andare su Excalidraw+?",
|
||||
"menuHint": "Esporta, preferenze, lingue, ..."
|
||||
},
|
||||
@@ -612,11 +631,12 @@
|
||||
"mermaid": {
|
||||
"title": "Da Mermaid a Excalidraw",
|
||||
"button": "Inserisci",
|
||||
"description": "Attualmente sono supportati solo diagrammi di <flowchartLink>flusso</flowchartLink>,<sequenceLink> sequenza, </sequenceLink> e <classLink>classe </classLink>. Gli altri tipi saranno rappresentati come immagini in Excalidraw.",
|
||||
"description": "",
|
||||
"syntax": "Sintassi Mermaid",
|
||||
"preview": "Anteprima",
|
||||
"label": "Mermaid",
|
||||
"inputPlaceholder": "Scrivi qui la definizione del diagramma Mermaid..."
|
||||
"inputPlaceholder": "Scrivi qui la definizione del diagramma Mermaid...",
|
||||
"autoFixAvailable": "Correzione automatica disponibile"
|
||||
},
|
||||
"ttd": {
|
||||
"error": "Errore!"
|
||||
@@ -641,7 +661,7 @@
|
||||
"placeholder": {
|
||||
"title": "Progettiamo il tuo diagramma",
|
||||
"description": "Descrivi il diagramma che vuoi creare e noi lo genereremo per te.",
|
||||
"hint": "Al momento conosciamo i diagrammi di flusso, di sequenza e di classe."
|
||||
"hint": ""
|
||||
},
|
||||
"preview": "Anteprima",
|
||||
"insert": "Inserisci",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "貼り付け",
|
||||
"pasteAsPlaintext": "書式なしテキストとして貼り付け",
|
||||
"pasteCharts": "チャートの貼り付け",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "すべて選択",
|
||||
"multiSelect": "複数選択",
|
||||
"moveCanvas": "キャンバスを移動",
|
||||
@@ -34,7 +38,7 @@
|
||||
"opacity": "透明度",
|
||||
"textAlign": "文字の配置",
|
||||
"edges": "角",
|
||||
"sharp": "四角",
|
||||
"sharp": "シャープ",
|
||||
"round": "丸",
|
||||
"arrowheads": "線の終点",
|
||||
"arrowhead_none": "なし",
|
||||
@@ -46,14 +50,21 @@
|
||||
"arrowhead_triangle_outline": "三角 (中抜き)",
|
||||
"arrowhead_diamond": "ひし形",
|
||||
"arrowhead_diamond_outline": "ひし形 (中抜き)",
|
||||
"arrowhead_crowfoot_many": "鳥の足記法(多対多)",
|
||||
"arrowhead_crowfoot_one": "鳥の足記法(一対一)",
|
||||
"arrowhead_crowfoot_one_or_many": "鳥の足記法(一対多)",
|
||||
"more_options": "詳細設定",
|
||||
"arrowtypes": "矢印の種類",
|
||||
"arrowhead_crowfoot_many": "カラスの足 (*)",
|
||||
"arrowhead_crowfoot_one": "カラスの足 (1)",
|
||||
"arrowhead_crowfoot_one_or_many": "カラスの足 (1..*)",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "その他のオプション",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "矢印タイプ",
|
||||
"arrowtype_sharp": "鋭い矢印",
|
||||
"arrowtype_round": "曲線矢印",
|
||||
"arrowtype_elbowed": "ひじ矢印",
|
||||
"arrowtype_elbowed": "折れ線矢印",
|
||||
"fontSize": "フォントの大きさ",
|
||||
"fontFamily": "フォントの種類",
|
||||
"addWatermark": "\"Made with Excalidraw\"と表示",
|
||||
@@ -75,14 +86,14 @@
|
||||
"right": "右寄せ",
|
||||
"extraBold": "極太",
|
||||
"architect": "建築家",
|
||||
"artist": "アーティスト",
|
||||
"artist": "画家",
|
||||
"cartoonist": "漫画家",
|
||||
"fileTitle": "ファイル名",
|
||||
"colorPicker": "カラーピッカー",
|
||||
"canvasColors": "キャンバス上で使用",
|
||||
"canvasBackground": "キャンバスの背景",
|
||||
"drawingCanvas": "キャンバスの描画",
|
||||
"clearCanvas": "キャンバスを片付ける",
|
||||
"clearCanvas": "キャンバスを消去",
|
||||
"layers": "レイヤー",
|
||||
"actions": "操作",
|
||||
"language": "言語",
|
||||
@@ -101,7 +112,7 @@
|
||||
"libraryLoadingMessage": "ライブラリを読み込み中…",
|
||||
"libraries": "ライブラリを参照する",
|
||||
"loadingScene": "シーンを読み込み中…",
|
||||
"loadScene": "ファイルからシーン",
|
||||
"loadScene": "ファイルからシーンを開く",
|
||||
"align": "配置",
|
||||
"alignTop": "上揃え",
|
||||
"alignBottom": "下揃え",
|
||||
@@ -160,18 +171,22 @@
|
||||
"prompt": "プロンプト",
|
||||
"followUs": "フォローする",
|
||||
"discordChat": "Discord チャット",
|
||||
"zoomToFitViewport": "表寿範囲に合わせてズーム",
|
||||
"zoomToFitViewport": "表示範囲に合わせてズーム",
|
||||
"zoomToFitSelection": "選択範囲に合わせてズーム",
|
||||
"zoomToFit": "すべての要素が収まるようにズーム",
|
||||
"installPWA": "Excalidrawをローカルにインストール(PWA)",
|
||||
"autoResize": "テキストの自動サイズ変更を有効化",
|
||||
"imageCropping": "画像のトリミング",
|
||||
"unCroppedDimension": "",
|
||||
"copyElementLink": "",
|
||||
"imageCropping": "画像の切り抜き",
|
||||
"unCroppedDimension": "元のサイズ",
|
||||
"copyElementLink": "オブジェクトへのリンクをコピー",
|
||||
"linkToElement": "オブジェクトにリンク",
|
||||
"wrapSelectionInFrame": "選択範囲を枠で折り返す",
|
||||
"wrapSelectionInFrame": "選択範囲をフレームで囲む",
|
||||
"tab": "Tab",
|
||||
"shapeSwitch": "図形形状を変更"
|
||||
"shapeSwitch": "図形形状を変更",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "オブジェクトにリンク",
|
||||
@@ -185,15 +200,15 @@
|
||||
"search": {
|
||||
"inputPlaceholder": "ライブラリを検索",
|
||||
"heading": "ライブラリと一致",
|
||||
"noResults": "一致するアイテムが見つかりませんでした…",
|
||||
"noResults": "一致するアイテムはありません…",
|
||||
"clearSearch": "検索のクリア"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"title": "キャンバスで検索",
|
||||
"noMatch": "一致なし…",
|
||||
"singleResult": "結果",
|
||||
"multipleResults": "結果数",
|
||||
"noMatch": "一致する結果はありません…",
|
||||
"singleResult": "件",
|
||||
"multipleResults": "件",
|
||||
"placeholder": "キャンバス内のテキストを検索…",
|
||||
"frames": "フレーム",
|
||||
"texts": "テキスト"
|
||||
@@ -240,7 +255,7 @@
|
||||
"embeddableInteractionButton": "クリックして操作"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "この操作によってキャンバス全体が消えます。よろしいですか?",
|
||||
"clearReset": "キャンバス全体を消去します。本当によろしいですか?",
|
||||
"couldNotCreateShareableLink": "共有URLを作成できませんでした。",
|
||||
"couldNotCreateShareableLinkTooBig": "共有可能なリンクを作成できませんでした: シーンが大きすぎます",
|
||||
"couldNotLoadInvalidFile": "無効なファイルを読み込めませんでした。",
|
||||
@@ -249,8 +264,8 @@
|
||||
"couldNotCopyToClipboard": "クリップボードにコピーできませんでした。",
|
||||
"decryptFailed": "データを復号できませんでした。",
|
||||
"uploadedSecurly": "データのアップロードはエンドツーエンド暗号化によって保護されています。Excalidrawサーバーと第三者はデータの内容を見ることができません。",
|
||||
"loadSceneOverridePrompt": "外部図面を読み込むと、既存のコンテンツが置き換わります。続行しますか?",
|
||||
"collabStopOverridePrompt": "セッションを停止すると、ローカルに保存されている図が上書きされます。 本当によろしいですか?\n\n(ローカルの図を保持したい場合は、セッションを停止せずにブラウザタブを閉じてください。)",
|
||||
"loadSceneOverridePrompt": "外部の描画データを読み込むと、既存のコンテンツが置き換わります。続行しますか?",
|
||||
"collabStopOverridePrompt": "セッションを停止すると、ローカルに保存されている図が上書きされます。 本当によろしいですか?\n\n(ローカルの図を保持したい場合は、セッションを停止せずにブラウザーのタブを閉じてください。)",
|
||||
"errorAddingToLibrary": "アイテムをライブラリに追加できませんでした",
|
||||
"errorRemovingFromLibrary": "ライブラリからアイテムを削除できませんでした",
|
||||
"confirmAddLibrary": "{{numShapes}} 個の図形をライブラリに追加します。よろしいですか?",
|
||||
@@ -261,7 +276,7 @@
|
||||
"removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?",
|
||||
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。共同編集は無効化されています。",
|
||||
"collabOfflineWarning": "インターネットに接続されていません。\n変更は保存されません!",
|
||||
"localStorageQuotaExceeded": ""
|
||||
"localStorageQuotaExceeded": "ブラウザーのストレージ容量を超えました。変更は保存されません。"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "サポートされていないファイル形式です。",
|
||||
@@ -271,13 +286,13 @@
|
||||
"failedToFetchImage": "画像の読み込みに失敗しました。",
|
||||
"cannotResolveCollabServer": "コラボレーションサーバに接続できませんでした。ページを再読み込みして、もう一度お試しください。",
|
||||
"importLibraryError": "ライブラリを読み込めませんでした。",
|
||||
"saveLibraryError": "",
|
||||
"saveLibraryError": "ライブラリをストレージに保存できませんでした。変更を失わないように、ローカルにファイルとして保存してください。",
|
||||
"collabSaveFailed": "バックエンドデータベースに保存できませんでした。問題が解決しない場合は、作業を失わないようにローカルにファイルを保存してください。",
|
||||
"collabSaveFailed_sizeExceeded": "キャンバスが大きすぎるため、バックエンドデータベースに保存できませんでした。問題が解決しない場合は、作業を失わないようにローカルにファイルを保存してください。",
|
||||
"imageToolNotSupported": "画像ツールは使用不可です。",
|
||||
"brave_measure_text_error": {
|
||||
"line1": "<bold>Aggressly Block Fingerprinting</bold> の設定が有効なBraveブラウザを使用しているようです。",
|
||||
"line2": "これにより、図面の <bold>テキスト要素</bold> が壊れる可能性があります。",
|
||||
"line1": "<bold>Aggressly Block Fingerprinting</bold> 設定が有効なBraveブラウザーを使用しているようです。",
|
||||
"line2": "これにより、描画内の<bold>テキスト要素</bold>が壊れる可能性があります。",
|
||||
"line3": "この設定を無効にすることを強く推奨します。 <link>設定手順</link> をこちらから確認できます。",
|
||||
"line4": "この設定を無効にすると、テキスト要素の表示が修正されません。 GitHub で <issueLink>Issue</issueLink> を開くか、 <discordLink>Discord</discordLink> にご記入ください"
|
||||
},
|
||||
@@ -292,9 +307,9 @@
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "選択",
|
||||
"lasso": "",
|
||||
"lasso": "なげなわ選択",
|
||||
"image": "画像を挿入",
|
||||
"rectangle": "矩形",
|
||||
"rectangle": "長方形",
|
||||
"diamond": "ひし形",
|
||||
"ellipse": "楕円",
|
||||
"arrow": "矢印",
|
||||
@@ -304,31 +319,31 @@
|
||||
"library": "ライブラリ",
|
||||
"lock": "描画後も使用中のツールを選択したままにする",
|
||||
"penMode": "ペンモード - タッチ防止",
|
||||
"link": "",
|
||||
"link": "選択した図形にリンクを追加・更新",
|
||||
"eraser": "消しゴム",
|
||||
"frame": "フレームツール",
|
||||
"frame": "フレーム",
|
||||
"magicframe": "ワイヤーフレームからコードを生成",
|
||||
"embeddable": "Web埋め込み",
|
||||
"laser": "レーザーポインター",
|
||||
"hand": "手 (パンニングツール)",
|
||||
"extraTools": "その他のツール",
|
||||
"mermaidToExcalidraw": "Mermaid を Excalidraw に変換",
|
||||
"convertElementType": ""
|
||||
"convertElementType": "図形タイプを切り替え"
|
||||
},
|
||||
"element": {
|
||||
"rectangle": "",
|
||||
"diamond": "",
|
||||
"rectangle": "長方形",
|
||||
"diamond": "ひし形",
|
||||
"ellipse": "楕円",
|
||||
"arrow": "",
|
||||
"line": "",
|
||||
"freedraw": "",
|
||||
"arrow": "矢印",
|
||||
"line": "線",
|
||||
"freedraw": "フリーハンド",
|
||||
"text": "文字",
|
||||
"image": "画像",
|
||||
"group": "グループ",
|
||||
"frame": "フレーム",
|
||||
"magicframe": "ワイヤーフレームからコードを生成",
|
||||
"embeddable": "",
|
||||
"selection": "",
|
||||
"embeddable": "Web埋め込み",
|
||||
"selection": "選択",
|
||||
"iframe": "IFrame"
|
||||
},
|
||||
"headings": {
|
||||
@@ -337,34 +352,34 @@
|
||||
"shapes": "図形"
|
||||
},
|
||||
"hints": {
|
||||
"dismissSearch": "",
|
||||
"canvasPanning": "",
|
||||
"dismissSearch": "{{shortcut}} で検索を閉じる",
|
||||
"canvasPanning": "キャンバスを移動するには、{{shortcut_1}} か {{shortcut_2}} を押しながらドラッグ、または手のひらツールを使用",
|
||||
"linearElement": "クリックすると複数の頂点からなる曲線を開始、ドラッグすると直線",
|
||||
"arrowTool": "",
|
||||
"arrowBindModifiers": "",
|
||||
"arrowTool": "クリックで複数の点を追加、ドラッグで直線。{{shortcut}} を再度押すと矢印タイプが変更されます。",
|
||||
"arrowBindModifiers": "{{shortcut_1}} を押し続けるとバインドを無効化、{{shortcut_2}} を押し続けると固定点にバインド",
|
||||
"freeDraw": "クリックしてドラッグします。離すと終了します",
|
||||
"text": "ヒント: 選択ツールを使用して任意の場所をダブルクリックしてテキストを追加することもできます",
|
||||
"embeddable": "クリックしてドラッグし、ウェブサイトを埋め込む",
|
||||
"text_selected": "",
|
||||
"text_editing": "",
|
||||
"linearElementMulti": "",
|
||||
"lockAngle": "",
|
||||
"resize": "",
|
||||
"resizeImage": "",
|
||||
"rotate": "",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_line_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"text_selected": "ダブルクリック、または {{shortcut}} を押してテキストを編集",
|
||||
"text_editing": "{{shortcut_1}} または {{shortcut_2}} を押して編集を終了",
|
||||
"linearElementMulti": "最後の点をクリック、または {{shortcut_1}} か {{shortcut_2}} を押して完了",
|
||||
"lockAngle": "{{shortcut}} を押し続けて角度を固定",
|
||||
"resize": "{{shortcut_1}} を押し続けて縦横比を維持、\n{{shortcut_2}} を押し続けて中心からリサイズ",
|
||||
"resizeImage": "{{shortcut_1}} を押し続けて自由にリサイズ、\n{{shortcut_2}} を押し続けて中心からリサイズ",
|
||||
"rotate": "{{shortcut}} を押し続けて角度を固定して回転",
|
||||
"lineEditor_info": "{{shortcut_1}} を押しながらダブルクリック、または {{shortcut_2}} を押して点を編集",
|
||||
"lineEditor_line_info": "ダブルクリック、または {{shortcut}} を押して点を編集",
|
||||
"lineEditor_pointSelected": "{{shortcut_1}} で点を削除、\n{{shortcut_2}} で複製、またはドラッグで移動",
|
||||
"lineEditor_nothingSelected": "点を選択して編集 ({{shortcut_1}} を押し続けて複数選択)、\nまたは {{shortcut_2}} を押しながらクリックで新しい点を追加",
|
||||
"publishLibrary": "自分のライブラリを公開",
|
||||
"bindTextToElement": "",
|
||||
"createFlowchart": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": "",
|
||||
"bindTextToElement": "{{shortcut}} でテキストを追加",
|
||||
"createFlowchart": "{{shortcut}} でフローチャートを作成",
|
||||
"deepBoxSelect": "{{shortcut}} を押し続けて詳細選択・ドラッグ防止",
|
||||
"eraserRevert": "{{shortcut}} を押し続けて削除マークを取り消し",
|
||||
"firefox_clipboard_write": "この機能は、\"dom.events.asyncClipboard.clipboardItem\" フラグを \"true\" に設定することで有効になる可能性があります。Firefox でブラウザーの設定を変更するには、\"about:config\" ページを参照してください。",
|
||||
"disableSnapping": "",
|
||||
"enterCropEditor": "",
|
||||
"leaveCropEditor": ""
|
||||
"disableSnapping": "{{shortcut}} を押し続けてスナップを無効化",
|
||||
"enterCropEditor": "画像をダブルクリック、または {{shortcut}} を押して切り抜き",
|
||||
"leaveCropEditor": "画像の外をクリック、または {{shortcut_1}} か {{shortcut_2}} を押して切り抜きを終了"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "プレビューを表示できません",
|
||||
@@ -373,17 +388,17 @@
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain": "エラーが発生しました。もう一度やり直してください。 <button>ページを再読み込みする。</button>",
|
||||
"clearCanvasMessage": "再読み込みがうまくいかない場合は、 <button>キャンバスを消去しています</button>",
|
||||
"clearCanvasMessage": "再読み込みがうまくいかない場合は、<button>キャンバスを消去</button>してみてください。",
|
||||
"clearCanvasCaveat": " これにより作業が失われます ",
|
||||
"trackedToSentry": "識別子のエラー {{eventId}} が我々のシステムで追跡されました。",
|
||||
"openIssueMessage": "エラーに関するシーン情報を含めないように非常に慎重に設定しました。もしあなたのシーンがプライベートでない場合は、私たちのフォローアップを検討してください。 <button>バグ報告</button> GitHub のIssueに以下の情報をコピーして貼り付けてください。",
|
||||
"trackedToSentry": "エラー (識別子: {{eventId}}) はシステムに記録されました。",
|
||||
"openIssueMessage": "エラーにシーン情報が含まれないよう配慮しています。シーンが非公開でなければ、<button>バグ報告</button>へのご協力をお願いします。以下の情報をコピーしてGitHub Issueに貼り付けてください。",
|
||||
"sceneContent": "シーンの内容:"
|
||||
},
|
||||
"shareDialog": {
|
||||
"or": "または"
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "図面にコラボレーションするよう人々を招待します。",
|
||||
"desc_intro": "他の人を描画の共同編集に招待しましょう。",
|
||||
"desc_privacy": "心配しないでください、セッションはエンドツーエンドで暗号化されており、完全にプライベートです。私たちのサーバーでさえも、あなたが描いたものを見ることができません。",
|
||||
"button_startSession": "セッションを開始する",
|
||||
"button_stopSession": "セッションを終了する",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "エラー"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "ディスクに保存",
|
||||
"disk_details": "シーンデータを後からインポートできるファイルにエクスポートします。",
|
||||
@@ -411,8 +430,8 @@
|
||||
"click": "クリック",
|
||||
"deepSelect": "深い選択",
|
||||
"deepBoxSelect": "ボックス内の深い選択、およびドラッグの抑止",
|
||||
"createFlowchart": "",
|
||||
"navigateFlowchart": "",
|
||||
"createFlowchart": "汎用要素からフローチャートを作成",
|
||||
"navigateFlowchart": "フローチャート内を移動",
|
||||
"curvedArrow": "カーブした矢印",
|
||||
"curvedLine": "曲線",
|
||||
"documentation": "ドキュメント",
|
||||
@@ -436,8 +455,8 @@
|
||||
"toggleElementLock": "選択したアイテムをロック/ロック解除",
|
||||
"movePageUpDown": "ページを上下に移動",
|
||||
"movePageLeftRight": "ページを左右に移動",
|
||||
"cropStart": "",
|
||||
"cropFinish": ""
|
||||
"cropStart": "画像を切り抜き",
|
||||
"cropFinish": "画像の切り抜きを終了"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "キャンバスを消去"
|
||||
@@ -463,10 +482,10 @@
|
||||
"required": "必須項目",
|
||||
"website": "有効な URL を入力してください"
|
||||
},
|
||||
"noteDescription": "以下に含めるライブラリを提出してください <link>公開ライブラリのリポジトリ</link>他の人が作図に使えるようにするためです",
|
||||
"noteGuidelines": "最初にライブラリを手動で承認する必要があります。次をお読みください <link>ガイドライン</link> 送信する前に、GitHubアカウントが必要になりますが、必須ではありません。",
|
||||
"noteLicense": "提出することにより、ライブラリが次の下で公開されることに同意します: <link>MIT ライセンス </link>つまり誰でも制限なく使えるということです",
|
||||
"noteItems": "各ライブラリ項目は、フィルタリングのために独自の名前を持つ必要があります。以下のライブラリアイテムが含まれます:",
|
||||
"noteDescription": "あなたのライブラリを<link>公開ライブラリリポジトリ</link>に投稿して、他の人が作図に使えるようにしましょう。",
|
||||
"noteGuidelines": "投稿されたライブラリは担当者が審査します。投稿前に<link>ガイドライン</link>をお読みください。修正をお願いすることがあるため、GitHubアカウントがあると便利ですが、必須ではありません。",
|
||||
"noteLicense": "投稿することで、ライブラリが<link>MITライセンス</link>で公開されることに同意したものとみなします。これは誰でも自由に利用できることを意味します。",
|
||||
"noteItems": "各アイテムにはフィルタリング用に固有の名前が必要です。以下のアイテムが含まれます:",
|
||||
"atleastOneLibItem": "開始するには少なくとも1つのライブラリ項目を選択してください",
|
||||
"republishWarning": "注意: 選択された項目の中には、すでに公開/投稿済みと表示されているものがあります。既存のライブラリや投稿を更新する場合のみ、アイテムを再投稿してください。"
|
||||
},
|
||||
@@ -508,15 +527,15 @@
|
||||
},
|
||||
"stats": {
|
||||
"angle": "角度",
|
||||
"shapes": "",
|
||||
"shapes": "図形",
|
||||
"height": "高さ",
|
||||
"scene": "シーン",
|
||||
"selected": "選択済み",
|
||||
"storage": "ストレージ",
|
||||
"fullTitle": "",
|
||||
"fullTitle": "キャンバス・図形のプロパティ",
|
||||
"title": "プロパティ",
|
||||
"generalStats": "",
|
||||
"elementProperties": "",
|
||||
"generalStats": "全般",
|
||||
"elementProperties": "図形のプロパティ",
|
||||
"total": "合計",
|
||||
"version": "バージョン",
|
||||
"versionCopy": "クリックしてコピー",
|
||||
@@ -528,7 +547,7 @@
|
||||
"copyStyles": "スタイルをコピーしました。",
|
||||
"copyToClipboard": "クリップボードにコピー",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} を PNG 形式でクリップボードにコピーしました\n({{exportColorScheme}})",
|
||||
"copyToClipboardAsSvg": "",
|
||||
"copyToClipboardAsSvg": "{{exportSelection}} を SVG 形式でクリップボードにコピーしました\n({{exportColorScheme}})",
|
||||
"fileSaved": "ファイルを保存しました",
|
||||
"fileSavedToFilename": "{filename} に保存しました",
|
||||
"canvas": "キャンバス",
|
||||
@@ -536,7 +555,7 @@
|
||||
"pasteAsSingleElement": "{{shortcut}} を使用して単一の要素として貼り付けるか、\n既存のテキストエディタに貼り付け",
|
||||
"unableToEmbed": "この URL の埋め込みは現在許可されていません。URL のホワイトリストへの追加をリクエストするには、GitHub で Issue を上げてください。",
|
||||
"unrecognizedLinkFormat": "埋め込もうとしたリンクは期待するフォーマットと一致しません。埋め込み元のサイトで提供される「embed」の文字列を貼り付けてください。",
|
||||
"elementLinkCopied": ""
|
||||
"elementLinkCopied": "リンクをクリップボードにコピーしました"
|
||||
},
|
||||
"colors": {
|
||||
"transparent": "透明",
|
||||
@@ -557,9 +576,9 @@
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_line2": "",
|
||||
"center_heading_line3": "",
|
||||
"center_heading": "描画データはブラウザーのストレージに保存されます。",
|
||||
"center_heading_line2": "ブラウザーのストレージは予期せず消去されることがあります。",
|
||||
"center_heading_line3": "作業を失わないよう、定期的にファイルへ保存してください。",
|
||||
"center_heading_plus": "代わりにExcalidraw+を開きますか?",
|
||||
"menuHint": "エクスポート、設定、言語..."
|
||||
},
|
||||
@@ -571,7 +590,7 @@
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"color": "",
|
||||
"color": "色",
|
||||
"mostUsedCustomColors": "最も使用されているカスタム色",
|
||||
"colors": "色",
|
||||
"shades": "影",
|
||||
@@ -600,76 +619,77 @@
|
||||
"loadFromFile": {
|
||||
"title": "ファイルからロード",
|
||||
"button": "ファイルからロード",
|
||||
"description": "ファイルからのロードは、<bold>現在の描画内容を置き換えます</bold>。<br></br>その前に、以下の選択肢のいずれかにより描画内容を保存できます。"
|
||||
"description": "ファイルからの読み込みは、<bold>現在の描画内容を置き換えます</bold>。<br></br>その前に、以下の選択肢のいずれかにより描画内容を保存できます。"
|
||||
},
|
||||
"shareableLink": {
|
||||
"title": "リンクからロード",
|
||||
"title": "リンクから読み込み",
|
||||
"button": "描画内容を置き換える",
|
||||
"description": "外部図面のロードは、<bold>現在の描画内容を置き換えます</bold>。<br></br>その前に、以下の選択肢のいずれかにより描画内容を保存できます。"
|
||||
"description": "外部の描画データの読み込みは、<bold>現在の描画内容を置き換えます</bold>。<br></br>その前に、以下の選択肢のいずれかにより描画内容を保存できます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mermaid": {
|
||||
"title": "Mermaid を Excalidraw に変換",
|
||||
"button": "挿入",
|
||||
"description": "現在、<flowchartLink>Flowchart</flowchartLink>、<sequenceLink>Sequence</sequenceLink>、<classLink>Class</classLink> のダイアグラムのみに対応しています。その他の種類は、Excalidraw では画像として描画されます。",
|
||||
"description": "",
|
||||
"syntax": "Mermaid 構文",
|
||||
"preview": "プレビュー",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"label": "Mermaid",
|
||||
"inputPlaceholder": "Mermaid のダイアグラム定義をここに入力...",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
"error": "エラー!"
|
||||
},
|
||||
"chat": {
|
||||
"inputPlaceholder": "",
|
||||
"inputPlaceholderWithMessages": "",
|
||||
"generating": "",
|
||||
"rateLimitRemaining": "",
|
||||
"inputPlaceholder": "ダイアグラムのアイデアを入力... ({{shortcut}} で改行)",
|
||||
"inputPlaceholderWithMessages": "ダイアグラムを修正...",
|
||||
"generating": "生成中...",
|
||||
"rateLimitRemaining": "本日の残りリクエスト回数: {{count}}",
|
||||
"role": {
|
||||
"user": "",
|
||||
"assistant": "",
|
||||
"system": ""
|
||||
"user": "あなた",
|
||||
"assistant": "AIアシスタント",
|
||||
"system": "システム"
|
||||
},
|
||||
"aiBeta": "",
|
||||
"label": "",
|
||||
"menu": "",
|
||||
"newChat": "",
|
||||
"deleteChat": "",
|
||||
"deleteMessage": "",
|
||||
"viewAsMermaid": "",
|
||||
"aiBeta": "AI ベータ版",
|
||||
"label": "チャット",
|
||||
"menu": "メニュー",
|
||||
"newChat": "新しいチャット",
|
||||
"deleteChat": "チャットを削除",
|
||||
"deleteMessage": "メッセージを削除",
|
||||
"viewAsMermaid": "Mermaidで表示",
|
||||
"placeholder": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"title": "ダイアグラムをデザインしよう",
|
||||
"description": "作りたいダイアグラムを説明してください。AIが生成します。",
|
||||
"hint": ""
|
||||
},
|
||||
"preview": "",
|
||||
"insert": "",
|
||||
"retry": "",
|
||||
"preview": "プレビュー",
|
||||
"insert": "挿入",
|
||||
"retry": "再試行",
|
||||
"errors": {
|
||||
"promptTooShort": "",
|
||||
"promptTooLong": "",
|
||||
"generationFailed": "",
|
||||
"invalidDiagram": "",
|
||||
"fixInMermaid": "",
|
||||
"aiRepair": "",
|
||||
"requestAborted": "",
|
||||
"requestFailed": "",
|
||||
"mermaidParseError": ""
|
||||
"promptTooShort": "プロンプトが短すぎます ({{min}} 文字以上)",
|
||||
"promptTooLong": "プロンプトが長すぎます ({{max}} 文字以下)",
|
||||
"generationFailed": "生成に失敗しました",
|
||||
"invalidDiagram": "無効なダイアグラムが生成されました。手動で編集、自動修正で再試行、または別のプロンプトをお試しください。",
|
||||
"fixInMermaid": "Mermaidで手動編集 →",
|
||||
"aiRepair": "再生成 (自動修正) →",
|
||||
"requestAborted": "リクエストが中断されました",
|
||||
"requestFailed": "リクエストに失敗しました",
|
||||
"mermaidParseError": "Mermaid構文エラー"
|
||||
},
|
||||
"rateLimit": {
|
||||
"messageLimit": "",
|
||||
"generalRateLimit": "",
|
||||
"messageLimitInputPlaceholder": ""
|
||||
"messageLimit": "無料プランのAI利用上限に達しました。Excalidraw+をお試しいただくか、また明日ご利用ください。",
|
||||
"generalRateLimit": "操作が速すぎたようです。もう少し待ってから再度お試しください。",
|
||||
"messageLimitInputPlaceholder": "メッセージの送信上限に達しました"
|
||||
},
|
||||
"upsellBtnLabel": ""
|
||||
"upsellBtnLabel": "Plusにアップグレード"
|
||||
},
|
||||
"quickSearch": {
|
||||
"placeholder": ""
|
||||
"placeholder": "クイック検索"
|
||||
},
|
||||
"fontList": {
|
||||
"badge": {
|
||||
"old": ""
|
||||
"old": "旧"
|
||||
},
|
||||
"sceneFonts": "このシーン内",
|
||||
"availableFonts": "利用可能フォント",
|
||||
@@ -680,36 +700,36 @@
|
||||
"hint": {
|
||||
"text": "ユーザーをクリックしてフォロー",
|
||||
"followStatus": "現在このユーザーをフォローしています",
|
||||
"inCall": "",
|
||||
"micMuted": "",
|
||||
"isSpeaking": ""
|
||||
"inCall": "ユーザーは音声通話中です",
|
||||
"micMuted": "ユーザーのマイクはミュート中です",
|
||||
"isSpeaking": "ユーザーは発話中です"
|
||||
}
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "",
|
||||
"title": "コマンドパレット",
|
||||
"shortcuts": {
|
||||
"select": "選択",
|
||||
"confirm": "確認",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"recents": "",
|
||||
"recents": "最近使ったもの",
|
||||
"search": {
|
||||
"placeholder": "",
|
||||
"noMatch": ""
|
||||
"placeholder": "メニュー、コマンドを検索して便利な機能を見つけよう",
|
||||
"noMatch": "一致するコマンドはありません..."
|
||||
},
|
||||
"itemNotAvailable": "",
|
||||
"itemNotAvailable": "コマンドを利用できません...",
|
||||
"shortcutHint": "コマンドパレットには{{shortcut}}を使用"
|
||||
},
|
||||
"keys": {
|
||||
"ctrl": "",
|
||||
"option": "",
|
||||
"cmd": "",
|
||||
"alt": "",
|
||||
"escape": "",
|
||||
"enter": "",
|
||||
"shift": "",
|
||||
"spacebar": "",
|
||||
"delete": "",
|
||||
"mmb": ""
|
||||
"ctrl": "Ctrl",
|
||||
"option": "Option",
|
||||
"cmd": "Cmd",
|
||||
"alt": "Alt",
|
||||
"escape": "Esc",
|
||||
"enter": "Enter",
|
||||
"shift": "Shift",
|
||||
"spacebar": "スペース",
|
||||
"delete": "Delete",
|
||||
"mmb": "スクロールホイール"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Qoyıw",
|
||||
"pasteAsPlaintext": "Ápiwayı tekst retinde qoyıw",
|
||||
"pasteCharts": "Diagrammalardı qoyıw",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Barlıǵın tańlaw",
|
||||
"multiSelect": "",
|
||||
"moveCanvas": "",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Qátelik"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Diskke saqlaw",
|
||||
"disk_details": "",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Senṭeḍ",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteCharts": "Senṭeḍ udlifen",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Fren akk",
|
||||
"multiSelect": "Rnu aferdis ɣer tefrayt",
|
||||
"moveCanvas": "Smutti taɣzut n usuneɣ",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Tuccḍa"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Sekles deg uḍebsi",
|
||||
"disk_details": "Sekles isefka n usayes deg ufaylu ansi ara tizmireḍ ad d-tketreḍ areḍqal.",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"paste": "Қою",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteCharts": "Диаграммаларды қою",
|
||||
"chartType_bar": "",
|
||||
"chartType_line": "",
|
||||
"chartType_radar": "",
|
||||
"chartType_plaintext": "",
|
||||
"selectAll": "Бәрін таңдау",
|
||||
"multiSelect": "",
|
||||
"moveCanvas": "",
|
||||
@@ -49,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "",
|
||||
"arrowhead_crowfoot_one": "",
|
||||
"arrowhead_crowfoot_one_or_many": "",
|
||||
"arrowhead_cardinality_one": "",
|
||||
"arrowhead_cardinality_many": "",
|
||||
"arrowhead_cardinality_one_or_many": "",
|
||||
"arrowhead_cardinality_exactly_one": "",
|
||||
"arrowhead_cardinality_zero_or_one": "",
|
||||
"arrowhead_cardinality_zero_or_many": "",
|
||||
"more_options": "",
|
||||
"cardinality": "",
|
||||
"arrowtypes": "",
|
||||
"arrowtype_sharp": "",
|
||||
"arrowtype_round": "",
|
||||
@@ -171,7 +182,11 @@
|
||||
"linkToElement": "",
|
||||
"wrapSelectionInFrame": "",
|
||||
"tab": "",
|
||||
"shapeSwitch": ""
|
||||
"shapeSwitch": "",
|
||||
"preferences": "",
|
||||
"preferences_toolLock": "",
|
||||
"arrowBinding": "",
|
||||
"midpointSnapping": ""
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "",
|
||||
@@ -395,6 +410,10 @@
|
||||
"errorDialog": {
|
||||
"title": "Қате"
|
||||
},
|
||||
"progressDialog": {
|
||||
"title": "",
|
||||
"defaultMessage": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "Сахна деректерін кейін қайта импорттауға болатын файлға экспорттаңыз.",
|
||||
@@ -616,7 +635,8 @@
|
||||
"syntax": "",
|
||||
"preview": "",
|
||||
"label": "",
|
||||
"inputPlaceholder": ""
|
||||
"inputPlaceholder": "",
|
||||
"autoFixAvailable": ""
|
||||
},
|
||||
"ttd": {
|
||||
"error": ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user