mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-20 07:49:59 +02: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