mobile actions
This commit is contained in:
parent
e064bc236f
commit
ddb5dc313c
@ -110,8 +110,8 @@
|
||||
--default-button-size: 2rem;
|
||||
|
||||
.compact-action-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: transparent;
|
||||
@ -167,6 +167,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.compact-shape-actions-island {
|
||||
@ -199,6 +204,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-shape-actions {
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.shape-actions-theme-scope {
|
||||
--button-border: transparent;
|
||||
--button-bg: var(--color-surface-mid);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import {
|
||||
@ -86,6 +86,7 @@ import type {
|
||||
AppState,
|
||||
} from "../types";
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
import { Island } from "./Island";
|
||||
|
||||
// Common CSS class combinations
|
||||
const PROPERTIES_CLASSES = clsx([
|
||||
@ -305,27 +306,19 @@ export const SelectedShapeActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CompactShapeActions = ({
|
||||
const CombinedShapeProperties = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
renderAction,
|
||||
app,
|
||||
setAppState,
|
||||
targetElements,
|
||||
container,
|
||||
}: {
|
||||
targetElements: ExcalidrawElement[];
|
||||
appState: UIAppState;
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
app: AppClassProperties;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
container: HTMLDivElement | null;
|
||||
}) => {
|
||||
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)) ||
|
||||
@ -334,56 +327,20 @@ export const CompactShapeActions = ({
|
||||
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 ||
|
||||
const showShowCombinedProperties =
|
||||
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))) && (
|
||||
targetElements.some((element) => canChangeRoundness(element.type));
|
||||
|
||||
if (!showShowCombinedProperties) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactStrokeStyles"}
|
||||
@ -449,11 +406,33 @@ export const CompactShapeActions = ({
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
};
|
||||
|
||||
{/* Combined Arrow Properties */}
|
||||
{(toolIsArrow(appState.activeTool.type) ||
|
||||
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||
const CombinedArrowProperties = ({
|
||||
appState,
|
||||
renderAction,
|
||||
setAppState,
|
||||
targetElements,
|
||||
container,
|
||||
app,
|
||||
}: {
|
||||
targetElements: ExcalidrawElement[];
|
||||
appState: UIAppState;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
container: HTMLDivElement | null;
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const showShowArrowProperties =
|
||||
toolIsArrow(appState.activeTool.type) ||
|
||||
targetElements.some((element) => toolIsArrow(element.type));
|
||||
|
||||
if (!showShowArrowProperties) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactArrowProperties"}
|
||||
@ -524,22 +503,27 @@ export const CompactShapeActions = ({
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
};
|
||||
|
||||
{/* Linear Editor */}
|
||||
{showLineEditorAction && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
)}
|
||||
const CombinedTextProperties = ({
|
||||
appState,
|
||||
renderAction,
|
||||
setAppState,
|
||||
targetElements,
|
||||
container,
|
||||
elementsMap,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
targetElements: ExcalidrawElement[];
|
||||
container: HTMLDivElement | null;
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||
}) => {
|
||||
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
||||
|
||||
{/* Text Properties */}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeFontFamily")}
|
||||
</div>
|
||||
return (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactTextProperties"}
|
||||
@ -607,25 +591,49 @@ export const CompactShapeActions = ({
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
};
|
||||
|
||||
{/* Dedicated Copy Button */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("duplicateSelection")}
|
||||
</div>
|
||||
)}
|
||||
const CombinedExtraActions = ({
|
||||
appState,
|
||||
renderAction,
|
||||
targetElements,
|
||||
setAppState,
|
||||
container,
|
||||
app,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
targetElements: ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
container: HTMLDivElement | null;
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const isEditingTextOrNewElement = Boolean(
|
||||
appState.editingTextElement || appState.newElement,
|
||||
);
|
||||
const showCropEditorAction =
|
||||
!appState.croppingElementId &&
|
||||
targetElements.length === 1 &&
|
||||
isImageElement(targetElements[0]);
|
||||
const showLinkIcon = targetElements.length === 1;
|
||||
const showAlignActions = alignActionsPredicate(appState, app);
|
||||
let isSingleElementBoundContainer = false;
|
||||
if (
|
||||
targetElements.length === 2 &&
|
||||
(hasBoundTextElement(targetElements[0]) ||
|
||||
hasBoundTextElement(targetElements[1]))
|
||||
) {
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
|
||||
{/* Dedicated Delete Button */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
)}
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
{/* Combined Other Actions */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
if (isEditingTextOrNewElement || targetElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactOtherProperties"}
|
||||
@ -662,7 +670,6 @@ export const CompactShapeActions = ({
|
||||
container={container}
|
||||
style={{
|
||||
maxWidth: "12rem",
|
||||
// center the popover content
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
@ -731,11 +738,281 @@ export const CompactShapeActions = ({
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
};
|
||||
|
||||
const LinearEditorAction = ({
|
||||
appState,
|
||||
renderAction,
|
||||
targetElements,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
targetElements: ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
}) => {
|
||||
const showLineEditorAction =
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
|
||||
if (!showLineEditorAction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 { container } = useExcalidrawContainer();
|
||||
|
||||
const isEditingTextOrNewElement = Boolean(
|
||||
appState.editingTextElement || appState.newElement,
|
||||
);
|
||||
|
||||
const showLineEditorAction =
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
|
||||
return (
|
||||
<div className="compact-shape-actions">
|
||||
{/* Stroke Color */}
|
||||
{canChangeStrokeColor(appState, targetElements) && (
|
||||
<div
|
||||
className={clsx("compact-action-item")}
|
||||
style={{
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Color */}
|
||||
{canChangeBackgroundColor(appState, targetElements) && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeBackgroundColor")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CombinedShapeProperties
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
setAppState={setAppState}
|
||||
targetElements={targetElements}
|
||||
container={container}
|
||||
/>
|
||||
|
||||
<CombinedArrowProperties
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
setAppState={setAppState}
|
||||
targetElements={targetElements}
|
||||
container={container}
|
||||
app={app}
|
||||
/>
|
||||
{/* 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>
|
||||
<CombinedTextProperties
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
setAppState={setAppState}
|
||||
targetElements={targetElements}
|
||||
container={container}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<CombinedExtraActions
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
targetElements={targetElements}
|
||||
setAppState={setAppState}
|
||||
container={container}
|
||||
app={app}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 { container } = useExcalidrawContainer();
|
||||
const mobileActionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const width = mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0;
|
||||
|
||||
const WIDTH = 26;
|
||||
const GAP = 8;
|
||||
|
||||
// max 6 actions + undo
|
||||
const MIN_WIDTH = 7 * WIDTH + 6 * GAP;
|
||||
|
||||
const ADDITIONAL_WIDTH = WIDTH + GAP;
|
||||
|
||||
const showDelete = width >= MIN_WIDTH;
|
||||
const showDuplicate = width >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
|
||||
const showRedo = width >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH;
|
||||
|
||||
return (
|
||||
<Island
|
||||
className="compact-shape-actions mobile-shape-actions"
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
boxShadow: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
margin: "0 0.25rem",
|
||||
zIndex: 2,
|
||||
height: WIDTH * 1.75,
|
||||
alignItems: "center",
|
||||
gap: GAP,
|
||||
}}
|
||||
ref={mobileActionsRef}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: GAP,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{canChangeStrokeColor(appState, targetElements) && (
|
||||
<div className={clsx("compact-action-item")}>
|
||||
{renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
)}
|
||||
{canChangeBackgroundColor(appState, targetElements) && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeBackgroundColor")}
|
||||
</div>
|
||||
)}
|
||||
<CombinedShapeProperties
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
setAppState={setAppState}
|
||||
targetElements={targetElements}
|
||||
container={container}
|
||||
/>
|
||||
{/* Combined Arrow Properties */}
|
||||
<CombinedArrowProperties
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
setAppState={setAppState}
|
||||
targetElements={targetElements}
|
||||
container={container}
|
||||
app={app}
|
||||
/>
|
||||
{/* Linear Editor */}
|
||||
<LinearEditorAction
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
targetElements={targetElements}
|
||||
/>
|
||||
{/* Text Properties */}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeFontFamily")}
|
||||
</div>
|
||||
<CombinedTextProperties
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
setAppState={setAppState}
|
||||
targetElements={targetElements}
|
||||
container={container}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDuplicate && renderAction("duplicateSelection")}
|
||||
{showDelete && renderAction("deleteSelectedElements")}
|
||||
|
||||
{/* Combined Other Actions */}
|
||||
<CombinedExtraActions
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
targetElements={targetElements}
|
||||
setAppState={setAppState}
|
||||
container={container}
|
||||
app={app}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: GAP,
|
||||
}}
|
||||
>
|
||||
{renderAction("undo")}
|
||||
{showRedo && renderAction("redo")}
|
||||
</div>
|
||||
</Island>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
activeTool,
|
||||
appState,
|
||||
|
||||
@ -106,6 +106,7 @@ export const FontPicker = React.memo(
|
||||
<FontPickerTrigger
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
isOpened={isOpened}
|
||||
compactMode={compactMode}
|
||||
/>
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
|
||||
@ -338,11 +338,13 @@ export const FontPickerList = React.memo(
|
||||
onKeyDown={onKeyDown}
|
||||
preventAutoFocusOnTouch={!!app.state.editingTextElement}
|
||||
>
|
||||
{app.state.stylesPanelMode === "full" && (
|
||||
<QuickSearch
|
||||
ref={inputRef}
|
||||
placeholder={t("quickSearch.placeholder")}
|
||||
onChange={debounce(setSearchTerm, 20)}
|
||||
/>
|
||||
)}
|
||||
<ScrollableList
|
||||
className="dropdown-menu fonts manual-hover"
|
||||
placeholder={t("fontList.empty")}
|
||||
|
||||
@ -11,11 +11,13 @@ import { useExcalidrawSetAppState } from "../App";
|
||||
interface FontPickerTriggerProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
isOpened?: boolean;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
export const FontPickerTrigger = ({
|
||||
selectedFontFamily,
|
||||
isOpened = false,
|
||||
compactMode = false,
|
||||
}: FontPickerTriggerProps) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
@ -37,6 +39,8 @@ export const FontPickerTrigger = ({
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
width: compactMode ? "1.625rem" : undefined,
|
||||
height: compactMode ? "1.625rem" : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user