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
10 changed files with 346 additions and 344 deletions
@@ -1,6 +1,9 @@
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";
@@ -8,10 +11,8 @@ import {
hasBoundTextElement,
isArrowBoundToElement,
isArrowElement,
isCurvedArrow,
isElbowArrow,
isLinearElement,
isSharpArrow,
isUsingAdaptiveRadius,
} from "@excalidraw/element";
@@ -34,6 +35,8 @@ import {
CLASSES,
getFontString,
isProdEnv,
mapFind,
reduceToCommonValue,
updateActiveTool,
} from "@excalidraw/common";
@@ -55,9 +58,7 @@ import type {
ConvertibleGenericTypes,
ConvertibleLinearTypes,
ConvertibleTypes,
ExcalidrawArrowElement,
ExcalidrawDiamondElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawLinearElement,
@@ -77,7 +78,7 @@ import {
sceneCoordsToViewportCoords,
} from "..";
import { trackEvent } from "../analytics";
import { atom, editorJotaiStore, useSetAtom } from "../editor-jotai";
import { atom } from "../editor-jotai";
import "./ConvertElementTypePopup.scss";
import { ToolButton } from "./ToolButton";
@@ -98,6 +99,12 @@ import type { AppClassProperties } from "../types";
const GAP_HORIZONTAL = 8;
const GAP_VERTICAL = 10;
type ExcalidrawConvertibleElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawLinearElement;
// indicates order of switching
const GENERIC_TYPES = ["rectangle", "diamond", "ellipse"] as const;
// indicates order of switching
@@ -131,28 +138,21 @@ export const convertElementTypePopupAtom = atom<{
type: "panel";
} | null>(null);
// NOTE doesn't need to be an atom. Review once we integrate with properties panel.
export const fontSize_conversionCacheAtom = atom<{
[id: string]: {
fontSize: number;
elementType: ConvertibleGenericTypes;
};
} | null>(null);
type CacheKey = string & { _brand: "CacheKey" };
// NOTE doesn't need to be an atom. Review once we integrate with properties panel.
export const linearElement_conversionCacheAtom = atom<{
[id: string]: {
properties:
| Partial<ExcalidrawLinearElement>
| Partial<ExcalidrawElbowArrowElement>;
initialType: ConvertibleLinearTypes;
};
} | null>(null);
const FONT_SIZE_CONVERSION_CACHE = new Map<
ExcalidrawElement["id"],
{
fontSize: number;
}
>();
const LINEAR_ELEMENT_CONVERSION_CACHE = new Map<
CacheKey,
ExcalidrawLinearElement
>();
const ConvertElementTypePopup = ({ app }: { app: App }) => {
const setFontSizeCache = useSetAtom(fontSize_conversionCacheAtom);
const setLinearElementCache = useSetAtom(linearElement_conversionCacheAtom);
const selectedElements = app.scene.getSelectedElements(app.state);
const elementsCategoryRef = useRef<ConversionType>(null);
@@ -179,10 +179,10 @@ const ConvertElementTypePopup = ({ app }: { app: App }) => {
useEffect(() => {
return () => {
setFontSizeCache(null);
setLinearElementCache(null);
FONT_SIZE_CONVERSION_CACHE.clear();
LINEAR_ELEMENT_CONVERSION_CACHE.clear();
};
}, [setFontSizeCache, setLinearElementCache]);
}, []);
return <Panel app={app} elements={selectedElements} />;
};
@@ -215,7 +215,8 @@ const Panel = ({
: conversionType === "linear"
? linearElements.every(
(element) =>
getArrowType(element) === getArrowType(linearElements[0]),
getLinearElementSubType(element) ===
getLinearElementSubType(linearElements[0]),
)
: false;
@@ -264,51 +265,29 @@ const Panel = ({
}, [genericElements, linearElements, app.scene, app.state]);
useEffect(() => {
if (editorJotaiStore.get(linearElement_conversionCacheAtom)) {
return;
}
for (const linearElement of linearElements) {
const initialType = getArrowType(linearElement);
const cachedProperties =
initialType === "line"
? getLineProperties(linearElement)
: initialType === "sharpArrow"
? getSharpArrowProperties(linearElement)
: initialType === "curvedArrow"
? getCurvedArrowProperties(linearElement)
: initialType === "elbowArrow"
? getElbowArrowProperties(linearElement)
: {};
editorJotaiStore.set(linearElement_conversionCacheAtom, {
...editorJotaiStore.get(linearElement_conversionCacheAtom),
[linearElement.id]: {
properties: cachedProperties,
initialType,
},
});
const cacheKey = toCacheKey(
linearElement.id,
getConvertibleType(linearElement),
);
if (!LINEAR_ELEMENT_CONVERSION_CACHE.has(cacheKey)) {
LINEAR_ELEMENT_CONVERSION_CACHE.set(cacheKey, linearElement);
}
}
}, [linearElements]);
useEffect(() => {
if (editorJotaiStore.get(fontSize_conversionCacheAtom)) {
return;
}
for (const element of genericElements) {
const boundText = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundText) {
editorJotaiStore.set(fontSize_conversionCacheAtom, {
...editorJotaiStore.get(fontSize_conversionCacheAtom),
[element.id]: {
if (!FONT_SIZE_CONVERSION_CACHE.has(element.id)) {
const boundText = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundText) {
FONT_SIZE_CONVERSION_CACHE.set(element.id, {
fontSize: boundText.fontSize,
elementType: element.type as ConvertibleGenericTypes,
},
});
});
}
}
}
}, [genericElements, app.scene]);
@@ -350,7 +329,7 @@ const Panel = ({
sameType &&
((conversionType === "generic" && genericElements[0].type === type) ||
(conversionType === "linear" &&
getArrowType(linearElements[0]) === type));
getLinearElementSubType(linearElements[0]) === type));
return (
<ToolButton
@@ -500,14 +479,11 @@ export const convertElementTypes = (
app.scene.getNonDeletedElementsMap(),
);
if (boundText) {
if (
editorJotaiStore.get(fontSize_conversionCacheAtom)?.[element.id]
?.elementType === nextType
) {
if (FONT_SIZE_CONVERSION_CACHE.get(element.id)) {
mutateElement(boundText, app.scene.getNonDeletedElementsMap(), {
fontSize:
editorJotaiStore.get(fontSize_conversionCacheAtom)?.[element.id]
?.fontSize ?? boundText.fontSize,
FONT_SIZE_CONVERSION_CACHE.get(element.id)?.fontSize ??
boundText.fontSize,
});
}
@@ -535,124 +511,101 @@ export const convertElementTypes = (
selectedElements,
) as ExcalidrawLinearElement[];
const arrowType = getArrowType(convertibleLinearElements[0]);
const sameType = convertibleLinearElements.every(
(element) => getArrowType(element) === arrowType,
);
if (!nextType) {
const commonSubType = reduceToCommonValue(
convertibleLinearElements,
getLinearElementSubType,
);
const index = sameType ? LINEAR_TYPES.indexOf(arrowType) : -1;
nextType =
nextType ??
LINEAR_TYPES[
(index + LINEAR_TYPES.length + advancement) % LINEAR_TYPES.length
];
const index = commonSubType ? LINEAR_TYPES.indexOf(commonSubType) : -1;
nextType =
LINEAR_TYPES[
(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) {
const { properties, initialType } =
editorJotaiStore.get(linearElement_conversionCacheAtom)?.[
element.id
] || {};
const cachedElement = LINEAR_ELEMENT_CONVERSION_CACHE.get(
toCacheKey(element.id, nextType),
);
// If the initial type is not elbow, and when we switch to elbow,
// the linear line might be "bent" and the points would likely be different.
// When we then switch to other non elbow types from this converted elbow,
// we still want to use the original points instead.
// if switching to the original subType or a subType we've already
// converted to, reuse the cached element to get the original properties
// (needed for simple->elbow->simple conversions or between line
// and arrows)
if (
initialType &&
properties &&
isElbowArrow(element) &&
initialType !== "elbowArrow" &&
nextType !== "elbowArrow"
cachedElement &&
getLinearElementSubType(cachedElement) === nextType
) {
// first convert back to the original type
const originalType = convertElementType(
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;
nextElementsMap.set(cachedElement.id, cachedElement);
convertedElements.push(cachedElement);
} else {
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()) {
if (convertedElements[element.id]) {
nextElements.push(convertedElements[element.id]);
} else {
nextElements.push(element);
}
}
app.scene.replaceAllElements(nextElements);
for (const element of Object.values(convertedElements)) {
const cachedLinear = editorJotaiStore.get(
linearElement_conversionCacheAtom,
)?.[element.id];
if (cachedLinear) {
const { properties, initialType } = cachedLinear;
if (initialType === nextType) {
mutateElement(
// post normalization
for (const element of convertedElements) {
if (isLinearElement(element)) {
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,
});
}
const updates = updateElbowArrowPoints(
element,
app.scene.getNonDeletedElementsMap(),
properties,
{
points: nextPoints,
fixedSegments,
},
);
continue;
}
}
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,
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
...updates,
});
} 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(
app.scene.getSelectedElements(app.state),
);
@@ -708,83 +661,11 @@ const isEligibleLinearElement = (element: ExcalidrawElement) => {
);
};
const getArrowType = (element: ExcalidrawLinearElement) => {
if (isSharpArrow(element)) {
return "sharpArrow";
}
if (isCurvedArrow(element)) {
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 toCacheKey = (
elementId: ExcalidrawElement["id"],
convertitleType: ConvertibleTypes,
) => {
return `${elementId}:${convertitleType}` as CacheKey;
};
const filterGenericConvetibleElements = (elements: ExcalidrawElement[]) =>
@@ -1045,4 +926,13 @@ const isValidConversion = (
return false;
};
const getConvertibleType = (
element: ExcalidrawConvertibleElement,
): ConvertibleTypes => {
if (isLinearElement(element)) {
return getLinearElementSubType(element);
}
return element.type;
};
export default ConvertElementTypePopup;
+24 -16
View File
@@ -1,32 +1,35 @@
import React, { useEffect } from "react";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n";
import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = {
updateData: (value: number) => void;
appState: any;
elements: any;
app: AppClassProperties;
testId?: string;
};
export const Range = ({
updateData,
appState,
elements,
testId,
}: RangeProps) => {
export const Range = ({ updateData, app, testId }: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const value = getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
);
const selectedElements = app.scene.getSelectedElements(app.state);
let hasCommonOpacity = true;
const firstElement = selectedElements.at(0);
const leastCommonOpacity = selectedElements.reduce((acc, element) => {
if (acc != null && acc !== element.opacity) {
hasCommonOpacity = false;
}
if (acc == null || acc > element.opacity) {
return element.opacity;
}
return acc;
}, firstElement?.opacity ?? null);
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
@@ -45,6 +48,11 @@ export const Range = ({
{t("labels.opacity")}
<div className="range-wrapper">
<input
style={{
["--color-slider-track" as string]: hasCommonOpacity
? undefined
: "var(--button-bg)",
}}
ref={rangeRef}
type="range"
min="0"