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:
parent
7e41026812
commit
51dbd4831b
82
packages/common/src/utils.test.ts
Normal file
82
packages/common/src/utils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
};
|
||||||
|
@ -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">,
|
||||||
|
@ -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";
|
||||||
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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) {
|
||||||
ret =
|
const selectedElements = app.scene.getSelectedElements(app.state);
|
||||||
getCommonAttributeOfSelectedElements(
|
const targetElements =
|
||||||
isRelevantElement === true
|
isRelevantElement === true
|
||||||
? nonDeletedElements
|
? selectedElements
|
||||||
: nonDeletedElements.filter((el) => isRelevantElement(el)),
|
: selectedElements.filter((el) => isRelevantElement(el));
|
||||||
appState,
|
|
||||||
getAttribute,
|
ret =
|
||||||
) ??
|
reduceToCommonValue(targetElements, 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
|
||||||
|
@ -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,53 +265,31 @@ 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) {
|
||||||
|
if (!FONT_SIZE_CONVERSION_CACHE.has(element.id)) {
|
||||||
const boundText = getBoundTextElement(
|
const boundText = getBoundTextElement(
|
||||||
element,
|
element,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
if (boundText) {
|
if (boundText) {
|
||||||
editorJotaiStore.set(fontSize_conversionCacheAtom, {
|
FONT_SIZE_CONVERSION_CACHE.set(element.id, {
|
||||||
...editorJotaiStore.get(fontSize_conversionCacheAtom),
|
|
||||||
[element.id]: {
|
|
||||||
fontSize: boundText.fontSize,
|
fontSize: boundText.fontSize,
|
||||||
elementType: element.type as ConvertibleGenericTypes,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [genericElements, app.scene]);
|
}, [genericElements, app.scene]);
|
||||||
|
|
||||||
const SHAPES: [string, ReactNode][] =
|
const SHAPES: [string, ReactNode][] =
|
||||||
@ -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,96 +511,52 @@ 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,
|
|
||||||
// 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 (
|
|
||||||
initialType &&
|
|
||||||
properties &&
|
|
||||||
isElbowArrow(element) &&
|
|
||||||
initialType !== "elbowArrow" &&
|
|
||||||
nextType !== "elbowArrow"
|
|
||||||
) {
|
|
||||||
// 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;
|
|
||||||
|
// 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 (
|
||||||
|
cachedElement &&
|
||||||
|
getLinearElementSubType(cachedElement) === nextType
|
||||||
|
) {
|
||||||
|
nextElementsMap.set(cachedElement.id, cachedElement);
|
||||||
|
convertedElements.push(cachedElement);
|
||||||
} 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()) {
|
|
||||||
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(
|
|
||||||
element,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
properties,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// post normalization
|
||||||
|
for (const element of convertedElements) {
|
||||||
|
if (isLinearElement(element)) {
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
const nextPoints = convertLineToElbow(element);
|
const nextPoints = convertLineToElbow(element);
|
||||||
if (nextPoints.length < 2) {
|
if (nextPoints.length < 2) {
|
||||||
@ -650,9 +582,30 @@ export const convertElementTypes = (
|
|||||||
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
|
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
|
||||||
...updates,
|
...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 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;
|
||||||
|
@ -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"
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
getElementsWithinSelection,
|
getElementsWithinSelection,
|
||||||
getCommonAttributeOfSelectedElements,
|
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
Loading…
x
Reference in New Issue
Block a user