import { AppState } from "../../src/types"; import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ColorPicker } from "../components/ColorPicker"; import { IconPicker } from "../components/IconPicker"; import { ArrowheadArrowIcon, ArrowheadBarIcon, ArrowheadDotIcon, ArrowheadTriangleIcon, ArrowheadNoneIcon, EdgeRoundIcon, EdgeSharpIcon, FillCrossHatchIcon, FillHachureIcon, FillSolidIcon, FontFamilyCodeIcon, FontFamilyHandDrawnIcon, FontFamilyNormalIcon, FontSizeExtraLargeIcon, FontSizeLargeIcon, FontSizeMediumIcon, FontSizeSmallIcon, SloppinessArchitectIcon, SloppinessArtistIcon, SloppinessCartoonistIcon, StrokeStyleDashedIcon, StrokeStyleDottedIcon, StrokeStyleSolidIcon, StrokeWidthIcon, TextAlignCenterIcon, TextAlignLeftIcon, TextAlignRightIcon, TextAlignTopIcon, TextAlignBottomIcon, TextAlignMiddleIcon, } from "../components/icons"; import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, VERTICAL_ALIGN, } from "../constants"; import { getNonDeletedElements, isTextElement, redrawTextBoundingBox, } from "../element"; import { mutateElement, newElementWith } from "../element/mutateElement"; import { getBoundTextElement, getContainerElement, } from "../element/textElement"; import { isBoundToContainer, isLinearElement, isLinearElementType, } from "../element/typeChecks"; import { Arrowhead, ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextElement, FontFamilyValues, TextAlign, VerticalAlign, } from "../element/types"; import { getLanguage, t } from "../i18n"; import { KEYS } from "../keys"; import { randomInteger } from "../random"; import { canChangeSharpness, canHaveArrowheads, getCommonAttributeOfSelectedElements, getSelectedElements, getTargetElements, isSomeElementSelected, } from "../scene"; import { hasStrokeColor } from "../scene/comparisons"; import { arrayToMap } from "../utils"; import { register } from "./register"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const changeProperty = ( elements: readonly ExcalidrawElement[], appState: AppState, callback: (element: ExcalidrawElement) => ExcalidrawElement, includeBoundText = false, ) => { const selectedElementIds = arrayToMap( getSelectedElements(elements, appState, includeBoundText), ); return elements.map((element) => { if ( selectedElementIds.get(element.id) || element.id === appState.editingElement?.id ) { return callback(element); } return element; }); }; const getFormValue = function ( elements: readonly ExcalidrawElement[], appState: AppState, getAttribute: (element: ExcalidrawElement) => T, defaultValue?: T, ): T | null { const editingElement = appState.editingElement; const nonDeletedElements = getNonDeletedElements(elements); return ( (editingElement && getAttribute(editingElement)) ?? (isSomeElementSelected(nonDeletedElements, appState) ? getCommonAttributeOfSelectedElements( nonDeletedElements, appState, getAttribute, ) : defaultValue) ?? null ); }; const offsetElementAfterFontResize = ( prevElement: ExcalidrawTextElement, nextElement: ExcalidrawTextElement, ) => { if (isBoundToContainer(nextElement)) { 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, getNewFontSize: (element: ExcalidrawTextElement) => number, fallbackValue?: ExcalidrawTextElement["fontSize"], ) => { const newFontSizes = new Set(); return { elements: changeProperty( elements, appState, (oldElement) => { if (isTextElement(oldElement)) { const newFontSize = getNewFontSize(oldElement); newFontSizes.add(newFontSize); let newElement: ExcalidrawTextElement = newElementWith(oldElement, { fontSize: newFontSize, }); redrawTextBoundingBox(newElement, getContainerElement(oldElement)); newElement = offsetElementAfterFontResize(oldElement, newElement); return newElement; } return oldElement; }, true, ), 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, }, commitToHistory: true, }; }; // ----------------------------------------------------------------------------- export const actionChangeStrokeColor = register({ name: "changeStrokeColor", 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, }, commitToHistory: !!value.currentItemStrokeColor, }; }, PanelComponent: ({ elements, appState, updateData }) => ( <> element.strokeColor, appState.currentItemStrokeColor, )} onChange={(color) => updateData({ currentItemStrokeColor: color })} isActive={appState.openPopup === "strokeColorPicker"} setActive={(active) => updateData({ openPopup: active ? "strokeColorPicker" : null }) } elements={elements} appState={appState} /> ), }); export const actionChangeBackgroundColor = register({ name: "changeBackgroundColor", perform: (elements, appState, value) => { return { ...(value.currentItemBackgroundColor && { elements: changeProperty(elements, appState, (el) => newElementWith(el, { backgroundColor: value.currentItemBackgroundColor, }), ), }), appState: { ...appState, ...value, }, commitToHistory: !!value.currentItemBackgroundColor, }; }, PanelComponent: ({ elements, appState, updateData }) => ( <> element.backgroundColor, appState.currentItemBackgroundColor, )} onChange={(color) => updateData({ currentItemBackgroundColor: color })} isActive={appState.openPopup === "backgroundColorPicker"} setActive={(active) => updateData({ openPopup: active ? "backgroundColorPicker" : null }) } elements={elements} appState={appState} /> ), }); export const actionChangeFillStyle = register({ name: "changeFillStyle", perform: (elements, appState, value) => { return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { fillStyle: value, }), ), appState: { ...appState, currentItemFillStyle: value }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => (
{t("labels.fill")} , }, { value: "cross-hatch", text: t("labels.crossHatch"), icon: , }, { value: "solid", text: t("labels.solid"), icon: , }, ]} group="fill" value={getFormValue( elements, appState, (element) => element.fillStyle, appState.currentItemFillStyle, )} onChange={(value) => { updateData(value); }} />
), }); export const actionChangeStrokeWidth = register({ name: "changeStrokeWidth", perform: (elements, appState, value) => { return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { strokeWidth: value, }), ), appState: { ...appState, currentItemStrokeWidth: value }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => (
{t("labels.strokeWidth")} , }, { value: 2, text: t("labels.bold"), icon: , }, { value: 4, text: t("labels.extraBold"), icon: , }, ]} value={getFormValue( elements, appState, (element) => element.strokeWidth, appState.currentItemStrokeWidth, )} onChange={(value) => updateData(value)} />
), }); export const actionChangeSloppiness = register({ name: "changeSloppiness", perform: (elements, appState, value) => { return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { seed: randomInteger(), roughness: value, }), ), appState: { ...appState, currentItemRoughness: value }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => (
{t("labels.sloppiness")} , }, { value: 1, text: t("labels.artist"), icon: , }, { value: 2, text: t("labels.cartoonist"), icon: , }, ]} value={getFormValue( elements, appState, (element) => element.roughness, appState.currentItemRoughness, )} onChange={(value) => updateData(value)} />
), }); export const actionChangeStrokeStyle = register({ name: "changeStrokeStyle", perform: (elements, appState, value) => { return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { strokeStyle: value, }), ), appState: { ...appState, currentItemStrokeStyle: value }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => (
{t("labels.strokeStyle")} , }, { value: "dashed", text: t("labels.strokeStyle_dashed"), icon: , }, { value: "dotted", text: t("labels.strokeStyle_dotted"), icon: , }, ]} value={getFormValue( elements, appState, (element) => element.strokeStyle, appState.currentItemStrokeStyle, )} onChange={(value) => updateData(value)} />
), }); export const actionChangeOpacity = register({ name: "changeOpacity", perform: (elements, appState, value) => { return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { opacity: value, }), ), appState: { ...appState, currentItemOpacity: value }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => ( ), }); export const actionChangeFontSize = register({ name: "changeFontSize", perform: (elements, appState, value) => { return changeFontSize(elements, appState, () => value, value); }, PanelComponent: ({ elements, appState, updateData }) => (
{t("labels.fontSize")} , testId: "fontSize-small", }, { value: 20, text: t("labels.medium"), icon: , testId: "fontSize-medium", }, { value: 28, text: t("labels.large"), icon: , testId: "fontSize-large", }, { value: 36, text: t("labels.veryLarge"), icon: , testId: "fontSize-veryLarge", }, ]} value={getFormValue( elements, appState, (element) => { if (isTextElement(element)) { return element.fontSize; } const boundTextElement = getBoundTextElement(element); if (boundTextElement) { return boundTextElement.fontSize; } return null; }, appState.currentItemFontSize || DEFAULT_FONT_SIZE, )} onChange={(value) => updateData(value)} />
), }); export const actionDecreaseFontSize = register({ name: "decreaseFontSize", perform: (elements, appState, value) => { return changeFontSize(elements, appState, (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", perform: (elements, appState, value) => { return changeFontSize(elements, appState, (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) ); }, }); export const actionChangeFontFamily = register({ name: "changeFontFamily", perform: (elements, appState, value) => { return { elements: changeProperty( elements, appState, (oldElement) => { if (isTextElement(oldElement)) { const newElement: ExcalidrawTextElement = newElementWith( oldElement, { fontFamily: value, }, ); redrawTextBoundingBox(newElement, getContainerElement(oldElement)); return newElement; } return oldElement; }, true, ), appState: { ...appState, currentItemFontFamily: value, }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => { const options: { value: FontFamilyValues; text: string; icon: JSX.Element; }[] = [ { value: FONT_FAMILY.Virgil, text: t("labels.handDrawn"), icon: , }, { value: FONT_FAMILY.Helvetica, text: t("labels.normal"), icon: , }, { value: FONT_FAMILY.Cascadia, text: t("labels.code"), icon: , }, ]; return (
{t("labels.fontFamily")} group="font-family" options={options} value={getFormValue( elements, appState, (element) => { if (isTextElement(element)) { return element.fontFamily; } const boundTextElement = getBoundTextElement(element); if (boundTextElement) { return boundTextElement.fontFamily; } return null; }, appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, )} onChange={(value) => updateData(value)} />
); }, }); export const actionChangeTextAlign = register({ name: "changeTextAlign", perform: (elements, appState, value) => { return { elements: changeProperty( elements, appState, (oldElement) => { if (isTextElement(oldElement)) { const newElement: ExcalidrawTextElement = newElementWith( oldElement, { textAlign: value }, ); redrawTextBoundingBox(newElement, getContainerElement(oldElement)); return newElement; } return oldElement; }, true, ), appState: { ...appState, currentItemTextAlign: value, }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => { return (
{t("labels.textAlign")} group="text-align" options={[ { value: "left", text: t("labels.left"), icon: , }, { value: "center", text: t("labels.center"), icon: , }, { value: "right", text: t("labels.right"), icon: , }, ]} value={getFormValue( elements, appState, (element) => { if (isTextElement(element)) { return element.textAlign; } const boundTextElement = getBoundTextElement(element); if (boundTextElement) { return boundTextElement.textAlign; } return null; }, appState.currentItemTextAlign, )} onChange={(value) => updateData(value)} />
); }, }); export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", perform: (elements, appState, value) => { return { elements: changeProperty( elements, appState, (oldElement) => { if (isTextElement(oldElement)) { const newElement: ExcalidrawTextElement = newElementWith( oldElement, { verticalAlign: value }, ); redrawTextBoundingBox(newElement, getContainerElement(oldElement)); return newElement; } return oldElement; }, true, ), appState: { ...appState, }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => { return (
group="text-align" options={[ { value: VERTICAL_ALIGN.TOP, text: t("labels.alignTop"), icon: , }, { value: VERTICAL_ALIGN.MIDDLE, text: t("labels.centerVertically"), icon: , }, { value: VERTICAL_ALIGN.BOTTOM, text: t("labels.alignBottom"), icon: , }, ]} value={getFormValue(elements, appState, (element) => { if (isTextElement(element) && element.containerId) { return element.verticalAlign; } const boundTextElement = getBoundTextElement(element); if (boundTextElement) { return boundTextElement.verticalAlign; } return null; })} onChange={(value) => updateData(value)} />
); }, }); export const actionChangeSharpness = register({ name: "changeSharpness", perform: (elements, appState, value) => { const targetElements = getTargetElements( getNonDeletedElements(elements), appState, ); const shouldUpdateForNonLinearElements = targetElements.length ? targetElements.every((el) => !isLinearElement(el)) : !isLinearElementType(appState.activeTool.type); const shouldUpdateForLinearElements = targetElements.length ? targetElements.every(isLinearElement) : isLinearElementType(appState.activeTool.type); return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { strokeSharpness: value, }), ), appState: { ...appState, currentItemStrokeSharpness: shouldUpdateForNonLinearElements ? value : appState.currentItemStrokeSharpness, currentItemLinearStrokeSharpness: shouldUpdateForLinearElements ? value : appState.currentItemLinearStrokeSharpness, }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => (
{t("labels.edges")} , }, { value: "round", text: t("labels.round"), icon: , }, ]} value={getFormValue( elements, appState, (element) => element.strokeSharpness, (canChangeSharpness(appState.activeTool.type) && (isLinearElementType(appState.activeTool.type) ? appState.currentItemLinearStrokeSharpness : appState.currentItemStrokeSharpness)) || null, )} onChange={(value) => updateData(value)} />
), }); export const actionChangeArrowhead = register({ name: "changeArrowhead", 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, }, commitToHistory: true, }; }, PanelComponent: ({ elements, appState, updateData }) => { const isRTL = getLanguage().rtl; return (
{t("labels.arrowheads")}
, keyBinding: "q", }, { value: "arrow", text: t("labels.arrowhead_arrow"), icon: ( ), keyBinding: "w", }, { value: "bar", text: t("labels.arrowhead_bar"), icon: , keyBinding: "e", }, { value: "dot", text: t("labels.arrowhead_dot"), icon: , keyBinding: "r", }, { value: "triangle", text: t("labels.arrowhead_triangle"), icon: ( ), keyBinding: "t", }, ]} value={getFormValue( elements, appState, (element) => isLinearElement(element) && canHaveArrowheads(element.type) ? element.startArrowhead : appState.currentItemStartArrowhead, appState.currentItemStartArrowhead, )} onChange={(value) => updateData({ position: "start", type: value })} /> , }, { value: "arrow", text: t("labels.arrowhead_arrow"), keyBinding: "w", icon: ( ), }, { value: "bar", text: t("labels.arrowhead_bar"), keyBinding: "e", icon: , }, { value: "dot", text: t("labels.arrowhead_dot"), keyBinding: "r", icon: , }, { value: "triangle", text: t("labels.arrowhead_triangle"), icon: ( ), keyBinding: "t", }, ]} value={getFormValue( elements, appState, (element) => isLinearElement(element) && canHaveArrowheads(element.type) ? element.endArrowhead : appState.currentItemEndArrowhead, appState.currentItemEndArrowhead, )} onChange={(value) => updateData({ position: "end", type: value })} />
); }, });