feat: compact layout for tablets (#9910)
* feat: allow the hiding of top picks * feat: allow the hiding of default fonts * refactor: rename to compactMode * feat: introduce layout (incomplete) * tweak icons * do not show border * lint * add isTouchMobile to device * add isTouchMobile to device * refactor to use showCompactSidebar instead * hide library label in compact * fix icon color in dark theme * fix library and share btns getting hidden in smaller tablet widths * update tests * use a smaller gap between shapes * proper fix of range * quicker switching between different popovers * to not show properties panel at all when editing text * fix switching between different popovers for texts * fix popover not closable and font search auto focus * change properties for a new or editing text * change icon for more style settings * use bolt icon for extra actions * fix breakpoints * use rem for icon sizes * fix tests * improve switching between triggers (incomplete) * improve trigger switching (complete) * clean up code * put compact into app state * fix button size * remove redundant PanelComponentProps["compactMode"] * move fontSize UI on top * mobile detection (breakpoints incomplete) * tweak compact mode detection * rename appState prop & values * update snapshots --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user