diff --git a/public/index.html b/public/index.html index 599813861..35640c0dc 100644 --- a/public/index.html +++ b/public/index.html @@ -167,9 +167,6 @@ body, html { margin: 0; - --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, - Segoe UI, Roboto, Helvetica, Arial, sans-serif; - font-family: var(--ui-font); -webkit-text-size-adjust: 100%; width: 100%; diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 259b43e0c..1154d1ef1 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -1,7 +1,7 @@ import { ColorPicker } from "../components/ColorPicker"; -import { eraser, ZoomInIcon, ZoomOutIcon } from "../components/icons"; +import { ZoomInIcon, ZoomOutIcon } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; -import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; +import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; import { getCommonBounds, getNonDeletedElements } from "../element"; import { ExcalidrawElement } from "../element/types"; import { t } from "../i18n"; @@ -10,12 +10,15 @@ import { getNormalizedZoom, getSelectedElements } from "../scene"; import { centerScrollOn } from "../scene/scroll"; import { getStateForZoom } from "../scene/zoom"; import { AppState, NormalizedZoomValue } from "../types"; -import { getShortcutKey, updateActiveTool } from "../utils"; +import { getShortcutKey, setCursor, updateActiveTool } from "../utils"; import { register } from "./register"; import { Tooltip } from "../components/Tooltip"; import { newElementWith } from "../element/mutateElement"; -import { getDefaultAppState, isEraserActive } from "../appState"; -import clsx from "clsx"; +import { + getDefaultAppState, + isEraserActive, + isHandToolActive, +} from "../appState"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", @@ -306,15 +309,15 @@ export const actionToggleTheme = register({ }, }); -export const actionErase = register({ - name: "eraser", +export const actionToggleEraserTool = register({ + name: "toggleEraserTool", trackEvent: { category: "toolbar" }, perform: (elements, appState) => { let activeTool: AppState["activeTool"]; if (isEraserActive(appState)) { activeTool = updateActiveTool(appState, { - ...(appState.activeTool.lastActiveToolBeforeEraser || { + ...(appState.activeTool.lastActiveTool || { type: "selection", }), lastActiveToolBeforeEraser: null, @@ -337,17 +340,38 @@ export const actionErase = register({ }; }, keyTest: (event) => event.key === KEYS.E, - PanelComponent: ({ elements, appState, updateData, data }) => ( - { - updateData(null); - }} - size={data?.size || "medium"} - > - ), +}); + +export const actionToggleHandTool = register({ + name: "toggleHandTool", + trackEvent: { category: "toolbar" }, + perform: (elements, appState, _, app) => { + let activeTool: AppState["activeTool"]; + + if (isHandToolActive(appState)) { + activeTool = updateActiveTool(appState, { + ...(appState.activeTool.lastActiveTool || { + type: "selection", + }), + lastActiveToolBeforeEraser: null, + }); + } else { + activeTool = updateActiveTool(appState, { + type: "hand", + lastActiveToolBeforeEraser: appState.activeTool, + }); + setCursor(app.canvas, CURSOR_TYPE.GRAB); + } + + return { + appState: { + ...appState, + selectedElementIds: {}, + selectedGroupIds: {}, + activeTool, + }, + commitToHistory: true, + }; + }, + keyTest: (event) => event.key === KEYS.H, }); diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index 2e78a1a37..3508de0ad 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -145,7 +145,7 @@ export const actionFinalize = register({ let activeTool: AppState["activeTool"]; if (appState.activeTool.type === "eraser") { activeTool = updateActiveTool(appState, { - ...(appState.activeTool.lastActiveToolBeforeEraser || { + ...(appState.activeTool.lastActiveTool || { type: "selection", }), lastActiveToolBeforeEraser: null, diff --git a/src/actions/actionHistory.tsx b/src/actions/actionHistory.tsx index 76aa9d787..2e0f4c091 100644 --- a/src/actions/actionHistory.tsx +++ b/src/actions/actionHistory.tsx @@ -5,10 +5,11 @@ import { t } from "../i18n"; import History, { HistoryEntry } from "../history"; import { ExcalidrawElement } from "../element/types"; import { AppState } from "../types"; -import { isWindows, KEYS } from "../keys"; +import { KEYS } from "../keys"; import { newElementWith } from "../element/mutateElement"; import { fixBindingsAfterDeletion } from "../element/binding"; import { arrayToMap } from "../utils"; +import { isWindows } from "../constants"; const writeData = ( prevElements: readonly ExcalidrawElement[], diff --git a/src/actions/actionZindex.tsx b/src/actions/actionZindex.tsx index 07da26217..17ecde1a6 100644 --- a/src/actions/actionZindex.tsx +++ b/src/actions/actionZindex.tsx @@ -5,7 +5,7 @@ import { moveAllLeft, moveAllRight, } from "../zindex"; -import { KEYS, isDarwin, CODES } from "../keys"; +import { KEYS, CODES } from "../keys"; import { t } from "../i18n"; import { getShortcutKey } from "../utils"; import { register } from "./register"; @@ -15,6 +15,7 @@ import { SendBackwardIcon, SendToBackIcon, } from "../components/icons"; +import { isDarwin } from "../constants"; export const actionSendBackward = register({ name: "sendBackward", diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index 0029ee120..8d5b164ea 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -1,5 +1,5 @@ +import { isDarwin } from "../constants"; import { t } from "../i18n"; -import { isDarwin } from "../keys"; import { getShortcutKey } from "../utils"; import { ActionName } from "./types"; diff --git a/src/actions/types.ts b/src/actions/types.ts index 6d097cd72..9a695ee5e 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -125,10 +125,11 @@ const actionNames = [ "decreaseFontSize", "unbindText", "hyperlink", - "eraser", "bindText", "toggleLock", "toggleLinearEditor", + "toggleEraserTool", + "toggleHandTool", ] as const; // So we can have the `isActionName` type guard diff --git a/src/appState.ts b/src/appState.ts index d1cbe92f0..f02d5943c 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -45,7 +45,7 @@ export const getDefaultAppState = (): Omit< type: "selection", customType: null, locked: false, - lastActiveToolBeforeEraser: null, + lastActiveTool: null, }, penMode: false, penDetected: false, @@ -228,3 +228,11 @@ export const isEraserActive = ({ }: { activeTool: AppState["activeTool"]; }) => activeTool.type === "eraser"; + +export const isHandToolActive = ({ + activeTool, +}: { + activeTool: AppState["activeTool"]; +}) => { + return activeTool.type === "hand"; +}; diff --git a/src/clipboard.ts b/src/clipboard.ts index bf90a4b17..5f7950c53 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -180,16 +180,16 @@ export const parseClipboard = async ( }; export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { - let promise; try { // in Safari so far we need to construct the ClipboardItem synchronously // (i.e. in the same tick) otherwise browser will complain for lack of // user intent. Using a Promise ClipboardItem constructor solves this. // https://bugs.webkit.org/show_bug.cgi?id=222262 // - // not await so that we can detect whether the thrown error likely relates - // to a lack of support for the Promise ClipboardItem constructor - promise = navigator.clipboard.write([ + // Note that Firefox (and potentially others) seems to support Promise + // ClipboardItem constructor, but throws on an unrelated MIME type error. + // So we need to await this and fallback to awaiting the blob if applicable. + await navigator.clipboard.write([ new window.ClipboardItem({ [MIME_TYPES.png]: blob, }), @@ -207,7 +207,6 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { throw error; } } - await promise; }; export const copyTextToSystemClipboard = async (text: string | null) => { diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 2f0bd0067..7e3899979 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -224,9 +224,10 @@ export const ShapesSwitcher = ({ <> {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { const label = t(`toolBar.${value}`); - const letter = key && (typeof key === "string" ? key : key[0]); + const letter = + key && capitalizeString(typeof key === "string" ? key : key[0]); const shortcut = letter - ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}` + ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; return ( (null); + +export const ActiveConfirmDialog = () => { + const [activeConfirmDialog, setActiveConfirmDialog] = useAtom( + activeConfirmDialogAtom, + ); + const actionManager = useExcalidrawActionManager(); + + if (!activeConfirmDialog) { + return null; + } + + if (activeConfirmDialog === "clearCanvas") { + return ( + { + actionManager.executeAction(actionClearCanvas); + setActiveConfirmDialog(null); + }} + onCancel={() => setActiveConfirmDialog(null)} + title={t("clearCanvasDialog.title")} + > +

{t("alerts.clearReset")}

+
+ ); + } + + return null; +}; diff --git a/src/components/App.tsx b/src/components/App.tsx index b05c4abc9..f544c5f3d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -41,7 +41,11 @@ import { ActionManager } from "../actions/manager"; import { getActions } from "../actions/register"; import { ActionResult } from "../actions/types"; import { trackEvent } from "../analytics"; -import { getDefaultAppState, isEraserActive } from "../appState"; +import { + getDefaultAppState, + isEraserActive, + isHandToolActive, +} from "../appState"; import { parseClipboard } from "../clipboard"; import { APP_NAME, @@ -57,6 +61,7 @@ import { EVENT, GRID_SIZE, IMAGE_RENDER_TIMEOUT, + isAndroid, LINE_CONFIRM_THRESHOLD, MAX_ALLOWED_FILE_BYTES, MIME_TYPES, @@ -166,7 +171,6 @@ import { shouldRotateWithDiscreteAngle, isArrowKey, KEYS, - isAndroid, } from "../keys"; import { distance2d, getGridPoint, isPathALoop } from "../math"; import { renderScene } from "../renderer/renderScene"; @@ -274,6 +278,7 @@ import { import { shouldShowBoundingBox } from "../element/transformHandles"; import { Fonts } from "../scene/Fonts"; import { actionPaste } from "../actions/actionClipboard"; +import { actionToggleHandTool } from "../actions/actionCanvas"; const deviceContextInitialValue = { isSmScreen: false, @@ -577,6 +582,7 @@ class App extends React.Component { elements={this.scene.getNonDeletedElements()} onLockToggle={this.toggleLock} onPenModeToggle={this.togglePenMode} + onHandToolToggle={this.onHandToolToggle} onInsertElements={(elements) => this.addElementsFromPasteOrLibrary({ elements, @@ -1815,6 +1821,10 @@ class App extends React.Component { }); }; + onHandToolToggle = () => { + this.actionManager.executeAction(actionToggleHandTool); + }; + scrollToContent = ( target: | ExcalidrawElement @@ -2232,11 +2242,13 @@ class App extends React.Component { private setActiveTool = ( tool: - | { type: typeof SHAPES[number]["value"] | "eraser" } + | { type: typeof SHAPES[number]["value"] | "eraser" | "hand" } | { type: "custom"; customType: string }, ) => { const nextActiveTool = updateActiveTool(this.state, tool); - if (!isHoldingSpace) { + if (nextActiveTool.type === "hand") { + setCursor(this.canvas, CURSOR_TYPE.GRAB); + } else if (!isHoldingSpace) { setCursorForShape(this.canvas, this.state); } if (isToolIcon(document.activeElement)) { @@ -2907,7 +2919,12 @@ class App extends React.Component { null; } - if (isHoldingSpace || isPanning || isDraggingScrollBar) { + if ( + isHoldingSpace || + isPanning || + isDraggingScrollBar || + isHandToolActive(this.state) + ) { return; } @@ -3499,7 +3516,10 @@ class App extends React.Component { ); } else if (this.state.activeTool.type === "custom") { setCursor(this.canvas, CURSOR_TYPE.AUTO); - } else if (this.state.activeTool.type !== "eraser") { + } else if ( + this.state.activeTool.type !== "eraser" && + this.state.activeTool.type !== "hand" + ) { this.createGenericElementOnPointerDown( this.state.activeTool.type, pointerDownState, @@ -3610,6 +3630,7 @@ class App extends React.Component { gesture.pointers.size <= 1 && (event.button === POINTER_BUTTON.WHEEL || (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) || + isHandToolActive(this.state) || this.state.viewModeEnabled) ) || isTextElement(this.state.editingElement) diff --git a/src/components/ExportDialog.scss b/src/components/ExportDialog.scss index c95a116e7..7bc0c808e 100644 --- a/src/components/ExportDialog.scss +++ b/src/components/ExportDialog.scss @@ -96,6 +96,10 @@ width: 5rem; height: 5rem; margin: 0 0.2em; + padding: 0; + display: flex; + align-items: center; + justify-content: center; border-radius: 1rem; background-color: var(--button-color); diff --git a/src/components/HandButton.tsx b/src/components/HandButton.tsx new file mode 100644 index 000000000..ce63791e1 --- /dev/null +++ b/src/components/HandButton.tsx @@ -0,0 +1,32 @@ +import "./ToolIcon.scss"; + +import clsx from "clsx"; +import { ToolButton } from "./ToolButton"; +import { handIcon } from "./icons"; +import { KEYS } from "../keys"; + +type LockIconProps = { + title?: string; + name?: string; + checked: boolean; + onChange?(): void; + isMobile?: boolean; +}; + +export const HandButton = (props: LockIconProps) => { + return ( + props.onChange?.()} + /> + ); +}; diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 065d0f77e..69f3e6a5c 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -1,10 +1,12 @@ import React from "react"; import { t } from "../i18n"; -import { isDarwin, isWindows, KEYS } from "../keys"; +import { KEYS } from "../keys"; import { Dialog } from "./Dialog"; import { getShortcutKey } from "../utils"; import "./HelpDialog.scss"; import { ExternalLinkIcon } from "./icons"; +import { probablySupportsClipboardBlob } from "../clipboard"; +import { isDarwin, isFirefox, isWindows } from "../constants"; const Header = () => (
@@ -67,6 +69,10 @@ function* intersperse(as: JSX.Element[][], delim: string | null) { } } +const upperCaseSingleChars = (str: string) => { + return str.replace(/\b[a-z]\b/, (c) => c.toUpperCase()); +}; + const Shortcut = ({ label, shortcuts, @@ -81,7 +87,9 @@ const Shortcut = ({ ? [...shortcut.slice(0, -2).split("+"), "+"] : shortcut.split("+"); - return keys.map((key) => {key}); + return keys.map((key) => ( + {upperCaseSingleChars(key)} + )); }); return ( @@ -118,6 +126,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { className="HelpDialog__island--tools" caption={t("helpDialog.tools")} > + void }) => { label={t("labels.pasteAsPlaintext")} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]} /> - + {/* firefox supports clipboard API under a flag, so we'll + show users what they can do in the error message */} + {(probablySupportsClipboardBlob || isFirefox) && ( + + )} SVG - {probablySupportsClipboardBlob && ( + {/* firefox supports clipboard API under a flag, + so let's throw and tell people what they can do */} + {(probablySupportsClipboardBlob || isFirefox) && ( onExportToClipboard(exportedElements)} diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 5401ccde7..120d2cd9c 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -50,6 +50,9 @@ import { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; import { jotaiScope } from "../jotai"; import { useAtom } from "jotai"; import MainMenu from "./main-menu/MainMenu"; +import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; +import { HandButton } from "./HandButton"; +import { isHandToolActive } from "../appState"; interface LayerUIProps { actionManager: ActionManager; @@ -59,6 +62,7 @@ interface LayerUIProps { setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onLockToggle: () => void; + onHandToolToggle: () => void; onPenModeToggle: () => void; onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; showExitZenModeBtn: boolean; @@ -86,6 +90,7 @@ const LayerUI = ({ elements, canvas, onLockToggle, + onHandToolToggle, onPenModeToggle, onInsertElements, showExitZenModeBtn, @@ -306,13 +311,20 @@ const LayerUI = ({ penDetected={appState.penDetected} /> onLockToggle()} + onChange={onLockToggle} title={t("toolBar.lock")} /> +
+ onHandToolToggle()} + title={t("toolBar.hand")} + isMobile + /> + - {/* {actionManager.renderAction("eraser", { - // size: "small", - })} */} @@ -389,6 +398,7 @@ const LayerUI = ({ }} /> )} + {renderImageExportDialog()} {renderJSONExportDialog()} {appState.pasteDialog.shown && ( @@ -411,7 +421,8 @@ const LayerUI = ({ renderJSONExportDialog={renderJSONExportDialog} renderImageExportDialog={renderImageExportDialog} setAppState={setAppState} - onLockToggle={() => onLockToggle()} + onLockToggle={onLockToggle} + onHandToolToggle={onHandToolToggle} onPenModeToggle={onPenModeToggle} canvas={canvas} onImageAction={onImageAction} diff --git a/src/components/LibraryMenuHeaderContent.tsx b/src/components/LibraryMenuHeaderContent.tsx index beeb8dd17..43ea96899 100644 --- a/src/components/LibraryMenuHeaderContent.tsx +++ b/src/components/LibraryMenuHeaderContent.tsx @@ -187,6 +187,7 @@ export const LibraryMenuHeader: React.FC<{ setIsLibraryMenuOpen(false)} + onSelect={() => setIsLibraryMenuOpen(false)} className="library-menu" > {!itemsSelected && ( diff --git a/src/components/LockButton.tsx b/src/components/LockButton.tsx index cbcf2b33a..a039a5779 100644 --- a/src/components/LockButton.tsx +++ b/src/components/LockButton.tsx @@ -9,7 +9,6 @@ type LockIconProps = { name?: string; checked: boolean; onChange?(): void; - zenModeEnabled?: boolean; isMobile?: boolean; }; diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index fdad6effa..fa5fd3c32 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -22,6 +22,8 @@ import { LibraryButton } from "./LibraryButton"; import { PenModeButton } from "./PenModeButton"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions"; +import { HandButton } from "./HandButton"; +import { isHandToolActive } from "../appState"; type MobileMenuProps = { appState: AppState; @@ -31,6 +33,7 @@ type MobileMenuProps = { setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onLockToggle: () => void; + onHandToolToggle: () => void; onPenModeToggle: () => void; canvas: HTMLCanvasElement | null; @@ -53,6 +56,7 @@ export const MobileMenu = ({ actionManager, setAppState, onLockToggle, + onHandToolToggle, onPenModeToggle, canvas, onImageAction, @@ -91,6 +95,13 @@ export const MobileMenu = ({ {renderTopRightUI && renderTopRightUI(true, appState)}
+ {!appState.viewModeEnabled && ( + + )} - {!appState.viewModeEnabled && ( - - )} + onHandToolToggle()} + title={t("toolBar.hand")} + isMobile + />
diff --git a/src/components/ToolButton.tsx b/src/components/ToolButton.tsx index ef124ae40..9ea45385d 100644 --- a/src/components/ToolButton.tsx +++ b/src/components/ToolButton.tsx @@ -19,7 +19,7 @@ type ToolButtonBaseProps = { name?: string; id?: string; size?: ToolButtonSize; - keyBindingLabel?: string; + keyBindingLabel?: string | null; showAriaLabel?: boolean; hidden?: boolean; visible?: boolean; diff --git a/src/components/dropdownMenu/DropdownMenuContent.tsx b/src/components/dropdownMenu/DropdownMenuContent.tsx index 873a99d8d..8ec2b6e63 100644 --- a/src/components/dropdownMenu/DropdownMenuContent.tsx +++ b/src/components/dropdownMenu/DropdownMenuContent.tsx @@ -4,16 +4,23 @@ import { Island } from "../Island"; import { useDevice } from "../App"; import clsx from "clsx"; import Stack from "../Stack"; +import React from "react"; +import { DropdownMenuContentPropsContext } from "./common"; const MenuContent = ({ children, onClickOutside, className = "", + onSelect, style, }: { children?: React.ReactNode; onClickOutside?: () => void; className?: string; + /** + * Called when any menu item is selected (clicked on). + */ + onSelect?: (event: Event) => void; style?: React.CSSProperties; }) => { const device = useDevice(); @@ -24,28 +31,32 @@ const MenuContent = ({ const classNames = clsx(`dropdown-menu ${className}`, { "dropdown-menu--mobile": device.isMobile, }).trim(); + return ( -
- {/* the zIndex ensures this menu has higher stacking order, + +
+ {/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} - {device.isMobile ? ( - {children} - ) : ( - - {children} - - )} -
+ {device.isMobile ? ( + {children} + ) : ( + + {children} + + )} +
+ ); }; -export default MenuContent; MenuContent.displayName = "DropdownMenuContent"; + +export default MenuContent; diff --git a/src/components/dropdownMenu/DropdownMenuItem.tsx b/src/components/dropdownMenu/DropdownMenuItem.tsx index 4f8db982d..9a8e92462 100644 --- a/src/components/dropdownMenu/DropdownMenuItem.tsx +++ b/src/components/dropdownMenu/DropdownMenuItem.tsx @@ -1,10 +1,10 @@ import React from "react"; +import { + getDrodownMenuItemClassName, + useHandleDropdownMenuItemClick, +} from "./common"; import MenuItemContent from "./DropdownMenuItemContent"; -export const getDrodownMenuItemClassName = (className = "") => { - return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim(); -}; - const DropdownMenuItem = ({ icon, onSelect, @@ -14,15 +14,17 @@ const DropdownMenuItem = ({ ...rest }: { icon?: JSX.Element; - onSelect: () => void; + onSelect: (event: Event) => void; children: React.ReactNode; shortcut?: string; className?: string; -} & React.ButtonHTMLAttributes) => { +} & Omit, "onSelect">) => { + const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); + return (