|
@@ -1,5 +1,6 @@
|
|
|
import clsx from "clsx";
|
|
|
import { useState } from "react";
|
|
|
+import * as Popover from "@radix-ui/react-popover";
|
|
|
|
|
|
import {
|
|
|
CLASSES,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
isImageElement,
|
|
|
isLinearElement,
|
|
|
isTextElement,
|
|
|
+ isArrowElement,
|
|
|
} from "@excalidraw/element";
|
|
|
|
|
|
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
|
@@ -46,15 +48,20 @@ import {
|
|
|
hasStrokeWidth,
|
|
|
} from "../scene";
|
|
|
|
|
|
+import { getFormValue } from "../actions/actionProperties";
|
|
|
+
|
|
|
+import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
|
|
|
+
|
|
|
import { getToolbarTools } from "./shapes";
|
|
|
|
|
|
import "./Actions.scss";
|
|
|
|
|
|
-import { useDevice } from "./App";
|
|
|
+import { useDevice, useExcalidrawContainer } from "./App";
|
|
|
import Stack from "./Stack";
|
|
|
import { ToolButton } from "./ToolButton";
|
|
|
import { Tooltip } from "./Tooltip";
|
|
|
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
|
|
+import { PropertiesPopover } from "./PropertiesPopover";
|
|
|
import {
|
|
|
EmbedIcon,
|
|
|
extraToolsIcon,
|
|
@@ -63,11 +70,29 @@ import {
|
|
|
laserPointerToolIcon,
|
|
|
MagicIcon,
|
|
|
LassoIcon,
|
|
|
+ sharpArrowIcon,
|
|
|
+ roundArrowIcon,
|
|
|
+ elbowArrowIcon,
|
|
|
+ TextSizeIcon,
|
|
|
+ adjustmentsIcon,
|
|
|
+ DotsHorizontalIcon,
|
|
|
} from "./icons";
|
|
|
|
|
|
-import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
|
|
+import type {
|
|
|
+ AppClassProperties,
|
|
|
+ AppProps,
|
|
|
+ UIAppState,
|
|
|
+ Zoom,
|
|
|
+ AppState,
|
|
|
+} from "../types";
|
|
|
import type { ActionManager } from "../actions/manager";
|
|
|
|
|
|
+// Common CSS class combinations
|
|
|
+const PROPERTIES_CLASSES = clsx([
|
|
|
+ CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
|
|
|
+ "properties-content",
|
|
|
+]);
|
|
|
+
|
|
|
export const canChangeStrokeColor = (
|
|
|
appState: UIAppState,
|
|
|
targetElements: ExcalidrawElement[],
|
|
@@ -280,6 +305,437 @@ export const SelectedShapeActions = ({
|
|
|
);
|
|
|
};
|
|
|
|
|
|
+export const CompactShapeActions = ({
|
|
|
+ appState,
|
|
|
+ elementsMap,
|
|
|
+ renderAction,
|
|
|
+ app,
|
|
|
+ setAppState,
|
|
|
+}: {
|
|
|
+ appState: UIAppState;
|
|
|
+ elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
|
|
+ renderAction: ActionManager["renderAction"];
|
|
|
+ app: AppClassProperties;
|
|
|
+ setAppState: React.Component<any, AppState>["setState"];
|
|
|
+}) => {
|
|
|
+ const targetElements = getTargetElements(elementsMap, appState);
|
|
|
+ const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
|
|
+ const { container } = useExcalidrawContainer();
|
|
|
+
|
|
|
+ const isEditingTextOrNewElement = Boolean(
|
|
|
+ appState.editingTextElement || appState.newElement,
|
|
|
+ );
|
|
|
+
|
|
|
+ const showFillIcons =
|
|
|
+ (hasBackground(appState.activeTool.type) &&
|
|
|
+ !isTransparent(appState.currentItemBackgroundColor)) ||
|
|
|
+ targetElements.some(
|
|
|
+ (element) =>
|
|
|
+ hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
|
|
+ );
|
|
|
+
|
|
|
+ const showLinkIcon = targetElements.length === 1;
|
|
|
+
|
|
|
+ const showLineEditorAction =
|
|
|
+ !appState.selectedLinearElement?.isEditing &&
|
|
|
+ targetElements.length === 1 &&
|
|
|
+ isLinearElement(targetElements[0]) &&
|
|
|
+ !isElbowArrow(targetElements[0]);
|
|
|
+
|
|
|
+ const showCropEditorAction =
|
|
|
+ !appState.croppingElementId &&
|
|
|
+ targetElements.length === 1 &&
|
|
|
+ isImageElement(targetElements[0]);
|
|
|
+
|
|
|
+ const showAlignActions = alignActionsPredicate(appState, app);
|
|
|
+
|
|
|
+ let isSingleElementBoundContainer = false;
|
|
|
+ if (
|
|
|
+ targetElements.length === 2 &&
|
|
|
+ (hasBoundTextElement(targetElements[0]) ||
|
|
|
+ hasBoundTextElement(targetElements[1]))
|
|
|
+ ) {
|
|
|
+ isSingleElementBoundContainer = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="compact-shape-actions">
|
|
|
+ {/* Stroke Color */}
|
|
|
+ {canChangeStrokeColor(appState, targetElements) && (
|
|
|
+ <div className={clsx("compact-action-item")}>
|
|
|
+ {renderAction("changeStrokeColor")}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Background Color */}
|
|
|
+ {canChangeBackgroundColor(appState, targetElements) && (
|
|
|
+ <div className="compact-action-item">
|
|
|
+ {renderAction("changeBackgroundColor")}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Combined Properties (Fill, Stroke, Opacity) */}
|
|
|
+ {(showFillIcons ||
|
|
|
+ hasStrokeWidth(appState.activeTool.type) ||
|
|
|
+ targetElements.some((element) => hasStrokeWidth(element.type)) ||
|
|
|
+ hasStrokeStyle(appState.activeTool.type) ||
|
|
|
+ targetElements.some((element) => hasStrokeStyle(element.type)) ||
|
|
|
+ canChangeRoundness(appState.activeTool.type) ||
|
|
|
+ targetElements.some((element) => canChangeRoundness(element.type))) && (
|
|
|
+ <div className="compact-action-item">
|
|
|
+ <Popover.Root
|
|
|
+ open={appState.openPopup === "compactStrokeStyles"}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ if (open) {
|
|
|
+ setAppState({ openPopup: "compactStrokeStyles" });
|
|
|
+ } else {
|
|
|
+ setAppState({ openPopup: null });
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Popover.Trigger asChild>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="compact-action-button properties-trigger"
|
|
|
+ title={t("labels.stroke")}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+
|
|
|
+ setAppState({
|
|
|
+ openPopup:
|
|
|
+ appState.openPopup === "compactStrokeStyles"
|
|
|
+ ? null
|
|
|
+ : "compactStrokeStyles",
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {adjustmentsIcon}
|
|
|
+ </button>
|
|
|
+ </Popover.Trigger>
|
|
|
+ {appState.openPopup === "compactStrokeStyles" && (
|
|
|
+ <PropertiesPopover
|
|
|
+ className={PROPERTIES_CLASSES}
|
|
|
+ container={container}
|
|
|
+ style={{ maxWidth: "13rem" }}
|
|
|
+ onClose={() => {}}
|
|
|
+ >
|
|
|
+ <div className="selected-shape-actions">
|
|
|
+ {showFillIcons && renderAction("changeFillStyle")}
|
|
|
+ {(hasStrokeWidth(appState.activeTool.type) ||
|
|
|
+ targetElements.some((element) =>
|
|
|
+ hasStrokeWidth(element.type),
|
|
|
+ )) &&
|
|
|
+ renderAction("changeStrokeWidth")}
|
|
|
+ {(hasStrokeStyle(appState.activeTool.type) ||
|
|
|
+ targetElements.some((element) =>
|
|
|
+ hasStrokeStyle(element.type),
|
|
|
+ )) && (
|
|
|
+ <>
|
|
|
+ {renderAction("changeStrokeStyle")}
|
|
|
+ {renderAction("changeSloppiness")}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {(canChangeRoundness(appState.activeTool.type) ||
|
|
|
+ targetElements.some((element) =>
|
|
|
+ canChangeRoundness(element.type),
|
|
|
+ )) &&
|
|
|
+ renderAction("changeRoundness")}
|
|
|
+ {renderAction("changeOpacity")}
|
|
|
+ </div>
|
|
|
+ </PropertiesPopover>
|
|
|
+ )}
|
|
|
+ </Popover.Root>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Combined Arrow Properties */}
|
|
|
+ {(toolIsArrow(appState.activeTool.type) ||
|
|
|
+ targetElements.some((element) => toolIsArrow(element.type))) && (
|
|
|
+ <div className="compact-action-item">
|
|
|
+ <Popover.Root
|
|
|
+ open={appState.openPopup === "compactArrowProperties"}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ if (open) {
|
|
|
+ setAppState({ openPopup: "compactArrowProperties" });
|
|
|
+ } else {
|
|
|
+ setAppState({ openPopup: null });
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Popover.Trigger asChild>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="compact-action-button properties-trigger"
|
|
|
+ title={t("labels.arrowtypes")}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+
|
|
|
+ setAppState({
|
|
|
+ openPopup:
|
|
|
+ appState.openPopup === "compactArrowProperties"
|
|
|
+ ? null
|
|
|
+ : "compactArrowProperties",
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {(() => {
|
|
|
+ // Show an icon based on the current arrow type
|
|
|
+ const arrowType = getFormValue(
|
|
|
+ targetElements,
|
|
|
+ app,
|
|
|
+ (element) => {
|
|
|
+ if (isArrowElement(element)) {
|
|
|
+ return element.elbowed
|
|
|
+ ? "elbow"
|
|
|
+ : element.roundness
|
|
|
+ ? "round"
|
|
|
+ : "sharp";
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ },
|
|
|
+ (element) => isArrowElement(element),
|
|
|
+ (hasSelection) =>
|
|
|
+ hasSelection ? null : appState.currentItemArrowType,
|
|
|
+ );
|
|
|
+
|
|
|
+ if (arrowType === "elbow") {
|
|
|
+ return elbowArrowIcon;
|
|
|
+ }
|
|
|
+ if (arrowType === "round") {
|
|
|
+ return roundArrowIcon;
|
|
|
+ }
|
|
|
+ return sharpArrowIcon;
|
|
|
+ })()}
|
|
|
+ </button>
|
|
|
+ </Popover.Trigger>
|
|
|
+ {appState.openPopup === "compactArrowProperties" && (
|
|
|
+ <PropertiesPopover
|
|
|
+ container={container}
|
|
|
+ className="properties-content"
|
|
|
+ style={{ maxWidth: "13rem" }}
|
|
|
+ onClose={() => {}}
|
|
|
+ >
|
|
|
+ {renderAction("changeArrowProperties")}
|
|
|
+ </PropertiesPopover>
|
|
|
+ )}
|
|
|
+ </Popover.Root>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Linear Editor */}
|
|
|
+ {showLineEditorAction && (
|
|
|
+ <div className="compact-action-item">
|
|
|
+ {renderAction("toggleLinearEditor")}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Text Properties */}
|
|
|
+ {(appState.activeTool.type === "text" ||
|
|
|
+ targetElements.some(isTextElement)) && (
|
|
|
+ <>
|
|
|
+ <div className="compact-action-item">
|
|
|
+ {renderAction("changeFontFamily")}
|
|
|
+ </div>
|
|
|
+ <div className="compact-action-item">
|
|
|
+ <Popover.Root
|
|
|
+ open={appState.openPopup === "compactTextProperties"}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ if (open) {
|
|
|
+ if (appState.editingTextElement) {
|
|
|
+ saveCaretPosition();
|
|
|
+ }
|
|
|
+ setAppState({ openPopup: "compactTextProperties" });
|
|
|
+ } else {
|
|
|
+ setAppState({ openPopup: null });
|
|
|
+ if (appState.editingTextElement) {
|
|
|
+ restoreCaretPosition();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Popover.Trigger asChild>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="compact-action-button properties-trigger"
|
|
|
+ title={t("labels.textAlign")}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+
|
|
|
+ if (appState.openPopup === "compactTextProperties") {
|
|
|
+ setAppState({ openPopup: null });
|
|
|
+ } else {
|
|
|
+ if (appState.editingTextElement) {
|
|
|
+ saveCaretPosition();
|
|
|
+ }
|
|
|
+ setAppState({ openPopup: "compactTextProperties" });
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {TextSizeIcon}
|
|
|
+ </button>
|
|
|
+ </Popover.Trigger>
|
|
|
+ {appState.openPopup === "compactTextProperties" && (
|
|
|
+ <PropertiesPopover
|
|
|
+ className={PROPERTIES_CLASSES}
|
|
|
+ container={container}
|
|
|
+ style={{ maxWidth: "13rem" }}
|
|
|
+ // Improve focus handling for text editing scenarios
|
|
|
+ preventAutoFocusOnTouch={!!appState.editingTextElement}
|
|
|
+ onClose={() => {
|
|
|
+ // Refocus text editor when popover closes with caret restoration
|
|
|
+ if (appState.editingTextElement) {
|
|
|
+ restoreCaretPosition();
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="selected-shape-actions">
|
|
|
+ {(appState.activeTool.type === "text" ||
|
|
|
+ targetElements.some(isTextElement)) &&
|
|
|
+ renderAction("changeFontSize")}
|
|
|
+ {(appState.activeTool.type === "text" ||
|
|
|
+ suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
|
|
+ renderAction("changeTextAlign")}
|
|
|
+ {shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
|
|
+ renderAction("changeVerticalAlign")}
|
|
|
+ </div>
|
|
|
+ </PropertiesPopover>
|
|
|
+ )}
|
|
|
+ </Popover.Root>
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Dedicated Copy Button */}
|
|
|
+ {!isEditingTextOrNewElement && targetElements.length > 0 && (
|
|
|
+ <div className="compact-action-item">
|
|
|
+ {renderAction("duplicateSelection")}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Dedicated Delete Button */}
|
|
|
+ {!isEditingTextOrNewElement && targetElements.length > 0 && (
|
|
|
+ <div className="compact-action-item">
|
|
|
+ {renderAction("deleteSelectedElements")}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Combined Other Actions */}
|
|
|
+ {!isEditingTextOrNewElement && targetElements.length > 0 && (
|
|
|
+ <div className="compact-action-item">
|
|
|
+ <Popover.Root
|
|
|
+ open={appState.openPopup === "compactOtherProperties"}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ if (open) {
|
|
|
+ setAppState({ openPopup: "compactOtherProperties" });
|
|
|
+ } else {
|
|
|
+ setAppState({ openPopup: null });
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Popover.Trigger asChild>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="compact-action-button properties-trigger"
|
|
|
+ title={t("labels.actions")}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+ setAppState({
|
|
|
+ openPopup:
|
|
|
+ appState.openPopup === "compactOtherProperties"
|
|
|
+ ? null
|
|
|
+ : "compactOtherProperties",
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {DotsHorizontalIcon}
|
|
|
+ </button>
|
|
|
+ </Popover.Trigger>
|
|
|
+ {appState.openPopup === "compactOtherProperties" && (
|
|
|
+ <PropertiesPopover
|
|
|
+ className={PROPERTIES_CLASSES}
|
|
|
+ container={container}
|
|
|
+ style={{
|
|
|
+ maxWidth: "12rem",
|
|
|
+ // center the popover content
|
|
|
+ justifyContent: "center",
|
|
|
+ alignItems: "center",
|
|
|
+ }}
|
|
|
+ onClose={() => {}}
|
|
|
+ >
|
|
|
+ <div className="selected-shape-actions">
|
|
|
+ <fieldset>
|
|
|
+ <legend>{t("labels.layers")}</legend>
|
|
|
+ <div className="buttonList">
|
|
|
+ {renderAction("sendToBack")}
|
|
|
+ {renderAction("sendBackward")}
|
|
|
+ {renderAction("bringForward")}
|
|
|
+ {renderAction("bringToFront")}
|
|
|
+ </div>
|
|
|
+ </fieldset>
|
|
|
+
|
|
|
+ {showAlignActions && !isSingleElementBoundContainer && (
|
|
|
+ <fieldset>
|
|
|
+ <legend>{t("labels.align")}</legend>
|
|
|
+ <div className="buttonList">
|
|
|
+ {isRTL ? (
|
|
|
+ <>
|
|
|
+ {renderAction("alignRight")}
|
|
|
+ {renderAction("alignHorizontallyCentered")}
|
|
|
+ {renderAction("alignLeft")}
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ {renderAction("alignLeft")}
|
|
|
+ {renderAction("alignHorizontallyCentered")}
|
|
|
+ {renderAction("alignRight")}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {targetElements.length > 2 &&
|
|
|
+ renderAction("distributeHorizontally")}
|
|
|
+ {/* breaks the row ˇˇ */}
|
|
|
+ <div style={{ flexBasis: "100%", height: 0 }} />
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: "flex",
|
|
|
+ flexWrap: "wrap",
|
|
|
+ gap: ".5rem",
|
|
|
+ marginTop: "-0.5rem",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {renderAction("alignTop")}
|
|
|
+ {renderAction("alignVerticallyCentered")}
|
|
|
+ {renderAction("alignBottom")}
|
|
|
+ {targetElements.length > 2 &&
|
|
|
+ renderAction("distributeVertically")}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </fieldset>
|
|
|
+ )}
|
|
|
+ <fieldset>
|
|
|
+ <legend>{t("labels.actions")}</legend>
|
|
|
+ <div className="buttonList">
|
|
|
+ {renderAction("group")}
|
|
|
+ {renderAction("ungroup")}
|
|
|
+ {showLinkIcon && renderAction("hyperlink")}
|
|
|
+ {showCropEditorAction && renderAction("cropEditor")}
|
|
|
+ </div>
|
|
|
+ </fieldset>
|
|
|
+ </div>
|
|
|
+ </PropertiesPopover>
|
|
|
+ )}
|
|
|
+ </Popover.Root>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
export const ShapesSwitcher = ({
|
|
|
activeTool,
|
|
|
appState,
|