import { pointFrom } from "@excalidraw/math"; import { useEffect, useMemo, useRef, useState } from "react"; import { BUCKET_FILL_BACKGROUND_PICKS, COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, ARROW_TYPE, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, ROUNDNESS, STROKE_WIDTH_KEYS, VERTICAL_ALIGN, KEYS, randomInteger, arrayToMap, getFontFamilyString, getLineHeight, isTransparent, getStrokeWidthByKey, reduceToCommonValue, invariant, FONT_SIZES, type StrokeWidthKey, } from "@excalidraw/common"; import { canBecomePolygon, normalizeStickyNote, getNonDeletedElements, isNonDeletedElement, syncStickyNoteInk, } from "@excalidraw/element"; import { bindBindingElement, calculateFixedPointForElbowArrowBinding, updateBoundElements, } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element"; import { getArrowheadForPicker } from "@excalidraw/element"; import { getBoundTextElement, getBaseFontSize, getBaseFontSizeUpdate, redrawTextBoundingBox, } from "@excalidraw/element"; import { isArrowElement, isBoundToContainer, isElbowArrow, isLinearElement, isLineElement, isStickyNoteElement, isTextElement, isUsingAdaptiveRadius, } from "@excalidraw/element"; import { getColorTargetElement, getColorUpdate, hasFillStyle, hasStrokeColor, } from "@excalidraw/element"; import { updateElbowArrowPoints, CaptureUpdateAction, toggleLinePolygonState, } from "@excalidraw/element"; import { deriveStylesPanelMode } from "@excalidraw/common"; import type { LocalPoint, Radians } from "@excalidraw/math"; import type { Arrowhead, ElementsMap, ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, ExcalidrawTextElement, FontFamilyValues, StrokeVariability, NonDeleted, NonDeletedExcalidrawElement, TextAlign, VerticalAlign, } from "@excalidraw/element/types"; import type { ElementUpdate, Scene } from "@excalidraw/element"; import type { CaptureUpdateActionType } from "@excalidraw/element"; import { trackEvent } from "../analytics"; import { RadioSelection } from "../components/RadioSelection"; import { IconButton } from "../components/IconButton"; import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { FontPicker } from "../components/FontPicker/FontPicker"; import { IconPicker } from "../components/IconPicker"; import { Range } from "../components/Range"; 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, ArrowheadCardinalityExactlyOneIcon, ArrowheadCardinalityManyIcon, ArrowheadCardinalityOneIcon, ArrowheadCardinalityOneOrManyIcon, ArrowheadCardinalityZeroOrManyIcon, ArrowheadCardinalityZeroOrOneIcon, strokeVariabilityConstantIcon, strokeVariabilityVariableIcon, } from "../components/icons"; import { Fonts } from "../fonts"; import { getLanguage, t } from "../i18n"; import { canHaveArrowheads, getSelectedElements, getTargetElements, isSomeElementSelected, } from "../scene"; import { withCaretPositionPreservation, restoreCaretPosition, } from "../hooks/useTextEditorFocus"; import { getShortcutKey } from "../shortcut"; import { getColorTargetAppStateUpdates, resolveColorTarget, } from "./colorTargets"; import { register } from "./register"; import type { AppClassProperties, AppState, Primitive } from "../types"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const getStylesPanelInfo = (app: AppClassProperties) => { const stylesPanelMode = deriveStylesPanelMode(app.editorInterface); return { stylesPanelMode, isCompact: stylesPanelMode !== "full", isMobile: stylesPanelMode === "mobile", } as const; }; export const changeProperty = ( elements: readonly ExcalidrawElement[], appState: AppState, callback: (element: NonDeletedExcalidrawElement) => 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 ) { // selected & editing elements are non-deleted if (!isNonDeletedElement(element)) { // SAFETY: This should never happen, but log it just in case console.error( "[NONDELETED][INVARIANT] changeProperty(): skipping deleted selected/editing element", ); } const nextElement = callback(element as NonDeletedExcalidrawElement); // sticky notes keep their data invariants (never-transparent colors, // solid fill, minimum size) whatever property was written; a no-op // normalization returns the same object return isStickyNoteElement(nextElement) ? normalizeStickyNote(nextElement) : nextElement; } return element; }); }; export const getFormValue = function ( elements: readonly ExcalidrawElement[], app: AppClassProperties, /** * input value (usually the element attribute value, * but depends on what the action's PanelComponent input expects) */ getValue: (element: ExcalidrawElement) => T, elementPredicate: true | ((element: ExcalidrawElement) => boolean), defaultValue: T | ((isSomeElementSelected: boolean) => T), ): T { const editingTextElement = app.state.editingTextElement; const nonDeletedElements = getNonDeletedElements(elements); let ret: T | null = null; if (editingTextElement) { ret = getValue(editingTextElement); } if (!ret) { const hasSelection = isSomeElementSelected(nonDeletedElements, app.state); if (hasSelection) { const selectedElements = app.scene.getSelectedElements(app.state); const targetElements = elementPredicate === true ? selectedElements : selectedElements.filter((el) => elementPredicate(el)); ret = reduceToCommonValue(targetElements, getValue) ?? (typeof defaultValue === "function" ? defaultValue(true) : defaultValue); } else { ret = typeof defaultValue === "function" ? defaultValue(false) : defaultValue; } } return ret; }; const offsetElementAfterFontResize = ( prevElement: ExcalidrawTextElement, nextElement: ExcalidrawTextElement, scene: Scene, ) => { if (isBoundToContainer(nextElement) || !nextElement.autoResize) { return nextElement; } return scene.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, }); }; const changeFontSize = ( elements: readonly ExcalidrawElement[], appState: AppState, app: AppClassProperties, getNewFontSize: (element: ExcalidrawTextElement) => number, fallbackValue?: ExcalidrawTextElement["fontSize"], ) => { const newFontSizes = new Set(); const elementsMap = app.scene.getNonDeletedElementsMap(); const updatedElements = changeProperty( elements, appState, (oldElement) => { if (isTextElement(oldElement)) { const newFontSize = getNewFontSize(oldElement); newFontSizes.add(newFontSize); const container = app.scene.getContainerElement(oldElement); let newElement: ExcalidrawTextElement = newElementWith( oldElement, getBaseFontSizeUpdate(oldElement, newFontSize, elementsMap), ); redrawTextBoundingBox(newElement, container, app.scene); newElement = offsetElementAfterFontResize( oldElement, newElement, app.scene, ); return newElement; } return oldElement; }, true, ); // Update arrow elements after text elements have been updated getSelectedElements(elements, appState, { includeBoundTextElement: true, }).forEach((element) => { if (isTextElement(element)) { updateBoundElements(element, app.scene); } }); 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< Partial & { color?: string } >({ name: "changeStrokeColor", label: "labels.stroke", trackEvent: false, perform: (elements, appState, value) => { const { color, ...appStateUpdates } = value ?? {}; if (color === undefined) { return { appState: { ...appState, ...appStateUpdates }, captureUpdate: CaptureUpdateAction.EVENTUALLY, }; } // resolved from the state the action runs against — never from the // render-time closure of the memoized picker (see `resolveColorTarget`) const target = resolveColorTarget(appState, elements, "strokeColor"); const elementsMap = arrayToMap(elements); return { // a note and its label share one ink: coloring the label while editing // it (no selection) colors the note too — the footer paints with it elements: syncStickyNoteInk( changeProperty( elements, appState, (el) => hasStrokeColor(el.type) ? newElementWith( el, getColorUpdate(el, "strokeColor", color, elementsMap), ) : el, true, ), elementsMap, ), appState: { ...appState, ...appStateUpdates, ...getColorTargetAppStateUpdates(target, color), }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { const { stylesPanelMode } = getStylesPanelInfo(app); const target = resolveColorTarget(appState, elements, "strokeColor"); // a note has no stroke: its "stroke" is the ink of its text and footer const label = target.kind === "sticky" ? t("labels.textColor") : t("labels.stroke"); return ( <> {stylesPanelMode === "full" && } element.strokeColor, true, (hasSelection) => (!hasSelection ? target.currentValue : null), )} onChange={(color) => updateData({ color })} elements={elements} appState={appState} updateData={updateData} /> ); }, }); export const actionChangeBackgroundColor = register< Partial & { color?: string } >({ name: "changeBackgroundColor", label: "labels.changeBackground", trackEvent: false, perform: (elements, appState, value, app) => { const { color, ...appStateUpdates } = value ?? {}; if (color === undefined) { return { appState: { ...appState, ...appStateUpdates }, captureUpdate: CaptureUpdateAction.EVENTUALLY, }; } const target = resolveColorTarget(appState, elements, "backgroundColor"); const elementsMap = arrayToMap(elements); let nextElements; const selectedElements = app.scene.getSelectedElements(appState); const shouldEnablePolygon = !isTransparent(color) && // `every` is vacuously true with nothing selected (editing text) selectedElements.length > 0 && selectedElements.every( (el) => isLineElement(el) && canBecomePolygon(el.points), ); if (shouldEnablePolygon) { const selectedElementsMap = arrayToMap(selectedElements); nextElements = elements.map((el) => { if (selectedElementsMap.has(el.id) && isLineElement(el)) { return newElementWith(el, { backgroundColor: color, ...toggleLinePolygonState(el, true), }); } return el; }); } else { nextElements = changeProperty(elements, appState, (el) => // a note's label passes the pick on to the note (below) getColorTargetElement(el, "backgroundColor", elementsMap) === el ? newElementWith( el, getColorUpdate(el, "backgroundColor", color, elementsMap), ) : el, ); // editing a note's label (no selection): the label has no fill, the // pick colors the note — text and background in one editing pass const editingText = appState.editingTextElement && elementsMap.get(appState.editingTextElement.id); const editingTarget = editingText && getColorTargetElement(editingText, "backgroundColor", elementsMap); if (editingTarget && editingTarget !== editingText) { nextElements = nextElements.map((el) => el.id === editingTarget.id ? newElementWith( el, getColorUpdate(el, "backgroundColor", color, elementsMap), ) : el, ); } } return { elements: nextElements, appState: { ...appState, ...appStateUpdates, ...getColorTargetAppStateUpdates(target, color), }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { const { stylesPanelMode } = getStylesPanelInfo(app); const target = resolveColorTarget(appState, elements, "backgroundColor"); // while editing a note's label the picker shows and sets the note's fill const elementsMap = app.scene.getNonDeletedElementsMap(); return ( <> {stylesPanelMode === "full" && ( )} getColorTargetElement(element, "backgroundColor", elementsMap) .backgroundColor, true, (hasSelection) => (!hasSelection ? target.currentValue : null), )} onChange={(color) => updateData({ color })} elements={elements} appState={appState} updateData={updateData} /> ); }, }); export const actionChangeBucketFillBackgroundColor = register< Pick >({ name: "changeBucketFillBackgroundColor", label: "labels.changeBackground", trackEvent: false, // the bucket fill tool has no element to mutate; it shares // `currentItemBackgroundColor` but hides `transparent` (an invisible fill // would be a no-op) and shows the effective fallback color instead perform: (elements, appState, value) => { return { appState: { ...appState, ...value, }, captureUpdate: CaptureUpdateAction.EVENTUALLY, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { const { stylesPanelMode } = getStylesPanelInfo(app); return ( <> {stylesPanelMode === "full" && ( )}