diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 69d6211293f9..0076eead113c 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -120,6 +120,11 @@ 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; @@ -622,6 +646,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( @@ -820,6 +849,7 @@ const ExcalidrawWrapper = () => { isCollabEnabled={!isCollabDisabled} theme={appTheme} setTheme={(theme) => setAppTheme(theme)} + refresh={() => forceRefresh((prev) => !prev)} /> { )} - + excalidrawAPI?.refresh()} /> {excalidrawAPI && } @@ -1077,6 +1107,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/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/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/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index c8ad39106e5f..69cc97b04348 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -49,6 +49,7 @@ import { } from "./icons"; import { KEYS } from "../keys"; import { useTunnels } from "../context/tunnels"; +import { CLASSES } from "../constants"; export const canChangeStrokeColor = ( appState: UIAppState, @@ -431,9 +432,9 @@ export const ZoomActions = ({ zoom: Zoom; trayMode?: boolean; //zsviczian note also changes to Stack.Col and Stack.Row }) => ( - {renderAction("zoomOut")} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 126a337b3a1b..7dee13b3134e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2736,6 +2736,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, @@ -6885,8 +6890,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 ( !( @@ -6894,14 +6899,18 @@ class App extends React.Component { (event.button === POINTER_BUTTON.WHEEL || (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) || isHandToolActive(this.state) || + //!isLastPointerActive added by zsviczian (but don't remember why....) (this.state.viewModeEnabled && !isLaserPointerActive(this.state))) - ) || - this.state.editingTextElement + ) ) { 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 = @@ -10026,7 +10035,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, @@ -10034,10 +10042,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, @@ -10643,7 +10647,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/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 (