summaryrefslogtreecommitdiffstats
path: root/excalidraw-app/components
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commitbfc2cec7d43eb8eaa46dd3f91084932381257059 (patch)
tree0857e3aac2cff922826d4871ff54536b26fad6fc /excalidraw-app/components
parent225db4a7805befe009fe055fc2ef5daedd6c04f9 (diff)
refactor: excalidraw-app/
Diffstat (limited to 'excalidraw-app/components')
-rw-r--r--excalidraw-app/components/AI.tsx159
-rw-r--r--excalidraw-app/components/AppFooter.tsx29
-rw-r--r--excalidraw-app/components/AppMainMenu.tsx86
-rw-r--r--excalidraw-app/components/AppWelcomeScreen.tsx72
-rw-r--r--excalidraw-app/components/DebugCanvas.tsx344
-rw-r--r--excalidraw-app/components/EncryptedIcon.tsx21
-rw-r--r--excalidraw-app/components/ExcalidrawPlusAppLink.tsx19
-rw-r--r--excalidraw-app/components/ExportToExcalidrawPlus.tsx133
-rw-r--r--excalidraw-app/components/GitHubCorner.tsx45
-rw-r--r--excalidraw-app/components/TopErrorBoundary.tsx146
10 files changed, 1054 insertions, 0 deletions
diff --git a/excalidraw-app/components/AI.tsx b/excalidraw-app/components/AI.tsx
new file mode 100644
index 0000000..ba13849
--- /dev/null
+++ b/excalidraw-app/components/AI.tsx
@@ -0,0 +1,159 @@
+import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
+import {
+ DiagramToCodePlugin,
+ exportToBlob,
+ getTextFromElements,
+ MIME_TYPES,
+ TTDDialog,
+} from "@excalidraw/excalidraw";
+import { getDataURL } from "@excalidraw/excalidraw/data/blob";
+import { safelyParseJSON } from "@excalidraw/excalidraw/utils";
+
+export const AIComponents = ({
+ excalidrawAPI,
+}: {
+ excalidrawAPI: ExcalidrawImperativeAPI;
+}) => {
+ return (
+ <>
+ <DiagramToCodePlugin
+ generate={async ({ frame, children }) => {
+ const appState = excalidrawAPI.getAppState();
+
+ const blob = await exportToBlob({
+ elements: children,
+ appState: {
+ ...appState,
+ exportBackground: true,
+ viewBackgroundColor: appState.viewBackgroundColor,
+ },
+ exportingFrame: frame,
+ files: excalidrawAPI.getFiles(),
+ mimeType: MIME_TYPES.jpg,
+ });
+
+ const dataURL = await getDataURL(blob);
+
+ const textFromFrameChildren = getTextFromElements(children);
+
+ const response = await fetch(
+ `${
+ import.meta.env.VITE_APP_AI_BACKEND
+ }/v1/ai/diagram-to-code/generate`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ texts: textFromFrameChildren,
+ image: dataURL,
+ theme: appState.theme,
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const text = await response.text();
+ const errorJSON = safelyParseJSON(text);
+
+ if (!errorJSON) {
+ throw new Error(text);
+ }
+
+ if (errorJSON.statusCode === 429) {
+ return {
+ html: `<html>
+ <body style="margin: 0; text-align: center">
+ <div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
+ <div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
+ </br>
+ </br>
+ <div>You can also try <a href="${
+ import.meta.env.VITE_APP_PLUS_LP
+ }/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">kj-diagramming cloud</a> to get more requests.</div>
+ </div>
+ </body>
+ </html>`,
+ };
+ }
+
+ throw new Error(errorJSON.message || text);
+ }
+
+ try {
+ const { html } = await response.json();
+
+ if (!html) {
+ throw new Error("Generation failed (invalid response)");
+ }
+ return {
+ html,
+ };
+ } catch (error: any) {
+ throw new Error("Generation failed (invalid response)");
+ }
+ }}
+ />
+
+ <TTDDialog
+ onTextSubmit={async (input) => {
+ try {
+ const response = await fetch(
+ `${
+ import.meta.env.VITE_APP_AI_BACKEND
+ }/v1/ai/text-to-diagram/generate`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ prompt: input }),
+ },
+ );
+
+ const rateLimit = response.headers.has("X-Ratelimit-Limit")
+ ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
+ : undefined;
+
+ const rateLimitRemaining = response.headers.has(
+ "X-Ratelimit-Remaining",
+ )
+ ? parseInt(
+ response.headers.get("X-Ratelimit-Remaining") || "0",
+ 10,
+ )
+ : undefined;
+
+ const json = await response.json();
+
+ if (!response.ok) {
+ if (response.status === 429) {
+ return {
+ rateLimit,
+ rateLimitRemaining,
+ error: new Error(
+ "Too many requests today, please try again tomorrow!",
+ ),
+ };
+ }
+
+ throw new Error(json.message || "Generation failed...");
+ }
+
+ const generatedResponse = json.generatedResponse;
+ if (!generatedResponse) {
+ throw new Error("Generation failed...");
+ }
+
+ return { generatedResponse, rateLimit, rateLimitRemaining };
+ } catch (err: any) {
+ throw new Error("Request failed");
+ }
+ }}
+ />
+ </>
+ );
+};
diff --git a/excalidraw-app/components/AppFooter.tsx b/excalidraw-app/components/AppFooter.tsx
new file mode 100644
index 0000000..e81df7c
--- /dev/null
+++ b/excalidraw-app/components/AppFooter.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import { Footer } from "@excalidraw/excalidraw/index";
+import { EncryptedIcon } from "./EncryptedIcon";
+import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
+
+export const AppFooter = React.memo(
+ ({ onChange }: { onChange: () => void }) => {
+ return (
+ <Footer>
+ <div
+ style={{
+ display: "flex",
+ gap: ".5rem",
+ alignItems: "center",
+ }}
+ >
+ {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
+ {isExcalidrawPlusSignedUser ? (
+ <ExcalidrawPlusAppLink />
+ ) : (
+ <EncryptedIcon />
+ )}
+ </div>
+ </Footer>
+ );
+ },
+);
diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx
new file mode 100644
index 0000000..426ec69
--- /dev/null
+++ b/excalidraw-app/components/AppMainMenu.tsx
@@ -0,0 +1,86 @@
+import React from "react";
+import {
+ loginIcon,
+ ExcalLogo,
+ eyeIcon,
+} from "@excalidraw/excalidraw/components/icons";
+import type { Theme } from "@excalidraw/excalidraw/element/types";
+import { MainMenu } from "@excalidraw/excalidraw/index";
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+import { LanguageList } from "../app-language/LanguageList";
+import { saveDebugState } from "./DebugCanvas";
+
+export const AppMainMenu: React.FC<{
+ onCollabDialogOpen: () => any;
+ isCollaborating: boolean;
+ isCollabEnabled: boolean;
+ theme: Theme | "system";
+ setTheme: (theme: Theme | "system") => void;
+ refresh: () => void;
+}> = React.memo((props) => {
+ return (
+ <MainMenu>
+ <MainMenu.DefaultItems.LoadScene />
+ <MainMenu.DefaultItems.SaveToActiveFile />
+ <MainMenu.DefaultItems.Export />
+ <MainMenu.DefaultItems.SaveAsImage />
+ {props.isCollabEnabled && (
+ <MainMenu.DefaultItems.LiveCollaborationTrigger
+ isCollaborating={props.isCollaborating}
+ onSelect={() => props.onCollabDialogOpen()}
+ />
+ )}
+ <MainMenu.DefaultItems.CommandPalette className="highlighted" />
+ <MainMenu.DefaultItems.SearchMenu />
+ <MainMenu.DefaultItems.Help />
+ <MainMenu.DefaultItems.ClearCanvas />
+ <MainMenu.Separator />
+ <MainMenu.ItemLink
+ icon={ExcalLogo}
+ href={`${
+ import.meta.env.VITE_APP_PLUS_LP
+ }/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
+ className=""
+ >
+ kj-diagramming cloud
+ </MainMenu.ItemLink>
+ <MainMenu.DefaultItems.Socials />
+ <MainMenu.ItemLink
+ icon={loginIcon}
+ href={`${import.meta.env.VITE_APP_PLUS_APP}${
+ isExcalidrawPlusSignedUser ? "" : "/sign-up"
+ }?utm_source=signin&utm_medium=app&utm_content=hamburger`}
+ className="highlighted"
+ >
+ {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
+ </MainMenu.ItemLink>
+ {import.meta.env.DEV && (
+ <MainMenu.Item
+ icon={eyeIcon}
+ onClick={() => {
+ if (window.visualDebug) {
+ delete window.visualDebug;
+ saveDebugState({ enabled: false });
+ } else {
+ window.visualDebug = { data: [] };
+ saveDebugState({ enabled: true });
+ }
+ props?.refresh();
+ }}
+ >
+ Visual Debug
+ </MainMenu.Item>
+ )}
+ <MainMenu.Separator />
+ <MainMenu.DefaultItems.ToggleTheme
+ allowSystemTheme
+ theme={props.theme}
+ onSelect={props.setTheme}
+ />
+ <MainMenu.ItemCustom>
+ <LanguageList style={{ width: "100%" }} />
+ </MainMenu.ItemCustom>
+ <MainMenu.DefaultItems.ChangeCanvasBackground />
+ </MainMenu>
+ );
+});
diff --git a/excalidraw-app/components/AppWelcomeScreen.tsx b/excalidraw-app/components/AppWelcomeScreen.tsx
new file mode 100644
index 0000000..139a951
--- /dev/null
+++ b/excalidraw-app/components/AppWelcomeScreen.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { loginIcon } from "@excalidraw/excalidraw/components/icons";
+import { useI18n } from "@excalidraw/excalidraw/i18n";
+import { WelcomeScreen } from "@excalidraw/excalidraw/index";
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+import { POINTER_EVENTS } from "@excalidraw/excalidraw/constants";
+
+export const AppWelcomeScreen: React.FC<{
+ onCollabDialogOpen: () => any;
+ isCollabEnabled: boolean;
+}> = React.memo((props) => {
+ const { t } = useI18n();
+ let headingContent;
+
+ if (isExcalidrawPlusSignedUser) {
+ headingContent = t("welcomeScreen.app.center_heading_plus")
+ .split(/(kj-diagramming)/)
+ .map((bit, idx) => {
+ if (bit === "kj-diagramming") {
+ return (
+ <a
+ style={{ pointerEvents: POINTER_EVENTS.inheritFromUI }}
+ href={`${
+ import.meta.env.VITE_APP_PLUS_APP
+ }?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
+ key={idx}
+ >
+ kj-diagramming
+ </a>
+ );
+ }
+ return bit;
+ });
+ } else {
+ headingContent = t("welcomeScreen.app.center_heading");
+ }
+
+ return (
+ <WelcomeScreen>
+ <WelcomeScreen.Hints.MenuHint>
+ {t("welcomeScreen.app.menuHint")}
+ </WelcomeScreen.Hints.MenuHint>
+ <WelcomeScreen.Hints.ToolbarHint />
+ <WelcomeScreen.Hints.HelpHint />
+ <WelcomeScreen.Center>
+ <WelcomeScreen.Center.Heading>
+ {headingContent}
+ </WelcomeScreen.Center.Heading>
+ <WelcomeScreen.Center.Menu>
+ <WelcomeScreen.Center.MenuItemLoadScene />
+ <WelcomeScreen.Center.MenuItemHelp />
+ {props.isCollabEnabled && (
+ <WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
+ onSelect={() => props.onCollabDialogOpen()}
+ />
+ )}
+ {!isExcalidrawPlusSignedUser && (
+ <WelcomeScreen.Center.MenuItemLink
+ href={`${
+ import.meta.env.VITE_APP_PLUS_LP
+ }/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
+ shortcut={null}
+ icon={loginIcon}
+ >
+ Sign up
+ </WelcomeScreen.Center.MenuItemLink>
+ )}
+ </WelcomeScreen.Center.Menu>
+ </WelcomeScreen.Center>
+ </WelcomeScreen>
+ );
+});
diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx
new file mode 100644
index 0000000..31898ba
--- /dev/null
+++ b/excalidraw-app/components/DebugCanvas.tsx
@@ -0,0 +1,344 @@
+import { useCallback, useImperativeHandle, useRef } from "react";
+import { type AppState } from "@excalidraw/excalidraw/types";
+import { throttleRAF } from "@excalidraw/excalidraw/utils";
+import {
+ bootstrapCanvas,
+ getNormalizedCanvasDimensions,
+} from "@excalidraw/excalidraw/renderer/helpers";
+import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
+import {
+ ArrowheadArrowIcon,
+ CloseIcon,
+ TrashIcon,
+} from "@excalidraw/excalidraw/components/icons";
+import { STORAGE_KEYS } from "../app_constants";
+import type { Curve } from "../../packages/math";
+import {
+ isLineSegment,
+ type GlobalPoint,
+ type LineSegment,
+} from "../../packages/math";
+import { isCurve } from "../../packages/math/curve";
+
+const renderLine = (
+ context: CanvasRenderingContext2D,
+ zoom: number,
+ segment: LineSegment<GlobalPoint>,
+ color: string,
+) => {
+ context.save();
+ context.strokeStyle = color;
+ context.beginPath();
+ context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
+ context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
+ context.stroke();
+ context.restore();
+};
+
+const renderCubicBezier = (
+ context: CanvasRenderingContext2D,
+ zoom: number,
+ [start, control1, control2, end]: Curve<GlobalPoint>,
+ color: string,
+) => {
+ context.save();
+ context.strokeStyle = color;
+ context.beginPath();
+ context.moveTo(start[0] * zoom, start[1] * zoom);
+ context.bezierCurveTo(
+ control1[0] * zoom,
+ control1[1] * zoom,
+ control2[0] * zoom,
+ control2[1] * zoom,
+ end[0] * zoom,
+ end[1] * zoom,
+ );
+ context.stroke();
+ context.restore();
+};
+
+const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
+ context.strokeStyle = "#888";
+ context.save();
+ context.beginPath();
+ context.moveTo(-10 * zoom, -10 * zoom);
+ context.lineTo(10 * zoom, 10 * zoom);
+ context.moveTo(10 * zoom, -10 * zoom);
+ context.lineTo(-10 * zoom, 10 * zoom);
+ context.stroke();
+ context.save();
+};
+
+const render = (
+ frame: DebugElement[],
+ context: CanvasRenderingContext2D,
+ appState: AppState,
+) => {
+ frame.forEach((el: DebugElement) => {
+ switch (true) {
+ case isLineSegment(el.data):
+ renderLine(
+ context,
+ appState.zoom.value,
+ el.data as LineSegment<GlobalPoint>,
+ el.color,
+ );
+ break;
+ case isCurve(el.data):
+ renderCubicBezier(
+ context,
+ appState.zoom.value,
+ el.data as Curve<GlobalPoint>,
+ el.color,
+ );
+ break;
+ default:
+ throw new Error(`Unknown element type ${JSON.stringify(el)}`);
+ }
+ });
+};
+
+const _debugRenderer = (
+ canvas: HTMLCanvasElement,
+ appState: AppState,
+ scale: number,
+ refresh: () => void,
+) => {
+ const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
+ canvas,
+ scale,
+ );
+
+ if (appState.height !== canvas.height || appState.width !== canvas.width) {
+ refresh();
+ }
+
+ const context = bootstrapCanvas({
+ canvas,
+ scale,
+ normalizedWidth,
+ normalizedHeight,
+ viewBackgroundColor: "transparent",
+ });
+
+ // Apply zoom
+ context.save();
+ context.translate(
+ appState.scrollX * appState.zoom.value,
+ appState.scrollY * appState.zoom.value,
+ );
+
+ renderOrigin(context, appState.zoom.value);
+
+ if (
+ window.visualDebug?.currentFrame &&
+ window.visualDebug?.data &&
+ window.visualDebug.data.length > 0
+ ) {
+ // Render only one frame
+ const [idx] = debugFrameData();
+
+ render(window.visualDebug.data[idx], context, appState);
+ } else {
+ // Render all debug frames
+ window.visualDebug?.data.forEach((frame) => {
+ render(frame, context, appState);
+ });
+ }
+
+ if (window.visualDebug) {
+ window.visualDebug!.data =
+ window.visualDebug?.data.map((frame) =>
+ frame.filter((el) => el.permanent),
+ ) ?? [];
+ }
+};
+
+const debugFrameData = (): [number, number] => {
+ const currentFrame = window.visualDebug?.currentFrame ?? 0;
+ const frameCount = window.visualDebug?.data.length ?? 0;
+
+ if (frameCount > 0) {
+ return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
+ }
+
+ return [0, 0];
+};
+
+export const saveDebugState = (debug: { enabled: boolean }) => {
+ try {
+ localStorage.setItem(
+ STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
+ JSON.stringify(debug),
+ );
+ } catch (error: any) {
+ console.error(error);
+ }
+};
+
+export const debugRenderer = throttleRAF(
+ (
+ canvas: HTMLCanvasElement,
+ appState: AppState,
+ scale: number,
+ refresh: () => void,
+ ) => {
+ _debugRenderer(canvas, appState, scale, refresh);
+ },
+ { trailing: true },
+);
+
+export const loadSavedDebugState = () => {
+ let debug;
+ try {
+ const savedDebugState = localStorage.getItem(
+ STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
+ );
+ if (savedDebugState) {
+ debug = JSON.parse(savedDebugState) as { enabled: boolean };
+ }
+ } catch (error: any) {
+ console.error(error);
+ }
+
+ return debug ?? { enabled: false };
+};
+
+export const isVisualDebuggerEnabled = () =>
+ Array.isArray(window.visualDebug?.data);
+
+export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
+ const moveForward = useCallback(() => {
+ if (
+ !window.visualDebug?.currentFrame ||
+ isNaN(window.visualDebug?.currentFrame ?? -1)
+ ) {
+ window.visualDebug!.currentFrame = 0;
+ }
+ window.visualDebug!.currentFrame += 1;
+ onChange();
+ }, [onChange]);
+ const moveBackward = useCallback(() => {
+ if (
+ !window.visualDebug?.currentFrame ||
+ isNaN(window.visualDebug?.currentFrame ?? -1) ||
+ window.visualDebug?.currentFrame < 1
+ ) {
+ window.visualDebug!.currentFrame = 1;
+ }
+ window.visualDebug!.currentFrame -= 1;
+ onChange();
+ }, [onChange]);
+ const reset = useCallback(() => {
+ window.visualDebug!.currentFrame = undefined;
+ onChange();
+ }, [onChange]);
+ const trashFrames = useCallback(() => {
+ if (window.visualDebug) {
+ window.visualDebug.currentFrame = undefined;
+ window.visualDebug.data = [];
+ }
+ onChange();
+ }, [onChange]);
+
+ return (
+ <>
+ <button
+ className="ToolIcon_type_button"
+ data-testid="debug-forward"
+ aria-label="Move forward"
+ type="button"
+ onClick={trashFrames}
+ >
+ <div
+ className="ToolIcon__icon"
+ aria-hidden="true"
+ aria-disabled="false"
+ >
+ {TrashIcon}
+ </div>
+ </button>
+ <button
+ className="ToolIcon_type_button"
+ data-testid="debug-forward"
+ aria-label="Move forward"
+ type="button"
+ onClick={moveBackward}
+ >
+ <div
+ className="ToolIcon__icon"
+ aria-hidden="true"
+ aria-disabled="false"
+ >
+ <ArrowheadArrowIcon flip />
+ </div>
+ </button>
+ <button
+ className="ToolIcon_type_button"
+ data-testid="debug-forward"
+ aria-label="Move forward"
+ type="button"
+ onClick={reset}
+ >
+ <div
+ className="ToolIcon__icon"
+ aria-hidden="true"
+ aria-disabled="false"
+ >
+ {CloseIcon}
+ </div>
+ </button>
+ <button
+ className="ToolIcon_type_button"
+ data-testid="debug-backward"
+ aria-label="Move backward"
+ type="button"
+ onClick={moveForward}
+ >
+ <div
+ className="ToolIcon__icon"
+ aria-hidden="true"
+ aria-disabled="false"
+ >
+ <ArrowheadArrowIcon />
+ </div>
+ </button>
+ </>
+ );
+};
+
+interface DebugCanvasProps {
+ appState: AppState;
+ scale: number;
+ ref?: React.Ref<HTMLCanvasElement>;
+}
+
+const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
+ const { width, height } = appState;
+
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
+ ref,
+ () => canvasRef.current,
+ [canvasRef],
+ );
+
+ return (
+ <canvas
+ style={{
+ width,
+ height,
+ position: "absolute",
+ zIndex: 2,
+ pointerEvents: "none",
+ }}
+ width={width * scale}
+ height={height * scale}
+ ref={canvasRef}
+ >
+ Debug Canvas
+ </canvas>
+ );
+};
+
+export default DebugCanvas;
diff --git a/excalidraw-app/components/EncryptedIcon.tsx b/excalidraw-app/components/EncryptedIcon.tsx
new file mode 100644
index 0000000..a3a8417
--- /dev/null
+++ b/excalidraw-app/components/EncryptedIcon.tsx
@@ -0,0 +1,21 @@
+import { shield } from "@excalidraw/excalidraw/components/icons";
+import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
+import { useI18n } from "@excalidraw/excalidraw/i18n";
+
+export const EncryptedIcon = () => {
+ const { t } = useI18n();
+
+ return (
+ <a
+ className="encrypted-icon tooltip"
+ href="https://plus.excalidraw.com/blog/end-to-end-encryption"
+ target="_blank"
+ rel="noopener noreferrer"
+ aria-label={t("encrypted.link")}
+ >
+ <Tooltip label={t("encrypted.tooltip")} long={true}>
+ {shield}
+ </Tooltip>
+ </a>
+ );
+};
diff --git a/excalidraw-app/components/ExcalidrawPlusAppLink.tsx b/excalidraw-app/components/ExcalidrawPlusAppLink.tsx
new file mode 100644
index 0000000..88cc0fc
--- /dev/null
+++ b/excalidraw-app/components/ExcalidrawPlusAppLink.tsx
@@ -0,0 +1,19 @@
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+
+export const ExcalidrawPlusAppLink = () => {
+ if (!isExcalidrawPlusSignedUser) {
+ return null;
+ }
+ return (
+ <a
+ href={`${
+ import.meta.env.VITE_APP_PLUS_APP
+ }?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
+ target="_blank"
+ rel="noreferrer"
+ className="plus-button"
+ >
+ Go to kj-diagramming cloud
+ </a>
+ );
+};
diff --git a/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/excalidraw-app/components/ExportToExcalidrawPlus.tsx
new file mode 100644
index 0000000..782ecd9
--- /dev/null
+++ b/excalidraw-app/components/ExportToExcalidrawPlus.tsx
@@ -0,0 +1,133 @@
+import React from "react";
+import { Card } from "@excalidraw/excalidraw/components/Card";
+import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
+import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
+import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
+import type {
+ FileId,
+ NonDeletedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import type {
+ AppState,
+ BinaryFileData,
+ BinaryFiles,
+} from "@excalidraw/excalidraw/types";
+import { nanoid } from "nanoid";
+import { useI18n } from "@excalidraw/excalidraw/i18n";
+import {
+ encryptData,
+ generateEncryptionKey,
+} from "@excalidraw/excalidraw/data/encryption";
+import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
+import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
+import { encodeFilesForUpload } from "../data/FileManager";
+import { uploadBytes, ref } from "firebase/storage";
+import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
+import { trackEvent } from "@excalidraw/excalidraw/analytics";
+import { getFrame } from "@excalidraw/excalidraw/utils";
+import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
+
+export const exportToExcalidrawPlus = async (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: Partial<AppState>,
+ files: BinaryFiles,
+ name: string,
+) => {
+ const storage = await loadFirebaseStorage();
+
+ const id = `${nanoid(12)}`;
+
+ const encryptionKey = (await generateEncryptionKey())!;
+ const encryptedData = await encryptData(
+ encryptionKey,
+ serializeAsJSON(elements, appState, files, "database"),
+ );
+
+ const blob = new Blob(
+ [encryptedData.iv, new Uint8Array(encryptedData.encryptedBuffer)],
+ {
+ type: MIME_TYPES.binary,
+ },
+ );
+
+ const storageRef = ref(storage, `/migrations/scenes/${id}`);
+ await uploadBytes(storageRef, blob, {
+ customMetadata: {
+ data: JSON.stringify({ version: 2, name }),
+ created: Date.now().toString(),
+ },
+ });
+
+ const filesMap = new Map<FileId, BinaryFileData>();
+ for (const element of elements) {
+ if (isInitializedImageElement(element) && files[element.fileId]) {
+ filesMap.set(element.fileId, files[element.fileId]);
+ }
+ }
+
+ if (filesMap.size) {
+ const filesToUpload = await encodeFilesForUpload({
+ files: filesMap,
+ encryptionKey,
+ maxBytes: FILE_UPLOAD_MAX_BYTES,
+ });
+
+ await saveFilesToFirebase({
+ prefix: `/migrations/files/scenes/${id}`,
+ files: filesToUpload,
+ });
+ }
+
+ window.open(
+ `${
+ import.meta.env.VITE_APP_PLUS_APP
+ }/import?excalidraw=${id},${encryptionKey}`,
+ );
+};
+
+export const ExportToExcalidrawPlus: React.FC<{
+ elements: readonly NonDeletedExcalidrawElement[];
+ appState: Partial<AppState>;
+ files: BinaryFiles;
+ name: string;
+ onError: (error: Error) => void;
+ onSuccess: () => void;
+}> = ({ elements, appState, files, name, onError, onSuccess }) => {
+ const { t } = useI18n();
+ return (
+ <Card color="primary">
+ <div className="Card-icon">
+ <ExcalidrawLogo
+ style={{
+ [`--color-logo-icon` as any]: "#fff",
+ width: "2.8rem",
+ height: "2.8rem",
+ }}
+ />
+ </div>
+ <h2>Excalidraw+</h2>
+ <div className="Card-details">
+ {t("exportDialog.excalidrawplus_description")}
+ </div>
+ <ToolButton
+ className="Card-button"
+ type="button"
+ title={t("exportDialog.excalidrawplus_button")}
+ aria-label={t("exportDialog.excalidrawplus_button")}
+ showAriaLabel={true}
+ onClick={async () => {
+ try {
+ trackEvent("export", "eplus", `ui (${getFrame()})`);
+ await exportToExcalidrawPlus(elements, appState, files, name);
+ onSuccess();
+ } catch (error: any) {
+ console.error(error);
+ if (error.name !== "AbortError") {
+ onError(new Error(t("exportDialog.excalidrawplus_exportError")));
+ }
+ }
+ }}
+ />
+ </Card>
+ );
+};
diff --git a/excalidraw-app/components/GitHubCorner.tsx b/excalidraw-app/components/GitHubCorner.tsx
new file mode 100644
index 0000000..4d74242
--- /dev/null
+++ b/excalidraw-app/components/GitHubCorner.tsx
@@ -0,0 +1,45 @@
+import oc from "open-color";
+import React from "react";
+import { THEME } from "@excalidraw/excalidraw/constants";
+import type { Theme } from "@excalidraw/excalidraw/element/types";
+
+// https://github.com/tholman/github-corners
+export const GitHubCorner = React.memo(
+ ({ theme, dir }: { theme: Theme; dir: string }) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="40"
+ height="40"
+ viewBox="0 0 250 250"
+ className="rtl-mirror"
+ style={{
+ marginTop: "calc(var(--space-factor) * -1)",
+ [dir === "rtl" ? "marginLeft" : "marginRight"]:
+ "calc(var(--space-factor) * -1)",
+ }}
+ >
+ <a
+ href="https://github.com/excalidraw/excalidraw"
+ target="_blank"
+ rel="noopener noreferrer"
+ aria-label="GitHub repository"
+ >
+ <path
+ d="M0 0l115 115h15l12 27 108 108V0z"
+ fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
+ />
+ <path
+ className="octo-arm"
+ d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
+ style={{ transformOrigin: "130px 106px" }}
+ fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
+ />
+ <path
+ className="octo-body"
+ d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
+ fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
+ />
+ </a>
+ </svg>
+ ),
+);
diff --git a/excalidraw-app/components/TopErrorBoundary.tsx b/excalidraw-app/components/TopErrorBoundary.tsx
new file mode 100644
index 0000000..e7e00be
--- /dev/null
+++ b/excalidraw-app/components/TopErrorBoundary.tsx
@@ -0,0 +1,146 @@
+import React from "react";
+import * as Sentry from "@sentry/browser";
+import { t } from "@excalidraw/excalidraw/i18n";
+import Trans from "@excalidraw/excalidraw/components/Trans";
+
+interface TopErrorBoundaryState {
+ hasError: boolean;
+ sentryEventId: string;
+ localStorage: string;
+}
+
+export class TopErrorBoundary extends React.Component<
+ any,
+ TopErrorBoundaryState
+> {
+ state: TopErrorBoundaryState = {
+ hasError: false,
+ sentryEventId: "",
+ localStorage: "",
+ };
+
+ render() {
+ return this.state.hasError ? this.errorSplash() : this.props.children;
+ }
+
+ componentDidCatch(error: Error, errorInfo: any) {
+ const _localStorage: any = {};
+ for (const [key, value] of Object.entries({ ...localStorage })) {
+ try {
+ _localStorage[key] = JSON.parse(value);
+ } catch (error: any) {
+ _localStorage[key] = value;
+ }
+ }
+
+ Sentry.withScope((scope) => {
+ scope.setExtras(errorInfo);
+ const eventId = Sentry.captureException(error);
+
+ this.setState((state) => ({
+ hasError: true,
+ sentryEventId: eventId,
+ localStorage: JSON.stringify(_localStorage),
+ }));
+ });
+ }
+
+ private selectTextArea(event: React.MouseEvent<HTMLTextAreaElement>) {
+ if (event.target !== document.activeElement) {
+ event.preventDefault();
+ (event.target as HTMLTextAreaElement).select();
+ }
+ }
+
+ private async createGithubIssue() {
+ let body = "";
+ try {
+ const templateStrFn = (
+ await import(
+ /* webpackChunkName: "bug-issue-template" */ "../bug-issue-template"
+ )
+ ).default;
+ body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
+ } catch (error: any) {
+ console.error(error);
+ }
+
+ window.open(
+ `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
+ "_blank",
+ "noopener noreferrer",
+ );
+ }
+
+ private errorSplash() {
+ return (
+ <div className="ErrorSplash excalidraw">
+ <div className="ErrorSplash-messageContainer">
+ <div className="ErrorSplash-paragraph bigger align-center">
+ <Trans
+ i18nKey="errorSplash.headingMain"
+ button={(el) => (
+ <button onClick={() => window.location.reload()}>{el}</button>
+ )}
+ />
+ </div>
+ <div className="ErrorSplash-paragraph align-center">
+ <Trans
+ i18nKey="errorSplash.clearCanvasMessage"
+ button={(el) => (
+ <button
+ onClick={() => {
+ try {
+ localStorage.clear();
+ window.location.reload();
+ } catch (error: any) {
+ console.error(error);
+ }
+ }}
+ >
+ {el}
+ </button>
+ )}
+ />
+ <br />
+ <div className="smaller">
+ <span role="img" aria-label="warning">
+ ⚠️
+ </span>
+ {t("errorSplash.clearCanvasCaveat")}
+ <span role="img" aria-hidden="true">
+ ⚠️
+ </span>
+ </div>
+ </div>
+ <div>
+ <div className="ErrorSplash-paragraph">
+ {t("errorSplash.trackedToSentry", {
+ eventId: this.state.sentryEventId,
+ })}
+ </div>
+ <div className="ErrorSplash-paragraph">
+ <Trans
+ i18nKey="errorSplash.openIssueMessage"
+ button={(el) => (
+ <button onClick={() => this.createGithubIssue()}>{el}</button>
+ )}
+ />
+ </div>
+ <div className="ErrorSplash-paragraph">
+ <div className="ErrorSplash-details">
+ <label>{t("errorSplash.sceneContent")}</label>
+ <textarea
+ rows={5}
+ onPointerDown={this.selectTextArea}
+ readOnly={true}
+ value={this.state.localStorage}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}