Merge branch 'master' into mtolmacs/feat/precise-hitboxes

This commit is contained in:
Márk Tolmács 2025-05-17 15:09:35 +02:00 committed by GitHub
commit 49613ad0c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 393 additions and 404 deletions

View File

@ -52,7 +52,7 @@
transform: none;
}
.excalidraw .panelColumn {
.excalidraw .selected-shape-actions {
text-align: left;
}

View File

@ -73,7 +73,7 @@ import type { Scene } from "@excalidraw/element";
import type { CaptureUpdateActionType } from "@excalidraw/element";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { RadioSelection } from "../components/RadioSelection";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker";
@ -418,50 +418,52 @@ export const actionChangeFillStyle = register({
return (
<fieldset>
<legend>{t("labels.fill")}</legend>
<ButtonIconSelect
type="button"
options={[
{
value: "hachure",
text: `${
allElementsZigZag ? t("labels.zigzag") : t("labels.hachure")
} (${getShortcutKey("Alt-Click")})`,
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
active: allElementsZigZag ? true : undefined,
testId: `fill-hachure`,
},
{
value: "cross-hatch",
text: t("labels.crossHatch"),
icon: FillCrossHatchIcon,
testId: `fill-cross-hatch`,
},
{
value: "solid",
text: t("labels.solid"),
icon: FillSolidIcon,
testId: `fill-solid`,
},
]}
value={getFormValue(
elements,
app,
(element) => element.fillStyle,
(element) => element.hasOwnProperty("fillStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
event.altKey &&
value === "hachure" &&
selectedElements.every((el) => el.fillStyle === "hachure")
? "zigzag"
: value;
<div className="buttonList">
<RadioSelection
type="button"
options={[
{
value: "hachure",
text: `${
allElementsZigZag ? t("labels.zigzag") : t("labels.hachure")
} (${getShortcutKey("Alt-Click")})`,
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
active: allElementsZigZag ? true : undefined,
testId: `fill-hachure`,
},
{
value: "cross-hatch",
text: t("labels.crossHatch"),
icon: FillCrossHatchIcon,
testId: `fill-cross-hatch`,
},
{
value: "solid",
text: t("labels.solid"),
icon: FillSolidIcon,
testId: `fill-solid`,
},
]}
value={getFormValue(
elements,
app,
(element) => element.fillStyle,
(element) => element.hasOwnProperty("fillStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
event.altKey &&
value === "hachure" &&
selectedElements.every((el) => el.fillStyle === "hachure")
? "zigzag"
: value;
updateData(nextValue);
}}
/>
updateData(nextValue);
}}
/>
</div>
</fieldset>
);
},
@ -485,38 +487,40 @@ export const actionChangeStrokeWidth = register({
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.strokeWidth")}</legend>
<ButtonIconSelect
group="stroke-width"
options={[
{
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue(
elements,
app,
(element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
)}
onChange={(value) => updateData(value)}
/>
<div className="buttonList">
<RadioSelection
group="stroke-width"
options={[
{
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue(
elements,
app,
(element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});
@ -540,35 +544,37 @@ export const actionChangeSloppiness = register({
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.sloppiness")}</legend>
<ButtonIconSelect
group="sloppiness"
options={[
{
value: 0,
text: t("labels.architect"),
icon: SloppinessArchitectIcon,
},
{
value: 1,
text: t("labels.artist"),
icon: SloppinessArtistIcon,
},
{
value: 2,
text: t("labels.cartoonist"),
icon: SloppinessCartoonistIcon,
},
]}
value={getFormValue(
elements,
app,
(element) => element.roughness,
(element) => element.hasOwnProperty("roughness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoughness,
)}
onChange={(value) => updateData(value)}
/>
<div className="buttonList">
<RadioSelection
group="sloppiness"
options={[
{
value: 0,
text: t("labels.architect"),
icon: SloppinessArchitectIcon,
},
{
value: 1,
text: t("labels.artist"),
icon: SloppinessArtistIcon,
},
{
value: 2,
text: t("labels.cartoonist"),
icon: SloppinessCartoonistIcon,
},
]}
value={getFormValue(
elements,
app,
(element) => element.roughness,
(element) => element.hasOwnProperty("roughness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoughness,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});
@ -591,35 +597,37 @@ export const actionChangeStrokeStyle = register({
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.strokeStyle")}</legend>
<ButtonIconSelect
group="strokeStyle"
options={[
{
value: "solid",
text: t("labels.strokeStyle_solid"),
icon: StrokeWidthBaseIcon,
},
{
value: "dashed",
text: t("labels.strokeStyle_dashed"),
icon: StrokeStyleDashedIcon,
},
{
value: "dotted",
text: t("labels.strokeStyle_dotted"),
icon: StrokeStyleDottedIcon,
},
]}
value={getFormValue(
elements,
app,
(element) => element.strokeStyle,
(element) => element.hasOwnProperty("strokeStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeStyle,
)}
onChange={(value) => updateData(value)}
/>
<div className="buttonList">
<RadioSelection
group="strokeStyle"
options={[
{
value: "solid",
text: t("labels.strokeStyle_solid"),
icon: StrokeWidthBaseIcon,
},
{
value: "dashed",
text: t("labels.strokeStyle_dashed"),
icon: StrokeStyleDashedIcon,
},
{
value: "dotted",
text: t("labels.strokeStyle_dotted"),
icon: StrokeStyleDottedIcon,
},
]}
value={getFormValue(
elements,
app,
(element) => element.strokeStyle,
(element) => element.hasOwnProperty("strokeStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeStyle,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});
@ -658,63 +666,65 @@ export const actionChangeFontSize = register({
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<ButtonIconSelect
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});
@ -1186,52 +1196,54 @@ export const actionChangeTextAlign = register({
return (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false>
group="text-align"
options={[
{
value: "left",
text: t("labels.left"),
icon: TextAlignLeftIcon,
testId: "align-left",
},
{
value: "center",
text: t("labels.center"),
icon: TextAlignCenterIcon,
testId: "align-horizontal-center",
},
{
value: "right",
text: t("labels.right"),
icon: TextAlignRightIcon,
testId: "align-right",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.textAlign;
}
const boundTextElement = getBoundTextElement(
element,
elementsMap,
);
if (boundTextElement) {
return boundTextElement.textAlign;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(element, elementsMap) !== null,
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
<div className="buttonList">
<RadioSelection<TextAlign | false>
group="text-align"
options={[
{
value: "left",
text: t("labels.left"),
icon: TextAlignLeftIcon,
testId: "align-left",
},
{
value: "center",
text: t("labels.center"),
icon: TextAlignCenterIcon,
testId: "align-horizontal-center",
},
{
value: "right",
text: t("labels.right"),
icon: TextAlignRightIcon,
testId: "align-right",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.textAlign;
}
const boundTextElement = getBoundTextElement(
element,
elementsMap,
);
if (boundTextElement) {
return boundTextElement.textAlign;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(element, elementsMap) !== null,
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
@ -1274,54 +1286,56 @@ export const actionChangeVerticalAlign = register({
PanelComponent: ({ elements, appState, updateData, app }) => {
return (
<fieldset>
<ButtonIconSelect<VerticalAlign | false>
group="text-align"
options={[
{
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
testId: "align-top",
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
testId: "align-middle",
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
testId: "align-bottom",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.verticalAlign;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)}
onChange={(value) => updateData(value)}
/>
<div className="buttonList">
<RadioSelection<VerticalAlign | false>
group="text-align"
options={[
{
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
testId: "align-top",
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
testId: "align-middle",
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
testId: "align-bottom",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.verticalAlign;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
@ -1369,32 +1383,38 @@ export const actionChangeRoundness = register({
return (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: EdgeSharpIcon,
},
{
value: "round",
text: t("labels.round"),
icon: EdgeRoundIcon,
},
]}
value={getFormValue(
elements,
app,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
)}
onChange={(value) => updateData(value)}
/>
<div className="buttonList">
<RadioSelection
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: EdgeSharpIcon,
},
{
value: "round",
text: t("labels.round"),
icon: EdgeRoundIcon,
},
]}
value={getFormValue(
elements,
app,
(element) =>
hasLegacyRoundness
? null
: element.roundness
? "round"
: "sharp",
(element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
@ -1710,48 +1730,50 @@ export const actionChangeArrowType = register({
return (
<fieldset>
<legend>{t("labels.arrowtypes")}</legend>
<ButtonIconSelect
group="arrowtypes"
options={[
{
value: ARROW_TYPE.sharp,
text: t("labels.arrowtype_sharp"),
icon: sharpArrowIcon,
testId: "sharp-arrow",
},
{
value: ARROW_TYPE.round,
text: t("labels.arrowtype_round"),
icon: roundArrowIcon,
testId: "round-arrow",
},
{
value: ARROW_TYPE.elbow,
text: t("labels.arrowtype_elbowed"),
icon: elbowArrowIcon,
testId: "elbow-arrow",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? ARROW_TYPE.elbow
: element.roundness
? ARROW_TYPE.round
: ARROW_TYPE.sharp;
}
<div className="buttonList">
<RadioSelection
group="arrowtypes"
options={[
{
value: ARROW_TYPE.sharp,
text: t("labels.arrowtype_sharp"),
icon: sharpArrowIcon,
testId: "sharp-arrow",
},
{
value: ARROW_TYPE.round,
text: t("labels.arrowtype_round"),
icon: roundArrowIcon,
testId: "round-arrow",
},
{
value: ARROW_TYPE.elbow,
text: t("labels.arrowtype_elbowed"),
icon: elbowArrowIcon,
testId: "elbow-arrow",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? ARROW_TYPE.elbow
: element.roundness
? ARROW_TYPE.round
: ARROW_TYPE.sharp;
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
)}
onChange={(value) => updateData(value)}
/>
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},

View File

@ -154,7 +154,7 @@ export const SelectedShapeActions = ({
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
return (
<div className="panelColumn">
<div className="selected-shape-actions">
<div>
{canChangeStrokeColor(appState, targetElements) &&
renderAction("changeStrokeColor")}

View File

@ -1,30 +0,0 @@
import clsx from "clsx";
export const ButtonSelect = <T extends Object>({
options,
value,
onChange,
group,
}: {
options: { value: T; text: string }[];
value: T | null;
onChange: (value: T) => void;
group: string;
}) => (
<div className="buttonList">
{options.map((option) => (
<label
key={option.text}
className={clsx({ active: value === option.value })}
>
<input
type="radio"
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
/>
{option.text}
</label>
))}
</div>
);

View File

@ -6,7 +6,7 @@ import { FONT_FAMILY } from "@excalidraw/common";
import type { FontFamilyValues } from "@excalidraw/element/types";
import { t } from "../../i18n";
import { ButtonIconSelect } from "../ButtonIconSelect";
import { RadioSelection } from "../RadioSelection";
import { ButtonSeparator } from "../ButtonSeparator";
import {
FontFamilyCodeIcon,
@ -82,12 +82,14 @@ export const FontPicker = React.memo(
return (
<div role="dialog" aria-modal="true" className="FontPicker__container">
<ButtonIconSelect<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
<div className="buttonList">
<RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />

View File

@ -4,8 +4,7 @@ import { ButtonIcon } from "./ButtonIcon";
import type { JSX } from "react";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
export const RadioSelection = <T extends Object>(
props: {
options: {
value: T;
@ -28,7 +27,7 @@ export const ButtonIconSelect = <T extends Object>(
}
),
) => (
<div className="buttonList">
<>
{props.options.map((option) =>
props.type === "button" ? (
<ButtonIcon
@ -56,5 +55,5 @@ export const ButtonIconSelect = <T extends Object>(
</label>
),
)}
</div>
</>
);

View File

@ -140,7 +140,7 @@ body.excalidraw-cursor-resize * {
justify-content: space-between;
}
.panelColumn {
.selected-shape-actions {
display: flex;
flex-direction: column;
row-gap: 0.75rem;
@ -245,10 +245,6 @@ body.excalidraw-cursor-resize * {
left: 0;
right: 0;
--bar-padding: calc(4 * var(--space-factor));
padding-top: #{"max(var(--bar-padding), var(--sat,0))"};
padding-right: var(--sar, 0);
padding-bottom: var(--sab, 0);
padding-left: var(--sal, 0);
z-index: 4;
display: flex;
align-items: flex-end;
@ -263,10 +259,6 @@ body.excalidraw-cursor-resize * {
display: flex;
flex-direction: column;
pointer-events: var(--ui-pointerEvents);
.panelColumn {
padding: 8px 8px 0 8px;
}
}
}
@ -302,6 +294,10 @@ body.excalidraw-cursor-resize * {
overflow-y: auto;
box-sizing: border-box;
margin-bottom: var(--bar-padding);
.selected-shape-actions {
padding: 8px 8px 0 8px;
}
}
.App-menu {