diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/actions/actionProperties.tsx | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/actions/actionProperties.tsx')
| -rw-r--r-- | packages/excalidraw/actions/actionProperties.tsx | 1777 |
1 files changed, 1777 insertions, 0 deletions
diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx new file mode 100644 index 0000000..0c2ad3b --- /dev/null +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -0,0 +1,1777 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { AppClassProperties, AppState, Primitive } from "../types"; +import type { CaptureUpdateActionType } from "../store"; +import { + DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, + DEFAULT_ELEMENT_BACKGROUND_PICKS, + DEFAULT_ELEMENT_STROKE_COLOR_PALETTE, + DEFAULT_ELEMENT_STROKE_PICKS, +} from "../colors"; +import { trackEvent } from "../analytics"; +import { ButtonIconSelect } from "../components/ButtonIconSelect"; +import { ColorPicker } from "../components/ColorPicker/ColorPicker"; +import { IconPicker } from "../components/IconPicker"; +import { FontPicker } from "../components/FontPicker/FontPicker"; +// TODO barnabasmolnar/editor-redesign +// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, +// ArrowHead icons +import { + ArrowheadArrowIcon, + ArrowheadBarIcon, + ArrowheadCircleIcon, + ArrowheadTriangleIcon, + ArrowheadNoneIcon, + StrokeStyleDashedIcon, + StrokeStyleDottedIcon, + TextAlignTopIcon, + TextAlignBottomIcon, + TextAlignMiddleIcon, + FillHachureIcon, + FillCrossHatchIcon, + FillSolidIcon, + SloppinessArchitectIcon, + SloppinessArtistIcon, + SloppinessCartoonistIcon, + StrokeWidthBaseIcon, + StrokeWidthBoldIcon, + StrokeWidthExtraBoldIcon, + FontSizeSmallIcon, + FontSizeMediumIcon, + FontSizeLargeIcon, + FontSizeExtraLargeIcon, + EdgeSharpIcon, + EdgeRoundIcon, + TextAlignLeftIcon, + TextAlignCenterIcon, + TextAlignRightIcon, + FillZigZagIcon, + ArrowheadTriangleOutlineIcon, + ArrowheadCircleOutlineIcon, + ArrowheadDiamondIcon, + ArrowheadDiamondOutlineIcon, + fontSizeIcon, + sharpArrowIcon, + roundArrowIcon, + elbowArrowIcon, + ArrowheadCrowfootIcon, + ArrowheadCrowfootOneIcon, + ArrowheadCrowfootOneOrManyIcon, +} from "../components/icons"; +import { + ARROW_TYPE, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + FONT_FAMILY, + ROUNDNESS, + STROKE_WIDTH, + VERTICAL_ALIGN, +} from "../constants"; +import { + getNonDeletedElements, + isTextElement, + redrawTextBoundingBox, +} from "../element"; +import { mutateElement, newElementWith } from "../element/mutateElement"; +import { getBoundTextElement } from "../element/textElement"; +import { + isArrowElement, + isBoundToContainer, + isElbowArrow, + isLinearElement, + isUsingAdaptiveRadius, +} from "../element/typeChecks"; +import type { + Arrowhead, + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextElement, + FontFamilyValues, + TextAlign, + VerticalAlign, + NonDeletedSceneElementsMap, +} from "../element/types"; +import { getLanguage, t } from "../i18n"; +import { KEYS } from "../keys"; +import { randomInteger } from "../random"; +import { + canHaveArrowheads, + getCommonAttributeOfSelectedElements, + getSelectedElements, + getTargetElements, + isSomeElementSelected, +} from "../scene"; +import { hasStrokeColor } from "../scene/comparisons"; +import { + arrayToMap, + getFontFamilyString, + getShortcutKey, + tupleToCoors, +} from "../utils"; +import { register } from "./register"; +import { CaptureUpdateAction } from "../store"; +import { Fonts, getLineHeight } from "../fonts"; +import { + bindLinearElement, + bindPointToSnapToElementOutline, + calculateFixedPointForElbowArrowBinding, + getHoveredElementForBinding, + updateBoundElements, +} from "../element/binding"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import type { LocalPoint } from "@excalidraw/math"; +import { pointFrom } from "@excalidraw/math"; +import { Range } from "../components/Range"; + +const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; + +export const changeProperty = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + callback: (element: ExcalidrawElement) => ExcalidrawElement, + includeBoundText = false, +) => { + const selectedElementIds = arrayToMap( + getSelectedElements(elements, appState, { + includeBoundTextElement: includeBoundText, + }), + ); + + return elements.map((element) => { + if ( + selectedElementIds.get(element.id) || + element.id === appState.editingTextElement?.id + ) { + return callback(element); + } + return element; + }); +}; + +export const getFormValue = function <T extends Primitive>( + elements: readonly ExcalidrawElement[], + appState: AppState, + getAttribute: (element: ExcalidrawElement) => T, + isRelevantElement: true | ((element: ExcalidrawElement) => boolean), + defaultValue: T | ((isSomeElementSelected: boolean) => T), +): T { + const editingTextElement = appState.editingTextElement; + const nonDeletedElements = getNonDeletedElements(elements); + + let ret: T | null = null; + + if (editingTextElement) { + ret = getAttribute(editingTextElement); + } + + if (!ret) { + const hasSelection = isSomeElementSelected(nonDeletedElements, appState); + + if (hasSelection) { + ret = + getCommonAttributeOfSelectedElements( + isRelevantElement === true + ? nonDeletedElements + : nonDeletedElements.filter((el) => isRelevantElement(el)), + appState, + getAttribute, + ) ?? + (typeof defaultValue === "function" + ? defaultValue(true) + : defaultValue); + } else { + ret = + typeof defaultValue === "function" ? defaultValue(false) : defaultValue; + } + } + + return ret; +}; + +const offsetElementAfterFontResize = ( + prevElement: ExcalidrawTextElement, + nextElement: ExcalidrawTextElement, +) => { + if (isBoundToContainer(nextElement) || !nextElement.autoResize) { + return nextElement; + } + return mutateElement( + nextElement, + { + x: + prevElement.textAlign === "left" + ? prevElement.x + : prevElement.x + + (prevElement.width - nextElement.width) / + (prevElement.textAlign === "center" ? 2 : 1), + // centering vertically is non-standard, but for Excalidraw I think + // it makes sense + y: prevElement.y + (prevElement.height - nextElement.height) / 2, + }, + false, + ); +}; + +const changeFontSize = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + app: AppClassProperties, + getNewFontSize: (element: ExcalidrawTextElement) => number, + fallbackValue?: ExcalidrawTextElement["fontSize"], +) => { + const newFontSizes = new Set<number>(); + + const updatedElements = changeProperty( + elements, + appState, + (oldElement) => { + if (isTextElement(oldElement)) { + const newFontSize = getNewFontSize(oldElement); + newFontSizes.add(newFontSize); + + let newElement: ExcalidrawTextElement = newElementWith(oldElement, { + fontSize: newFontSize, + }); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), + ); + + newElement = offsetElementAfterFontResize(oldElement, newElement); + + return newElement; + } + return oldElement; + }, + true, + ); + + // Update arrow elements after text elements have been updated + const updatedElementsMap = arrayToMap(updatedElements); + getSelectedElements(elements, appState, { + includeBoundTextElement: true, + }).forEach((element) => { + if (isTextElement(element)) { + updateBoundElements( + element, + updatedElementsMap as NonDeletedSceneElementsMap, + ); + } + }); + + return { + elements: updatedElements, + appState: { + ...appState, + // update state only if we've set all select text elements to + // the same font size + currentItemFontSize: + newFontSizes.size === 1 + ? [...newFontSizes][0] + : fallbackValue ?? appState.currentItemFontSize, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; +}; + +// ----------------------------------------------------------------------------- + +export const actionChangeStrokeColor = register({ + name: "changeStrokeColor", + label: "labels.stroke", + trackEvent: false, + perform: (elements, appState, value) => { + return { + ...(value.currentItemStrokeColor && { + elements: changeProperty( + elements, + appState, + (el) => { + return hasStrokeColor(el.type) + ? newElementWith(el, { + strokeColor: value.currentItemStrokeColor, + }) + : el; + }, + true, + ), + }), + appState: { + ...appState, + ...value, + }, + captureUpdate: !!value.currentItemStrokeColor + ? CaptureUpdateAction.IMMEDIATELY + : CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ elements, appState, updateData, appProps }) => ( + <> + <h3 aria-hidden="true">{t("labels.stroke")}</h3> + <ColorPicker + topPicks={DEFAULT_ELEMENT_STROKE_PICKS} + palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE} + type="elementStroke" + label={t("labels.stroke")} + color={getFormValue( + elements, + appState, + (element) => element.strokeColor, + true, + appState.currentItemStrokeColor, + )} + onChange={(color) => updateData({ currentItemStrokeColor: color })} + elements={elements} + appState={appState} + updateData={updateData} + /> + </> + ), +}); + +export const actionChangeBackgroundColor = register({ + name: "changeBackgroundColor", + label: "labels.changeBackground", + trackEvent: false, + perform: (elements, appState, value) => { + return { + ...(value.currentItemBackgroundColor && { + elements: changeProperty(elements, appState, (el) => + newElementWith(el, { + backgroundColor: value.currentItemBackgroundColor, + }), + ), + }), + appState: { + ...appState, + ...value, + }, + captureUpdate: !!value.currentItemBackgroundColor + ? CaptureUpdateAction.IMMEDIATELY + : CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ elements, appState, updateData, appProps }) => ( + <> + <h3 aria-hidden="true">{t("labels.background")}</h3> + <ColorPicker + topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS} + palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE} + type="elementBackground" + label={t("labels.background")} + color={getFormValue( + elements, + appState, + (element) => element.backgroundColor, + true, + appState.currentItemBackgroundColor, + )} + onChange={(color) => updateData({ currentItemBackgroundColor: color })} + elements={elements} + appState={appState} + updateData={updateData} + /> + </> + ), +}); + +export const actionChangeFillStyle = register({ + name: "changeFillStyle", + label: "labels.fill", + trackEvent: false, + perform: (elements, appState, value, app) => { + trackEvent( + "element", + "changeFillStyle", + `${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`, + ); + return { + elements: changeProperty(elements, appState, (el) => + newElementWith(el, { + fillStyle: value, + }), + ), + appState: { ...appState, currentItemFillStyle: value }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => { + const selectedElements = getSelectedElements(elements, appState); + const allElementsZigZag = + selectedElements.length > 0 && + selectedElements.every((el) => el.fillStyle === "zigzag"); + + return ( + <fieldset> + <legend>{t("labels.fill")}</legend> + <ButtonIconSelect + type="button" + options={[ + { + value: "hachure", + text: `${ + allElementsZigZag ? t("labels.zigzag") : t("labels.hachure") + } (${getShortcutKey("Alt-Click")})`, + icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon, + active: allElementsZigZag ? true : undefined, + testId: `fill-hachure`, + }, + { + value: "cross-hatch", + text: t("labels.crossHatch"), + icon: FillCrossHatchIcon, + testId: `fill-cross-hatch`, + }, + { + value: "solid", + text: t("labels.solid"), + icon: FillSolidIcon, + testId: `fill-solid`, + }, + ]} + value={getFormValue( + elements, + appState, + (element) => element.fillStyle, + (element) => element.hasOwnProperty("fillStyle"), + (hasSelection) => + hasSelection ? null : appState.currentItemFillStyle, + )} + onClick={(value, event) => { + const nextValue = + event.altKey && + value === "hachure" && + selectedElements.every((el) => el.fillStyle === "hachure") + ? "zigzag" + : value; + + updateData(nextValue); + }} + /> + </fieldset> + ); + }, +}); + +export const actionChangeStrokeWidth = register({ + name: "changeStrokeWidth", + label: "labels.strokeWidth", + trackEvent: false, + perform: (elements, appState, value) => { + return { + elements: changeProperty(elements, appState, (el) => + newElementWith(el, { + strokeWidth: value, + }), + ), + appState: { ...appState, currentItemStrokeWidth: value }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => ( + <fieldset> + <legend>{t("labels.strokeWidth")}</legend> + <ButtonIconSelect + group="stroke-width" + options={[ + { + value: STROKE_WIDTH.thin, + text: t("labels.thin"), + icon: StrokeWidthBaseIcon, + testId: "strokeWidth-thin", + }, + { + value: STROKE_WIDTH.bold, + text: t("labels.bold"), + icon: StrokeWidthBoldIcon, + testId: "strokeWidth-bold", + }, + { + value: STROKE_WIDTH.extraBold, + text: t("labels.extraBold"), + icon: StrokeWidthExtraBoldIcon, + testId: "strokeWidth-extraBold", + }, + ]} + value={getFormValue( + elements, + appState, + (element) => element.strokeWidth, + (element) => element.hasOwnProperty("strokeWidth"), + (hasSelection) => + hasSelection ? null : appState.currentItemStrokeWidth, + )} + onChange={(value) => updateData(value)} + /> + </fieldset> + ), +}); + +export const actionChangeSloppiness = register({ + name: "changeSloppiness", + label: "labels.sloppiness", + trackEvent: false, + perform: (elements, appState, value) => { + return { + elements: changeProperty(elements, appState, (el) => + newElementWith(el, { + seed: randomInteger(), + roughness: value, + }), + ), + appState: { ...appState, currentItemRoughness: value }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => ( + <fieldset> + <legend>{t("labels.sloppiness")}</legend> + <ButtonIconSelect + group="sloppiness" + options={[ + { + value: 0, + text: t("labels.architect"), + icon: SloppinessArchitectIcon, + }, + { + value: 1, + text: t("labels.artist"), + icon: SloppinessArtistIcon, + }, + { + value: 2, + text: t("labels.cartoonist"), + icon: SloppinessCartoonistIcon, + }, + ]} + value={getFormValue( + elements, + appState, + (element) => element.roughness, + (element) => element.hasOwnProperty("roughness"), + (hasSelection) => + hasSelection ? null : appState.currentItemRoughness, + )} + onChange={(value) => updateData(value)} + /> + </fieldset> + ), +}); + +export const actionChangeStrokeStyle = register({ + name: "changeStrokeStyle", + label: "labels.strokeStyle", + trackEvent: false, + perform: (elements, appState, value) => { + return { + elements: changeProperty(elements, appState, (el) => + newElementWith(el, { + strokeStyle: value, + }), + ), + appState: { ...appState, currentItemStrokeStyle: value }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => ( + <fieldset> + <legend>{t("labels.strokeStyle")}</legend> + <ButtonIconSelect + group="strokeStyle" + options={[ + { + value: "solid", + text: t("labels.strokeStyle_solid"), + icon: StrokeWidthBaseIcon, + }, + { + value: "dashed", + text: t("labels.strokeStyle_dashed"), + icon: StrokeStyleDashedIcon, + }, + { + value: "dotted", + text: t("labels.strokeStyle_dotted"), + icon: StrokeStyleDottedIcon, + }, + ]} + value={getFormValue( + elements, + appState, + (element) => element.strokeStyle, + (element) => element.hasOwnProperty("strokeStyle"), + (hasSelection) => + hasSelection ? null : appState.currentItemStrokeStyle, + )} + onChange={(value) => updateData(value)} + /> + </fieldset> + ), +}); + +export const actionChangeOpacity = register({ + name: "changeOpacity", + label: "labels.opacity", + trackEvent: false, + perform: (elements, appState, value) => { + return { + elements: changeProperty( + elements, + appState, + (el) => + newElementWith(el, { + opacity: value, + }), + true, + ), + appState: { ...appState, currentItemOpacity: value }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => ( + <Range + updateData={updateData} + elements={elements} + appState={appState} + testId="opacity" + /> + ), +}); + +export const actionChangeFontSize = register({ + name: "changeFontSize", + label: "labels.fontSize", + trackEvent: false, + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, () => value, value); + }, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <fieldset> + <legend>{t("labels.fontSize")}</legend> + <ButtonIconSelect + group="font-size" + options={[ + { + value: 16, + text: t("labels.small"), + icon: FontSizeSmallIcon, + testId: "fontSize-small", + }, + { + value: 20, + text: t("labels.medium"), + icon: FontSizeMediumIcon, + testId: "fontSize-medium", + }, + { + value: 28, + text: t("labels.large"), + icon: FontSizeLargeIcon, + testId: "fontSize-large", + }, + { + value: 36, + text: t("labels.veryLarge"), + icon: FontSizeExtraLargeIcon, + testId: "fontSize-veryLarge", + }, + ]} + value={getFormValue( + elements, + appState, + (element) => { + if (isTextElement(element)) { + return element.fontSize; + } + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); + if (boundTextElement) { + return boundTextElement.fontSize; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, + (hasSelection) => + hasSelection + ? null + : appState.currentItemFontSize || DEFAULT_FONT_SIZE, + )} + onChange={(value) => updateData(value)} + /> + </fieldset> + ), +}); + +export const actionDecreaseFontSize = register({ + name: "decreaseFontSize", + label: "labels.decreaseFontSize", + icon: fontSizeIcon, + trackEvent: false, + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, (element) => + Math.round( + // get previous value before relative increase (doesn't work fully + // due to rounding and float precision issues) + (1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize, + ), + ); + }, + keyTest: (event) => { + return ( + event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + // KEYS.COMMA needed for MacOS + (event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA) + ); + }, +}); + +export const actionIncreaseFontSize = register({ + name: "increaseFontSize", + label: "labels.increaseFontSize", + icon: fontSizeIcon, + trackEvent: false, + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, (element) => + Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)), + ); + }, + keyTest: (event) => { + return ( + event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + // KEYS.PERIOD needed for MacOS + (event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD) + ); + }, +}); + +type ChangeFontFamilyData = Partial< + Pick< + AppState, + "openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily" + > +> & { + /** cache of selected & editing elements populated on opened popup */ + cachedElements?: Map<string, ExcalidrawElement>; + /** flag to reset all elements to their cached versions */ + resetAll?: true; + /** flag to reset all containers to their cached versions */ + resetContainers?: true; +}; + +export const actionChangeFontFamily = register({ + name: "changeFontFamily", + label: "labels.fontFamily", + trackEvent: false, + perform: (elements, appState, value, app) => { + const { cachedElements, resetAll, resetContainers, ...nextAppState } = + value as ChangeFontFamilyData; + + if (resetAll) { + const nextElements = changeProperty( + elements, + appState, + (element) => { + const cachedElement = cachedElements?.get(element.id); + if (cachedElement) { + const newElement = newElementWith(element, { + ...cachedElement, + }); + + return newElement; + } + + return element; + }, + true, + ); + + return { + elements: nextElements, + appState: { + ...appState, + ...nextAppState, + }, + captureUpdate: CaptureUpdateAction.NEVER, + }; + } + + const { currentItemFontFamily, currentHoveredFontFamily } = value; + + let nextCaptureUpdateAction: CaptureUpdateActionType = + CaptureUpdateAction.EVENTUALLY; + let nextFontFamily: FontFamilyValues | undefined; + let skipOnHoverRender = false; + + if (currentItemFontFamily) { + nextFontFamily = currentItemFontFamily; + nextCaptureUpdateAction = CaptureUpdateAction.IMMEDIATELY; + } else if (currentHoveredFontFamily) { + nextFontFamily = currentHoveredFontFamily; + nextCaptureUpdateAction = CaptureUpdateAction.EVENTUALLY; + + const selectedTextElements = getSelectedElements(elements, appState, { + includeBoundTextElement: true, + }).filter((element) => isTextElement(element)); + + // skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined + if (selectedTextElements.length > 200) { + skipOnHoverRender = true; + } else { + let i = 0; + let textLengthAccumulator = 0; + + while ( + i < selectedTextElements.length && + textLengthAccumulator < 5000 + ) { + const textElement = selectedTextElements[i] as ExcalidrawTextElement; + textLengthAccumulator += textElement?.originalText.length || 0; + i++; + } + + if (textLengthAccumulator > 5000) { + skipOnHoverRender = true; + } + } + } + + const result = { + appState: { + ...appState, + ...nextAppState, + }, + captureUpdate: nextCaptureUpdateAction, + }; + + if (nextFontFamily && !skipOnHoverRender) { + const elementContainerMapping = new Map< + ExcalidrawTextElement, + ExcalidrawElement | null + >(); + let uniqueChars = new Set<string>(); + let skipFontFaceCheck = false; + + const fontsCache = Array.from(Fonts.loadedFontsCache.values()); + const fontFamily = Object.entries(FONT_FAMILY).find( + ([_, value]) => value === nextFontFamily, + )?.[0]; + + // skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine) + if ( + currentHoveredFontFamily && + fontFamily && + fontsCache.some((sig) => sig.startsWith(fontFamily)) + ) { + skipFontFaceCheck = true; + } + + // following causes re-render so make sure we changed the family + // otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg + Object.assign(result, { + elements: changeProperty( + elements, + appState, + (oldElement) => { + if ( + isTextElement(oldElement) && + (oldElement.fontFamily !== nextFontFamily || + currentItemFontFamily) // force update on selection + ) { + const newElement: ExcalidrawTextElement = newElementWith( + oldElement, + { + fontFamily: nextFontFamily, + lineHeight: getLineHeight(nextFontFamily!), + }, + ); + + const cachedContainer = + cachedElements?.get(oldElement.containerId || "") || {}; + + const container = app.scene.getContainerElement(oldElement); + + if (resetContainers && container && cachedContainer) { + // reset the container back to it's cached version + mutateElement(container, { ...cachedContainer }, false); + } + + if (!skipFontFaceCheck) { + uniqueChars = new Set([ + ...uniqueChars, + ...Array.from(newElement.originalText), + ]); + } + + elementContainerMapping.set(newElement, container); + + return newElement; + } + + return oldElement; + }, + true, + ), + }); + + // size is irrelevant, but necessary + const fontString = `10px ${getFontFamilyString({ + fontFamily: nextFontFamily, + })}`; + const chars = Array.from(uniqueChars.values()).join(); + + if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) { + // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded + for (const [element, container] of elementContainerMapping) { + // trigger synchronous redraw + redrawTextBoundingBox( + element, + container, + app.scene.getNonDeletedElementsMap(), + false, + ); + } + } else { + // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded + window.document.fonts.load(fontString, chars).then((fontFaces) => { + for (const [element, container] of elementContainerMapping) { + // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts) + const latestElement = app.scene.getElement(element.id); + const latestContainer = container + ? app.scene.getElement(container.id) + : null; + + if (latestElement) { + // trigger async redraw + redrawTextBoundingBox( + latestElement as ExcalidrawTextElement, + latestContainer, + app.scene.getNonDeletedElementsMap(), + false, + ); + } + } + + // trigger update once we've mutated all the elements, which also updates our cache + app.fonts.onLoaded(fontFaces); + }); + } + } + + return result; + }, + PanelComponent: ({ elements, appState, app, updateData }) => { + const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map()); + const prevSelectedFontFamilyRef = useRef<number | null>(null); + // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them + const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({}); + const isUnmounted = useRef(true); + + const selectedFontFamily = useMemo(() => { + const getFontFamily = ( + elementsArray: readonly ExcalidrawElement[], + elementsMap: Map<string, ExcalidrawElement>, + ) => + getFormValue( + elementsArray, + appState, + (element) => { + if (isTextElement(element)) { + return element.fontFamily; + } + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + return boundTextElement.fontFamily; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement(element, elementsMap) !== null, + (hasSelection) => + hasSelection + ? null + : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, + ); + + // popup opened, use cached elements + if ( + batchedData.openPopup === "fontFamily" && + appState.openPopup === "fontFamily" + ) { + return getFontFamily( + Array.from(cachedElementsRef.current?.values() ?? []), + cachedElementsRef.current, + ); + } + + // popup closed, use all elements + if (!batchedData.openPopup && appState.openPopup !== "fontFamily") { + return getFontFamily(elements, app.scene.getNonDeletedElementsMap()); + } + + // 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; + }, [batchedData.openPopup, appState, elements, app.scene]); + + useEffect(() => { + prevSelectedFontFamilyRef.current = selectedFontFamily; + }, [selectedFontFamily]); + + useEffect(() => { + if (Object.keys(batchedData).length) { + updateData(batchedData); + // reset the data after we've used the data + setBatchedData({}); + } + // call update only on internal state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [batchedData]); + + useEffect(() => { + isUnmounted.current = false; + + return () => { + isUnmounted.current = true; + }; + }, []); + + return ( + <fieldset> + <legend>{t("labels.fontFamily")}</legend> + <FontPicker + isOpened={appState.openPopup === "fontFamily"} + selectedFontFamily={selectedFontFamily} + hoveredFontFamily={appState.currentHoveredFontFamily} + onSelect={(fontFamily) => { + setBatchedData({ + openPopup: null, + currentHoveredFontFamily: null, + currentItemFontFamily: fontFamily, + }); + + // defensive clear so immediate close won't abuse the cached elements + cachedElementsRef.current.clear(); + }} + onHover={(fontFamily) => { + setBatchedData({ + currentHoveredFontFamily: fontFamily, + cachedElements: new Map(cachedElementsRef.current), + resetContainers: true, + }); + }} + onLeave={() => { + setBatchedData({ + currentHoveredFontFamily: null, + cachedElements: new Map(cachedElementsRef.current), + resetAll: true, + }); + }} + onPopupChange={(open) => { + if (open) { + // open, populate the cache from scratch + cachedElementsRef.current.clear(); + + const { editingTextElement } = appState; + + // still check type to be safe + if (editingTextElement?.type === "text") { + // retrieve the latest version from the scene, as `editingTextElement` isn't mutated + const latesteditingTextElement = app.scene.getElement( + editingTextElement.id, + ); + + // inside the wysiwyg editor + cachedElementsRef.current.set( + editingTextElement.id, + newElementWith( + latesteditingTextElement || editingTextElement, + {}, + true, + ), + ); + } else { + const selectedElements = getSelectedElements( + elements, + appState, + { + includeBoundTextElement: true, + }, + ); + + for (const element of selectedElements) { + cachedElementsRef.current.set( + element.id, + newElementWith(element, {}, true), + ); + } + } + + setBatchedData({ + openPopup: "fontFamily", + }); + } else { + // close, use the cache and clear it afterwards + const data = { + openPopup: null, + currentHoveredFontFamily: null, + cachedElements: new Map(cachedElementsRef.current), + resetAll: true, + } as ChangeFontFamilyData; + + if (isUnmounted.current) { + // in case the component was unmounted by the parent, trigger the update directly + updateData({ ...batchedData, ...data }); + } else { + setBatchedData(data); + } + + cachedElementsRef.current.clear(); + } + }} + /> + </fieldset> + ); + }, +}); + +export const actionChangeTextAlign = register({ + name: "changeTextAlign", + label: "Change text alignment", + trackEvent: false, + perform: (elements, appState, value, app) => { + return { + elements: changeProperty( + elements, + appState, + (oldElement) => { + if (isTextElement(oldElement)) { + const newElement: ExcalidrawTextElement = newElementWith( + oldElement, + { textAlign: value }, + ); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), + ); + return newElement; + } + + return oldElement; + }, + true, + ), + appState: { + ...appState, + currentItemTextAlign: value, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData, app }) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); + return ( + <fieldset> + <legend>{t("labels.textAlign")}</legend> + <ButtonIconSelect<TextAlign | false> + group="text-align" + options={[ + { + value: "left", + text: t("labels.left"), + icon: TextAlignLeftIcon, + testId: "align-left", + }, + { + value: "center", + text: t("labels.center"), + icon: TextAlignCenterIcon, + testId: "align-horizontal-center", + }, + { + value: "right", + text: t("labels.right"), + icon: TextAlignRightIcon, + testId: "align-right", + }, + ]} + value={getFormValue( + elements, + appState, + (element) => { + if (isTextElement(element)) { + return element.textAlign; + } + const boundTextElement = getBoundTextElement( + element, + elementsMap, + ); + if (boundTextElement) { + return boundTextElement.textAlign; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement(element, elementsMap) !== null, + (hasSelection) => + hasSelection ? null : appState.currentItemTextAlign, + )} + onChange={(value) => updateData(value)} + /> + </fieldset> + ); + }, +}); + +export const actionChangeVerticalAlign = register({ + name: "changeVerticalAlign", + label: "Change vertical alignment", + trackEvent: { category: "element" }, + perform: (elements, appState, value, app) => { + return { + elements: changeProperty( + elements, + appState, + (oldElement) => { + if (isTextElement(oldElement)) { + const newElement: ExcalidrawTextElement = newElementWith( + oldElement, + { verticalAlign: value }, + ); + + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), + ); + return newElement; + } + + return oldElement; + }, + true, + ), + appState: { + ...appState, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData, app }) => { + return ( + <fieldset> + <ButtonIconSelect<VerticalAlign | false> + group="text-align" + options={[ + { + value: VERTICAL_ALIGN.TOP, + text: t("labels.alignTop"), + icon: <TextAlignTopIcon theme={appState.theme} />, + testId: "align-top", + }, + { + value: VERTICAL_ALIGN.MIDDLE, + text: t("labels.centerVertically"), + icon: <TextAlignMiddleIcon theme={appState.theme} />, + testId: "align-middle", + }, + { + value: VERTICAL_ALIGN.BOTTOM, + text: t("labels.alignBottom"), + icon: <TextAlignBottomIcon theme={appState.theme} />, + testId: "align-bottom", + }, + ]} + value={getFormValue( + elements, + appState, + (element) => { + if (isTextElement(element) && element.containerId) { + return element.verticalAlign; + } + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); + if (boundTextElement) { + return boundTextElement.verticalAlign; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, + (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), + )} + onChange={(value) => updateData(value)} + /> + </fieldset> + ); + }, +}); + +export const actionChangeRoundness = register({ + name: "changeRoundness", + label: "Change edge roundness", + trackEvent: false, + perform: (elements, appState, value) => { + return { + elements: changeProperty(elements, appState, (el) => { + if (isElbowArrow(el)) { + return el; + } + + return newElementWith(el, { + roundness: + value === "round" + ? { + type: isUsingAdaptiveRadius(el.type) + ? ROUNDNESS.ADAPTIVE_RADIUS + : ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + }); + }), + appState: { + ...appState, + currentItemRoundness: value, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => { + const targetElements = getTargetElements( + getNonDeletedElements(elements), + appState, + ); + + const hasLegacyRoundness = targetElements.some( + (el) => el.roundness?.type === ROUNDNESS.LEGACY, + ); + + return ( + <fieldset> + <legend>{t("labels.edges")}</legend> + <ButtonIconSelect + group="edges" + options={[ + { + value: "sharp", + text: t("labels.sharp"), + icon: EdgeSharpIcon, + }, + { + value: "round", + text: t("labels.round"), + icon: EdgeRoundIcon, + }, + ]} + value={getFormValue( + elements, + appState, + (element) => + hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", + (element) => + !isArrowElement(element) && element.hasOwnProperty("roundness"), + (hasSelection) => + hasSelection ? null : appState.currentItemRoundness, + )} + onChange={(value) => updateData(value)} + /> + </fieldset> + ); + }, +}); + +const getArrowheadOptions = (flip: boolean) => { + return [ + { + value: null, + text: t("labels.arrowhead_none"), + keyBinding: "q", + icon: ArrowheadNoneIcon, + }, + { + value: "arrow", + text: t("labels.arrowhead_arrow"), + keyBinding: "w", + icon: <ArrowheadArrowIcon flip={flip} />, + }, + { + value: "triangle", + text: t("labels.arrowhead_triangle"), + icon: <ArrowheadTriangleIcon flip={flip} />, + keyBinding: "e", + }, + { + value: "triangle_outline", + text: t("labels.arrowhead_triangle_outline"), + icon: <ArrowheadTriangleOutlineIcon flip={flip} />, + keyBinding: "r", + }, + { + value: "circle", + text: t("labels.arrowhead_circle"), + keyBinding: "a", + icon: <ArrowheadCircleIcon flip={flip} />, + }, + { + value: "circle_outline", + text: t("labels.arrowhead_circle_outline"), + keyBinding: "s", + icon: <ArrowheadCircleOutlineIcon flip={flip} />, + }, + { + value: "diamond", + text: t("labels.arrowhead_diamond"), + icon: <ArrowheadDiamondIcon flip={flip} />, + keyBinding: "d", + }, + { + value: "diamond_outline", + text: t("labels.arrowhead_diamond_outline"), + icon: <ArrowheadDiamondOutlineIcon flip={flip} />, + keyBinding: "f", + }, + { + value: "bar", + text: t("labels.arrowhead_bar"), + keyBinding: "z", + icon: <ArrowheadBarIcon flip={flip} />, + }, + { + value: "crowfoot_one", + text: t("labels.arrowhead_crowfoot_one"), + icon: <ArrowheadCrowfootOneIcon flip={flip} />, + keyBinding: "c", + }, + { + value: "crowfoot_many", + text: t("labels.arrowhead_crowfoot_many"), + icon: <ArrowheadCrowfootIcon flip={flip} />, + keyBinding: "x", + }, + { + value: "crowfoot_one_or_many", + text: t("labels.arrowhead_crowfoot_one_or_many"), + icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />, + keyBinding: "v", + }, + ] as const; +}; + +export const actionChangeArrowhead = register({ + name: "changeArrowhead", + label: "Change arrowheads", + trackEvent: false, + perform: ( + elements, + appState, + value: { position: "start" | "end"; type: Arrowhead }, + ) => { + return { + elements: changeProperty(elements, appState, (el) => { + if (isLinearElement(el)) { + const { position, type } = value; + + if (position === "start") { + const element: ExcalidrawLinearElement = newElementWith(el, { + startArrowhead: type, + }); + return element; + } else if (position === "end") { + const element: ExcalidrawLinearElement = newElementWith(el, { + endArrowhead: type, + }); + return element; + } + } + + return el; + }), + appState: { + ...appState, + [value.position === "start" + ? "currentItemStartArrowhead" + : "currentItemEndArrowhead"]: value.type, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => { + const isRTL = getLanguage().rtl; + + return ( + <fieldset> + <legend>{t("labels.arrowheads")}</legend> + <div className="iconSelectList buttonList"> + <IconPicker + label="arrowhead_start" + options={getArrowheadOptions(!isRTL)} + value={getFormValue<Arrowhead | null>( + elements, + appState, + (element) => + isLinearElement(element) && canHaveArrowheads(element.type) + ? element.startArrowhead + : appState.currentItemStartArrowhead, + true, + appState.currentItemStartArrowhead, + )} + onChange={(value) => updateData({ position: "start", type: value })} + numberOfOptionsToAlwaysShow={4} + /> + <IconPicker + label="arrowhead_end" + group="arrowheads" + options={getArrowheadOptions(!!isRTL)} + value={getFormValue<Arrowhead | null>( + elements, + appState, + (element) => + isLinearElement(element) && canHaveArrowheads(element.type) + ? element.endArrowhead + : appState.currentItemEndArrowhead, + true, + appState.currentItemEndArrowhead, + )} + onChange={(value) => updateData({ position: "end", type: value })} + numberOfOptionsToAlwaysShow={4} + /> + </div> + </fieldset> + ); + }, +}); + +export const actionChangeArrowType = register({ + name: "changeArrowType", + label: "Change arrow types", + trackEvent: false, + perform: (elements, appState, value, app) => { + const newElements = changeProperty(elements, appState, (el) => { + if (!isArrowElement(el)) { + return el; + } + const newElement = newElementWith(el, { + roundness: + value === ARROW_TYPE.round + ? { + type: ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + elbowed: value === ARROW_TYPE.elbow, + points: + value === ARROW_TYPE.elbow || el.elbowed + ? [el.points[0], el.points[el.points.length - 1]] + : el.points, + }); + + if (isElbowArrow(newElement)) { + const elementsMap = app.scene.getNonDeletedElementsMap(); + + app.dismissLinearEditor(); + + const startGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + 0, + elementsMap, + ); + const endGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + -1, + elementsMap, + ); + const startHoveredElement = + !newElement.startBinding && + getHoveredElementForBinding( + tupleToCoors(startGlobalPoint), + elements, + elementsMap, + appState.zoom, + false, + true, + ); + const endHoveredElement = + !newElement.endBinding && + getHoveredElementForBinding( + tupleToCoors(endGlobalPoint), + elements, + elementsMap, + appState.zoom, + false, + true, + ); + const startElement = startHoveredElement + ? startHoveredElement + : newElement.startBinding && + (elementsMap.get( + newElement.startBinding.elementId, + ) as ExcalidrawBindableElement); + const endElement = endHoveredElement + ? endHoveredElement + : newElement.endBinding && + (elementsMap.get( + newElement.endBinding.elementId, + ) as ExcalidrawBindableElement); + + const finalStartPoint = startHoveredElement + ? bindPointToSnapToElementOutline( + newElement, + startHoveredElement, + "start", + ) + : startGlobalPoint; + const finalEndPoint = endHoveredElement + ? bindPointToSnapToElementOutline( + newElement, + endHoveredElement, + "end", + ) + : endGlobalPoint; + + startHoveredElement && + bindLinearElement( + newElement, + startHoveredElement, + "start", + elementsMap, + ); + endHoveredElement && + bindLinearElement(newElement, endHoveredElement, "end", elementsMap); + + mutateElement(newElement, { + points: [finalStartPoint, finalEndPoint].map( + (p): LocalPoint => + pointFrom(p[0] - newElement.x, p[1] - newElement.y), + ), + ...(startElement && newElement.startBinding + ? { + startBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.startBinding!, + ...calculateFixedPointForElbowArrowBinding( + newElement, + startElement, + "start", + elementsMap, + ), + }, + } + : {}), + ...(endElement && newElement.endBinding + ? { + endBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.endBinding, + ...calculateFixedPointForElbowArrowBinding( + newElement, + endElement, + "end", + elementsMap, + ), + }, + } + : {}), + }); + + LinearElementEditor.updateEditorMidPointsCache( + newElement, + elementsMap, + app.state, + ); + } + + return newElement; + }); + + const newState = { + ...appState, + currentItemArrowType: value, + }; + + // Change the arrow type and update any other state settings for + // the arrow. + const selectedId = appState.selectedLinearElement?.elementId; + if (selectedId) { + const selected = newElements.find((el) => el.id === selectedId); + if (selected) { + newState.selectedLinearElement = new LinearElementEditor( + selected as ExcalidrawLinearElement, + ); + } + } + + return { + elements: newElements, + appState: newState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => { + return ( + <fieldset> + <legend>{t("labels.arrowtypes")}</legend> + <ButtonIconSelect + group="arrowtypes" + options={[ + { + value: ARROW_TYPE.sharp, + text: t("labels.arrowtype_sharp"), + icon: sharpArrowIcon, + testId: "sharp-arrow", + }, + { + value: ARROW_TYPE.round, + text: t("labels.arrowtype_round"), + icon: roundArrowIcon, + testId: "round-arrow", + }, + { + value: ARROW_TYPE.elbow, + text: t("labels.arrowtype_elbowed"), + icon: elbowArrowIcon, + testId: "elbow-arrow", + }, + ]} + value={getFormValue( + elements, + appState, + (element) => { + if (isArrowElement(element)) { + return element.elbowed + ? ARROW_TYPE.elbow + : element.roundness + ? ARROW_TYPE.round + : ARROW_TYPE.sharp; + } + + return null; + }, + (element) => isArrowElement(element), + (hasSelection) => + hasSelection ? null : appState.currentItemArrowType, + )} + onChange={(value) => updateData(value)} + /> + </fieldset> + ); + }, +}); |
