diff --git a/.env.production b/.env.production index 7cf326b8a775..a618b2436b3f 100644 --- a/.env.production +++ b/.env.production @@ -21,4 +21,4 @@ VITE_APP_WS_SERVER_URL=http://localhost:5012 # VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' VITE_APP_ENABLE_TRACKING=false -VITE_APP_DISABLE_SENTRY=true \ No newline at end of file +VITE_APP_DISABLE_SENTRY=true diff --git a/dev-docs/docs/@excalidraw/mermaid-to-excalidraw/api.mdx b/dev-docs/docs/@excalidraw/mermaid-to-excalidraw/api.mdx index a88bda5ef36e..f3212b43eace 100644 --- a/dev-docs/docs/@excalidraw/mermaid-to-excalidraw/api.mdx +++ b/dev-docs/docs/@excalidraw/mermaid-to-excalidraw/api.mdx @@ -14,7 +14,7 @@ This API receives the mermaid syntax as the input, and resolves to skeleton Exca import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw"; import { convertToExcalidrawElements} from "@excalidraw/excalidraw" try { - const { elements, files } = await parseMermaid(mermaidSyntax: string, { + const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, { fontSize: number, }); const excalidrawElements = convertToExcalidrawElements(elements); diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index d0c4ef0a5f8e..3f15a8e6c614 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -22,7 +22,6 @@ import { t } from "../packages/excalidraw/i18n"; import { Excalidraw, LiveCollaborationTrigger, - TTDDialog, TTDDialogTrigger, StoreAction, reconcileElements, @@ -121,6 +120,12 @@ import { import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; import { getPreferredLanguage } from "./app-language/language-detector"; import { useAppLangCode } from "./app-language/language-state"; +import DebugCanvas, { + debugRenderer, + isVisualDebuggerEnabled, + loadSavedDebugState, +} from "./components/DebugCanvas"; +import { AIComponents } from "./components/AI"; polyfill(); @@ -337,6 +342,8 @@ const ExcalidrawWrapper = () => { resolvablePromise(); } + const debugCanvasRef = useRef(null); + useEffect(() => { trackEvent("load", "frame", getFrame()); // Delayed so that the app has a time to load the latest SW @@ -362,6 +369,23 @@ const ExcalidrawWrapper = () => { migrationAdapter: LibraryLocalStorageMigrationAdapter, }); + const [, forceRefresh] = useState(false); + + useEffect(() => { + if (import.meta.env.DEV) { + const debugState = loadSavedDebugState(); + + if (debugState.enabled && !window.visualDebug) { + window.visualDebug = { + data: [], + }; + } else { + delete window.visualDebug; + } + forceRefresh((prev) => !prev); + } + }, [excalidrawAPI]); + useEffect(() => { if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) { return; @@ -626,6 +650,11 @@ const ExcalidrawWrapper = () => { } }); } + + // Render the debug scene if the debug canvas is available + if (debugCanvasRef.current && excalidrawAPI) { + debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio); + } }; const [latestShareableLink, setLatestShareableLink] = useState( @@ -824,6 +853,7 @@ const ExcalidrawWrapper = () => { isCollabEnabled={!isCollabDisabled} theme={appTheme} setTheme={(theme) => setAppTheme(theme)} + refresh={() => forceRefresh((prev) => !prev)} /> { )} - - { - 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..."); - } + excalidrawAPI?.refresh()} /> + {excalidrawAPI && } - return { generatedResponse, rateLimit, rateLimitRemaining }; - } catch (err: any) { - throw new Error("Request failed"); - } - }} - /> {isCollaborating && isOffline && (
@@ -1136,6 +1111,13 @@ const ExcalidrawWrapper = () => { }, ]} /> + {isVisualDebuggerEnabled() && excalidrawAPI && ( + + )}
); diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index f4b56496df0e..1dc6c6f4622f 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -40,6 +40,7 @@ export const STORAGE_KEYS = { LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_COLLAB: "excalidraw-collab", LOCAL_STORAGE_THEME: "excalidraw-theme", + LOCAL_STORAGE_DEBUG: "excalidraw-debug", VERSION_DATA_STATE: "version-dataState", VERSION_FILES: "version-files", diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index e9a4b5bf00fc..0a16f9584f91 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -116,20 +116,26 @@ class Portal { } } - this.collab.excalidrawAPI.updateScene({ - elements: this.collab.excalidrawAPI - .getSceneElementsIncludingDeleted() - .map((element) => { - if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) { - // this will signal collaborators to pull image data from server - // (using mutation instead of newElementWith otherwise it'd break - // in-progress dragging) - return newElementWith(element, { status: "saved" }); - } - return element; - }), - storeAction: StoreAction.UPDATE, - }); + let isChanged = false; + const newElements = this.collab.excalidrawAPI + .getSceneElementsIncludingDeleted() + .map((element) => { + if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) { + isChanged = true; + // this will signal collaborators to pull image data from server + // (using mutation instead of newElementWith otherwise it'd break + // in-progress dragging) + return newElementWith(element, { status: "saved" }); + } + return element; + }); + + if (isChanged) { + this.collab.excalidrawAPI.updateScene({ + elements: newElements, + storeAction: StoreAction.UPDATE, + }); + } }, FILE_UPLOAD_TIMEOUT); broadcastScene = async ( diff --git a/excalidraw-app/collab/RoomDialog.tsx b/excalidraw-app/collab/RoomDialog.tsx deleted file mode 100644 index 74266d3d91bd..000000000000 --- a/excalidraw-app/collab/RoomDialog.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { useRef, useState } from "react"; -import * as Popover from "@radix-ui/react-popover"; - -import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard"; -import { trackEvent } from "../../packages/excalidraw/analytics"; -import { getFrame } from "../../packages/excalidraw/utils"; -import { useI18n } from "../../packages/excalidraw/i18n"; -import { KEYS } from "../../packages/excalidraw/keys"; - -import { Dialog } from "../../packages/excalidraw/components/Dialog"; -import { - copyIcon, - playerPlayIcon, - playerStopFilledIcon, - share, - shareIOS, - shareWindows, - tablerCheckIcon, -} from "../../packages/excalidraw/components/icons"; -import { TextField } from "../../packages/excalidraw/components/TextField"; -import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; - -import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg"; -import "./RoomDialog.scss"; - -const getShareIcon = () => { - const navigator = window.navigator as any; - const isAppleBrowser = /Apple/.test(navigator.vendor); - const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1; - - if (isAppleBrowser) { - return shareIOS; - } else if (isWindowsBrowser) { - return shareWindows; - } - - return share; -}; - -export type RoomModalProps = { - handleClose: () => void; - activeRoomLink: string; - username: string; - onUsernameChange: (username: string) => void; - onRoomCreate: () => void; - onRoomDestroy: () => void; - setErrorMessage: (message: string) => void; -}; - -export const RoomModal = ({ - activeRoomLink, - onRoomCreate, - onRoomDestroy, - setErrorMessage, - username, - onUsernameChange, - handleClose, -}: RoomModalProps) => { - const { t } = useI18n(); - const [justCopied, setJustCopied] = useState(false); - const timerRef = useRef(0); - const ref = useRef(null); - const isShareSupported = "share" in navigator; - - const copyRoomLink = async () => { - try { - await copyTextToSystemClipboard(activeRoomLink); - } catch (e) { - setErrorMessage(t("errors.copyToSystemClipboardFailed")); - } - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - - ref.current?.select(); - }; - - const shareRoomLink = async () => { - try { - await navigator.share({ - title: t("roomDialog.shareTitle"), - text: t("roomDialog.shareTitle"), - url: activeRoomLink, - }); - } catch (error: any) { - // Just ignore. - } - }; - - if (activeRoomLink) { - return ( - <> -

- {t("labels.liveCollaboration")} -

- event.key === KEYS.ENTER && handleClose()} - /> -
- - {isShareSupported && ( - - )} - - - - - event.preventDefault()} - onCloseAutoFocus={(event) => event.preventDefault()} - className="RoomDialog__popover" - side="top" - align="end" - sideOffset={5.5} - > - {tablerCheckIcon} copied - - -
-
-

- - {t("roomDialog.desc_privacy")} -

-

{t("roomDialog.desc_exitSession")}

-
- -
- { - trackEvent("share", "room closed"); - onRoomDestroy(); - }} - /> -
- - ); - } - - return ( - <> -
- -
-
- {t("labels.liveCollaboration")} -
- -
- {t("roomDialog.desc_intro")} - {t("roomDialog.desc_privacy")} -
- -
- { - trackEvent("share", "room creation", `ui (${getFrame()})`); - onRoomCreate(); - }} - /> -
- - ); -}; - -const RoomDialog = (props: RoomModalProps) => { - return ( - -
- -
-
- ); -}; - -export default RoomDialog; diff --git a/excalidraw-app/components/AI.tsx b/excalidraw-app/components/AI.tsx new file mode 100644 index 000000000000..621d6befa7a0 --- /dev/null +++ b/excalidraw-app/components/AI.tsx @@ -0,0 +1,159 @@ +import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types"; +import { + DiagramToCodePlugin, + exportToBlob, + getTextFromElements, + MIME_TYPES, + TTDDialog, +} from "../../packages/excalidraw"; +import { getDataURL } from "../../packages/excalidraw/data/blob"; +import { safelyParseJSON } from "../../packages/excalidraw/utils"; + +export const AIComponents = ({ + excalidrawAPI, +}: { + excalidrawAPI: ExcalidrawImperativeAPI; +}) => { + return ( + <> + { + 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: ` + +
+
Too many requests today,
please try again tomorrow!
+
+
+
You can also try Excalidraw+ to get more requests.
+
+ + `, + }; + } + + 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)"); + } + }} + /> + + { + 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 index 6248732183b2..ea8152a25d32 100644 --- a/excalidraw-app/components/AppFooter.tsx +++ b/excalidraw-app/components/AppFooter.tsx @@ -3,23 +3,27 @@ import { Footer } from "../../packages/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(() => { - return ( -
-
- {isExcalidrawPlusSignedUser ? ( - - ) : ( - - )} -
-
- ); -}); +export const AppFooter = React.memo( + ({ onChange }: { onChange: () => void }) => { + return ( +
+
+ {isVisualDebuggerEnabled() && } + {isExcalidrawPlusSignedUser ? ( + + ) : ( + + )} +
+
+ ); + }, +); diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index eb3f24cafbc2..04bddedefca8 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -2,11 +2,13 @@ import React from "react"; import { loginIcon, ExcalLogo, + eyeIcon, } from "../../packages/excalidraw/components/icons"; import type { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; import { isExcalidrawPlusSignedUser } from "../app_constants"; import { LanguageList } from "../app-language/LanguageList"; +import { saveDebugState } from "./DebugCanvas"; export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; @@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{ isCollabEnabled: boolean; theme: Theme | "system"; setTheme: (theme: Theme | "system") => void; + refresh: () => void; }> = React.memo((props) => { return ( @@ -50,6 +53,23 @@ export const AppMainMenu: React.FC<{ > {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} + {import.meta.env.DEV && ( + { + if (window.visualDebug) { + delete window.visualDebug; + saveDebugState({ enabled: false }); + } else { + window.visualDebug = { data: [] }; + saveDebugState({ enabled: true }); + } + props?.refresh(); + }} + > + Visual Debug + + )} { + context.save(); + context.strokeStyle = color; + context.beginPath(); + context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom); + context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom); + context.stroke(); + context.restore(); +}; + +const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { + context.strokeStyle = "#888"; + context.save(); + context.beginPath(); + context.moveTo(-10 * zoom, -10 * zoom); + context.lineTo(10 * zoom, 10 * zoom); + context.moveTo(10 * zoom, -10 * zoom); + context.lineTo(-10 * zoom, 10 * zoom); + context.stroke(); + context.save(); +}; + +const render = ( + frame: DebugElement[], + context: CanvasRenderingContext2D, + appState: AppState, +) => { + frame.forEach((el) => { + switch (true) { + case isLineSegment(el.data): + renderLine(context, appState.zoom.value, el.data, el.color); + break; + } + }); +}; + +const _debugRenderer = ( + canvas: HTMLCanvasElement, + appState: AppState, + scale: number, +) => { + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + 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) => { + _debugRenderer(canvas, appState, scale); + }, + { 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 ( + <> + + + + + + ); +}; + +interface DebugCanvasProps { + appState: AppState; + scale: number; +} + +const DebugCanvas = forwardRef( + ({ appState, scale }, ref) => { + const { width, height } = appState; + + const canvasRef = useRef(null); + useImperativeHandle( + ref, + () => canvasRef.current, + [canvasRef], + ); + + return ( + + Debug Canvas + + ); + }, +); + +export default DebugCanvas; diff --git a/excalidraw-app/share/ShareDialog.scss b/excalidraw-app/share/ShareDialog.scss index 87fde849146e..436f411248af 100644 --- a/excalidraw-app/share/ShareDialog.scss +++ b/excalidraw-app/share/ShareDialog.scss @@ -58,8 +58,8 @@ font-size: 0.75rem; line-height: 110%; - background: var(--color-success-lighter); - color: var(--color-success); + background: var(--color-success); + color: var(--color-success-text); & > svg { width: 0.875rem; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx index 6511eec127e9..d0a078cd25b2 100644 --- a/excalidraw-app/share/ShareDialog.tsx +++ b/excalidraw-app/share/ShareDialog.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from "react"; -import * as Popover from "@radix-ui/react-popover"; import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard"; import { trackEvent } from "../../packages/excalidraw/analytics"; import { getFrame } from "../../packages/excalidraw/utils"; @@ -14,7 +13,6 @@ import { share, shareIOS, shareWindows, - tablerCheckIcon, } from "../../packages/excalidraw/components/icons"; import { TextField } from "../../packages/excalidraw/components/TextField"; import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; @@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai"; import "./ShareDialog.scss"; import { useUIAppState } from "../../packages/excalidraw/context/ui-appState"; +import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator"; type OnExportToBackend = () => void; type ShareDialogType = "share" | "collaborationOnly"; @@ -63,10 +62,11 @@ const ActiveRoomDialog = ({ handleClose: () => void; }) => { const { t } = useI18n(); - const [justCopied, setJustCopied] = useState(false); + const [, setJustCopied] = useState(false); const timerRef = useRef(0); const ref = useRef(null); const isShareSupported = "share" in navigator; + const { onCopy, copyStatus } = useCopyStatus(); const copyRoomLink = async () => { try { @@ -130,26 +130,16 @@ const ActiveRoomDialog = ({ onClick={shareRoomLink} /> )} - - - - - event.preventDefault()} - onCloseAutoFocus={(event) => event.preventDefault()} - className="ShareDialog__popover" - side="top" - align="end" - sideOffset={5.5} - > - {tablerCheckIcon} copied - - + { + copyRoomLink(); + onCopy(); + }} + />

diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index e4f998d01656..4d34bf968c31 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -10,7 +10,7 @@ import { } from "../clipboard"; import { actionDeleteSelected } from "./actionDeleteSelected"; import { exportCanvas, prepareElementsForExport } from "../data/index"; -import { isTextElement } from "../element"; +import { getTextFromElements, isTextElement } from "../element"; import { t } from "../i18n"; import { isFirefox } from "../constants"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; @@ -239,16 +239,8 @@ export const copyText = register({ includeBoundTextElement: true, }); - const text = selectedElements - .reduce((acc: string[], element) => { - if (isTextElement(element)) { - acc.push(element.text); - } - return acc; - }, []) - .join("\n\n"); try { - copyTextToSystemClipboard(text); + copyTextToSystemClipboard(getTextFromElements(selectedElements)); } catch (e) { throw new Error(t("errors.copyToSystemClipboardFailed")); } diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 0197fb7db0a6..0dfbb4c9aa0e 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -179,7 +179,7 @@ export const actionFinalize = register({ newElement: null, selectionElement: null, multiElement: null, - editingElement: null, + editingTextElement: null, startBoundElement: null, suggestedBindings: [], selectedElementIds: diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index d860c4143bad..c1e35674adc7 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -21,7 +21,7 @@ const executeHistoryAction = ( if ( !appState.multiElement && !appState.resizingElement && - !appState.editingElement && + !appState.editingTextElement && !appState.newElement && !appState.selectedElementsAreBeingDragged && !appState.selectionElement && diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index f9c66e96f89f..069cf84158a9 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -133,7 +133,7 @@ export const changeProperty = ( return elements.map((element) => { if ( selectedElementIds.get(element.id) || - element.id === appState.editingElement?.id + element.id === appState.editingTextElement?.id ) { return callback(element); } @@ -148,13 +148,13 @@ export const getFormValue = function ( isRelevantElement: true | ((element: ExcalidrawElement) => boolean), defaultValue: T | ((isSomeElementSelected: boolean) => T), ): T { - const editingElement = appState.editingElement; + const editingTextElement = appState.editingTextElement; const nonDeletedElements = getNonDeletedElements(elements); let ret: T | null = null; - if (editingElement) { - ret = getAttribute(editingElement); + if (editingTextElement) { + ret = getAttribute(editingTextElement); } if (!ret) { @@ -1076,19 +1076,20 @@ export const actionChangeFontFamily = register({ // open, populate the cache from scratch cachedElementsRef.current.clear(); - const { editingElement } = appState; + const { editingTextElement } = appState; - if (editingElement?.type === "text") { - // retrieve the latest version from the scene, as `editingElement` isn't mutated - const latestEditingElement = app.scene.getElement( - editingElement.id, + // still check type to be safe + if (editingTextElement?.type === "text") { + // retrieve the latest version from the scene, as `editingTextElement` isn't mutated + const latesteditingTextElement = app.scene.getElement( + editingTextElement.id, ); // inside the wysiwyg editor cachedElementsRef.current.set( - editingElement.id, + editingTextElement.id, newElementWith( - latestEditingElement || editingElement, + latesteditingTextElement || editingTextElement, {}, true, ), diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 75afd1bfa89c..faad34057b94 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -44,7 +44,7 @@ export const getDefaultAppState = (): Omit< cursorButton: "up", activeEmbeddable: null, newElement: null, - editingElement: null, + editingTextElement: null, editingGroupId: null, editingLinearElement: null, activeTool: { @@ -165,7 +165,7 @@ const APP_STATE_STORAGE_CONF = (< cursorButton: { browser: true, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false }, newElement: { browser: false, export: false, server: false }, - editingElement: { browser: false, export: false, server: false }, + editingTextElement: { browser: false, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false }, activeTool: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 91102aef2cef..b818d1a23f1e 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -45,11 +45,11 @@ import { frameToolIcon, mermaidLogoIcon, laserPointerToolIcon, - OpenAIIcon, MagicIcon, } from "./icons"; import { KEYS } from "../keys"; import { useTunnels } from "../context/tunnels"; +import { CLASSES } from "../constants"; export const canChangeStrokeColor = ( appState: UIAppState, @@ -104,7 +104,9 @@ export const SelectedShapeActions = ({ ) { isSingleElementBoundContainer = true; } - const isEditing = Boolean(appState.editingElement); + const isEditingTextOrNewElement = Boolean( + appState.editingTextElement || appState.newElement, + ); const device = useDevice(); const isRTL = document.documentElement.getAttribute("dir") === "rtl"; @@ -234,7 +236,7 @@ export const SelectedShapeActions = ({

)} - {!isEditing && targetElements.length > 0 && ( + {!isEditingTextOrNewElement && targetElements.length > 0 && (
{t("labels.actions")}
@@ -400,7 +402,7 @@ export const ShapesSwitcher = ({ > {t("toolBar.mermaidToExcalidraw")} - {app.props.aiEnabled !== false && ( + {app.props.aiEnabled !== false && app.plugins.diagramToCode && ( <> app.onMagicframeToolSelect()} @@ -410,20 +412,6 @@ export const ShapesSwitcher = ({ {t("toolBar.magicframe")} AI - { - trackEvent("ai", "open-settings", "d2c"); - app.setOpenDialog({ - name: "settings", - source: "settings", - tab: "diagram-to-code", - }); - }} - icon={OpenAIIcon} - data-testid="toolbar-magicSettings" - > - {t("toolBar.magicSettings")} - )} @@ -439,7 +427,7 @@ export const ZoomActions = ({ renderAction: ActionManager["renderAction"]; zoom: Zoom; }) => ( - + {renderAction("zoomOut")} {renderAction("resetZoom")} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b0293b781b26..ef5d7bc2225b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -83,7 +83,6 @@ import { ZOOM_STEP, POINTER_EVENTS, TOOL_TYPE, - EDITOR_LS_KEYS, isIOS, supportsResizeObserver, DEFAULT_COLLISION_THRESHOLD, @@ -183,6 +182,7 @@ import type { ExcalidrawIframeElement, ExcalidrawEmbeddableElement, Ordered, + MagicGenerationData, ExcalidrawNonSelectionElement, ExcalidrawArrowElement, } from "../element/types"; @@ -257,6 +257,7 @@ import type { UnsubscribeCallback, EmbedsValidationStatus, ElementsPendingErasure, + GenerateDiagramToCode, NullableGridSize, } from "../types"; import { @@ -405,13 +406,9 @@ import { } from "../cursor"; import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../element/ElementCanvasButtons"; -import type { MagicCacheData } from "../data/magic"; -import { diagramToHTML } from "../data/magic"; -import { exportToBlob } from "../../utils/export"; import { COLOR_PALETTE } from "../colors"; import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; -import { EditorLocalStorage } from "../data/EditorLocalStorage"; import FollowMode from "./FollowMode/FollowMode"; import { Store, StoreAction } from "../store"; import { AnimationFrameHandler } from "../animation-frame-handler"; @@ -435,6 +432,7 @@ import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { getVisibleSceneBounds } from "../element/bounds"; import { isMaybeMermaidDefinition } from "../mermaid"; +import NewElementCanvas from "./canvases/NewElementCanvas"; import { mutateElbowArrow } from "../element/routing"; import { FlowChartCreator, @@ -1018,7 +1016,7 @@ class App extends React.Component { if (isIframeElement(el)) { src = null; - const data: MagicCacheData = (el.customData?.generationData ?? + const data: MagicGenerationData = (el.customData?.generationData ?? this.magicGenerations.get(el.id)) || { status: "error", message: "No generation data", @@ -1475,25 +1473,21 @@ class App extends React.Component { scrollY: this.state.scrollY, height: this.state.height, width: this.state.width, - editingElement: this.state.editingElement, + editingTextElement: this.state.editingTextElement, + newElementId: this.state.newElement?.id, pendingImageElementId: this.state.pendingImageElementId, }); const allElementsMap = this.scene.getNonDeletedElementsMap(); const shouldBlockPointerEvents = - !( - this.state.editingElement && isLinearElement(this.state.editingElement) - ) && - (this.state.selectionElement || - this.state.newElement || - this.state.selectedElementsAreBeingDragged || - this.state.resizingElement || - (this.state.activeTool.type === "laser" && - // technically we can just test on this once we make it more safe - this.state.cursorButton === "down") || - (this.state.editingElement && - !isTextElement(this.state.editingElement))); + this.state.selectionElement || + this.state.newElement || + this.state.selectedElementsAreBeingDragged || + this.state.resizingElement || + (this.state.activeTool.type === "laser" && + // technically we can just test on this once we make it more safe + this.state.cursorButton === "down"); const firstSelectedElement = selectedElements[0]; @@ -1559,10 +1553,6 @@ class App extends React.Component { } app={this} isCollaborating={this.props.isCollaborating} - openAIKey={this.OPENAI_KEY} - isOpenAIKeyPersisted={this.OPENAI_KEY_IS_PERSISTED} - onOpenAIAPIKeyChange={this.onOpenAIKeyChange} - onMagicSettingsConfirm={this.onMagicSettingsConfirm} > {this.props.children} @@ -1704,6 +1694,27 @@ class App extends React.Component { this.flowChartCreator.pendingNodes, }} /> + {this.state.newElement && ( + + )} { private magicGenerations = new Map< ExcalidrawIframeElement["id"], - MagicCacheData + MagicGenerationData >(); private updateMagicGeneration = ({ @@ -1815,7 +1826,7 @@ class App extends React.Component { data, }: { frameElement: ExcalidrawIframeElement; - data: MagicCacheData; + data: MagicGenerationData; }) => { if (data.status === "pending") { // We don't wanna persist pending state to storage. It should be in-app @@ -1838,31 +1849,26 @@ class App extends React.Component { this.triggerRender(); }; - private getTextFromElements(elements: readonly ExcalidrawElement[]) { - const text = elements - .reduce((acc: string[], element) => { - if (isTextElement(element)) { - acc.push(element.text); - } - return acc; - }, []) - .join("\n\n"); - return text; + public plugins: { + diagramToCode?: { + generate: GenerateDiagramToCode; + }; + } = {}; + + public setPlugins(plugins: Partial) { + Object.assign(this.plugins, plugins); } private async onMagicFrameGenerate( magicFrame: ExcalidrawMagicFrameElement, source: "button" | "upstream", ) { - if (!this.OPENAI_KEY) { + const generateDiagramToCode = this.plugins.diagramToCode?.generate; + + if (!generateDiagramToCode) { this.setState({ - openDialog: { - name: "settings", - tab: "diagram-to-code", - source: "generation", - }, + errorMessage: "No diagram to code plugin found", }); - trackEvent("ai", "generate (missing key)", "d2c"); return; } @@ -1901,68 +1907,50 @@ class App extends React.Component { selectedElementIds: { [frameElement.id]: true }, }); - const blob = await exportToBlob({ - elements: this.scene.getNonDeletedElements(), - appState: { - ...this.state, - exportBackground: true, - viewBackgroundColor: this.state.viewBackgroundColor, - }, - exportingFrame: magicFrame, - files: this.files, - }); - - const dataURL = await getDataURL(blob); + trackEvent("ai", "generate (start)", "d2c"); + try { + const { html } = await generateDiagramToCode({ + frame: magicFrame, + children: magicFrameChildren, + }); - const textFromFrameChildren = this.getTextFromElements(magicFrameChildren); + trackEvent("ai", "generate (success)", "d2c"); - trackEvent("ai", "generate (start)", "d2c"); + if (!html.trim()) { + this.updateMagicGeneration({ + frameElement, + data: { + status: "error", + code: "ERR_OAI", + message: "Nothing genereated :(", + }, + }); + return; + } - const result = await diagramToHTML({ - image: dataURL, - apiKey: this.OPENAI_KEY, - text: textFromFrameChildren, - theme: this.state.theme, - }); + const parsedHtml = + html.includes("") && html.includes("") + ? html.slice( + html.indexOf(""), + html.indexOf("") + "".length, + ) + : html; - if (!result.ok) { - trackEvent("ai", "generate (failed)", "d2c"); - console.error(result.error); this.updateMagicGeneration({ frameElement, - data: { - status: "error", - code: "ERR_OAI", - message: result.error?.message || "Unknown error during generation", - }, + data: { status: "done", html: parsedHtml }, }); - return; - } - trackEvent("ai", "generate (success)", "d2c"); - - if (result.choices[0].message.content == null) { + } catch (error: any) { + trackEvent("ai", "generate (failed)", "d2c"); this.updateMagicGeneration({ frameElement, data: { status: "error", code: "ERR_OAI", - message: "Nothing genereated :(", + message: error.message || "Unknown error during generation", }, }); - return; } - - const message = result.choices[0].message.content; - - const html = message.slice( - message.indexOf(""), - message.indexOf("") + "".length, - ); - - this.updateMagicGeneration({ - frameElement, - data: { status: "done", html }, - }); } private onIframeSrcCopy(element: ExcalidrawIframeElement) { @@ -1976,70 +1964,7 @@ class App extends React.Component { } } - private OPENAI_KEY: string | null = EditorLocalStorage.get( - EDITOR_LS_KEYS.OAI_API_KEY, - ); - private OPENAI_KEY_IS_PERSISTED: boolean = - EditorLocalStorage.has(EDITOR_LS_KEYS.OAI_API_KEY) || false; - - private onOpenAIKeyChange = ( - openAIKey: string | null, - shouldPersist: boolean, - ) => { - this.OPENAI_KEY = openAIKey || null; - if (shouldPersist) { - const didPersist = EditorLocalStorage.set( - EDITOR_LS_KEYS.OAI_API_KEY, - openAIKey, - ); - this.OPENAI_KEY_IS_PERSISTED = didPersist; - } else { - this.OPENAI_KEY_IS_PERSISTED = false; - } - }; - - private onMagicSettingsConfirm = ( - apiKey: string, - shouldPersist: boolean, - source: "tool" | "generation" | "settings", - ) => { - this.OPENAI_KEY = apiKey || null; - this.onOpenAIKeyChange(this.OPENAI_KEY, shouldPersist); - - if (source === "settings") { - return; - } - - const selectedElements = this.scene.getSelectedElements({ - selectedElementIds: this.state.selectedElementIds, - }); - - if (apiKey) { - if (selectedElements.length) { - this.onMagicframeToolSelect(); - } else { - this.setActiveTool({ type: "magicframe" }); - } - } else if (!isMagicFrameElement(selectedElements[0])) { - // even if user didn't end up setting api key, let's pick the tool - // so they can draw up a frame and move forward - this.setActiveTool({ type: "magicframe" }); - } - }; - public onMagicframeToolSelect = () => { - if (!this.OPENAI_KEY) { - this.setState({ - openDialog: { - name: "settings", - tab: "diagram-to-code", - source: "tool", - }, - }); - trackEvent("ai", "tool-select (missing key)", "d2c"); - return; - } - const selectedElements = this.scene.getSelectedElements({ selectedElementIds: this.state.selectedElementIds, }); @@ -2166,7 +2091,7 @@ class App extends React.Component { let didUpdate = false; - let editingElement: AppState["editingElement"] | null = null; + let editingTextElement: AppState["editingTextElement"] | null = null; if (actionResult.elements) { this.scene.replaceAllElements(actionResult.elements); didUpdate = true; @@ -2179,7 +2104,7 @@ class App extends React.Component { this.addNewImagesToImageCache(); } - if (actionResult.appState || editingElement || this.state.contextMenu) { + if (actionResult.appState || editingTextElement || this.state.contextMenu) { let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false; let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false; const theme = @@ -2195,23 +2120,24 @@ class App extends React.Component { zenModeEnabled = this.props.zenModeEnabled; } - editingElement = actionResult.appState?.editingElement || null; + editingTextElement = actionResult.appState?.editingTextElement || null; - // make sure editingElement points to latest element reference - if (actionResult.elements && editingElement) { + // make sure editingTextElement points to latest element reference + if (actionResult.elements && editingTextElement) { actionResult.elements.forEach((element) => { if ( - editingElement?.id === element.id && - editingElement !== element && - isNonDeletedElement(element) + editingTextElement?.id === element.id && + editingTextElement !== element && + isNonDeletedElement(element) && + isTextElement(element) ) { - editingElement = element; + editingTextElement = element; } }); } - if (editingElement?.isDeleted) { - editingElement = null; + if (editingTextElement?.isDeleted) { + editingTextElement = null; } this.setState((state) => { @@ -2223,7 +2149,7 @@ class App extends React.Component { // or programmatically from the host, so it will need to be // rewritten later contextMenu: null, - editingElement, + editingTextElement, viewModeEnabled, zenModeEnabled, theme, @@ -2652,6 +2578,11 @@ class App extends React.Component { addEventListener(window, EVENT.RESIZE, this.onResize, false), addEventListener(window, EVENT.UNLOAD, this.onUnload, false), addEventListener(window, EVENT.BLUR, this.onBlur, false), + addEventListener( + this.excalidrawContainerRef.current, + EVENT.WHEEL, + this.handleWheel, + ), addEventListener( this.excalidrawContainerRef.current, EVENT.DRAG_OVER, @@ -2802,9 +2733,9 @@ class App extends React.Component { } // failsafe in case the state is being updated in incorrect order resulting - // in the editingElement being now a deleted element - if (this.state.editingElement?.isDeleted) { - this.setState({ editingElement: null }); + // in the editingTextElement being now a deleted element + if (this.state.editingTextElement?.isDeleted) { + this.setState({ editingTextElement: null }); } if ( @@ -2860,7 +2791,7 @@ class App extends React.Component { } const scrolledOutside = // hide when editing text - isTextElement(this.state.editingElement) + this.state.editingTextElement ? false : !atLeastOneVisibleElement && elementsMap.size > 0; if (this.state.scrolledOutside !== scrolledOutside) { @@ -4136,7 +4067,7 @@ class App extends React.Component { } if (isArrowKey(event.key)) { - const selectedElements = this.scene.getSelectedElements({ + let selectedElements = this.scene.getSelectedElements({ selectedElementIds: this.state.selectedElementIds, includeBoundTextElement: true, includeElementsInFrames: true, @@ -4146,17 +4077,37 @@ class App extends React.Component { | ExcalidrawArrowElement | undefined; - const step = elbowArrow - ? elbowArrow.startBinding || elbowArrow.endBinding - ? 0 - : ELEMENT_TRANSLATE_AMOUNT - : (this.getEffectiveGridSize() && - (event.shiftKey - ? ELEMENT_TRANSLATE_AMOUNT - : this.getEffectiveGridSize())) || + const arrowIdsToRemove = new Set(); + + selectedElements + .filter(isElbowArrow) + .filter((arrow) => { + const startElementNotInSelection = + arrow.startBinding && + !selectedElements.some( + (el) => el.id === arrow.startBinding?.elementId, + ); + const endElementNotInSelection = + arrow.endBinding && + !selectedElements.some( + (el) => el.id === arrow.endBinding?.elementId, + ); + return startElementNotInSelection || endElementNotInSelection; + }) + .forEach((arrow) => arrowIdsToRemove.add(arrow.id)); + + selectedElements = selectedElements.filter( + (el) => !arrowIdsToRemove.has(el.id), + ); + + const step = + (this.getEffectiveGridSize() && (event.shiftKey - ? ELEMENT_SHIFT_TRANSLATE_AMOUNT - : ELEMENT_TRANSLATE_AMOUNT); + ? ELEMENT_TRANSLATE_AMOUNT + : this.getEffectiveGridSize())) || + (event.shiftKey + ? ELEMENT_SHIFT_TRANSLATE_AMOUNT + : ELEMENT_TRANSLATE_AMOUNT); let offsetX = 0; let offsetY = 0; @@ -4729,7 +4680,7 @@ class App extends React.Component { this.setState({ newElement: null, - editingElement: null, + editingTextElement: null, }); if (this.state.activeTool.locked) { setCursorForShape(this.interactiveCanvas, this.state); @@ -5103,7 +5054,7 @@ class App extends React.Component { }), }); } - this.setState({ editingElement: element }); + this.setState({ editingTextElement: element }); if (!existingTextElement) { if (container && shouldBindToContainer) { @@ -5560,9 +5511,13 @@ class App extends React.Component { lastPoint[1], ) >= LINE_CONFIRM_THRESHOLD ) { - mutateElement(multiElement, { - points: [...points, [scenePointerX - rx, scenePointerY - ry]], - }); + mutateElement( + multiElement, + { + points: [...points, [scenePointerX - rx, scenePointerY - ry]], + }, + false, + ); } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); // in this branch, we're inside the commit zone, and no uncommitted @@ -5579,9 +5534,13 @@ class App extends React.Component { ) < LINE_CONFIRM_THRESHOLD ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - mutateElement(multiElement, { - points: points.slice(0, -1), - }); + mutateElement( + multiElement, + { + points: points.slice(0, -1), + }, + false, + ); } else { const [gridX, gridY] = getGridPoint( scenePointerX, @@ -5627,20 +5586,30 @@ class App extends React.Component { undefined, { isDragging: true, + informMutation: false, }, ); } else { // update last uncommitted point - mutateElement(multiElement, { - points: [ - ...points.slice(0, -1), - [ - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, + mutateElement( + multiElement, + { + points: [ + ...points.slice(0, -1), + [ + lastCommittedX + dxFromLastCommitted, + lastCommittedY + dyFromLastCommitted, + ], ], - ], - }); + }, + false, + ); } + + // in this path, we're mutating multiElement to reflect + // how it will be after adding pointer position as the next point + // trigger update here so that new element canvas renders again to reflect this + this.triggerRender(false); } return; @@ -6043,7 +6012,7 @@ class App extends React.Component { : {}), appState: { newElement: null, - editingElement: null, + editingTextElement: null, startBoundElement: null, suggestedBindings: [], selectedElementIds: makeNextSelectedElementIds( @@ -6226,7 +6195,6 @@ class App extends React.Component { this.setState({ newElement: pendingImageElement as ExcalidrawNonSelectionElement, - editingElement: pendingImageElement, pendingImageElementId: null, multiElement: null, }); @@ -6421,8 +6389,8 @@ class App extends React.Component { }; // Returns whether the event is a panning - private handleCanvasPanUsingWheelOrSpaceDrag = ( - event: React.PointerEvent, + public handleCanvasPanUsingWheelOrSpaceDrag = ( + event: React.PointerEvent | MouseEvent, ): boolean => { if ( !( @@ -6431,13 +6399,16 @@ class App extends React.Component { (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) || isHandToolActive(this.state) || this.state.viewModeEnabled) - ) || - isTextElement(this.state.editingElement) + ) ) { return false; } isPanning = true; - event.preventDefault(); + + if (!this.state.editingTextElement) { + // preventing defualt while text editing messes with cursor/focus + event.preventDefault(); + } let nextPastePrevented = false; const isLinux = @@ -6976,7 +6947,7 @@ class App extends React.Component { // if we're currently still editing text, clicking outside // should only finalize it, not create another (irrespective // of state.activeTool.locked) - if (isTextElement(this.state.editingElement)) { + if (this.state.editingTextElement) { return; } let sceneX = pointerDownState.origin.x; @@ -7027,6 +6998,8 @@ class App extends React.Component { y: gridY, }); + const simulatePressure = event.pressure === 0.5; + const element = newFreeDrawElement({ type: elementType, x: gridX, @@ -7039,11 +7012,15 @@ class App extends React.Component { roughness: this.state.currentItemRoughness, opacity: this.state.currentItemOpacity, roundness: null, - simulatePressure: event.pressure === 0.5, + simulatePressure, locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, + points: [[0, 0]], + pressures: simulatePressure ? [] : [event.pressure], }); + this.scene.insertElement(element); + this.setState((prevState) => { const nextSelectedElementIds = { ...prevState.selectedElementIds, @@ -7057,21 +7034,12 @@ class App extends React.Component { }; }); - const pressures = element.simulatePressure - ? element.pressures - : [...element.pressures, event.pressure]; - - mutateElement(element, { - points: [[0, 0]], - pressures, - }); - const boundElement = getHoveredElementForBinding( pointerDownState.origin, this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), ); - this.scene.insertElement(element); + this.setState({ newElement: element, startBoundElement: boundElement, @@ -7372,7 +7340,6 @@ class App extends React.Component { this.scene.insertElement(element); this.setState({ newElement: element, - editingElement: element, startBoundElement: boundElement, suggestedBindings: [], }); @@ -7764,11 +7731,11 @@ class App extends React.Component { // prevent dragging even if we're no longer holding cmd/ctrl otherwise // it would have weird results (stuff jumping all over the screen) - // Checking for editingElement to avoid jump while editing on mobile #6503 + // Checking for editingTextElement to avoid jump while editing on mobile #6503 if ( selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl && - !this.state.editingElement && + !this.state.editingTextElement && this.state.activeEmbeddable?.state !== "active" ) { const dragOffset = { @@ -7963,9 +7930,17 @@ class App extends React.Component { ? newElement.pressures : [...newElement.pressures, event.pressure]; - mutateElement(newElement, { - points: [...points, [dx, dy]], - pressures, + mutateElement( + newElement, + { + points: [...points, [dx, dy]], + pressures, + }, + false, + ); + + this.setState({ + newElement, }); } } else if (isLinearElement(newElement)) { @@ -7984,9 +7959,13 @@ class App extends React.Component { } if (points.length === 1) { - mutateElement(newElement, { - points: [...points, [dx, dy]], - }); + mutateElement( + newElement, + { + points: [...points, [dx, dy]], + }, + false, + ); } else if (points.length > 1 && isElbowArrow(newElement)) { mutateElbowArrow( newElement, @@ -7996,14 +7975,23 @@ class App extends React.Component { undefined, { isDragging: true, + informMutation: false, }, ); } else if (points.length === 2) { - mutateElement(newElement, { - points: [...points.slice(0, -1), [dx, dy]], - }); + mutateElement( + newElement, + { + points: [...points.slice(0, -1), [dx, dy]], + }, + false, + ); } + this.setState({ + newElement, + }); + if (isBindingElement(newElement, false)) { // When creating a linear element by dragging this.maybeSuggestBindingsForLinearElementAtCoords( @@ -8015,7 +8003,7 @@ class App extends React.Component { } else { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; - this.maybeDragNewGenericElement(pointerDownState, event); + this.maybeDragNewGenericElement(pointerDownState, event, false); } } @@ -8173,12 +8161,6 @@ class App extends React.Component { frameToHighlight: null, elementsToHighlight: null, cursorButton: "up", - // text elements are reset on finalize, and resetting on pointerup - // may cause issues with double taps - editingElement: - multiElement || isTextElement(this.state.editingElement) - ? this.state.editingElement - : null, snapLines: updateStable(prevState.snapLines, []), originSnapOffset: null, })); @@ -8363,7 +8345,7 @@ class App extends React.Component { }); this.setState({ multiElement: newElement, - editingElement: this.state.newElement, + newElement, }); } else if (pointerDownState.drag.hasOccurred && !multiElement) { if ( @@ -8400,6 +8382,8 @@ class App extends React.Component { newElement: null, })); } + // so that the scene gets rendered again to display the newly drawn linear as well + this.scene.triggerUpdate(); } return; } @@ -8464,6 +8448,8 @@ class App extends React.Component { if (newElement) { mutateElement(newElement, getNormalizedDimensions(newElement)); + // the above does not guarantee the scene to be rendered again, hence the trigger below + this.scene.triggerUpdate(); } if (pointerDownState.drag.hasOccurred) { @@ -9272,7 +9258,7 @@ class App extends React.Component { this.setState( { pendingImageElementId: null, - editingElement: null, + newElement: null, activeTool: updateActiveTool(this.state, { type: "selection" }), }, () => { @@ -9494,7 +9480,6 @@ class App extends React.Component { // NOTE wheel, touchstart, touchend events must be registered outside // of react because react binds them them passively (so we can't prevent // default on them) - this.interactiveCanvas.addEventListener(EVENT.WHEEL, this.handleWheel); this.interactiveCanvas.addEventListener( EVENT.TOUCH_START, this.onTouchStart, @@ -9502,10 +9487,6 @@ class App extends React.Component { this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd); // ----------------------------------------------------------------------- } else { - this.interactiveCanvas?.removeEventListener( - EVENT.WHEEL, - this.handleWheel, - ); this.interactiveCanvas?.removeEventListener( EVENT.TOUCH_START, this.onTouchStart, @@ -9768,23 +9749,25 @@ class App extends React.Component { private maybeDragNewGenericElement = ( pointerDownState: PointerDownState, event: MouseEvent | KeyboardEvent, + informMutation = true, ): void => { const selectionElement = this.state.selectionElement; const pointerCoords = pointerDownState.lastCoords; if (selectionElement && this.state.activeTool.type !== "eraser") { - dragNewElement( - selectionElement, - this.state.activeTool.type, - pointerDownState.origin.x, - pointerDownState.origin.y, - pointerCoords.x, - pointerCoords.y, - distance(pointerDownState.origin.x, pointerCoords.x), - distance(pointerDownState.origin.y, pointerCoords.y), - shouldMaintainAspectRatio(event), - shouldResizeFromCenter(event), - this.state.zoom.value, - ); + dragNewElement({ + newElement: selectionElement, + elementType: this.state.activeTool.type, + originX: pointerDownState.origin.x, + originY: pointerDownState.origin.y, + x: pointerCoords.x, + y: pointerCoords.y, + width: distance(pointerDownState.origin.x, pointerCoords.x), + height: distance(pointerDownState.origin.y, pointerCoords.y), + shouldMaintainAspectRatio: shouldMaintainAspectRatio(event), + shouldResizeFromCenter: shouldResizeFromCenter(event), + zoom: this.state.zoom.value, + informMutation, + }); return; } @@ -9833,23 +9816,28 @@ class App extends React.Component { snapLines, }); - dragNewElement( + dragNewElement({ newElement, - this.state.activeTool.type, - pointerDownState.originInGrid.x, - pointerDownState.originInGrid.y, - gridX, - gridY, - distance(pointerDownState.originInGrid.x, gridX), - distance(pointerDownState.originInGrid.y, gridY), - isImageElement(newElement) + elementType: this.state.activeTool.type, + originX: pointerDownState.originInGrid.x, + originY: pointerDownState.originInGrid.y, + x: gridX, + y: gridY, + width: distance(pointerDownState.originInGrid.x, gridX), + height: distance(pointerDownState.originInGrid.y, gridY), + shouldMaintainAspectRatio: isImageElement(newElement) ? !shouldMaintainAspectRatio(event) : shouldMaintainAspectRatio(event), - shouldResizeFromCenter(event), - this.state.zoom.value, - aspectRatio, - this.state.originSnapOffset, - ); + shouldResizeFromCenter: shouldResizeFromCenter(event), + zoom: this.state.zoom.value, + widthAspectRatio: aspectRatio, + originOffset: this.state.originSnapOffset, + informMutation, + }); + + this.setState({ + newElement, + }); // highlight elements that are to be added to frames on frames creation if ( @@ -10093,7 +10081,19 @@ class App extends React.Component { ( event: WheelEvent | React.WheelEvent, ) => { + // if not scrolling on canvas/wysiwyg, ignore + if ( + !( + event.target instanceof HTMLCanvasElement || + event.target instanceof HTMLTextAreaElement || + event.target instanceof HTMLIFrameElement + ) + ) { + return; + } + event.preventDefault(); + if (isPanning) { return; } diff --git a/packages/excalidraw/components/DiagramToCodePlugin/DiagramToCodePlugin.tsx b/packages/excalidraw/components/DiagramToCodePlugin/DiagramToCodePlugin.tsx new file mode 100644 index 000000000000..950599963564 --- /dev/null +++ b/packages/excalidraw/components/DiagramToCodePlugin/DiagramToCodePlugin.tsx @@ -0,0 +1,17 @@ +import { useLayoutEffect } from "react"; +import { useApp } from "../App"; +import type { GenerateDiagramToCode } from "../../types"; + +export const DiagramToCodePlugin = (props: { + generate: GenerateDiagramToCode; +}) => { + const app = useApp(); + + useLayoutEffect(() => { + app.setPlugins({ + diagramToCode: { generate: props.generate }, + }); + }, [app, props.generate]); + + return null; +}; diff --git a/packages/excalidraw/components/FilledButton.scss b/packages/excalidraw/components/FilledButton.scss index d23c9d104961..1f689e96970a 100644 --- a/packages/excalidraw/components/FilledButton.scss +++ b/packages/excalidraw/components/FilledButton.scss @@ -1,5 +1,19 @@ @import "../css/variables.module.scss"; +@keyframes successStatusAnimation { + 0% { + transform: scale(0.35); + } + + 50% { + transform: scale(1.25); + } + + 100% { + transform: scale(1); + } +} + .excalidraw { .ExcButton { --text-color: transparent; @@ -16,11 +30,20 @@ .Spinner { --spinner-color: var(--color-surface-lowest); - position: absolute; + } + + .ExcButton__statusIcon { visibility: visible; + position: absolute; + + width: 1.2rem; + height: 1.2rem; + + animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1); } - &[disabled] { + &.ExcButton--status-loading, + &.ExcButton--status-success { pointer-events: none; .ExcButton__contents { @@ -28,6 +51,10 @@ } } + &[disabled] { + pointer-events: none; + } + &, &__contents { display: flex; @@ -119,6 +146,46 @@ } } + &--color-success { + &.ExcButton--variant-filled { + --text-color: var(--color-success-text); + --back-color: var(--color-success); + + .Spinner { + --spinner-color: var(--color-success); + } + + &:hover { + --back-color: var(--color-success-darker); + } + + &:active { + --back-color: var(--color-success-darkest); + } + } + + &.ExcButton--variant-outlined, + &.ExcButton--variant-icon { + --text-color: var(--color-success-contrast); + --border-color: var(--color-success-contrast); + --back-color: transparent; + + .Spinner { + --spinner-color: var(--color-success-contrast); + } + + &:hover { + --text-color: var(--color-success-contrast-hover); + --border-color: var(--color-success-contrast-hover); + } + + &:active { + --text-color: var(--color-success-contrast-active); + --border-color: var(--color-success-contrast-active); + } + } + } + &--color-muted { &.ExcButton--variant-filled { --text-color: var(--island-bg-color); diff --git a/packages/excalidraw/components/FilledButton.tsx b/packages/excalidraw/components/FilledButton.tsx index ff17db623f7c..1360908484c6 100644 --- a/packages/excalidraw/components/FilledButton.tsx +++ b/packages/excalidraw/components/FilledButton.tsx @@ -5,9 +5,15 @@ import "./FilledButton.scss"; import { AbortError } from "../errors"; import Spinner from "./Spinner"; import { isPromiseLike } from "../utils"; +import { tablerCheckIcon } from "./icons"; export type ButtonVariant = "filled" | "outlined" | "icon"; -export type ButtonColor = "primary" | "danger" | "warning" | "muted"; +export type ButtonColor = + | "primary" + | "danger" + | "warning" + | "muted" + | "success"; export type ButtonSize = "medium" | "large"; export type FilledButtonProps = { @@ -15,6 +21,7 @@ export type FilledButtonProps = { children?: React.ReactNode; onClick?: (event: React.MouseEvent) => void; + status?: null | "loading" | "success"; variant?: ButtonVariant; color?: ButtonColor; @@ -37,6 +44,7 @@ export const FilledButton = forwardRef( size = "medium", fullWidth, className, + status, }, ref, ) => { @@ -46,8 +54,11 @@ export const FilledButton = forwardRef( const ret = onClick?.(event); if (isPromiseLike(ret)) { - try { + // delay loading state to prevent flicker in case of quick response + const timer = window.setTimeout(() => { setIsLoading(true); + }, 50); + try { await ret; } catch (error: any) { if (!(error instanceof AbortError)) { @@ -56,11 +67,15 @@ export const FilledButton = forwardRef( console.warn(error); } } finally { + clearTimeout(timer); setIsLoading(false); } } }; + const _status = isLoading ? "loading" : status; + color = _status === "success" ? "success" : color; + return (