diff options
Diffstat (limited to 'excalidraw-app/components')
| -rw-r--r-- | excalidraw-app/components/AI.tsx | 159 | ||||
| -rw-r--r-- | excalidraw-app/components/AppFooter.tsx | 29 | ||||
| -rw-r--r-- | excalidraw-app/components/AppMainMenu.tsx | 86 | ||||
| -rw-r--r-- | excalidraw-app/components/AppWelcomeScreen.tsx | 72 | ||||
| -rw-r--r-- | excalidraw-app/components/DebugCanvas.tsx | 344 | ||||
| -rw-r--r-- | excalidraw-app/components/EncryptedIcon.tsx | 21 | ||||
| -rw-r--r-- | excalidraw-app/components/ExcalidrawPlusAppLink.tsx | 19 | ||||
| -rw-r--r-- | excalidraw-app/components/ExportToExcalidrawPlus.tsx | 133 | ||||
| -rw-r--r-- | excalidraw-app/components/GitHubCorner.tsx | 45 | ||||
| -rw-r--r-- | excalidraw-app/components/TopErrorBoundary.tsx | 146 |
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> + ); + } +} |
