946 lines
26 KiB
TypeScript
946 lines
26 KiB
TypeScript
import clsx from "clsx";
|
|
import { useRef } from "react";
|
|
import { Popover } from "radix-ui";
|
|
|
|
import { CLASSES } from "@excalidraw/common";
|
|
|
|
import { isArrowElement } from "@excalidraw/element";
|
|
|
|
import type {
|
|
ExcalidrawElement,
|
|
NonDeletedElementsMap,
|
|
NonDeletedSceneElementsMap,
|
|
} from "@excalidraw/element/types";
|
|
|
|
import { actionToggleZenMode } from "../actions";
|
|
|
|
import { t } from "../i18n";
|
|
import { getTargetElements } from "../scene";
|
|
|
|
import { getFormValue } from "../actions/actionProperties";
|
|
|
|
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
|
|
|
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
|
|
|
import "./Actions.scss";
|
|
|
|
import { useExcalidrawContainer } from "./App";
|
|
import Stack from "./Stack";
|
|
import { Tooltip } from "./Tooltip";
|
|
import { PropertiesPopover } from "./PropertiesPopover";
|
|
import {
|
|
sharpArrowIcon,
|
|
roundArrowIcon,
|
|
elbowArrowIcon,
|
|
TextSizeIcon,
|
|
adjustmentsIcon,
|
|
DotsHorizontalIcon,
|
|
pencilIcon,
|
|
} from "./icons";
|
|
|
|
import { Island } from "./Island";
|
|
|
|
import { getShapeActionPredicates } from "./shapeActionPredicates";
|
|
|
|
import type { ShapeActionPredicates } from "./shapeActionPredicates";
|
|
import type { AppClassProperties, UIAppState, AppState } from "../types";
|
|
import type { ActionManager } from "../actions/manager";
|
|
|
|
// re-exported for consumers outside the styles panel (e.g. CommandPalette)
|
|
export {
|
|
canChangeStrokeColor,
|
|
canChangeBackgroundColor,
|
|
} from "./shapeActionPredicates";
|
|
|
|
// Common CSS class combinations
|
|
const PROPERTIES_CLASSES = clsx([
|
|
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
|
|
"properties-content",
|
|
]);
|
|
|
|
/**
|
|
* The "arrange" (z-order) fieldset, identical across every styles-panel layout.
|
|
*/
|
|
const LayersFieldset = ({
|
|
renderAction,
|
|
}: {
|
|
renderAction: ActionManager["renderAction"];
|
|
}) => (
|
|
<fieldset>
|
|
<legend>{t("labels.layers")}</legend>
|
|
<div className="buttonList">
|
|
{renderAction("sendToBack")}
|
|
{renderAction("sendBackward")}
|
|
{renderAction("bringForward")}
|
|
{renderAction("bringToFront")}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
|
|
/**
|
|
* The align + distribute fieldset, identical across every styles-panel layout.
|
|
* Button order is mirrored for RTL so the leftmost button always aligns left.
|
|
*/
|
|
const AlignFieldset = ({
|
|
renderAction,
|
|
showDistribute,
|
|
}: {
|
|
renderAction: ActionManager["renderAction"];
|
|
showDistribute: boolean;
|
|
}) => {
|
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
|
|
|
return (
|
|
<fieldset>
|
|
<legend>{t("labels.align")}</legend>
|
|
<div className="buttonList">
|
|
{isRTL ? (
|
|
<>
|
|
{renderAction("alignRight")}
|
|
{renderAction("alignHorizontallyCentered")}
|
|
{renderAction("alignLeft")}
|
|
</>
|
|
) : (
|
|
<>
|
|
{renderAction("alignLeft")}
|
|
{renderAction("alignHorizontallyCentered")}
|
|
{renderAction("alignRight")}
|
|
</>
|
|
)}
|
|
{showDistribute && 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")}
|
|
{showDistribute && renderAction("distributeVertically")}
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Full styles panel: the wide, always-expanded layout used on desktop when the
|
|
* UI is in "full" mode.
|
|
*/
|
|
export const SelectedShapeActions = ({
|
|
appState,
|
|
elementsMap,
|
|
renderAction,
|
|
app,
|
|
}: {
|
|
appState: UIAppState;
|
|
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
|
renderAction: ActionManager["renderAction"];
|
|
app: AppClassProperties;
|
|
}) => {
|
|
const targetElements = getTargetElements(elementsMap, appState);
|
|
const predicates = getShapeActionPredicates(
|
|
appState,
|
|
targetElements,
|
|
elementsMap,
|
|
app,
|
|
);
|
|
|
|
// the bucket fill tool configures only the fill it creates: color, fill
|
|
// style, and opacity (shared `currentItem*` values; no stroke properties)
|
|
if (appState.activeTool.type === "bucketfill") {
|
|
return (
|
|
<div className="selected-shape-actions">
|
|
<div>{renderAction("changeBucketFillBackgroundColor")}</div>
|
|
{renderAction("changeFillStyle")}
|
|
{renderAction("changeOpacity")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="selected-shape-actions">
|
|
<div>{predicates.strokeColor && renderAction("changeStrokeColor")}</div>
|
|
{predicates.backgroundColor && (
|
|
<div>{renderAction("changeBackgroundColor")}</div>
|
|
)}
|
|
{predicates.fill && renderAction("changeFillStyle")}
|
|
|
|
{predicates.strokeWidth && renderAction("changeStrokeWidth")}
|
|
|
|
{predicates.strokeStyle && <>{renderAction("changeStrokeStyle")}</>}
|
|
|
|
{predicates.freedrawMode && renderAction("changeFreedrawMode")}
|
|
|
|
{predicates.sloppiness && <>{renderAction("changeSloppiness")}</>}
|
|
|
|
{predicates.roundness && <>{renderAction("changeRoundness")}</>}
|
|
|
|
{predicates.arrowType && <>{renderAction("changeArrowType")}</>}
|
|
|
|
{predicates.text && (
|
|
<>
|
|
<fieldset>{renderAction("changeFontFamily")}</fieldset>
|
|
{renderAction("changeFontSize")}
|
|
{predicates.textAlign && renderAction("changeTextAlign")}
|
|
</>
|
|
)}
|
|
|
|
{predicates.verticalAlign && renderAction("changeVerticalAlign")}
|
|
{predicates.arrowheads && <>{renderAction("changeArrowhead")}</>}
|
|
|
|
{predicates.opacity && renderAction("changeOpacity")}
|
|
|
|
{predicates.layers && <LayersFieldset renderAction={renderAction} />}
|
|
|
|
{predicates.align && (
|
|
<AlignFieldset
|
|
renderAction={renderAction}
|
|
showDistribute={predicates.distribute}
|
|
/>
|
|
)}
|
|
{predicates.showExtraActions && (
|
|
<fieldset>
|
|
<legend>{t("labels.actions")}</legend>
|
|
<div className="buttonList">
|
|
{renderAction("duplicateSelection")}
|
|
{renderAction("deleteSelectedElements")}
|
|
{renderAction("group")}
|
|
{renderAction("ungroup")}
|
|
{predicates.link && renderAction("hyperlink")}
|
|
{predicates.cropEditor && renderAction("cropEditor")}
|
|
{predicates.lineEditor && renderAction("toggleLinearEditor")}
|
|
</div>
|
|
</fieldset>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CombinedShapeProperties = ({
|
|
appState,
|
|
renderAction,
|
|
setAppState,
|
|
predicates,
|
|
container,
|
|
}: {
|
|
appState: UIAppState;
|
|
renderAction: ActionManager["renderAction"];
|
|
setAppState: React.Component<any, AppState>["setState"];
|
|
predicates: ShapeActionPredicates;
|
|
container: HTMLDivElement | null;
|
|
}) => {
|
|
const shouldShowCombinedProperties =
|
|
predicates.hasSelection ||
|
|
(appState.activeTool.type !== "selection" &&
|
|
appState.activeTool.type !== "eraser" &&
|
|
appState.activeTool.type !== "hand" &&
|
|
appState.activeTool.type !== "laser" &&
|
|
appState.activeTool.type !== "lasso");
|
|
const isOpen = appState.openPopup === "compactStrokeStyles";
|
|
|
|
if (!shouldShowCombinedProperties) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="compact-action-item">
|
|
<Popover.Root
|
|
open={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
setAppState({ openPopup: "compactStrokeStyles" });
|
|
} else {
|
|
setAppState({ openPopup: null });
|
|
}
|
|
}}
|
|
>
|
|
<Popover.Trigger asChild>
|
|
<button
|
|
type="button"
|
|
className={clsx("compact-action-button properties-trigger", {
|
|
active: isOpen,
|
|
})}
|
|
title={t("labels.stroke")}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
setAppState({
|
|
openPopup: isOpen ? null : "compactStrokeStyles",
|
|
});
|
|
}}
|
|
>
|
|
{adjustmentsIcon}
|
|
</button>
|
|
</Popover.Trigger>
|
|
{isOpen && (
|
|
<PropertiesPopover
|
|
className={PROPERTIES_CLASSES}
|
|
container={container}
|
|
style={{ maxWidth: "13rem" }}
|
|
onClose={() => {}}
|
|
>
|
|
<div className="selected-shape-actions">
|
|
{predicates.fill && renderAction("changeFillStyle")}
|
|
{predicates.strokeWidth && renderAction("changeStrokeWidth")}
|
|
{
|
|
/* in compact UI the freedraw pressure setting is rendered as a
|
|
standalone cycle button in the compact actions list; we render
|
|
it in the combined properties popup as well for clarity
|
|
*/
|
|
predicates.freedrawMode && renderAction("changeFreedrawMode")
|
|
}
|
|
{predicates.strokeStyle && (
|
|
<>{renderAction("changeStrokeStyle")}</>
|
|
)}
|
|
{predicates.sloppiness && <>{renderAction("changeSloppiness")}</>}
|
|
{predicates.roundness && renderAction("changeRoundness")}
|
|
{predicates.opacity && renderAction("changeOpacity")}
|
|
</div>
|
|
</PropertiesPopover>
|
|
)}
|
|
</Popover.Root>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CombinedArrowProperties = ({
|
|
appState,
|
|
renderAction,
|
|
setAppState,
|
|
targetElements,
|
|
predicates,
|
|
container,
|
|
app,
|
|
}: {
|
|
appState: UIAppState;
|
|
renderAction: ActionManager["renderAction"];
|
|
setAppState: React.Component<any, AppState>["setState"];
|
|
targetElements: ExcalidrawElement[];
|
|
predicates: ShapeActionPredicates;
|
|
container: HTMLDivElement | null;
|
|
app: AppClassProperties;
|
|
}) => {
|
|
if (!predicates.arrowType) {
|
|
return null;
|
|
}
|
|
|
|
const isOpen = appState.openPopup === "compactArrowProperties";
|
|
|
|
return (
|
|
<div className="compact-action-item">
|
|
<Popover.Root
|
|
open={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
setAppState({ openPopup: "compactArrowProperties" });
|
|
} else {
|
|
setAppState({ openPopup: null });
|
|
}
|
|
}}
|
|
>
|
|
<Popover.Trigger asChild>
|
|
<button
|
|
type="button"
|
|
className={clsx("compact-action-button properties-trigger", {
|
|
active: isOpen,
|
|
})}
|
|
title={t("labels.arrowtypes")}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
setAppState({
|
|
openPopup: isOpen ? 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>
|
|
{isOpen && (
|
|
<PropertiesPopover
|
|
container={container}
|
|
className="properties-content"
|
|
style={{ maxWidth: "13rem" }}
|
|
onClose={() => {}}
|
|
>
|
|
{renderAction("changeArrowProperties")}
|
|
</PropertiesPopover>
|
|
)}
|
|
</Popover.Root>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CombinedTextProperties = ({
|
|
appState,
|
|
renderAction,
|
|
setAppState,
|
|
predicates,
|
|
container,
|
|
}: {
|
|
appState: UIAppState;
|
|
renderAction: ActionManager["renderAction"];
|
|
setAppState: React.Component<any, AppState>["setState"];
|
|
predicates: ShapeActionPredicates;
|
|
container: HTMLDivElement | null;
|
|
}) => {
|
|
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(
|
|
container?.ownerDocument,
|
|
);
|
|
const isOpen = appState.openPopup === "compactTextProperties";
|
|
|
|
return (
|
|
<div className="compact-action-item">
|
|
<Popover.Root
|
|
open={isOpen}
|
|
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={clsx("compact-action-button properties-trigger", {
|
|
active: isOpen,
|
|
})}
|
|
title={t("labels.textAlign")}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (isOpen) {
|
|
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">
|
|
{predicates.text && renderAction("changeFontSize")}
|
|
{predicates.textAlign && renderAction("changeTextAlign")}
|
|
{predicates.verticalAlign && renderAction("changeVerticalAlign")}
|
|
</div>
|
|
</PropertiesPopover>
|
|
)}
|
|
</Popover.Root>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CombinedExtraActions = ({
|
|
appState,
|
|
renderAction,
|
|
predicates,
|
|
setAppState,
|
|
container,
|
|
showDuplicate,
|
|
showDelete,
|
|
}: {
|
|
appState: UIAppState;
|
|
renderAction: ActionManager["renderAction"];
|
|
predicates: ShapeActionPredicates;
|
|
setAppState: React.Component<any, AppState>["setState"];
|
|
container: HTMLDivElement | null;
|
|
showDuplicate?: boolean;
|
|
showDelete?: boolean;
|
|
}) => {
|
|
const isOpen = appState.openPopup === "compactOtherProperties";
|
|
|
|
if (!predicates.showExtraActions) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="compact-action-item">
|
|
<Popover.Root
|
|
open={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
setAppState({ openPopup: "compactOtherProperties" });
|
|
} else {
|
|
setAppState({ openPopup: null });
|
|
}
|
|
}}
|
|
>
|
|
<Popover.Trigger asChild>
|
|
<button
|
|
type="button"
|
|
className={clsx("compact-action-button properties-trigger", {
|
|
active: isOpen,
|
|
})}
|
|
title={t("labels.actions")}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setAppState({
|
|
openPopup: isOpen ? null : "compactOtherProperties",
|
|
});
|
|
}}
|
|
>
|
|
{DotsHorizontalIcon}
|
|
</button>
|
|
</Popover.Trigger>
|
|
{isOpen && (
|
|
<PropertiesPopover
|
|
className={PROPERTIES_CLASSES}
|
|
container={container}
|
|
style={{
|
|
maxWidth: "12rem",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
onClose={() => {}}
|
|
>
|
|
<div className="selected-shape-actions">
|
|
{predicates.layers && (
|
|
<LayersFieldset renderAction={renderAction} />
|
|
)}
|
|
|
|
{predicates.align && (
|
|
<AlignFieldset
|
|
renderAction={renderAction}
|
|
showDistribute={predicates.distribute}
|
|
/>
|
|
)}
|
|
<fieldset>
|
|
<legend>{t("labels.actions")}</legend>
|
|
<div className="buttonList">
|
|
{renderAction("group")}
|
|
{renderAction("ungroup")}
|
|
{predicates.linkSingleOnly && renderAction("hyperlink")}
|
|
{predicates.cropEditor && renderAction("cropEditor")}
|
|
{showDuplicate && renderAction("duplicateSelection")}
|
|
{showDelete && renderAction("deleteSelectedElements")}
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
</PropertiesPopover>
|
|
)}
|
|
</Popover.Root>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const LinearEditorAction = ({
|
|
renderAction,
|
|
predicates,
|
|
}: {
|
|
renderAction: ActionManager["renderAction"];
|
|
predicates: ShapeActionPredicates;
|
|
}) => {
|
|
if (!predicates.lineEditor) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="compact-action-item">
|
|
{renderAction("toggleLinearEditor")}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Compact styles panel — the collapsed, popover-driven layout used on tablets
|
|
* and on desktop when the UI is in "compact" mode.
|
|
*/
|
|
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 predicates = getShapeActionPredicates(
|
|
appState,
|
|
targetElements,
|
|
elementsMap,
|
|
app,
|
|
);
|
|
const { container } = useExcalidrawContainer();
|
|
|
|
return (
|
|
<div className="compact-shape-actions">
|
|
{/* Stroke Color */}
|
|
{predicates.strokeColor && (
|
|
<div className={clsx("compact-action-item")}>
|
|
{renderAction("changeStrokeColor")}
|
|
</div>
|
|
)}
|
|
|
|
{/* Background Color (the bucket fill variant excludes `transparent`) */}
|
|
{predicates.backgroundColor && (
|
|
<div className="compact-action-item">
|
|
{renderAction(
|
|
appState.activeTool.type === "bucketfill"
|
|
? "changeBucketFillBackgroundColor"
|
|
: "changeBackgroundColor",
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Freedraw pressure: standalone button cycling the variability mode */}
|
|
{predicates.freedrawMode && (
|
|
<div className="compact-action-item">
|
|
{renderAction("changeFreedrawMode", { cycle: true })}
|
|
</div>
|
|
)}
|
|
|
|
<CombinedShapeProperties
|
|
appState={appState}
|
|
renderAction={renderAction}
|
|
setAppState={setAppState}
|
|
predicates={predicates}
|
|
container={container}
|
|
/>
|
|
|
|
<CombinedArrowProperties
|
|
appState={appState}
|
|
renderAction={renderAction}
|
|
setAppState={setAppState}
|
|
targetElements={targetElements}
|
|
predicates={predicates}
|
|
container={container}
|
|
app={app}
|
|
/>
|
|
{/* Linear Editor */}
|
|
{predicates.lineEditor && (
|
|
<div className="compact-action-item">
|
|
{renderAction("toggleLinearEditor")}
|
|
</div>
|
|
)}
|
|
|
|
{/* Text Properties */}
|
|
{predicates.text && (
|
|
<>
|
|
<div className="compact-action-item">
|
|
{renderAction("changeFontFamily")}
|
|
</div>
|
|
<CombinedTextProperties
|
|
appState={appState}
|
|
renderAction={renderAction}
|
|
setAppState={setAppState}
|
|
predicates={predicates}
|
|
container={container}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Dedicated Copy Button */}
|
|
{predicates.showExtraActions && (
|
|
<div className="compact-action-item">
|
|
{renderAction("duplicateSelection")}
|
|
</div>
|
|
)}
|
|
|
|
{/* Dedicated Delete Button */}
|
|
{predicates.showExtraActions && (
|
|
<div className="compact-action-item">
|
|
{renderAction("deleteSelectedElements")}
|
|
</div>
|
|
)}
|
|
|
|
<CombinedExtraActions
|
|
appState={appState}
|
|
renderAction={renderAction}
|
|
predicates={predicates}
|
|
setAppState={setAppState}
|
|
container={container}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Mobile styles panel — the horizontal action bar used on phones, with an
|
|
* overflow measurement that promotes duplicate/delete out of the popover when
|
|
* there is room.
|
|
*/
|
|
export const MobileShapeActions = ({
|
|
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 predicates = getShapeActionPredicates(
|
|
appState,
|
|
targetElements,
|
|
elementsMap,
|
|
app,
|
|
);
|
|
const { container } = useExcalidrawContainer();
|
|
const mobileActionsRef = useRef<HTMLDivElement>(null);
|
|
|
|
const ACTIONS_WIDTH =
|
|
mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0;
|
|
|
|
// 7 actions + 2 for undo/redo
|
|
const MIN_ACTIONS = 9;
|
|
|
|
const GAP = 6;
|
|
const WIDTH = 32;
|
|
|
|
const MIN_WIDTH = MIN_ACTIONS * WIDTH + (MIN_ACTIONS - 1) * GAP;
|
|
|
|
const ADDITIONAL_WIDTH = WIDTH + GAP;
|
|
|
|
const showDeleteOutside = ACTIONS_WIDTH >= MIN_WIDTH + ADDITIONAL_WIDTH;
|
|
const showDuplicateOutside =
|
|
ACTIONS_WIDTH >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
|
|
|
|
return (
|
|
<Island
|
|
className="compact-shape-actions mobile-shape-actions"
|
|
style={{
|
|
flexDirection: "row",
|
|
boxShadow: "none",
|
|
padding: 0,
|
|
zIndex: 2,
|
|
backgroundColor: "transparent",
|
|
height: WIDTH * 1.35,
|
|
marginBottom: 4,
|
|
alignItems: "center",
|
|
gap: GAP,
|
|
pointerEvents: "none",
|
|
}}
|
|
ref={mobileActionsRef}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
gap: GAP,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{predicates.strokeColor && (
|
|
<div className={clsx("compact-action-item")}>
|
|
{renderAction("changeStrokeColor")}
|
|
</div>
|
|
)}
|
|
{/* Background Color (the bucket fill variant excludes `transparent`) */}
|
|
{predicates.backgroundColor && (
|
|
<div className="compact-action-item">
|
|
{renderAction(
|
|
appState.activeTool.type === "bucketfill"
|
|
? "changeBucketFillBackgroundColor"
|
|
: "changeBackgroundColor",
|
|
)}
|
|
</div>
|
|
)}
|
|
<CombinedShapeProperties
|
|
appState={appState}
|
|
renderAction={renderAction}
|
|
setAppState={setAppState}
|
|
predicates={predicates}
|
|
container={container}
|
|
/>
|
|
{/* Combined Arrow Properties */}
|
|
<CombinedArrowProperties
|
|
appState={appState}
|
|
renderAction={renderAction}
|
|
setAppState={setAppState}
|
|
targetElements={targetElements}
|
|
predicates={predicates}
|
|
container={container}
|
|
app={app}
|
|
/>
|
|
{/* Linear Editor */}
|
|
<LinearEditorAction
|
|
renderAction={renderAction}
|
|
predicates={predicates}
|
|
/>
|
|
{/* Text Properties */}
|
|
{predicates.text && (
|
|
<>
|
|
<div className="compact-action-item">
|
|
{renderAction("changeFontFamily")}
|
|
</div>
|
|
<CombinedTextProperties
|
|
appState={appState}
|
|
renderAction={renderAction}
|
|
setAppState={setAppState}
|
|
predicates={predicates}
|
|
container={container}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Combined Other Actions */}
|
|
<CombinedExtraActions
|
|
appState={appState}
|
|
renderAction={renderAction}
|
|
predicates={predicates}
|
|
setAppState={setAppState}
|
|
container={container}
|
|
showDuplicate={!showDuplicateOutside}
|
|
showDelete={!showDeleteOutside}
|
|
/>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
gap: GAP,
|
|
}}
|
|
>
|
|
<div className="compact-action-item">{renderAction("undo")}</div>
|
|
<div className="compact-action-item">{renderAction("redo")}</div>
|
|
{showDuplicateOutside && (
|
|
<div className="compact-action-item">
|
|
{renderAction("duplicateSelection")}
|
|
</div>
|
|
)}
|
|
{showDeleteOutside && (
|
|
<div className="compact-action-item">
|
|
{renderAction("deleteSelectedElements")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Island>
|
|
);
|
|
};
|
|
|
|
export const ZoomActions = ({
|
|
renderAction,
|
|
}: {
|
|
renderAction: ActionManager["renderAction"];
|
|
}) => (
|
|
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
|
|
<Stack.Row align="center">
|
|
{renderAction("zoomOut")}
|
|
{renderAction("resetZoom")}
|
|
{renderAction("zoomIn")}
|
|
</Stack.Row>
|
|
</Stack.Col>
|
|
);
|
|
|
|
export const UndoRedoActions = ({
|
|
renderAction,
|
|
className,
|
|
}: {
|
|
renderAction: ActionManager["renderAction"];
|
|
className?: string;
|
|
}) => (
|
|
<div className={`undo-redo-buttons ${className}`}>
|
|
<div className="undo-button-container">
|
|
<Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip>
|
|
</div>
|
|
<div className="redo-button-container">
|
|
<Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export const ExitZenModeButton = ({
|
|
actionManager,
|
|
showExitZenModeBtn,
|
|
}: {
|
|
actionManager: ActionManager;
|
|
showExitZenModeBtn: boolean;
|
|
}) => (
|
|
<button
|
|
type="button"
|
|
className={clsx("disable-zen-mode", {
|
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
|
})}
|
|
onClick={() => actionManager.executeAction(actionToggleZenMode)}
|
|
>
|
|
{t("buttons.exitZenMode")}
|
|
</button>
|
|
);
|
|
|
|
export const ExitViewModeButton = ({
|
|
actionManager,
|
|
}: {
|
|
actionManager: ActionManager;
|
|
}) => (
|
|
<button
|
|
type="button"
|
|
className="disable-view-mode"
|
|
onClick={() => actionManager.executeAction(actionToggleViewMode)}
|
|
>
|
|
{pencilIcon}
|
|
</button>
|
|
);
|