mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-29 18:04:21 +01:00 
			
		
		
		
	feat: introduce layout (incomplete)
This commit is contained in:
		| @@ -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 ( | ||||
|       <ColorPicker | ||||
| @@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register({ | ||||
|         elements={elements} | ||||
|         appState={appState} | ||||
|         updateData={updateData} | ||||
|         compactMode={data?.compactMode} | ||||
|       /> | ||||
|     ); | ||||
|   }, | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -321,9 +321,9 @@ export const actionChangeStrokeColor = register({ | ||||
|         : CaptureUpdateAction.EVENTUALLY, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ elements, appState, updateData, app }) => ( | ||||
|   PanelComponent: ({ elements, appState, updateData, app, data }) => ( | ||||
|     <> | ||||
|       <h3 aria-hidden="true">{t("labels.stroke")}</h3> | ||||
|       {!data?.compactMode && <h3 aria-hidden="true">{t("labels.stroke")}</h3>} | ||||
|       <ColorPicker | ||||
|         topPicks={DEFAULT_ELEMENT_STROKE_PICKS} | ||||
|         palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE} | ||||
| @@ -341,6 +341,7 @@ export const actionChangeStrokeColor = register({ | ||||
|         elements={elements} | ||||
|         appState={appState} | ||||
|         updateData={updateData} | ||||
|         compactMode={data?.compactMode} | ||||
|       /> | ||||
|     </> | ||||
|   ), | ||||
| @@ -398,9 +399,11 @@ export const actionChangeBackgroundColor = register({ | ||||
|       captureUpdate: CaptureUpdateAction.IMMEDIATELY, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ elements, appState, updateData, app }) => ( | ||||
|   PanelComponent: ({ elements, appState, updateData, app, data }) => ( | ||||
|     <> | ||||
|       <h3 aria-hidden="true">{t("labels.background")}</h3> | ||||
|       {!data?.compactMode && ( | ||||
|         <h3 aria-hidden="true">{t("labels.background")}</h3> | ||||
|       )} | ||||
|       <ColorPicker | ||||
|         topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS} | ||||
|         palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE} | ||||
| @@ -418,6 +421,7 @@ export const actionChangeBackgroundColor = register({ | ||||
|         elements={elements} | ||||
|         appState={appState} | ||||
|         updateData={updateData} | ||||
|         compactMode={data?.compactMode} | ||||
|       /> | ||||
|     </> | ||||
|   ), | ||||
| @@ -518,9 +522,9 @@ export const actionChangeStrokeWidth = register({ | ||||
|       captureUpdate: CaptureUpdateAction.IMMEDIATELY, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ elements, appState, updateData, app }) => ( | ||||
|   PanelComponent: ({ elements, appState, updateData, app, data }) => ( | ||||
|     <fieldset> | ||||
|       <legend>{t("labels.strokeWidth")}</legend> | ||||
|       {!data?.compactMode && <legend>{t("labels.strokeWidth")}</legend>} | ||||
|       <div className="buttonList"> | ||||
|         <RadioSelection | ||||
|           group="stroke-width" | ||||
| @@ -575,9 +579,9 @@ export const actionChangeSloppiness = register({ | ||||
|       captureUpdate: CaptureUpdateAction.IMMEDIATELY, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ elements, appState, updateData, app }) => ( | ||||
|   PanelComponent: ({ elements, appState, updateData, app, data }) => ( | ||||
|     <fieldset> | ||||
|       <legend>{t("labels.sloppiness")}</legend> | ||||
|       {!data?.compactMode && <legend>{t("labels.sloppiness")}</legend>} | ||||
|       <div className="buttonList"> | ||||
|         <RadioSelection | ||||
|           group="sloppiness" | ||||
| @@ -628,9 +632,9 @@ export const actionChangeStrokeStyle = register({ | ||||
|       captureUpdate: CaptureUpdateAction.IMMEDIATELY, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ elements, appState, updateData, app }) => ( | ||||
|   PanelComponent: ({ elements, appState, updateData, app, data }) => ( | ||||
|     <fieldset> | ||||
|       <legend>{t("labels.strokeStyle")}</legend> | ||||
|       {!data?.compactMode && <legend>{t("labels.strokeStyle")}</legend>} | ||||
|       <div className="buttonList"> | ||||
|         <RadioSelection | ||||
|           group="strokeStyle" | ||||
| @@ -1016,7 +1020,7 @@ export const actionChangeFontFamily = register({ | ||||
|  | ||||
|     return result; | ||||
|   }, | ||||
|   PanelComponent: ({ elements, appState, app, updateData }) => { | ||||
|   PanelComponent: ({ elements, appState, app, updateData, data }) => { | ||||
|     const cachedElementsRef = useRef<ElementsMap>(new Map()); | ||||
|     const prevSelectedFontFamilyRef = useRef<number | null>(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 ( | ||||
|       <fieldset> | ||||
|         <legend>{t("labels.fontFamily")}</legend> | ||||
|         {!data?.compactMode && <legend>{t("labels.fontFamily")}</legend>} | ||||
|         <FontPicker | ||||
|           isOpened={appState.openPopup === "fontFamily"} | ||||
|           selectedFontFamily={selectedFontFamily} | ||||
|           hoveredFontFamily={appState.currentHoveredFontFamily} | ||||
|           compactMode={data?.compactMode} | ||||
|           onSelect={(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 ( | ||||
|       <div className="selected-shape-actions"> | ||||
|         {renderAction("changeArrowType")} | ||||
|         {renderAction("changeArrowhead")} | ||||
|       </div> | ||||
|     ); | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| export const actionChangeArrowType = register({ | ||||
|   name: "changeArrowType", | ||||
|   label: "Change arrow types", | ||||
|   | ||||
| @@ -18,6 +18,7 @@ export { | ||||
|   actionChangeFontFamily, | ||||
|   actionChangeTextAlign, | ||||
|   actionChangeVerticalAlign, | ||||
|   actionChangeArrowProperties, | ||||
| } from "./actionProperties"; | ||||
|  | ||||
| export { | ||||
|   | ||||
| @@ -180,6 +180,7 @@ export class ActionManager { | ||||
|           app={this.app} | ||||
|           data={data} | ||||
|           renderAction={this.renderAction} | ||||
|           compactMode={Boolean(data?.compactMode)} | ||||
|         /> | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -69,6 +69,7 @@ export type ActionName = | ||||
|   | "changeStrokeStyle" | ||||
|   | "changeArrowhead" | ||||
|   | "changeArrowType" | ||||
|   | "changeArrowProperties" | ||||
|   | "changeOpacity" | ||||
|   | "changeFontSize" | ||||
|   | "toggleCanvasMenu" | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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<any, AppState>["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 ( | ||||
|     <div className="compact-shape-actions"> | ||||
|       {/* Stroke Color */} | ||||
|       {canChangeStrokeColor(appState, targetElements) && ( | ||||
|         <div className={clsx("compact-action-item")}> | ||||
|           {renderAction("changeStrokeColor", { compactMode: true })} | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {/* Background Color */} | ||||
|       {canChangeBackgroundColor(appState, targetElements) && ( | ||||
|         <div className="compact-action-item"> | ||||
|           {renderAction("changeBackgroundColor", { compactMode: true })} | ||||
|         </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={strokePopoverOpen} | ||||
|             onOpenChange={setStrokePopoverOpen} | ||||
|           > | ||||
|             <Popover.Trigger asChild> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 className="compact-action-button" | ||||
|                 title={t("labels.stroke")} | ||||
|               > | ||||
|                 {resizeIcon} | ||||
|               </button> | ||||
|             </Popover.Trigger> | ||||
|             {strokePopoverOpen && ( | ||||
|               <PropertiesPopover | ||||
|                 container={container} | ||||
|                 style={{ maxWidth: "13rem" }} | ||||
|                 onClose={() => setStrokePopoverOpen(false)} | ||||
|               > | ||||
|                 <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 === "arrowProperties"} | ||||
|             onOpenChange={(open) => { | ||||
|               setAppState({ openPopup: open ? "arrowProperties" : null }); | ||||
|             }} | ||||
|           > | ||||
|             <Popover.Trigger asChild> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 className="compact-action-button" | ||||
|                 title={t("labels.arrowtypes")} | ||||
|               > | ||||
|                 {(() => { | ||||
|                   // 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; // default | ||||
|                 })()} | ||||
|               </button> | ||||
|             </Popover.Trigger> | ||||
|             {appState.openPopup === "arrowProperties" && ( | ||||
|               <PropertiesPopover | ||||
|                 container={container} | ||||
|                 style={{ maxWidth: "13rem" }} | ||||
|                 onClose={() => setAppState({ openPopup: null })} | ||||
|               > | ||||
|                 {renderAction("changeArrowProperties")} | ||||
|               </PropertiesPopover> | ||||
|             )} | ||||
|           </Popover.Root> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {/* Linear Editor */} | ||||
|       {showLineEditorAction && ( | ||||
|         <div className="compact-action-item"> | ||||
|           {renderAction("toggleLinearEditor", { compactMode: true })} | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {/* Text Properties */} | ||||
|       {(appState.activeTool.type === "text" || | ||||
|         targetElements.some(isTextElement)) && ( | ||||
|         <> | ||||
|           <div className="compact-action-item"> | ||||
|             {renderAction("changeFontFamily", { | ||||
|               compactMode: true, | ||||
|             })} | ||||
|           </div> | ||||
|           <div className="compact-action-item"> | ||||
|             <Popover.Root | ||||
|               open={appState.openPopup === "textAlign"} | ||||
|               onOpenChange={(open) => { | ||||
|                 setAppState({ openPopup: open ? "textAlign" : null }); | ||||
|               }} | ||||
|             > | ||||
|               <Popover.Trigger asChild> | ||||
|                 <button | ||||
|                   type="button" | ||||
|                   className="compact-action-button" | ||||
|                   title={t("labels.textAlign")} | ||||
|                 > | ||||
|                   {TextSizeIcon} | ||||
|                 </button> | ||||
|               </Popover.Trigger> | ||||
|               {appState.openPopup === "textAlign" && ( | ||||
|                 <PropertiesPopover | ||||
|                   container={container} | ||||
|                   style={{ maxWidth: "13rem" }} | ||||
|                   onClose={() => setAppState({ openPopup: null })} | ||||
|                 > | ||||
|                   <div className="selected-shape-actions"> | ||||
|                     {(appState.activeTool.type === "text" || | ||||
|                       suppportsHorizontalAlign(targetElements, elementsMap)) && | ||||
|                       renderAction("changeTextAlign")} | ||||
|                     {shouldAllowVerticalAlign(targetElements, elementsMap) && | ||||
|                       renderAction("changeVerticalAlign")} | ||||
|                     {(appState.activeTool.type === "text" || | ||||
|                       targetElements.some(isTextElement)) && | ||||
|                       renderAction("changeFontSize")} | ||||
|                   </div> | ||||
|                 </PropertiesPopover> | ||||
|               )} | ||||
|             </Popover.Root> | ||||
|           </div> | ||||
|         </> | ||||
|       )} | ||||
|  | ||||
|       {/* Dedicated Copy Button */} | ||||
|       {!isEditingTextOrNewElement && targetElements.length > 0 && ( | ||||
|         <div className="compact-action-item"> | ||||
|           {renderAction("duplicateSelection", { compactMode: true })} | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {/* Dedicated Delete Button */} | ||||
|       {!isEditingTextOrNewElement && targetElements.length > 0 && ( | ||||
|         <div className="compact-action-item"> | ||||
|           {renderAction("deleteSelectedElements", { compactMode: true })} | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {/* Combined Other Actions */} | ||||
|       {!isEditingTextOrNewElement && targetElements.length > 0 && ( | ||||
|         <div className="compact-action-item"> | ||||
|           <Popover.Root | ||||
|             open={otherActionsPopoverOpen} | ||||
|             onOpenChange={setOtherActionsPopoverOpen} | ||||
|           > | ||||
|             <Popover.Trigger asChild> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 className="compact-action-button" | ||||
|                 title={t("labels.actions")} | ||||
|               > | ||||
|                 {settingsPlusIcon} | ||||
|               </button> | ||||
|             </Popover.Trigger> | ||||
|             {otherActionsPopoverOpen && ( | ||||
|               <PropertiesPopover | ||||
|                 container={container} | ||||
|                 style={{ | ||||
|                   maxWidth: "12rem", | ||||
|                   // center the popover content | ||||
|                   justifyContent: "center", | ||||
|                   alignItems: "center", | ||||
|                 }} | ||||
|                 onClose={() => setOtherActionsPopoverOpen(false)} | ||||
|               > | ||||
|                 <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, | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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 ( | ||||
|     <Popover.Trigger | ||||
| @@ -211,6 +215,33 @@ const ColorPickerTrigger = ({ | ||||
|       } | ||||
|     > | ||||
|       <div className="color-picker__button-outline">{!color && slashIcon}</div> | ||||
|       {compactMode && color && ( | ||||
|         <div className="color-picker__button-background"> | ||||
|           {mode === "background" ? ( | ||||
|             <span | ||||
|               style={{ | ||||
|                 color: | ||||
|                   color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD) | ||||
|                     ? "#fff" | ||||
|                     : "#111", | ||||
|               }} | ||||
|             > | ||||
|               {backgroundIcon} | ||||
|             </span> | ||||
|           ) : ( | ||||
|             <span | ||||
|               style={{ | ||||
|                 color: | ||||
|                   color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD) | ||||
|                     ? "#fff" | ||||
|                     : "#111", | ||||
|               }} | ||||
|             > | ||||
|               {strokeIcon} | ||||
|             </span> | ||||
|           )} | ||||
|         </div> | ||||
|       )} | ||||
|     </Popover.Trigger> | ||||
|   ); | ||||
| }; | ||||
| @@ -252,7 +283,13 @@ export const ColorPicker = ({ | ||||
|           }} | ||||
|         > | ||||
|           {/* serves as an active color indicator as well */} | ||||
|           <ColorPickerTrigger color={color} label={label} type={type} /> | ||||
|           <ColorPickerTrigger | ||||
|             color={color} | ||||
|             label={label} | ||||
|             type={type} | ||||
|             compactMode={compactMode} | ||||
|             mode={type === "elementStroke" ? "stroke" : "background"} | ||||
|           /> | ||||
|           {/* popup content */} | ||||
|           {appState.openPopup === type && ( | ||||
|             <ColorPickerPopupContent | ||||
|   | ||||
| @@ -11,5 +11,10 @@ | ||||
|         2rem + 4 * var(--default-button-size) | ||||
|       ); // 4 gaps + 4 buttons | ||||
|     } | ||||
|  | ||||
|     &--compact { | ||||
|       display: block; | ||||
|       grid-template-columns: none; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import * as Popover from "@radix-ui/react-popover"; | ||||
| import clsx from "clsx"; | ||||
| import React, { useCallback, useMemo } from "react"; | ||||
|  | ||||
| import { FONT_FAMILY } from "@excalidraw/common"; | ||||
| @@ -83,7 +84,13 @@ export const FontPicker = React.memo( | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
|       <div role="dialog" aria-modal="true" className="FontPicker__container"> | ||||
|       <div | ||||
|         role="dialog" | ||||
|         aria-modal="true" | ||||
|         className={clsx("FontPicker__container", { | ||||
|           "FontPicker__container--compact": compactMode, | ||||
|         })} | ||||
|       > | ||||
|         {!compactMode && ( | ||||
|           <div className="buttonList"> | ||||
|             <RadioSelection<FontFamilyValues | false> | ||||
|   | ||||
| @@ -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 = ({ | ||||
|     </div> | ||||
|   ); | ||||
|  | ||||
|   const renderSelectedShapeActions = () => ( | ||||
|     <Section | ||||
|       heading="selectedShapeActions" | ||||
|       className={clsx("selected-shape-actions zen-mode-transition", { | ||||
|         "transition-left": appState.zenModeEnabled, | ||||
|       })} | ||||
|     > | ||||
|       <Island | ||||
|         className={CLASSES.SHAPE_ACTIONS_MENU} | ||||
|         padding={2} | ||||
|         style={{ | ||||
|           // we want to make sure this doesn't overflow so subtracting the | ||||
|           // approximate height of hamburgerMenu + footer | ||||
|           maxHeight: `${appState.height - 166}px`, | ||||
|         }} | ||||
|   const renderSelectedShapeActions = (isTablet: boolean) => { | ||||
|     // Use compact layout for tablets (when device is mobile but not too small) | ||||
|  | ||||
|     return ( | ||||
|       <Section | ||||
|         heading="selectedShapeActions" | ||||
|         className={clsx("selected-shape-actions zen-mode-transition", { | ||||
|           "transition-left": appState.zenModeEnabled, | ||||
|         })} | ||||
|       > | ||||
|         <SelectedShapeActions | ||||
|           appState={appState} | ||||
|           elementsMap={app.scene.getNonDeletedElementsMap()} | ||||
|           renderAction={actionManager.renderAction} | ||||
|           app={app} | ||||
|         /> | ||||
|       </Island> | ||||
|     </Section> | ||||
|   ); | ||||
|         {isTablet ? ( | ||||
|           <Island | ||||
|             className={clsx( | ||||
|               // CLASSES.SHAPE_ACTIONS_MENU, | ||||
|               "compact-shape-actions-island", | ||||
|             )} | ||||
|             padding={0} | ||||
|             style={{ | ||||
|               // we want to make sure this doesn't overflow so subtracting the | ||||
|               // approximate height of hamburgerMenu + footer | ||||
|               maxHeight: `${appState.height - 166}px`, | ||||
|             }} | ||||
|           > | ||||
|             <CompactShapeActions | ||||
|               appState={appState} | ||||
|               elementsMap={app.scene.getNonDeletedElementsMap()} | ||||
|               renderAction={actionManager.renderAction} | ||||
|               app={app} | ||||
|               setAppState={setAppState} | ||||
|             /> | ||||
|           </Island> | ||||
|         ) : ( | ||||
|           <Island | ||||
|             className={CLASSES.SHAPE_ACTIONS_MENU} | ||||
|             padding={2} | ||||
|             style={{ | ||||
|               // we want to make sure this doesn't overflow so subtracting the | ||||
|               // approximate height of hamburgerMenu + footer | ||||
|               maxHeight: `${appState.height - 166}px`, | ||||
|             }} | ||||
|           > | ||||
|             <SelectedShapeActions | ||||
|               appState={appState} | ||||
|               elementsMap={app.scene.getNonDeletedElementsMap()} | ||||
|               renderAction={actionManager.renderAction} | ||||
|               app={app} | ||||
|             /> | ||||
|           </Island> | ||||
|         )} | ||||
|       </Section> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const renderFixedSideContainer = () => { | ||||
|     const shouldRenderSelectedShapeActions = showSelectedShapeActions( | ||||
| @@ -252,7 +283,8 @@ const LayerUI = ({ | ||||
|         <div className="App-menu App-menu_top"> | ||||
|           <Stack.Col gap={6} className={clsx("App-menu_top__left")}> | ||||
|             {renderCanvasActions()} | ||||
|             {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} | ||||
|             {shouldRenderSelectedShapeActions && | ||||
|               renderSelectedShapeActions(device.isTouchScreen)} | ||||
|           </Stack.Col> | ||||
|           {!appState.viewModeEnabled && | ||||
|             appState.openDialog?.name !== "elementLinkSelector" && ( | ||||
|   | ||||
| @@ -396,6 +396,19 @@ export const TextIcon = createIcon( | ||||
|   tablerIconProps, | ||||
| ); | ||||
|  | ||||
| export const TextSizeIcon = createIcon( | ||||
|   <g stroke="currentColor" strokeWidth="1.5"> | ||||
|     <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|     <path d="M3 7v-2h13v2" /> | ||||
|     <path d="M10 5v14" /> | ||||
|     <path d="M12 19h-4" /> | ||||
|     <path d="M15 13v-1h6v1" /> | ||||
|     <path d="M18 12v7" /> | ||||
|     <path d="M17 19h2" /> | ||||
|   </g>, | ||||
|   tablerIconProps, | ||||
| ); | ||||
|  | ||||
| // modified tabler-icons: photo | ||||
| export const ImageIcon = createIcon( | ||||
|   <g strokeWidth="1.25"> | ||||
| @@ -2269,3 +2282,46 @@ export const elementLinkIcon = createIcon( | ||||
|   </g>, | ||||
|   tablerIconProps, | ||||
| ); | ||||
|  | ||||
| export const resizeIcon = createIcon( | ||||
|   <g strokeWidth={1.5}> | ||||
|     <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|     <path d="M4 11v8a1 1 0 0 0 1 1h8m-9 -14v-1a1 1 0 0 1 1 -1h1m5 0h2m5 0h1a1 1 0 0 1 1 1v1m0 5v2m0 5v1a1 1 0 0 1 -1 1h-1" /> | ||||
|     <path d="M4 12h7a1 1 0 0 1 1 1v7" /> | ||||
|   </g>, | ||||
|   tablerIconProps, | ||||
| ); | ||||
|  | ||||
| export const settingsPlusIcon = createIcon( | ||||
|   <g strokeWidth={1.5}> | ||||
|     <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|     <path d="M12.483 20.935c-.862 .239 -1.898 -.178 -2.158 -1.252a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.08 .262 1.496 1.308 1.247 2.173" /> | ||||
|     <path d="M16 19h6" /> | ||||
|     <path d="M19 16v6" /> | ||||
|     <path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /> | ||||
|   </g>, | ||||
|   tablerIconProps, | ||||
| ); | ||||
|  | ||||
| export const backgroundIcon = createIcon( | ||||
|   <g strokeWidth={1.5}> | ||||
|     <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|     <path d="M4 8l4 -4" /> | ||||
|     <path d="M14 4l-10 10" /> | ||||
|     <path d="M4 20l16 -16" /> | ||||
|     <path d="M20 10l-10 10" /> | ||||
|     <path d="M20 16l-4 4" /> | ||||
|   </g>, | ||||
|   tablerIconProps, | ||||
| ); | ||||
|  | ||||
| export const strokeIcon = createIcon( | ||||
|   <g strokeWidth={1.5}> | ||||
|     <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|     <path d="M4 8v8" /> | ||||
|     <path d="M20 16v-8" /> | ||||
|     <path d="M8 4h8" /> | ||||
|     <path d="M8 20h8" /> | ||||
|   </g>, | ||||
|   tablerIconProps, | ||||
| ); | ||||
|   | ||||
| @@ -354,6 +354,9 @@ export interface AppState { | ||||
|     | "elementBackground" | ||||
|     | "elementStroke" | ||||
|     | "fontFamily" | ||||
|     | "fontSize" | ||||
|     | "textAlign" | ||||
|     | "arrowProperties" | ||||
|     | null; | ||||
|   openSidebar: { name: SidebarName; tab?: SidebarTabName } | null; | ||||
|   openDialog: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Ryan Di
					Ryan Di