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:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user