From b7eb1afe2f34cf956d5556d7087a6f86b91cac5f Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 21 Jul 2025 16:07:35 +1000 Subject: [PATCH] feat: introduce layout (incomplete) --- packages/excalidraw/actions/actionCanvas.tsx | 3 +- .../excalidraw/actions/actionLinearEditor.tsx | 4 + .../excalidraw/actions/actionProperties.tsx | 48 ++- packages/excalidraw/actions/index.ts | 1 + packages/excalidraw/actions/manager.tsx | 1 + packages/excalidraw/actions/types.ts | 1 + packages/excalidraw/components/Actions.scss | 103 +++++ packages/excalidraw/components/Actions.tsx | 384 +++++++++++++++++- .../components/ColorPicker/ColorPicker.scss | 10 + .../components/ColorPicker/ColorPicker.tsx | 41 +- .../components/FontPicker/FontPicker.scss | 5 + .../components/FontPicker/FontPicker.tsx | 9 +- packages/excalidraw/components/LayerUI.tsx | 84 ++-- packages/excalidraw/components/icons.tsx | 56 +++ packages/excalidraw/types.ts | 3 + 15 files changed, 708 insertions(+), 45 deletions(-) diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 80a9eedaa..71b27f0ec 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({ : CaptureUpdateAction.EVENTUALLY, }; }, - PanelComponent: ({ elements, appState, updateData, appProps }) => { + PanelComponent: ({ elements, appState, updateData, appProps, data }) => { // FIXME move me to src/components/mainMenu/DefaultItems.tsx return ( ); }, diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 28295d939..d84a9e780 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -78,6 +78,10 @@ export const actionToggleLinearEditor = register({ selectedElementIds: appState.selectedElementIds, })[0] as ExcalidrawLinearElement; + if (!selectedElement) { + return null; + } + const label = t( selectedElement.type === "arrow" ? "labels.lineEditor.editArrow" diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 63cfe7672..fd5525a70 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -321,9 +321,9 @@ export const actionChangeStrokeColor = register({ : CaptureUpdateAction.EVENTUALLY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => ( <> - + {!data?.compactMode && } ), @@ -398,9 +399,11 @@ export const actionChangeBackgroundColor = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => ( <> - + {!data?.compactMode && ( + + )} ), @@ -518,9 +522,9 @@ export const actionChangeStrokeWidth = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.strokeWidth")} + {!data?.compactMode && {t("labels.strokeWidth")}}
( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.sloppiness")} + {!data?.compactMode && {t("labels.sloppiness")}}
( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {t("labels.strokeStyle")} + {!data?.compactMode && {t("labels.strokeStyle")}}
{ + PanelComponent: ({ elements, appState, app, updateData, data }) => { const cachedElementsRef = useRef(new Map()); const prevSelectedFontFamilyRef = useRef(null); // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them @@ -1094,11 +1098,12 @@ export const actionChangeFontFamily = register({ return (
- {t("labels.fontFamily")} + {!data?.compactMode && {t("labels.fontFamily")}} { setBatchedData({ openPopup: null, @@ -1616,6 +1621,25 @@ export const actionChangeArrowhead = register({ }, }); +export const actionChangeArrowProperties = register({ + name: "changeArrowProperties", + label: "Change arrow properties", + trackEvent: false, + perform: (elements, appState, value, app) => { + // This action doesn't perform any changes directly + // It's just a container for the arrow type and arrowhead actions + return false; + }, + PanelComponent: ({ elements, appState, updateData, app, renderAction }) => { + return ( +
+ {renderAction("changeArrowType")} + {renderAction("changeArrowhead")} +
+ ); + }, +}); + export const actionChangeArrowType = register({ name: "changeArrowType", label: "Change arrow types", diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index f37747aeb..2719a5d0a 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -18,6 +18,7 @@ export { actionChangeFontFamily, actionChangeTextAlign, actionChangeVerticalAlign, + actionChangeArrowProperties, } from "./actionProperties"; export { diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx index f3314bf35..0517c6fd5 100644 --- a/packages/excalidraw/actions/manager.tsx +++ b/packages/excalidraw/actions/manager.tsx @@ -180,6 +180,7 @@ export class ActionManager { app={this.app} data={data} renderAction={this.renderAction} + compactMode={Boolean(data?.compactMode)} /> ); } diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index e6f363126..302a76fb4 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -69,6 +69,7 @@ export type ActionName = | "changeStrokeStyle" | "changeArrowhead" | "changeArrowType" + | "changeArrowProperties" | "changeOpacity" | "changeFontSize" | "toggleCanvasMenu" diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index 5826628de..c43a67635 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -91,3 +91,106 @@ } } } + +.compact-shape-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: calc(100vh - 200px); + overflow-y: auto; + padding: 0.5rem; + + .compact-action-item { + position: relative; + display: flex; + justify-content: center; + align-items: center; + min-height: 2.5rem; + + .compact-action-button { + width: 2rem; + height: 2rem; + border: none; + border-radius: var(--border-radius-lg); + background: var(--button-bg, var(--island-bg-color)); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--button-hover-bg, var(--island-bg-color)); + border-color: var( + --button-hover-border, + var(--button-border, var(--default-border-color)) + ); + } + + &:active { + background: var(--button-active-bg, var(--island-bg-color)); + border-color: var(--button-active-border, var(--color-primary-darkest)); + } + } + + .compact-popover-content { + .popover-section { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + + .popover-section-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .buttonList { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + } + } + } +} + +.compact-shape-actions-island { + width: fit-content; + overflow-x: hidden; +} + +.compact-popover-content { + .popover-section { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + + .popover-section-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .buttonList { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + } +} diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 919e9c688..241d4e7b8 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; -import { useState } from "react"; +import { useCallback, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; import { CLASSES, @@ -19,6 +20,8 @@ import { isImageElement, isLinearElement, isTextElement, + getBoundTextElement, + isArrowElement, } from "@excalidraw/element"; import { hasStrokeColor, toolIsArrow } from "@excalidraw/element"; @@ -46,15 +49,19 @@ import { hasStrokeWidth, } from "../scene"; +import { getFormValue } from "../actions/actionProperties"; + import { SHAPES } 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 { RadioSelection } from "./RadioSelection"; import { EmbedIcon, extraToolsIcon, @@ -63,9 +70,21 @@ import { laserPointerToolIcon, MagicIcon, LassoIcon, + sharpArrowIcon, + roundArrowIcon, + elbowArrowIcon, + TextSizeIcon, + resizeIcon, + settingsPlusIcon, } 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"; export const canChangeStrokeColor = ( @@ -280,6 +299,365 @@ export const SelectedShapeActions = ({ ); }; +export const CompactShapeActions = ({ + appState, + elementsMap, + renderAction, + app, + setAppState, +}: { + appState: UIAppState; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; + renderAction: ActionManager["renderAction"]; + app: AppClassProperties; + setAppState: React.Component["setState"]; +}) => { + const targetElements = getTargetElements(elementsMap, appState); + const [strokePopoverOpen, setStrokePopoverOpen] = useState(false); + const [otherActionsPopoverOpen, setOtherActionsPopoverOpen] = useState(false); + const fontSizePopoverOpen = appState.openPopup === "fontSize"; + const setFontSizePopoverOpen = useCallback( + (open: boolean) => { + setAppState({ openPopup: open ? "fontSize" : null }); + }, + [setAppState], + ); + 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.editingLinearElement && + 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 ( +
+ {/* Stroke Color */} + {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor", { compactMode: true })} +
+ )} + + {/* Background Color */} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor", { compactMode: true })} +
+ )} + + {/* 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))) && ( +
+ + + + + {strokePopoverOpen && ( + setStrokePopoverOpen(false)} + > +
+ {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")} +
+
+ )} +
+
+ )} + + {/* Combined Arrow Properties */} + {(toolIsArrow(appState.activeTool.type) || + targetElements.some((element) => toolIsArrow(element.type))) && ( +
+ { + setAppState({ openPopup: open ? "arrowProperties" : null }); + }} + > + + + + {appState.openPopup === "arrowProperties" && ( + setAppState({ openPopup: null })} + > + {renderAction("changeArrowProperties")} + + )} + +
+ )} + + {/* Linear Editor */} + {showLineEditorAction && ( +
+ {renderAction("toggleLinearEditor", { compactMode: true })} +
+ )} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {renderAction("changeFontFamily", { + compactMode: true, + })} +
+
+ { + setAppState({ openPopup: open ? "textAlign" : null }); + }} + > + + + + {appState.openPopup === "textAlign" && ( + setAppState({ openPopup: null })} + > +
+ {(appState.activeTool.type === "text" || + suppportsHorizontalAlign(targetElements, elementsMap)) && + renderAction("changeTextAlign")} + {shouldAllowVerticalAlign(targetElements, elementsMap) && + renderAction("changeVerticalAlign")} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && + renderAction("changeFontSize")} +
+
+ )} +
+
+ + )} + + {/* Dedicated Copy Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("duplicateSelection", { compactMode: true })} +
+ )} + + {/* Dedicated Delete Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("deleteSelectedElements", { compactMode: true })} +
+ )} + + {/* Combined Other Actions */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ + + + + {otherActionsPopoverOpen && ( + setOtherActionsPopoverOpen(false)} + > +
+
+ {t("labels.layers")} +
+ {renderAction("sendToBack")} + {renderAction("sendBackward")} + {renderAction("bringForward")} + {renderAction("bringToFront")} +
+
+ + {showAlignActions && !isSingleElementBoundContainer && ( +
+ {t("labels.align")} +
+ {isRTL ? ( + <> + {renderAction("alignRight")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignLeft")} + + ) : ( + <> + {renderAction("alignLeft")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignRight")} + + )} + {targetElements.length > 2 && + renderAction("distributeHorizontally")} + {/* breaks the row ˇˇ */} +
+
+ {renderAction("alignTop")} + {renderAction("alignVerticallyCentered")} + {renderAction("alignBottom")} + {targetElements.length > 2 && + renderAction("distributeVertically")} +
+
+
+ )} +
+ {t("labels.actions")} +
+ {renderAction("group")} + {renderAction("ungroup")} + {showLinkIcon && renderAction("hyperlink")} + {showCropEditorAction && renderAction("cropEditor")} +
+
+
+
+ )} +
+
+ )} +
+ ); +}; + export const ShapesSwitcher = ({ activeTool, appState, diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss index 1267bcc14..0e3768dcc 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.scss +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.scss @@ -86,6 +86,16 @@ } } + .color-picker__button-background { + display: flex; + align-items: center; + justify-content: center; + svg { + width: 100%; + height: 100%; + } + } + &.active { .color-picker__button-outline { position: absolute; diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index f66e9bd44..32d5c9f77 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -18,7 +18,7 @@ import { useExcalidrawContainer } from "../App"; import { ButtonSeparator } from "../ButtonSeparator"; import { activeEyeDropperAtom } from "../EyeDropper"; import { PropertiesPopover } from "../PropertiesPopover"; -import { slashIcon } from "../icons"; +import { backgroundIcon, slashIcon, strokeIcon } from "../icons"; import { ColorInput } from "./ColorInput"; import { Picker } from "./Picker"; @@ -189,10 +189,14 @@ const ColorPickerTrigger = ({ label, color, type, + compactMode = false, + mode = "background", }: { color: string | null; label: string; type: ColorPickerType; + compactMode?: boolean; + mode?: "background" | "stroke"; }) => { return (
{!color && slashIcon}
+ {compactMode && color && ( +
+ {mode === "background" ? ( + + {backgroundIcon} + + ) : ( + + {strokeIcon} + + )} +
+ )}
); }; @@ -252,7 +283,13 @@ export const ColorPicker = ({ }} > {/* serves as an active color indicator as well */} - + {/* popup content */} {appState.openPopup === type && ( +
{!compactMode && (
diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index d216f1d46..86118a6ec 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -28,7 +28,11 @@ import { useAtom, useAtomValue } from "../editor-jotai"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; -import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; +import { + SelectedShapeActions, + ShapesSwitcher, + CompactShapeActions, +} from "./Actions"; import { LoadingMessage } from "./LoadingMessage"; import { LockButton } from "./LockButton"; import { MobileMenu } from "./MobileMenu"; @@ -209,31 +213,58 @@ const LayerUI = ({
); - const renderSelectedShapeActions = () => ( -
- { + // Use compact layout for tablets (when device is mobile but not too small) + + return ( +
- - -
- ); + {isTablet ? ( + + + + ) : ( + + + + )} +
+ ); + }; const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = showSelectedShapeActions( @@ -252,7 +283,8 @@ const LayerUI = ({
{renderCanvasActions()} - {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} + {shouldRenderSelectedShapeActions && + renderSelectedShapeActions(device.isTouchScreen)} {!appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector" && ( diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 29bdc6d3c..413346416 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -396,6 +396,19 @@ export const TextIcon = createIcon( tablerIconProps, ); +export const TextSizeIcon = createIcon( + + + + + + + + + , + tablerIconProps, +); + // modified tabler-icons: photo export const ImageIcon = createIcon( @@ -2269,3 +2282,46 @@ export const elementLinkIcon = createIcon( , tablerIconProps, ); + +export const resizeIcon = createIcon( + + + + + , + tablerIconProps, +); + +export const settingsPlusIcon = createIcon( + + + + + + + , + tablerIconProps, +); + +export const backgroundIcon = createIcon( + + + + + + + + , + tablerIconProps, +); + +export const strokeIcon = createIcon( + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 7981e7b7f..36a6addd4 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -354,6 +354,9 @@ export interface AppState { | "elementBackground" | "elementStroke" | "fontFamily" + | "fontSize" + | "textAlign" + | "arrowProperties" | null; openSidebar: { name: SidebarName; tab?: SidebarTabName } | null; openDialog: