refactor: make element type conversion more generic (#9504)

* feat: add `reduceToCommonValue()` & improve opacity slider

* feat: generalize and simplify type conversion cache

* refactor: change cache from atoms to Map

* feat: always attempt to reuse original fontSize when converting generic types
This commit is contained in:
David Luzar 2025-05-10 20:06:16 +02:00 committed by GitHub
parent 7e41026812
commit 51dbd4831b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 346 additions and 344 deletions

View File

@ -0,0 +1,82 @@
import {
isTransparent,
mapFind,
reduceToCommonValue,
} from "@excalidraw/common";
describe("@excalidraw/common/utils", () => {
describe("isTransparent()", () => {
it("should return true when color is rgb transparent", () => {
expect(isTransparent("#ff00")).toEqual(true);
expect(isTransparent("#fff00000")).toEqual(true);
expect(isTransparent("transparent")).toEqual(true);
});
it("should return false when color is not transparent", () => {
expect(isTransparent("#ced4da")).toEqual(false);
});
});
describe("reduceToCommonValue()", () => {
it("should return the common value when all values are the same", () => {
expect(reduceToCommonValue([1, 1])).toEqual(1);
expect(reduceToCommonValue([0, 0])).toEqual(0);
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
expect(reduceToCommonValue([""])).toEqual("");
expect(reduceToCommonValue([0])).toEqual(0);
const o = {};
expect(reduceToCommonValue([o, o])).toEqual(o);
expect(
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
).toEqual(1);
expect(
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
).toEqual(1);
});
it("should return `null` when values are different", () => {
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
null,
);
});
it("should return `null` when some values are nullable", () => {
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
expect(reduceToCommonValue([null, 1])).toEqual(null);
expect(reduceToCommonValue([1, undefined])).toEqual(null);
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
expect(reduceToCommonValue([null])).toEqual(null);
expect(reduceToCommonValue([undefined])).toEqual(null);
expect(reduceToCommonValue([])).toEqual(null);
});
});
describe("mapFind()", () => {
it("should return the first mapped non-null element", () => {
{
let counter = 0;
const result = mapFind(["a", "b", "c"], (value) => {
counter++;
return value === "b" ? 42 : null;
});
expect(result).toEqual(42);
expect(counter).toBe(2);
}
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
expect(mapFind([1, 2], () => false)).toBe(false);
expect(mapFind([1, 2], () => "")).toBe("");
});
it("should return undefined if no mapped element is found", () => {
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
expect(mapFind([1, 2], () => null)).toBe(undefined);
});
});
});

View File

@ -544,6 +544,20 @@ export const findLastIndex = <T>(
return -1; return -1;
}; };
/** returns the first non-null mapped value */
export const mapFind = <T, K>(
collection: readonly T[],
iteratee: (value: T, index: number) => K | undefined | null,
): K | undefined => {
for (let idx = 0; idx < collection.length; idx++) {
const result = iteratee(collection[idx], idx);
if (result != null) {
return result;
}
}
return undefined;
};
export const isTransparent = (color: string) => { export const isTransparent = (color: string) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0"; const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00"; const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
@ -1244,11 +1258,39 @@ export const isReadonlyArray = (value?: any): value is readonly any[] => {
}; };
export const sizeOf = ( export const sizeOf = (
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>, value:
| readonly unknown[]
| Readonly<Map<string, unknown>>
| Readonly<Record<string, unknown>>
| ReadonlySet<unknown>,
): number => { ): number => {
return isReadonlyArray(value) return isReadonlyArray(value)
? value.length ? value.length
: value instanceof Map : value instanceof Map || value instanceof Set
? value.size ? value.size
: Object.keys(value).length; : Object.keys(value).length;
}; };
export const reduceToCommonValue = <T, R = T>(
collection: readonly T[] | ReadonlySet<T>,
getValue?: (item: T) => R,
): R | null => {
if (sizeOf(collection) === 0) {
return null;
}
const valueExtractor = getValue || ((item: T) => item as unknown as R);
let commonValue: R | null = null;
for (const item of collection) {
const value = valueExtractor(item);
if ((commonValue === null || commonValue === value) && value != null) {
commonValue = value;
} else {
return null;
}
}
return commonValue;
};

View File

@ -169,25 +169,6 @@ export const isSomeElementSelected = (function () {
return ret; return ret;
})(); })();
/**
* Returns common attribute (picked by `getAttribute` callback) of selected
* elements. If elements don't share the same value, returns `null`.
*/
export const getCommonAttributeOfSelectedElements = <T>(
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
getAttribute: (element: ExcalidrawElement) => T,
): T | null => {
const attributes = Array.from(
new Set(
getSelectedElements(elements, appState).map((element) =>
getAttribute(element),
),
),
);
return attributes.length === 1 ? attributes[0] : null;
};
export const getSelectedElements = ( export const getSelectedElements = (
elements: ElementsMapOrArray, elements: ElementsMapOrArray,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">, appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,

View File

@ -28,6 +28,7 @@ import type {
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@ -356,3 +357,18 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" && typeof box[1] === "number" &&
typeof box[2] === "number" && typeof box[2] === "number" &&
typeof box[3] === "number"; typeof box[3] === "number";
export const getLinearElementSubType = (
element: ExcalidrawLinearElement,
): ExcalidrawLinearElementSubType => {
if (isSharpArrow(element)) {
return "sharpArrow";
}
if (isCurvedArrow(element)) {
return "curvedArrow";
}
if (isElbowArrow(element)) {
return "elbowArrow";
}
return "line";
};

View File

@ -418,10 +418,12 @@ export type ElementsMapOrArray =
| readonly ExcalidrawElement[] | readonly ExcalidrawElement[]
| Readonly<ElementsMap>; | Readonly<ElementsMap>;
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse"; export type ExcalidrawLinearElementSubType =
export type ConvertibleLinearTypes =
| "line" | "line"
| "sharpArrow" | "sharpArrow"
| "curvedArrow" | "curvedArrow"
| "elbowArrow"; | "elbowArrow";
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes; export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;

View File

@ -20,6 +20,7 @@ import {
getShortcutKey, getShortcutKey,
tupleToCoors, tupleToCoors,
getLineHeight, getLineHeight,
reduceToCommonValue,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
@ -130,7 +131,6 @@ import { Fonts } from "../fonts";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { import {
canHaveArrowheads, canHaveArrowheads,
getCommonAttributeOfSelectedElements,
getSelectedElements, getSelectedElements,
getTargetElements, getTargetElements,
isSomeElementSelected, isSomeElementSelected,
@ -167,12 +167,12 @@ export const changeProperty = (
export const getFormValue = function <T extends Primitive>( export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean), isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = appState.editingTextElement; const editingTextElement = app.state.editingTextElement;
const nonDeletedElements = getNonDeletedElements(elements); const nonDeletedElements = getNonDeletedElements(elements);
let ret: T | null = null; let ret: T | null = null;
@ -182,17 +182,17 @@ export const getFormValue = function <T extends Primitive>(
} }
if (!ret) { if (!ret) {
const hasSelection = isSomeElementSelected(nonDeletedElements, appState); const hasSelection = isSomeElementSelected(nonDeletedElements, app.state);
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements =
isRelevantElement === true
? selectedElements
: selectedElements.filter((el) => isRelevantElement(el));
ret = ret =
getCommonAttributeOfSelectedElements( reduceToCommonValue(targetElements, getAttribute) ??
isRelevantElement === true
? nonDeletedElements
: nonDeletedElements.filter((el) => isRelevantElement(el)),
appState,
getAttribute,
) ??
(typeof defaultValue === "function" (typeof defaultValue === "function"
? defaultValue(true) ? defaultValue(true)
: defaultValue); : defaultValue);
@ -320,7 +320,7 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<> <>
<h3 aria-hidden="true">{t("labels.stroke")}</h3> <h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker <ColorPicker
@ -330,7 +330,7 @@ export const actionChangeStrokeColor = register({
label={t("labels.stroke")} label={t("labels.stroke")}
color={getFormValue( color={getFormValue(
elements, elements,
appState, app,
(element) => element.strokeColor, (element) => element.strokeColor,
true, true,
appState.currentItemStrokeColor, appState.currentItemStrokeColor,
@ -366,7 +366,7 @@ export const actionChangeBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<> <>
<h3 aria-hidden="true">{t("labels.background")}</h3> <h3 aria-hidden="true">{t("labels.background")}</h3>
<ColorPicker <ColorPicker
@ -376,7 +376,7 @@ export const actionChangeBackgroundColor = register({
label={t("labels.background")} label={t("labels.background")}
color={getFormValue( color={getFormValue(
elements, elements,
appState, app,
(element) => element.backgroundColor, (element) => element.backgroundColor,
true, true,
appState.currentItemBackgroundColor, appState.currentItemBackgroundColor,
@ -410,7 +410,7 @@ export const actionChangeFillStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const allElementsZigZag = const allElementsZigZag =
selectedElements.length > 0 && selectedElements.length > 0 &&
@ -446,7 +446,7 @@ export const actionChangeFillStyle = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => element.fillStyle, (element) => element.fillStyle,
(element) => element.hasOwnProperty("fillStyle"), (element) => element.hasOwnProperty("fillStyle"),
(hasSelection) => (hasSelection) =>
@ -483,7 +483,7 @@ export const actionChangeStrokeWidth = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
<ButtonIconSelect <ButtonIconSelect
@ -510,7 +510,7 @@ export const actionChangeStrokeWidth = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => element.strokeWidth, (element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"), (element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) => (hasSelection) =>
@ -538,7 +538,7 @@ export const actionChangeSloppiness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.sloppiness")}</legend> <legend>{t("labels.sloppiness")}</legend>
<ButtonIconSelect <ButtonIconSelect
@ -562,7 +562,7 @@ export const actionChangeSloppiness = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => element.roughness, (element) => element.roughness,
(element) => element.hasOwnProperty("roughness"), (element) => element.hasOwnProperty("roughness"),
(hasSelection) => (hasSelection) =>
@ -589,7 +589,7 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeStyle")}</legend> <legend>{t("labels.strokeStyle")}</legend>
<ButtonIconSelect <ButtonIconSelect
@ -613,7 +613,7 @@ export const actionChangeStrokeStyle = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => element.strokeStyle, (element) => element.strokeStyle,
(element) => element.hasOwnProperty("strokeStyle"), (element) => element.hasOwnProperty("strokeStyle"),
(hasSelection) => (hasSelection) =>
@ -644,13 +644,8 @@ export const actionChangeOpacity = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ app, updateData }) => (
<Range <Range updateData={updateData} app={app} testId="opacity" />
updateData={updateData}
elements={elements}
appState={appState}
testId="opacity"
/>
), ),
}); });
@ -694,7 +689,7 @@ export const actionChangeFontSize = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => { (element) => {
if (isTextElement(element)) { if (isTextElement(element)) {
return element.fontSize; return element.fontSize;
@ -992,7 +987,7 @@ export const actionChangeFontFamily = register({
) => ) =>
getFormValue( getFormValue(
elementsArray, elementsArray,
appState, app,
(element) => { (element) => {
if (isTextElement(element)) { if (isTextElement(element)) {
return element.fontFamily; return element.fontFamily;
@ -1030,7 +1025,7 @@ export const actionChangeFontFamily = register({
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had // popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
return prevSelectedFontFamilyRef.current; return prevSelectedFontFamilyRef.current;
}, [batchedData.openPopup, appState, elements, app.scene]); }, [batchedData.openPopup, appState, elements, app]);
useEffect(() => { useEffect(() => {
prevSelectedFontFamilyRef.current = selectedFontFamily; prevSelectedFontFamilyRef.current = selectedFontFamily;
@ -1216,7 +1211,7 @@ export const actionChangeTextAlign = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => { (element) => {
if (isTextElement(element)) { if (isTextElement(element)) {
return element.textAlign; return element.textAlign;
@ -1304,7 +1299,7 @@ export const actionChangeVerticalAlign = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => { (element) => {
if (isTextElement(element) && element.containerId) { if (isTextElement(element) && element.containerId) {
return element.verticalAlign; return element.verticalAlign;
@ -1362,7 +1357,7 @@ export const actionChangeRoundness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
@ -1391,7 +1386,7 @@ export const actionChangeRoundness = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => (element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) => (element) =>
@ -1521,7 +1516,7 @@ export const actionChangeArrowhead = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
return ( return (
@ -1533,7 +1528,7 @@ export const actionChangeArrowhead = register({
options={getArrowheadOptions(!isRTL)} options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
appState, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead ? element.startArrowhead
@ -1550,7 +1545,7 @@ export const actionChangeArrowhead = register({
options={getArrowheadOptions(!!isRTL)} options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
appState, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead ? element.endArrowhead
@ -1759,7 +1754,7 @@ export const actionChangeArrowType = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowtypes")}</legend> <legend>{t("labels.arrowtypes")}</legend>
@ -1787,7 +1782,7 @@ export const actionChangeArrowType = register({
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, app,
(element) => { (element) => {
if (isArrowElement(element)) { if (isArrowElement(element)) {
return element.elbowed return element.elbowed

View File

@ -1,6 +1,9 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { updateElbowArrowPoints } from "@excalidraw/element"; import {
getLinearElementSubType,
updateElbowArrowPoints,
} from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math"; import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
@ -8,10 +11,8 @@ import {
hasBoundTextElement, hasBoundTextElement,
isArrowBoundToElement, isArrowBoundToElement,
isArrowElement, isArrowElement,
isCurvedArrow,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isSharpArrow,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -34,6 +35,8 @@ import {
CLASSES, CLASSES,
getFontString, getFontString,
isProdEnv, isProdEnv,
mapFind,
reduceToCommonValue,
updateActiveTool, updateActiveTool,
} from "@excalidraw/common"; } from "@excalidraw/common";
@ -55,9 +58,7 @@ import type {
ConvertibleGenericTypes, ConvertibleGenericTypes,
ConvertibleLinearTypes, ConvertibleLinearTypes,
ConvertibleTypes, ConvertibleTypes,
ExcalidrawArrowElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -77,7 +78,7 @@ import {
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
} from ".."; } from "..";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { atom, editorJotaiStore, useSetAtom } from "../editor-jotai"; import { atom } from "../editor-jotai";
import "./ConvertElementTypePopup.scss"; import "./ConvertElementTypePopup.scss";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@ -98,6 +99,12 @@ import type { AppClassProperties } from "../types";
const GAP_HORIZONTAL = 8; const GAP_HORIZONTAL = 8;
const GAP_VERTICAL = 10; const GAP_VERTICAL = 10;
type ExcalidrawConvertibleElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawLinearElement;
// indicates order of switching // indicates order of switching
const GENERIC_TYPES = ["rectangle", "diamond", "ellipse"] as const; const GENERIC_TYPES = ["rectangle", "diamond", "ellipse"] as const;
// indicates order of switching // indicates order of switching
@ -131,28 +138,21 @@ export const convertElementTypePopupAtom = atom<{
type: "panel"; type: "panel";
} | null>(null); } | null>(null);
// NOTE doesn't need to be an atom. Review once we integrate with properties panel. type CacheKey = string & { _brand: "CacheKey" };
export const fontSize_conversionCacheAtom = atom<{
[id: string]: {
fontSize: number;
elementType: ConvertibleGenericTypes;
};
} | null>(null);
// NOTE doesn't need to be an atom. Review once we integrate with properties panel. const FONT_SIZE_CONVERSION_CACHE = new Map<
export const linearElement_conversionCacheAtom = atom<{ ExcalidrawElement["id"],
[id: string]: { {
properties: fontSize: number;
| Partial<ExcalidrawLinearElement> }
| Partial<ExcalidrawElbowArrowElement>; >();
initialType: ConvertibleLinearTypes;
}; const LINEAR_ELEMENT_CONVERSION_CACHE = new Map<
} | null>(null); CacheKey,
ExcalidrawLinearElement
>();
const ConvertElementTypePopup = ({ app }: { app: App }) => { const ConvertElementTypePopup = ({ app }: { app: App }) => {
const setFontSizeCache = useSetAtom(fontSize_conversionCacheAtom);
const setLinearElementCache = useSetAtom(linearElement_conversionCacheAtom);
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const elementsCategoryRef = useRef<ConversionType>(null); const elementsCategoryRef = useRef<ConversionType>(null);
@ -179,10 +179,10 @@ const ConvertElementTypePopup = ({ app }: { app: App }) => {
useEffect(() => { useEffect(() => {
return () => { return () => {
setFontSizeCache(null); FONT_SIZE_CONVERSION_CACHE.clear();
setLinearElementCache(null); LINEAR_ELEMENT_CONVERSION_CACHE.clear();
}; };
}, [setFontSizeCache, setLinearElementCache]); }, []);
return <Panel app={app} elements={selectedElements} />; return <Panel app={app} elements={selectedElements} />;
}; };
@ -215,7 +215,8 @@ const Panel = ({
: conversionType === "linear" : conversionType === "linear"
? linearElements.every( ? linearElements.every(
(element) => (element) =>
getArrowType(element) === getArrowType(linearElements[0]), getLinearElementSubType(element) ===
getLinearElementSubType(linearElements[0]),
) )
: false; : false;
@ -264,51 +265,29 @@ const Panel = ({
}, [genericElements, linearElements, app.scene, app.state]); }, [genericElements, linearElements, app.scene, app.state]);
useEffect(() => { useEffect(() => {
if (editorJotaiStore.get(linearElement_conversionCacheAtom)) {
return;
}
for (const linearElement of linearElements) { for (const linearElement of linearElements) {
const initialType = getArrowType(linearElement); const cacheKey = toCacheKey(
const cachedProperties = linearElement.id,
initialType === "line" getConvertibleType(linearElement),
? getLineProperties(linearElement) );
: initialType === "sharpArrow" if (!LINEAR_ELEMENT_CONVERSION_CACHE.has(cacheKey)) {
? getSharpArrowProperties(linearElement) LINEAR_ELEMENT_CONVERSION_CACHE.set(cacheKey, linearElement);
: initialType === "curvedArrow" }
? getCurvedArrowProperties(linearElement)
: initialType === "elbowArrow"
? getElbowArrowProperties(linearElement)
: {};
editorJotaiStore.set(linearElement_conversionCacheAtom, {
...editorJotaiStore.get(linearElement_conversionCacheAtom),
[linearElement.id]: {
properties: cachedProperties,
initialType,
},
});
} }
}, [linearElements]); }, [linearElements]);
useEffect(() => { useEffect(() => {
if (editorJotaiStore.get(fontSize_conversionCacheAtom)) {
return;
}
for (const element of genericElements) { for (const element of genericElements) {
const boundText = getBoundTextElement( if (!FONT_SIZE_CONVERSION_CACHE.has(element.id)) {
element, const boundText = getBoundTextElement(
app.scene.getNonDeletedElementsMap(), element,
); app.scene.getNonDeletedElementsMap(),
if (boundText) { );
editorJotaiStore.set(fontSize_conversionCacheAtom, { if (boundText) {
...editorJotaiStore.get(fontSize_conversionCacheAtom), FONT_SIZE_CONVERSION_CACHE.set(element.id, {
[element.id]: {
fontSize: boundText.fontSize, fontSize: boundText.fontSize,
elementType: element.type as ConvertibleGenericTypes, });
}, }
});
} }
} }
}, [genericElements, app.scene]); }, [genericElements, app.scene]);
@ -350,7 +329,7 @@ const Panel = ({
sameType && sameType &&
((conversionType === "generic" && genericElements[0].type === type) || ((conversionType === "generic" && genericElements[0].type === type) ||
(conversionType === "linear" && (conversionType === "linear" &&
getArrowType(linearElements[0]) === type)); getLinearElementSubType(linearElements[0]) === type));
return ( return (
<ToolButton <ToolButton
@ -500,14 +479,11 @@ export const convertElementTypes = (
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
if (boundText) { if (boundText) {
if ( if (FONT_SIZE_CONVERSION_CACHE.get(element.id)) {
editorJotaiStore.get(fontSize_conversionCacheAtom)?.[element.id]
?.elementType === nextType
) {
mutateElement(boundText, app.scene.getNonDeletedElementsMap(), { mutateElement(boundText, app.scene.getNonDeletedElementsMap(), {
fontSize: fontSize:
editorJotaiStore.get(fontSize_conversionCacheAtom)?.[element.id] FONT_SIZE_CONVERSION_CACHE.get(element.id)?.fontSize ??
?.fontSize ?? boundText.fontSize, boundText.fontSize,
}); });
} }
@ -535,124 +511,101 @@ export const convertElementTypes = (
selectedElements, selectedElements,
) as ExcalidrawLinearElement[]; ) as ExcalidrawLinearElement[];
const arrowType = getArrowType(convertibleLinearElements[0]); if (!nextType) {
const sameType = convertibleLinearElements.every( const commonSubType = reduceToCommonValue(
(element) => getArrowType(element) === arrowType, convertibleLinearElements,
); getLinearElementSubType,
);
const index = sameType ? LINEAR_TYPES.indexOf(arrowType) : -1; const index = commonSubType ? LINEAR_TYPES.indexOf(commonSubType) : -1;
nextType = nextType =
nextType ?? LINEAR_TYPES[
LINEAR_TYPES[ (index + LINEAR_TYPES.length + advancement) % LINEAR_TYPES.length
(index + LINEAR_TYPES.length + advancement) % LINEAR_TYPES.length ];
]; }
if (isConvertibleLinearType(nextType)) {
const convertedElements: ExcalidrawElement[] = [];
const nextElementsMap: Map<ExcalidrawElement["id"], ExcalidrawElement> =
app.scene.getElementsMapIncludingDeleted();
if (nextType && isConvertibleLinearType(nextType)) {
const convertedElements: Record<string, ExcalidrawElement> = {};
for (const element of convertibleLinearElements) { for (const element of convertibleLinearElements) {
const { properties, initialType } = const cachedElement = LINEAR_ELEMENT_CONVERSION_CACHE.get(
editorJotaiStore.get(linearElement_conversionCacheAtom)?.[ toCacheKey(element.id, nextType),
element.id );
] || {};
// If the initial type is not elbow, and when we switch to elbow, // if switching to the original subType or a subType we've already
// the linear line might be "bent" and the points would likely be different. // converted to, reuse the cached element to get the original properties
// When we then switch to other non elbow types from this converted elbow, // (needed for simple->elbow->simple conversions or between line
// we still want to use the original points instead. // and arrows)
if ( if (
initialType && cachedElement &&
properties && getLinearElementSubType(cachedElement) === nextType
isElbowArrow(element) &&
initialType !== "elbowArrow" &&
nextType !== "elbowArrow"
) { ) {
// first convert back to the original type nextElementsMap.set(cachedElement.id, cachedElement);
const originalType = convertElementType( convertedElements.push(cachedElement);
element,
initialType,
app,
) as ExcalidrawLinearElement;
// then convert to the target type
const converted = convertElementType(
initialType === "line"
? newLinearElement({
...originalType,
...properties,
type: "line",
})
: newArrowElement({
...originalType,
...properties,
type: "arrow",
}),
nextType,
app,
);
convertedElements[converted.id] = converted;
} else { } else {
const converted = convertElementType(element, nextType, app); const converted = convertElementType(element, nextType, app);
convertedElements[converted.id] = converted; nextElementsMap.set(converted.id, converted);
convertedElements.push(converted);
} }
} }
const nextElements = []; app.scene.replaceAllElements(nextElementsMap);
for (const element of app.scene.getElementsIncludingDeleted()) { // post normalization
if (convertedElements[element.id]) { for (const element of convertedElements) {
nextElements.push(convertedElements[element.id]); if (isLinearElement(element)) {
} else { if (isElbowArrow(element)) {
nextElements.push(element); const nextPoints = convertLineToElbow(element);
} if (nextPoints.length < 2) {
} // skip if not enough points to form valid segments
continue;
app.scene.replaceAllElements(nextElements); }
const fixedSegments: FixedSegment[] = [];
for (const element of Object.values(convertedElements)) { for (let i = 0; i < nextPoints.length - 1; i++) {
const cachedLinear = editorJotaiStore.get( fixedSegments.push({
linearElement_conversionCacheAtom, start: nextPoints[i],
)?.[element.id]; end: nextPoints[i + 1],
index: i + 1,
if (cachedLinear) { });
const { properties, initialType } = cachedLinear; }
const updates = updateElbowArrowPoints(
if (initialType === nextType) {
mutateElement(
element, element,
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
properties, {
points: nextPoints,
fixedSegments,
},
); );
continue; mutateElement(element, app.scene.getNonDeletedElementsMap(), {
} ...updates,
}
if (isElbowArrow(element)) {
const nextPoints = convertLineToElbow(element);
if (nextPoints.length < 2) {
// skip if not enough points to form valid segments
continue;
}
const fixedSegments: FixedSegment[] = [];
for (let i = 0; i < nextPoints.length - 1; i++) {
fixedSegments.push({
start: nextPoints[i],
end: nextPoints[i + 1],
index: i + 1,
}); });
} else {
// if we're converting to non-elbow linear element, check if
// we've already cached one of these linear elements so we can
// reuse the points (case: curved->elbow->line and similar)
const similarCachedLinearElement = mapFind(
["line", "sharpArrow", "curvedArrow"] as const,
(type) =>
LINEAR_ELEMENT_CONVERSION_CACHE.get(
toCacheKey(element.id, type),
),
);
if (similarCachedLinearElement) {
const points = similarCachedLinearElement.points;
app.scene.mutateElement(element, {
points,
});
}
} }
const updates = updateElbowArrowPoints(
element,
app.scene.getNonDeletedElementsMap(),
{
points: nextPoints,
fixedSegments,
},
);
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
...updates,
});
} }
} }
} }
const convertedSelectedLinearElements = filterLinearConvertibleElements( const convertedSelectedLinearElements = filterLinearConvertibleElements(
app.scene.getSelectedElements(app.state), app.scene.getSelectedElements(app.state),
); );
@ -708,83 +661,11 @@ const isEligibleLinearElement = (element: ExcalidrawElement) => {
); );
}; };
const getArrowType = (element: ExcalidrawLinearElement) => { const toCacheKey = (
if (isSharpArrow(element)) { elementId: ExcalidrawElement["id"],
return "sharpArrow"; convertitleType: ConvertibleTypes,
} ) => {
if (isCurvedArrow(element)) { return `${elementId}:${convertitleType}` as CacheKey;
return "curvedArrow";
}
if (isElbowArrow(element)) {
return "elbowArrow";
}
return "line";
};
const getLineProperties = (
element: ExcalidrawLinearElement,
): Partial<ExcalidrawLinearElement> => {
if (element.type === "line") {
return {
points: element.points,
roundness: element.roundness,
};
}
return {};
};
const getSharpArrowProperties = (
element: ExcalidrawLinearElement,
): Partial<ExcalidrawArrowElement> => {
if (isSharpArrow(element)) {
return {
points: element.points,
startArrowhead: element.startArrowhead,
endArrowhead: element.endArrowhead,
startBinding: element.startBinding,
endBinding: element.endBinding,
roundness: null,
};
}
return {};
};
const getCurvedArrowProperties = (
element: ExcalidrawLinearElement,
): Partial<ExcalidrawArrowElement> => {
if (isCurvedArrow(element)) {
return {
points: element.points,
startArrowhead: element.startArrowhead,
endArrowhead: element.endArrowhead,
startBinding: element.startBinding,
endBinding: element.endBinding,
roundness: element.roundness,
};
}
return {};
};
const getElbowArrowProperties = (
element: ExcalidrawLinearElement,
): Partial<ExcalidrawElbowArrowElement> => {
if (isElbowArrow(element)) {
return {
points: element.points,
startArrowhead: element.startArrowhead,
endArrowhead: element.endArrowhead,
startBinding: element.startBinding,
endBinding: element.endBinding,
roundness: null,
fixedSegments: element.fixedSegments,
startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial,
};
}
return {};
}; };
const filterGenericConvetibleElements = (elements: ExcalidrawElement[]) => const filterGenericConvetibleElements = (elements: ExcalidrawElement[]) =>
@ -1045,4 +926,13 @@ const isValidConversion = (
return false; return false;
}; };
const getConvertibleType = (
element: ExcalidrawConvertibleElement,
): ConvertibleTypes => {
if (isLinearElement(element)) {
return getLinearElementSubType(element);
}
return element.type;
};
export default ConvertElementTypePopup; export default ConvertElementTypePopup;

View File

@ -1,32 +1,35 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n"; import { t } from "../i18n";
import "./Range.scss"; import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = { export type RangeProps = {
updateData: (value: number) => void; updateData: (value: number) => void;
appState: any; app: AppClassProperties;
elements: any;
testId?: string; testId?: string;
}; };
export const Range = ({ export const Range = ({ updateData, app, testId }: RangeProps) => {
updateData,
appState,
elements,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null); const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null); const valueRef = React.useRef<HTMLDivElement>(null);
const value = getFormValue( const selectedElements = app.scene.getSelectedElements(app.state);
elements, let hasCommonOpacity = true;
appState, const firstElement = selectedElements.at(0);
(element) => element.opacity, const leastCommonOpacity = selectedElements.reduce((acc, element) => {
true, if (acc != null && acc !== element.opacity) {
appState.currentItemOpacity, hasCommonOpacity = false;
); }
if (acc == null || acc > element.opacity) {
return element.opacity;
}
return acc;
}, firstElement?.opacity ?? null);
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
useEffect(() => { useEffect(() => {
if (rangeRef.current && valueRef.current) { if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current; const rangeElement = rangeRef.current;
@ -45,6 +48,11 @@ export const Range = ({
{t("labels.opacity")} {t("labels.opacity")}
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
style={{
["--color-slider-track" as string]: hasCommonOpacity
? undefined
: "var(--button-bg)",
}}
ref={rangeRef} ref={rangeRef}
type="range" type="range"
min="0" min="0"

View File

@ -1,7 +1,6 @@
export { export {
isSomeElementSelected, isSomeElementSelected,
getElementsWithinSelection, getElementsWithinSelection,
getCommonAttributeOfSelectedElements,
getSelectedElements, getSelectedElements,
getTargetElements, getTargetElements,
} from "@excalidraw/element"; } from "@excalidraw/element";

View File

@ -1,13 +0,0 @@
import { isTransparent } from "@excalidraw/common";
describe("Test isTransparent", () => {
it("should return true when color is rgb transparent", () => {
expect(isTransparent("#ff00")).toEqual(true);
expect(isTransparent("#fff00000")).toEqual(true);
expect(isTransparent("transparent")).toEqual(true);
});
it("should return false when color is not transparent", () => {
expect(isTransparent("#ced4da")).toEqual(false);
});
});