diff options
Diffstat (limited to 'packages/excalidraw/actions')
42 files changed, 8507 insertions, 0 deletions
diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts new file mode 100644 index 0000000..3186e3b --- /dev/null +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -0,0 +1,63 @@ +import { register } from "./register"; +import { deepCopyElement } from "../element/newElement"; +import { randomId } from "../random"; +import { t } from "../i18n"; +import { LIBRARY_DISABLED_TYPES } from "../constants"; +import { CaptureUpdateAction } from "../store"; + +export const actionAddToLibrary = register({ + name: "addToLibrary", + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + includeElementsInFrames: true, + }); + + for (const type of LIBRARY_DISABLED_TYPES) { + if (selectedElements.some((element) => element.type === type)) { + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + errorMessage: t(`errors.libraryElementTypeError.${type}`), + }, + }; + } + } + + return app.library + .getLatestLibrary() + .then((items) => { + return app.library.setLibrary([ + { + id: randomId(), + status: "unpublished", + elements: selectedElements.map(deepCopyElement), + created: Date.now(), + }, + ...items, + ]); + }) + .then(() => { + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + toast: { message: t("toast.addedToLibrary") }, + }, + }; + }) + .catch((error) => { + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + errorMessage: error.message, + }, + }; + }); + }, + label: "labels.addToLibrary", +}); diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx new file mode 100644 index 0000000..53e8e61 --- /dev/null +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -0,0 +1,255 @@ +import type { Alignment } from "../align"; +import { alignElements } from "../align"; +import { + AlignBottomIcon, + AlignLeftIcon, + AlignRightIcon, + AlignTopIcon, + CenterHorizontallyIcon, + CenterVerticallyIcon, +} from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { getNonDeletedElements } from "../element"; +import { isFrameLikeElement } from "../element/typeChecks"; +import type { ExcalidrawElement } from "../element/types"; +import { updateFrameMembershipOfSelectedElements } from "../frame"; +import { t } from "../i18n"; +import { KEYS } from "../keys"; +import { isSomeElementSelected } from "../scene"; +import { CaptureUpdateAction } from "../store"; +import type { AppClassProperties, AppState, UIAppState } from "../types"; +import { arrayToMap, getShortcutKey } from "../utils"; +import { register } from "./register"; + +export const alignActionsPredicate = ( + appState: UIAppState, + app: AppClassProperties, +) => { + const selectedElements = app.scene.getSelectedElements(appState); + return ( + selectedElements.length > 1 && + // TODO enable aligning frames when implemented properly + !selectedElements.some((el) => isFrameLikeElement(el)) + ); +}; + +const alignSelectedElements = ( + elements: readonly ExcalidrawElement[], + appState: Readonly<AppState>, + app: AppClassProperties, + alignment: Alignment, +) => { + const selectedElements = app.scene.getSelectedElements(appState); + const elementsMap = arrayToMap(elements); + + const updatedElements = alignElements( + selectedElements, + elementsMap, + alignment, + app.scene, + ); + + const updatedElementsMap = arrayToMap(updatedElements); + + return updateFrameMembershipOfSelectedElements( + elements.map((element) => updatedElementsMap.get(element.id) || element), + appState, + app, + ); +}; + +export const actionAlignTop = register({ + name: "alignTop", + label: "labels.alignTop", + icon: AlignTopIcon, + trackEvent: { category: "element" }, + predicate: (elements, appState, appProps, app) => + alignActionsPredicate(appState, app), + perform: (elements, appState, _, app) => { + return { + appState, + elements: alignSelectedElements(elements, appState, app, { + position: "start", + axis: "y", + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!alignActionsPredicate(appState, app)} + type="button" + icon={AlignTopIcon} + onClick={() => updateData(null)} + title={`${t("labels.alignTop")} — ${getShortcutKey( + "CtrlOrCmd+Shift+Up", + )}`} + aria-label={t("labels.alignTop")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); + +export const actionAlignBottom = register({ + name: "alignBottom", + label: "labels.alignBottom", + icon: AlignBottomIcon, + trackEvent: { category: "element" }, + predicate: (elements, appState, appProps, app) => + alignActionsPredicate(appState, app), + perform: (elements, appState, _, app) => { + return { + appState, + elements: alignSelectedElements(elements, appState, app, { + position: "end", + axis: "y", + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!alignActionsPredicate(appState, app)} + type="button" + icon={AlignBottomIcon} + onClick={() => updateData(null)} + title={`${t("labels.alignBottom")} — ${getShortcutKey( + "CtrlOrCmd+Shift+Down", + )}`} + aria-label={t("labels.alignBottom")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); + +export const actionAlignLeft = register({ + name: "alignLeft", + label: "labels.alignLeft", + icon: AlignLeftIcon, + trackEvent: { category: "element" }, + predicate: (elements, appState, appProps, app) => + alignActionsPredicate(appState, app), + perform: (elements, appState, _, app) => { + return { + appState, + elements: alignSelectedElements(elements, appState, app, { + position: "start", + axis: "x", + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!alignActionsPredicate(appState, app)} + type="button" + icon={AlignLeftIcon} + onClick={() => updateData(null)} + title={`${t("labels.alignLeft")} — ${getShortcutKey( + "CtrlOrCmd+Shift+Left", + )}`} + aria-label={t("labels.alignLeft")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); + +export const actionAlignRight = register({ + name: "alignRight", + label: "labels.alignRight", + icon: AlignRightIcon, + trackEvent: { category: "element" }, + predicate: (elements, appState, appProps, app) => + alignActionsPredicate(appState, app), + perform: (elements, appState, _, app) => { + return { + appState, + elements: alignSelectedElements(elements, appState, app, { + position: "end", + axis: "x", + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!alignActionsPredicate(appState, app)} + type="button" + icon={AlignRightIcon} + onClick={() => updateData(null)} + title={`${t("labels.alignRight")} — ${getShortcutKey( + "CtrlOrCmd+Shift+Right", + )}`} + aria-label={t("labels.alignRight")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); + +export const actionAlignVerticallyCentered = register({ + name: "alignVerticallyCentered", + label: "labels.centerVertically", + icon: CenterVerticallyIcon, + trackEvent: { category: "element" }, + predicate: (elements, appState, appProps, app) => + alignActionsPredicate(appState, app), + perform: (elements, appState, _, app) => { + return { + appState, + elements: alignSelectedElements(elements, appState, app, { + position: "center", + axis: "y", + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!alignActionsPredicate(appState, app)} + type="button" + icon={CenterVerticallyIcon} + onClick={() => updateData(null)} + title={t("labels.centerVertically")} + aria-label={t("labels.centerVertically")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); + +export const actionAlignHorizontallyCentered = register({ + name: "alignHorizontallyCentered", + label: "labels.centerHorizontally", + icon: CenterHorizontallyIcon, + trackEvent: { category: "element" }, + predicate: (elements, appState, appProps, app) => + alignActionsPredicate(appState, app), + perform: (elements, appState, _, app) => { + return { + appState, + elements: alignSelectedElements(elements, appState, app, { + position: "center", + axis: "x", + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!alignActionsPredicate(appState, app)} + type="button" + icon={CenterHorizontallyIcon} + onClick={() => updateData(null)} + title={t("labels.centerHorizontally")} + aria-label={t("labels.centerHorizontally")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx new file mode 100644 index 0000000..b72ddee --- /dev/null +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -0,0 +1,329 @@ +import { + BOUND_TEXT_PADDING, + ROUNDNESS, + TEXT_ALIGN, + VERTICAL_ALIGN, +} from "../constants"; +import { isTextElement, newElement } from "../element"; +import { mutateElement } from "../element/mutateElement"; +import { + computeBoundTextPosition, + computeContainerDimensionForBoundText, + getBoundTextElement, + redrawTextBoundingBox, +} from "../element/textElement"; +import { + getOriginalContainerHeightFromCache, + resetOriginalContainerCache, + updateOriginalContainerCache, +} from "../element/containerCache"; +import { + hasBoundTextElement, + isTextBindableContainer, + isUsingAdaptiveRadius, +} from "../element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextContainer, + ExcalidrawTextElement, +} from "../element/types"; +import type { AppState } from "../types"; +import type { Mutable } from "../utility-types"; +import { arrayToMap, getFontString } from "../utils"; +import { register } from "./register"; +import { syncMovedIndices } from "../fractionalIndex"; +import { CaptureUpdateAction } from "../store"; +import { measureText } from "../element/textMeasurements"; + +export const actionUnbindText = register({ + name: "unbindText", + label: "labels.unbindText", + trackEvent: { category: "element" }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + + return selectedElements.some((element) => hasBoundTextElement(element)); + }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + const elementsMap = app.scene.getNonDeletedElementsMap(); + selectedElements.forEach((element) => { + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + const { width, height } = measureText( + boundTextElement.originalText, + getFontString(boundTextElement), + boundTextElement.lineHeight, + ); + const originalContainerHeight = getOriginalContainerHeightFromCache( + element.id, + ); + resetOriginalContainerCache(element.id); + const { x, y } = computeBoundTextPosition( + element, + boundTextElement, + elementsMap, + ); + mutateElement(boundTextElement as ExcalidrawTextElement, { + containerId: null, + width, + height, + text: boundTextElement.originalText, + x, + y, + }); + mutateElement(element, { + boundElements: element.boundElements?.filter( + (ele) => ele.id !== boundTextElement.id, + ), + height: originalContainerHeight + ? originalContainerHeight + : element.height, + }); + } + }); + return { + elements, + appState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); + +export const actionBindText = register({ + name: "bindText", + label: "labels.bindText", + trackEvent: { category: "element" }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + + if (selectedElements.length === 2) { + const textElement = + isTextElement(selectedElements[0]) || + isTextElement(selectedElements[1]); + + let bindingContainer; + if (isTextBindableContainer(selectedElements[0])) { + bindingContainer = selectedElements[0]; + } else if (isTextBindableContainer(selectedElements[1])) { + bindingContainer = selectedElements[1]; + } + if ( + textElement && + bindingContainer && + getBoundTextElement( + bindingContainer, + app.scene.getNonDeletedElementsMap(), + ) === null + ) { + return true; + } + } + return false; + }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + + let textElement: ExcalidrawTextElement; + let container: ExcalidrawTextContainer; + + if ( + isTextElement(selectedElements[0]) && + isTextBindableContainer(selectedElements[1]) + ) { + textElement = selectedElements[0]; + container = selectedElements[1]; + } else { + textElement = selectedElements[1] as ExcalidrawTextElement; + container = selectedElements[0] as ExcalidrawTextContainer; + } + mutateElement(textElement, { + containerId: container.id, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + textAlign: TEXT_ALIGN.CENTER, + autoResize: true, + }); + mutateElement(container, { + boundElements: (container.boundElements || []).concat({ + type: "text", + id: textElement.id, + }), + }); + const originalContainerHeight = container.height; + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); + // overwritting the cache with original container height so + // it can be restored when unbind + updateOriginalContainerCache(container.id, originalContainerHeight); + + return { + elements: pushTextAboveContainer(elements, container, textElement), + appState: { ...appState, selectedElementIds: { [container.id]: true } }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); + +const pushTextAboveContainer = ( + elements: readonly ExcalidrawElement[], + container: ExcalidrawElement, + textElement: ExcalidrawTextElement, +) => { + const updatedElements = elements.slice(); + const textElementIndex = updatedElements.findIndex( + (ele) => ele.id === textElement.id, + ); + updatedElements.splice(textElementIndex, 1); + + const containerIndex = updatedElements.findIndex( + (ele) => ele.id === container.id, + ); + updatedElements.splice(containerIndex + 1, 0, textElement); + syncMovedIndices(updatedElements, arrayToMap([container, textElement])); + + return updatedElements; +}; + +const pushContainerBelowText = ( + elements: readonly ExcalidrawElement[], + container: ExcalidrawElement, + textElement: ExcalidrawTextElement, +) => { + const updatedElements = elements.slice(); + const containerIndex = updatedElements.findIndex( + (ele) => ele.id === container.id, + ); + updatedElements.splice(containerIndex, 1); + + const textElementIndex = updatedElements.findIndex( + (ele) => ele.id === textElement.id, + ); + updatedElements.splice(textElementIndex, 0, container); + syncMovedIndices(updatedElements, arrayToMap([container, textElement])); + + return updatedElements; +}; + +export const actionWrapTextInContainer = register({ + name: "wrapTextInContainer", + label: "labels.createContainerFromText", + trackEvent: { category: "element" }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + const areTextElements = selectedElements.every((el) => isTextElement(el)); + return selectedElements.length > 0 && areTextElements; + }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + let updatedElements: readonly ExcalidrawElement[] = elements.slice(); + const containerIds: Mutable<AppState["selectedElementIds"]> = {}; + + for (const textElement of selectedElements) { + if (isTextElement(textElement)) { + const container = newElement({ + type: "rectangle", + backgroundColor: appState.currentItemBackgroundColor, + boundElements: [ + ...(textElement.boundElements || []), + { id: textElement.id, type: "text" }, + ], + angle: textElement.angle, + fillStyle: appState.currentItemFillStyle, + strokeColor: appState.currentItemStrokeColor, + roughness: appState.currentItemRoughness, + strokeWidth: appState.currentItemStrokeWidth, + strokeStyle: appState.currentItemStrokeStyle, + roundness: + appState.currentItemRoundness === "round" + ? { + type: isUsingAdaptiveRadius("rectangle") + ? ROUNDNESS.ADAPTIVE_RADIUS + : ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + opacity: 100, + locked: false, + x: textElement.x - BOUND_TEXT_PADDING, + y: textElement.y - BOUND_TEXT_PADDING, + width: computeContainerDimensionForBoundText( + textElement.width, + "rectangle", + ), + height: computeContainerDimensionForBoundText( + textElement.height, + "rectangle", + ), + groupIds: textElement.groupIds, + frameId: textElement.frameId, + }); + + // update bindings + if (textElement.boundElements?.length) { + const linearElementIds = textElement.boundElements + .filter((ele) => ele.type === "arrow") + .map((el) => el.id); + const linearElements = updatedElements.filter((ele) => + linearElementIds.includes(ele.id), + ) as ExcalidrawLinearElement[]; + linearElements.forEach((ele) => { + let startBinding = ele.startBinding; + let endBinding = ele.endBinding; + + if (startBinding?.elementId === textElement.id) { + startBinding = { + ...startBinding, + elementId: container.id, + }; + } + + if (endBinding?.elementId === textElement.id) { + endBinding = { ...endBinding, elementId: container.id }; + } + + if (startBinding || endBinding) { + mutateElement(ele, { startBinding, endBinding }, false); + } + }); + } + + mutateElement( + textElement, + { + containerId: container.id, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + boundElements: null, + textAlign: TEXT_ALIGN.CENTER, + autoResize: true, + }, + false, + ); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); + + updatedElements = pushContainerBelowText( + [...updatedElements, container], + container, + textElement, + ); + + containerIds[container.id] = true; + } + } + + return { + elements: updatedElements, + appState: { + ...appState, + selectedElementIds: containerIds, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx new file mode 100644 index 0000000..903a6d8 --- /dev/null +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -0,0 +1,557 @@ +import { ColorPicker } from "../components/ColorPicker/ColorPicker"; +import { + handIcon, + MoonIcon, + SunIcon, + TrashIcon, + zoomAreaIcon, + ZoomInIcon, + ZoomOutIcon, + ZoomResetIcon, +} from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { + CURSOR_TYPE, + MAX_ZOOM, + MIN_ZOOM, + THEME, + ZOOM_STEP, +} from "../constants"; +import { getCommonBounds, getNonDeletedElements } from "../element"; +import type { ExcalidrawElement } from "../element/types"; +import { t } from "../i18n"; +import { CODES, KEYS } from "../keys"; +import { getNormalizedZoom } from "../scene"; +import { centerScrollOn } from "../scene/scroll"; +import { getStateForZoom } from "../scene/zoom"; +import type { AppState, Offsets } from "../types"; +import { getShortcutKey, updateActiveTool } from "../utils"; +import { register } from "./register"; +import { Tooltip } from "../components/Tooltip"; +import { newElementWith } from "../element/mutateElement"; +import { + getDefaultAppState, + isEraserActive, + isHandToolActive, +} from "../appState"; +import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; +import type { SceneBounds } from "../element/bounds"; +import { setCursor } from "../cursor"; +import { CaptureUpdateAction } from "../store"; +import { clamp, roundToStep } from "@excalidraw/math"; + +export const actionChangeViewBackgroundColor = register({ + name: "changeViewBackgroundColor", + label: "labels.canvasBackground", + paletteName: "Change canvas background color", + trackEvent: false, + predicate: (elements, appState, props, app) => { + return ( + !!app.props.UIOptions.canvasActions.changeViewBackgroundColor && + !appState.viewModeEnabled + ); + }, + perform: (_, appState, value) => { + return { + appState: { ...appState, ...value }, + captureUpdate: !!value.viewBackgroundColor + ? CaptureUpdateAction.IMMEDIATELY + : CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ elements, appState, updateData, appProps }) => { + // FIXME move me to src/components/mainMenu/DefaultItems.tsx + return ( + <ColorPicker + palette={null} + topPicks={DEFAULT_CANVAS_BACKGROUND_PICKS} + label={t("labels.canvasBackground")} + type="canvasBackground" + color={appState.viewBackgroundColor} + onChange={(color) => updateData({ viewBackgroundColor: color })} + data-testid="canvas-background-picker" + elements={elements} + appState={appState} + updateData={updateData} + /> + ); + }, +}); + +export const actionClearCanvas = register({ + name: "clearCanvas", + label: "labels.clearCanvas", + paletteName: "Clear canvas", + icon: TrashIcon, + trackEvent: { category: "canvas" }, + predicate: (elements, appState, props, app) => { + return ( + !!app.props.UIOptions.canvasActions.clearCanvas && + !appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" + ); + }, + perform: (elements, appState, _, app) => { + app.imageCache.clear(); + return { + elements: elements.map((element) => + newElementWith(element, { isDeleted: true }), + ), + appState: { + ...getDefaultAppState(), + files: {}, + theme: appState.theme, + penMode: appState.penMode, + penDetected: appState.penDetected, + exportBackground: appState.exportBackground, + exportEmbedScene: appState.exportEmbedScene, + gridSize: appState.gridSize, + gridStep: appState.gridStep, + gridModeEnabled: appState.gridModeEnabled, + stats: appState.stats, + pasteDialog: appState.pasteDialog, + activeTool: + appState.activeTool.type === "image" + ? { ...appState.activeTool, type: "selection" } + : appState.activeTool, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); + +export const actionZoomIn = register({ + name: "zoomIn", + label: "buttons.zoomIn", + viewMode: true, + icon: ZoomInIcon, + trackEvent: { category: "canvas" }, + perform: (_elements, appState, _, app) => { + return { + appState: { + ...appState, + ...getStateForZoom( + { + viewportX: appState.width / 2 + appState.offsetLeft, + viewportY: appState.height / 2 + appState.offsetTop, + nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP), + }, + appState, + ), + userToFollow: null, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ updateData, appState }) => ( + <ToolButton + type="button" + className="zoom-in-button zoom-button" + icon={ZoomInIcon} + title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`} + aria-label={t("buttons.zoomIn")} + disabled={appState.zoom.value >= MAX_ZOOM} + onClick={() => { + updateData(null); + }} + /> + ), + keyTest: (event) => + (event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) && + (event[KEYS.CTRL_OR_CMD] || event.shiftKey), +}); + +export const actionZoomOut = register({ + name: "zoomOut", + label: "buttons.zoomOut", + icon: ZoomOutIcon, + viewMode: true, + trackEvent: { category: "canvas" }, + perform: (_elements, appState, _, app) => { + return { + appState: { + ...appState, + ...getStateForZoom( + { + viewportX: appState.width / 2 + appState.offsetLeft, + viewportY: appState.height / 2 + appState.offsetTop, + nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP), + }, + appState, + ), + userToFollow: null, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ updateData, appState }) => ( + <ToolButton + type="button" + className="zoom-out-button zoom-button" + icon={ZoomOutIcon} + title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`} + aria-label={t("buttons.zoomOut")} + disabled={appState.zoom.value <= MIN_ZOOM} + onClick={() => { + updateData(null); + }} + /> + ), + keyTest: (event) => + (event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) && + (event[KEYS.CTRL_OR_CMD] || event.shiftKey), +}); + +export const actionResetZoom = register({ + name: "resetZoom", + label: "buttons.resetZoom", + icon: ZoomResetIcon, + viewMode: true, + trackEvent: { category: "canvas" }, + perform: (_elements, appState, _, app) => { + return { + appState: { + ...appState, + ...getStateForZoom( + { + viewportX: appState.width / 2 + appState.offsetLeft, + viewportY: appState.height / 2 + appState.offsetTop, + nextZoom: getNormalizedZoom(1), + }, + appState, + ), + userToFollow: null, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ updateData, appState }) => ( + <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}> + <ToolButton + type="button" + className="reset-zoom-button zoom-button" + title={t("buttons.resetZoom")} + aria-label={t("buttons.resetZoom")} + onClick={() => { + updateData(null); + }} + > + {(appState.zoom.value * 100).toFixed(0)}% + </ToolButton> + </Tooltip> + ), + keyTest: (event) => + (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) && + (event[KEYS.CTRL_OR_CMD] || event.shiftKey), +}); + +const zoomValueToFitBoundsOnViewport = ( + bounds: SceneBounds, + viewportDimensions: { width: number; height: number }, + viewportZoomFactor: number = 1, // default to 1 if not provided +) => { + const [x1, y1, x2, y2] = bounds; + const commonBoundsWidth = x2 - x1; + const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth; + const commonBoundsHeight = y2 - y1; + const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight; + const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight); + + const adjustedZoomValue = + smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1); + + return Math.min(adjustedZoomValue, 1); +}; + +export const zoomToFitBounds = ({ + bounds, + appState, + canvasOffsets, + fitToViewport = false, + viewportZoomFactor = 1, + minZoom = -Infinity, + maxZoom = Infinity, +}: { + bounds: SceneBounds; + canvasOffsets?: Offsets; + appState: Readonly<AppState>; + /** whether to fit content to viewport (beyond >100%) */ + fitToViewport: boolean; + /** zoom content to cover X of the viewport, when fitToViewport=true */ + viewportZoomFactor?: number; + minZoom?: number; + maxZoom?: number; +}) => { + viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM); + + const [x1, y1, x2, y2] = bounds; + const centerX = (x1 + x2) / 2; + const centerY = (y1 + y2) / 2; + + const canvasOffsetLeft = canvasOffsets?.left ?? 0; + const canvasOffsetTop = canvasOffsets?.top ?? 0; + const canvasOffsetRight = canvasOffsets?.right ?? 0; + const canvasOffsetBottom = canvasOffsets?.bottom ?? 0; + + const effectiveCanvasWidth = + appState.width - canvasOffsetLeft - canvasOffsetRight; + const effectiveCanvasHeight = + appState.height - canvasOffsetTop - canvasOffsetBottom; + + let adjustedZoomValue; + + if (fitToViewport) { + const commonBoundsWidth = x2 - x1; + const commonBoundsHeight = y2 - y1; + + adjustedZoomValue = + Math.min( + effectiveCanvasWidth / commonBoundsWidth, + effectiveCanvasHeight / commonBoundsHeight, + ) * viewportZoomFactor; + } else { + adjustedZoomValue = zoomValueToFitBoundsOnViewport( + bounds, + { + width: effectiveCanvasWidth, + height: effectiveCanvasHeight, + }, + viewportZoomFactor, + ); + } + + const newZoomValue = getNormalizedZoom( + clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom), + ); + + const centerScroll = centerScrollOn({ + scenePoint: { x: centerX, y: centerY }, + viewportDimensions: { + width: appState.width, + height: appState.height, + }, + offsets: canvasOffsets, + zoom: { value: newZoomValue }, + }); + + return { + appState: { + ...appState, + scrollX: centerScroll.scrollX, + scrollY: centerScroll.scrollY, + zoom: { value: newZoomValue }, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; +}; + +export const zoomToFit = ({ + canvasOffsets, + targetElements, + appState, + fitToViewport, + viewportZoomFactor, + minZoom, + maxZoom, +}: { + canvasOffsets?: Offsets; + targetElements: readonly ExcalidrawElement[]; + appState: Readonly<AppState>; + /** whether to fit content to viewport (beyond >100%) */ + fitToViewport: boolean; + /** zoom content to cover X of the viewport, when fitToViewport=true */ + viewportZoomFactor?: number; + minZoom?: number; + maxZoom?: number; +}) => { + const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); + + return zoomToFitBounds({ + canvasOffsets, + bounds: commonBounds, + appState, + fitToViewport, + viewportZoomFactor, + minZoom, + maxZoom, + }); +}; + +// Note, this action differs from actionZoomToFitSelection in that it doesn't +// zoom beyond 100%. In other words, if the content is smaller than viewport +// size, it won't be zoomed in. +export const actionZoomToFitSelectionInViewport = register({ + name: "zoomToFitSelectionInViewport", + label: "labels.zoomToFitViewport", + icon: zoomAreaIcon, + trackEvent: { category: "canvas" }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + return zoomToFit({ + targetElements: selectedElements.length ? selectedElements : elements, + appState: { + ...appState, + userToFollow: null, + }, + fitToViewport: false, + canvasOffsets: app.getEditorUIOffsets(), + }); + }, + // NOTE shift-2 should have been assigned actionZoomToFitSelection. + // TBD on how proceed + keyTest: (event) => + event.code === CODES.TWO && + event.shiftKey && + !event.altKey && + !event[KEYS.CTRL_OR_CMD], +}); + +export const actionZoomToFitSelection = register({ + name: "zoomToFitSelection", + label: "helpDialog.zoomToSelection", + icon: zoomAreaIcon, + trackEvent: { category: "canvas" }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + return zoomToFit({ + targetElements: selectedElements.length ? selectedElements : elements, + appState: { + ...appState, + userToFollow: null, + }, + fitToViewport: true, + canvasOffsets: app.getEditorUIOffsets(), + }); + }, + // NOTE this action should use shift-2 per figma, alas + keyTest: (event) => + event.code === CODES.THREE && + event.shiftKey && + !event.altKey && + !event[KEYS.CTRL_OR_CMD], +}); + +export const actionZoomToFit = register({ + name: "zoomToFit", + label: "helpDialog.zoomToFit", + icon: zoomAreaIcon, + viewMode: true, + trackEvent: { category: "canvas" }, + perform: (elements, appState, _, app) => + zoomToFit({ + targetElements: elements, + appState: { + ...appState, + userToFollow: null, + }, + fitToViewport: false, + canvasOffsets: app.getEditorUIOffsets(), + }), + keyTest: (event) => + event.code === CODES.ONE && + event.shiftKey && + !event.altKey && + !event[KEYS.CTRL_OR_CMD], +}); + +export const actionToggleTheme = register({ + name: "toggleTheme", + label: (_, appState) => { + return appState.theme === THEME.DARK + ? "buttons.lightMode" + : "buttons.darkMode"; + }, + keywords: ["toggle", "dark", "light", "mode", "theme"], + icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon), + viewMode: true, + trackEvent: { category: "canvas" }, + perform: (_, appState, value) => { + return { + appState: { + ...appState, + theme: + value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT), + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, + predicate: (elements, appState, props, app) => { + return !!app.props.UIOptions.canvasActions.toggleTheme; + }, +}); + +export const actionToggleEraserTool = register({ + name: "toggleEraserTool", + label: "toolBar.eraser", + trackEvent: { category: "toolbar" }, + perform: (elements, appState) => { + let activeTool: AppState["activeTool"]; + + if (isEraserActive(appState)) { + activeTool = updateActiveTool(appState, { + ...(appState.activeTool.lastActiveTool || { + type: "selection", + }), + lastActiveToolBeforeEraser: null, + }); + } else { + activeTool = updateActiveTool(appState, { + type: "eraser", + lastActiveToolBeforeEraser: appState.activeTool, + }); + } + + return { + appState: { + ...appState, + selectedElementIds: {}, + selectedGroupIds: {}, + activeEmbeddable: null, + activeTool, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => event.key === KEYS.E, +}); + +export const actionToggleHandTool = register({ + name: "toggleHandTool", + label: "toolBar.hand", + paletteName: "Toggle hand tool", + trackEvent: { category: "toolbar" }, + icon: handIcon, + viewMode: false, + perform: (elements, appState, _, app) => { + let activeTool: AppState["activeTool"]; + + if (isHandToolActive(appState)) { + activeTool = updateActiveTool(appState, { + ...(appState.activeTool.lastActiveTool || { + type: "selection", + }), + lastActiveToolBeforeEraser: null, + }); + } else { + activeTool = updateActiveTool(appState, { + type: "hand", + lastActiveToolBeforeEraser: appState.activeTool, + }); + setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB); + } + + return { + appState: { + ...appState, + selectedElementIds: {}, + selectedGroupIds: {}, + activeEmbeddable: null, + activeTool, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + !event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H, +}); diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx new file mode 100644 index 0000000..fffe7b3 --- /dev/null +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -0,0 +1,281 @@ +import { CODES, KEYS } from "../keys"; +import { register } from "./register"; +import { + copyTextToSystemClipboard, + copyToClipboard, + createPasteEvent, + probablySupportsClipboardBlob, + probablySupportsClipboardWriteText, + readSystemClipboard, +} from "../clipboard"; +import { actionDeleteSelected } from "./actionDeleteSelected"; +import { exportCanvas, prepareElementsForExport } from "../data/index"; +import { getTextFromElements, isTextElement } from "../element"; +import { t } from "../i18n"; +import { isFirefox } from "../constants"; +import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; + +export const actionCopy = register({ + name: "copy", + label: "labels.copy", + icon: DuplicateIcon, + trackEvent: { category: "element" }, + perform: async (elements, appState, event: ClipboardEvent | null, app) => { + const elementsToCopy = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + includeElementsInFrames: true, + }); + + try { + await copyToClipboard(elementsToCopy, app.files, event); + } catch (error: any) { + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + errorMessage: error.message, + }, + }; + } + + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + // don't supply a shortcut since we handle this conditionally via onCopy event + keyTest: undefined, +}); + +export const actionPaste = register({ + name: "paste", + label: "labels.paste", + trackEvent: { category: "element" }, + perform: async (elements, appState, data, app) => { + let types; + try { + types = await readSystemClipboard(); + } catch (error: any) { + if (error.name === "AbortError" || error.name === "NotAllowedError") { + // user probably aborted the action. Though not 100% sure, it's best + // to not annoy them with an error message. + return false; + } + + console.error(`actionPaste ${error.name}: ${error.message}`); + + if (isFirefox) { + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + errorMessage: t("hints.firefox_clipboard_write"), + }, + }; + } + + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + errorMessage: t("errors.asyncPasteFailedOnRead"), + }, + }; + } + + try { + app.pasteFromClipboard(createPasteEvent({ types })); + } catch (error: any) { + console.error(error); + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + errorMessage: t("errors.asyncPasteFailedOnParse"), + }, + }; + } + + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + // don't supply a shortcut since we handle this conditionally via onCopy event + keyTest: undefined, +}); + +export const actionCut = register({ + name: "cut", + label: "labels.cut", + icon: cutIcon, + trackEvent: { category: "element" }, + perform: (elements, appState, event: ClipboardEvent | null, app) => { + actionCopy.perform(elements, appState, event, app); + return actionDeleteSelected.perform(elements, appState, null, app); + }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, +}); + +export const actionCopyAsSvg = register({ + name: "copyAsSvg", + label: "labels.copyAsSvg", + icon: svgIcon, + trackEvent: { category: "element" }, + perform: async (elements, appState, _data, app) => { + if (!app.canvas) { + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + + const { exportedElements, exportingFrame } = prepareElementsForExport( + elements, + appState, + true, + ); + + try { + await exportCanvas( + "clipboard-svg", + exportedElements, + appState, + app.files, + { + ...appState, + exportingFrame, + name: app.getName(), + }, + ); + + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + includeElementsInFrames: true, + }); + + return { + appState: { + toast: { + message: t("toast.copyToClipboardAsSvg", { + exportSelection: selectedElements.length + ? t("toast.selection") + : t("toast.canvas"), + exportColorScheme: appState.exportWithDarkMode + ? t("buttons.darkMode") + : t("buttons.lightMode"), + }), + }, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } catch (error: any) { + console.error(error); + return { + appState: { + errorMessage: error.message, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + }, + predicate: (elements) => { + return probablySupportsClipboardWriteText && elements.length > 0; + }, + keywords: ["svg", "clipboard", "copy"], +}); + +export const actionCopyAsPng = register({ + name: "copyAsPng", + label: "labels.copyAsPng", + icon: pngIcon, + trackEvent: { category: "element" }, + perform: async (elements, appState, _data, app) => { + if (!app.canvas) { + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + includeElementsInFrames: true, + }); + + const { exportedElements, exportingFrame } = prepareElementsForExport( + elements, + appState, + true, + ); + try { + await exportCanvas("clipboard", exportedElements, appState, app.files, { + ...appState, + exportingFrame, + name: app.getName(), + }); + return { + appState: { + ...appState, + toast: { + message: t("toast.copyToClipboardAsPng", { + exportSelection: selectedElements.length + ? t("toast.selection") + : t("toast.canvas"), + exportColorScheme: appState.exportWithDarkMode + ? t("buttons.darkMode") + : t("buttons.lightMode"), + }), + }, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } catch (error: any) { + console.error(error); + return { + appState: { + ...appState, + errorMessage: error.message, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + }, + predicate: (elements) => { + return probablySupportsClipboardBlob && elements.length > 0; + }, + keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, + keywords: ["png", "clipboard", "copy"], +}); + +export const copyText = register({ + name: "copyText", + label: "labels.copyText", + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + }); + + try { + copyTextToSystemClipboard(getTextFromElements(selectedElements)); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + predicate: (elements, appState, _, app) => { + return ( + probablySupportsClipboardWriteText && + app.scene + .getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + }) + .some(isTextElement) + ); + }, + keywords: ["text", "clipboard", "copy"], +}); diff --git a/packages/excalidraw/actions/actionCropEditor.tsx b/packages/excalidraw/actions/actionCropEditor.tsx new file mode 100644 index 0000000..643f666 --- /dev/null +++ b/packages/excalidraw/actions/actionCropEditor.tsx @@ -0,0 +1,55 @@ +import { register } from "./register"; +import { cropIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; +import { ToolButton } from "../components/ToolButton"; +import { t } from "../i18n"; +import { isImageElement } from "../element/typeChecks"; +import type { ExcalidrawImageElement } from "../element/types"; + +export const actionToggleCropEditor = register({ + name: "cropEditor", + label: "helpDialog.cropStart", + icon: cropIcon, + viewMode: true, + trackEvent: { category: "menu" }, + keywords: ["image", "crop"], + perform(elements, appState, _, app) { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + })[0] as ExcalidrawImageElement; + + return { + appState: { + ...appState, + isCropping: false, + croppingElementId: selectedElement.id, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + if ( + !appState.croppingElementId && + selectedElements.length === 1 && + isImageElement(selectedElements[0]) + ) { + return true; + } + return false; + }, + PanelComponent: ({ appState, updateData, app }) => { + const label = t("helpDialog.cropStart"); + + return ( + <ToolButton + type="button" + icon={cropIcon} + title={label} + aria-label={label} + onClick={() => updateData(null)} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionDeleteSelected.test.tsx b/packages/excalidraw/actions/actionDeleteSelected.test.tsx new file mode 100644 index 0000000..d48646f --- /dev/null +++ b/packages/excalidraw/actions/actionDeleteSelected.test.tsx @@ -0,0 +1,211 @@ +import React from "react"; +import { Excalidraw, mutateElement } from "../index"; +import { act, assertElements, render } from "../tests/test-utils"; +import { API } from "../tests/helpers/api"; +import { actionDeleteSelected } from "./actionDeleteSelected"; + +const { h } = window; + +describe("deleting selected elements when frame selected should keep children + select them", () => { + beforeEach(async () => { + await render(<Excalidraw />); + }); + + it("frame only", async () => { + const f1 = API.createElement({ + type: "frame", + }); + + const r1 = API.createElement({ + type: "rectangle", + frameId: f1.id, + }); + + API.setElements([f1, r1]); + + API.setSelectedElements([f1]); + + act(() => { + h.app.actionManager.executeAction(actionDeleteSelected); + }); + + assertElements(h.elements, [ + { id: f1.id, isDeleted: true }, + { id: r1.id, isDeleted: false, selected: true }, + ]); + }); + + it("frame + text container (text's frameId set)", async () => { + const f1 = API.createElement({ + type: "frame", + }); + + const r1 = API.createElement({ + type: "rectangle", + frameId: f1.id, + }); + + const t1 = API.createElement({ + type: "text", + width: 200, + height: 100, + fontSize: 20, + containerId: r1.id, + frameId: f1.id, + }); + + mutateElement(r1, { + boundElements: [{ type: "text", id: t1.id }], + }); + + API.setElements([f1, r1, t1]); + + API.setSelectedElements([f1]); + + act(() => { + h.app.actionManager.executeAction(actionDeleteSelected); + }); + + assertElements(h.elements, [ + { id: f1.id, isDeleted: true }, + { id: r1.id, isDeleted: false, selected: true }, + { id: t1.id, isDeleted: false }, + ]); + }); + + it("frame + text container (text's frameId not set)", async () => { + const f1 = API.createElement({ + type: "frame", + }); + + const r1 = API.createElement({ + type: "rectangle", + frameId: f1.id, + }); + + const t1 = API.createElement({ + type: "text", + width: 200, + height: 100, + fontSize: 20, + containerId: r1.id, + frameId: null, + }); + + mutateElement(r1, { + boundElements: [{ type: "text", id: t1.id }], + }); + + API.setElements([f1, r1, t1]); + + API.setSelectedElements([f1]); + + act(() => { + h.app.actionManager.executeAction(actionDeleteSelected); + }); + + assertElements(h.elements, [ + { id: f1.id, isDeleted: true }, + { id: r1.id, isDeleted: false, selected: true }, + { id: t1.id, isDeleted: false }, + ]); + }); + + it("frame + text container (text selected too)", async () => { + const f1 = API.createElement({ + type: "frame", + }); + + const r1 = API.createElement({ + type: "rectangle", + frameId: f1.id, + }); + + const t1 = API.createElement({ + type: "text", + width: 200, + height: 100, + fontSize: 20, + containerId: r1.id, + frameId: null, + }); + + mutateElement(r1, { + boundElements: [{ type: "text", id: t1.id }], + }); + + API.setElements([f1, r1, t1]); + + API.setSelectedElements([f1, t1]); + + act(() => { + h.app.actionManager.executeAction(actionDeleteSelected); + }); + + assertElements(h.elements, [ + { id: f1.id, isDeleted: true }, + { id: r1.id, isDeleted: false, selected: true }, + { id: t1.id, isDeleted: false }, + ]); + }); + + it("frame + labeled arrow", async () => { + const f1 = API.createElement({ + type: "frame", + }); + + const a1 = API.createElement({ + type: "arrow", + frameId: f1.id, + }); + + const t1 = API.createElement({ + type: "text", + width: 200, + height: 100, + fontSize: 20, + containerId: a1.id, + frameId: null, + }); + + mutateElement(a1, { + boundElements: [{ type: "text", id: t1.id }], + }); + + API.setElements([f1, a1, t1]); + + API.setSelectedElements([f1, t1]); + + act(() => { + h.app.actionManager.executeAction(actionDeleteSelected); + }); + + assertElements(h.elements, [ + { id: f1.id, isDeleted: true }, + { id: a1.id, isDeleted: false, selected: true }, + { id: t1.id, isDeleted: false }, + ]); + }); + + it("frame + children selected", async () => { + const f1 = API.createElement({ + type: "frame", + }); + const r1 = API.createElement({ + type: "rectangle", + frameId: f1.id, + }); + API.setElements([f1, r1]); + + API.setSelectedElements([f1, r1]); + + act(() => { + h.app.actionManager.executeAction(actionDeleteSelected); + }); + + assertElements(h.elements, [ + { id: f1.id, isDeleted: true }, + { id: r1.id, isDeleted: false, selected: true }, + ]); + }); +}); diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx new file mode 100644 index 0000000..c640f92 --- /dev/null +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -0,0 +1,311 @@ +import { getSelectedElements, isSomeElementSelected } from "../scene"; +import { KEYS } from "../keys"; +import { ToolButton } from "../components/ToolButton"; +import { t } from "../i18n"; +import { register } from "./register"; +import { getNonDeletedElements } from "../element"; +import type { ExcalidrawElement } from "../element/types"; +import type { AppClassProperties, AppState } from "../types"; +import { mutateElement, newElementWith } from "../element/mutateElement"; +import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { fixBindingsAfterDeletion } from "../element/binding"; +import { + isBoundToContainer, + isElbowArrow, + isFrameLikeElement, +} from "../element/typeChecks"; +import { updateActiveTool } from "../utils"; +import { TrashIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; +import { getContainerElement } from "../element/textElement"; +import { getFrameChildren } from "../frame"; + +const deleteSelectedElements = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + app: AppClassProperties, +) => { + const framesToBeDeleted = new Set( + getSelectedElements( + elements.filter((el) => isFrameLikeElement(el)), + appState, + ).map((el) => el.id), + ); + + const selectedElementIds: Record<ExcalidrawElement["id"], true> = {}; + + const elementsMap = app.scene.getNonDeletedElementsMap(); + + const processedElements = new Set<ExcalidrawElement["id"]>(); + + for (const frameId of framesToBeDeleted) { + const frameChildren = getFrameChildren(elements, frameId); + for (const el of frameChildren) { + if (processedElements.has(el.id)) { + continue; + } + + if (isBoundToContainer(el)) { + const containerElement = getContainerElement(el, elementsMap); + if (containerElement) { + selectedElementIds[containerElement.id] = true; + } + } else { + selectedElementIds[el.id] = true; + } + processedElements.add(el.id); + } + } + + let shouldSelectEditingGroup = true; + + const nextElements = elements.map((el) => { + if (appState.selectedElementIds[el.id]) { + const boundElement = isBoundToContainer(el) + ? getContainerElement(el, elementsMap) + : null; + + if (el.frameId && framesToBeDeleted.has(el.frameId)) { + shouldSelectEditingGroup = false; + selectedElementIds[el.id] = true; + return el; + } + + if ( + boundElement?.frameId && + framesToBeDeleted.has(boundElement?.frameId) + ) { + return el; + } + + if (el.boundElements) { + el.boundElements.forEach((candidate) => { + const bound = app.scene.getNonDeletedElementsMap().get(candidate.id); + if (bound && isElbowArrow(bound)) { + mutateElement(bound, { + startBinding: + el.id === bound.startBinding?.elementId + ? null + : bound.startBinding, + endBinding: + el.id === bound.endBinding?.elementId ? null : bound.endBinding, + }); + mutateElement(bound, { points: bound.points }); + } + }); + } + return newElementWith(el, { isDeleted: true }); + } + + // if deleting a frame, remove the children from it and select them + if (el.frameId && framesToBeDeleted.has(el.frameId)) { + shouldSelectEditingGroup = false; + if (!isBoundToContainer(el)) { + selectedElementIds[el.id] = true; + } + return newElementWith(el, { frameId: null }); + } + + if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) { + return newElementWith(el, { isDeleted: true }); + } + return el; + }); + + let nextEditingGroupId = appState.editingGroupId; + + // select next eligible element in currently editing group or supergroup + if (shouldSelectEditingGroup && appState.editingGroupId) { + const elems = getElementsInGroup( + nextElements, + appState.editingGroupId, + ).filter((el) => !el.isDeleted); + if (elems.length > 1) { + if (elems[0]) { + selectedElementIds[elems[0].id] = true; + } + } else { + nextEditingGroupId = null; + if (elems[0]) { + selectedElementIds[elems[0].id] = true; + } + + const lastElementInGroup = elems[0]; + if (lastElementInGroup) { + const editingGroupIdx = lastElementInGroup.groupIds.findIndex( + (groupId) => { + return groupId === appState.editingGroupId; + }, + ); + const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1]; + if (superGroupId) { + const elems = getElementsInGroup(nextElements, superGroupId).filter( + (el) => !el.isDeleted, + ); + if (elems.length > 1) { + nextEditingGroupId = superGroupId; + + elems.forEach((el) => { + selectedElementIds[el.id] = true; + }); + } + } + } + } + } + + return { + elements: nextElements, + appState: { + ...appState, + ...selectGroupsForSelectedElements( + { + selectedElementIds, + editingGroupId: nextEditingGroupId, + }, + nextElements, + appState, + null, + ), + }, + }; +}; + +const handleGroupEditingState = ( + appState: AppState, + elements: readonly ExcalidrawElement[], +): AppState => { + if (appState.editingGroupId) { + const siblingElements = getElementsInGroup( + getNonDeletedElements(elements), + appState.editingGroupId!, + ); + if (siblingElements.length) { + return { + ...appState, + selectedElementIds: { [siblingElements[0].id]: true }, + }; + } + } + return appState; +}; + +export const actionDeleteSelected = register({ + name: "deleteSelectedElements", + label: "labels.delete", + icon: TrashIcon, + trackEvent: { category: "element", action: "delete" }, + perform: (elements, appState, formData, app) => { + if (appState.editingLinearElement) { + const { + elementId, + selectedPointsIndices, + startBindingElement, + endBindingElement, + } = appState.editingLinearElement; + const elementsMap = app.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return false; + } + // case: no point selected → do nothing, as deleting the whole element + // is most likely a mistake, where you wanted to delete a specific point + // but failed to select it (or you thought it's selected, while it was + // only in a hover state) + if (selectedPointsIndices == null) { + return false; + } + + // case: deleting last remaining point + if (element.points.length < 2) { + const nextElements = elements.map((el) => { + if (el.id === element.id) { + return newElementWith(el, { isDeleted: true }); + } + return el; + }); + const nextAppState = handleGroupEditingState(appState, nextElements); + + return { + elements: nextElements, + appState: { + ...nextAppState, + editingLinearElement: null, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } + + // We cannot do this inside `movePoint` because it is also called + // when deleting the uncommitted point (which hasn't caused any binding) + const binding = { + startBindingElement: selectedPointsIndices?.includes(0) + ? null + : startBindingElement, + endBindingElement: selectedPointsIndices?.includes( + element.points.length - 1, + ) + ? null + : endBindingElement, + }; + + LinearElementEditor.deletePoints(element, selectedPointsIndices); + + return { + elements, + appState: { + ...appState, + editingLinearElement: { + ...appState.editingLinearElement, + ...binding, + selectedPointsIndices: + selectedPointsIndices?.[0] > 0 + ? [selectedPointsIndices[0] - 1] + : [0], + }, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } + + let { elements: nextElements, appState: nextAppState } = + deleteSelectedElements(elements, appState, app); + + fixBindingsAfterDeletion( + nextElements, + nextElements.filter((el) => el.isDeleted), + ); + + nextAppState = handleGroupEditingState(nextAppState, nextElements); + + return { + elements: nextElements, + appState: { + ...nextAppState, + activeTool: updateActiveTool(appState, { type: "selection" }), + multiElement: null, + activeEmbeddable: null, + }, + captureUpdate: isSomeElementSelected( + getNonDeletedElements(elements), + appState, + ) + ? CaptureUpdateAction.IMMEDIATELY + : CaptureUpdateAction.EVENTUALLY, + }; + }, + keyTest: (event, appState, elements) => + (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) && + !event[KEYS.CTRL_OR_CMD], + PanelComponent: ({ elements, appState, updateData }) => ( + <ToolButton + type="button" + icon={TrashIcon} + title={t("labels.delete")} + aria-label={t("labels.delete")} + onClick={() => updateData(null)} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx new file mode 100644 index 0000000..15d33b1 --- /dev/null +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -0,0 +1,110 @@ +import { + DistributeHorizontallyIcon, + DistributeVerticallyIcon, +} from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import type { Distribution } from "../distribute"; +import { distributeElements } from "../distribute"; +import { getNonDeletedElements } from "../element"; +import { isFrameLikeElement } from "../element/typeChecks"; +import type { ExcalidrawElement } from "../element/types"; +import { updateFrameMembershipOfSelectedElements } from "../frame"; +import { t } from "../i18n"; +import { CODES, KEYS } from "../keys"; +import { isSomeElementSelected } from "../scene"; +import { CaptureUpdateAction } from "../store"; +import type { AppClassProperties, AppState } from "../types"; +import { arrayToMap, getShortcutKey } from "../utils"; +import { register } from "./register"; + +const enableActionGroup = (appState: AppState, app: AppClassProperties) => { + const selectedElements = app.scene.getSelectedElements(appState); + return ( + selectedElements.length > 1 && + // TODO enable distributing frames when implemented properly + !selectedElements.some((el) => isFrameLikeElement(el)) + ); +}; + +const distributeSelectedElements = ( + elements: readonly ExcalidrawElement[], + appState: Readonly<AppState>, + app: AppClassProperties, + distribution: Distribution, +) => { + const selectedElements = app.scene.getSelectedElements(appState); + + const updatedElements = distributeElements( + selectedElements, + app.scene.getNonDeletedElementsMap(), + distribution, + ); + + const updatedElementsMap = arrayToMap(updatedElements); + + return updateFrameMembershipOfSelectedElements( + elements.map((element) => updatedElementsMap.get(element.id) || element), + appState, + app, + ); +}; + +export const distributeHorizontally = register({ + name: "distributeHorizontally", + label: "labels.distributeHorizontally", + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + return { + appState, + elements: distributeSelectedElements(elements, appState, app, { + space: "between", + axis: "x", + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!enableActionGroup(appState, app)} + type="button" + icon={DistributeHorizontallyIcon} + onClick={() => updateData(null)} + title={`${t("labels.distributeHorizontally")} — ${getShortcutKey( + "Alt+H", + )}`} + aria-label={t("labels.distributeHorizontally")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); + +export const distributeVertically = register({ + name: "distributeVertically", + label: "labels.distributeVertically", + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + return { + appState, + elements: distributeSelectedElements(elements, appState, app, { + space: "between", + axis: "y", + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!enableActionGroup(appState, app)} + type="button" + icon={DistributeVerticallyIcon} + onClick={() => updateData(null)} + title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`} + aria-label={t("labels.distributeVertically")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.test.tsx b/packages/excalidraw/actions/actionDuplicateSelection.test.tsx new file mode 100644 index 0000000..dc471df --- /dev/null +++ b/packages/excalidraw/actions/actionDuplicateSelection.test.tsx @@ -0,0 +1,530 @@ +import { Excalidraw } from "../index"; +import { + act, + assertElements, + getCloneByOrigId, + render, +} from "../tests/test-utils"; +import { API } from "../tests/helpers/api"; +import { actionDuplicateSelection } from "./actionDuplicateSelection"; +import React from "react"; +import { ORIG_ID } from "../constants"; + +const { h } = window; + +describe("actionDuplicateSelection", () => { + beforeEach(async () => { + await render(<Excalidraw />); + }); + + describe("duplicating frames", () => { + it("frame selected only", async () => { + const frame = API.createElement({ + type: "frame", + }); + + const rectangle = API.createElement({ + type: "rectangle", + frameId: frame.id, + }); + + API.setElements([frame, rectangle]); + API.setSelectedElements([frame]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: rectangle.id, frameId: frame.id }, + { [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id }, + { [ORIG_ID]: frame.id, selected: true }, + ]); + }); + + it("frame selected only (with text container)", async () => { + const frame = API.createElement({ + type: "frame", + }); + + const [rectangle, text] = API.createTextContainer({ frameId: frame.id }); + + API.setElements([frame, rectangle, text]); + API.setSelectedElements([frame]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: rectangle.id, frameId: frame.id }, + { id: text.id, containerId: rectangle.id, frameId: frame.id }, + { [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id }, + { + [ORIG_ID]: text.id, + containerId: getCloneByOrigId(rectangle.id)?.id, + frameId: getCloneByOrigId(frame.id)?.id, + }, + { [ORIG_ID]: frame.id, selected: true }, + ]); + }); + + it("frame + text container selected (order A)", async () => { + const frame = API.createElement({ + type: "frame", + }); + + const [rectangle, text] = API.createTextContainer({ frameId: frame.id }); + + API.setElements([frame, rectangle, text]); + API.setSelectedElements([frame, rectangle]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: rectangle.id, frameId: frame.id }, + { id: text.id, containerId: rectangle.id, frameId: frame.id }, + { + [ORIG_ID]: rectangle.id, + frameId: getCloneByOrigId(frame.id)?.id, + }, + { + [ORIG_ID]: text.id, + containerId: getCloneByOrigId(rectangle.id)?.id, + frameId: getCloneByOrigId(frame.id)?.id, + }, + { + [ORIG_ID]: frame.id, + selected: true, + }, + ]); + }); + + it("frame + text container selected (order B)", async () => { + const frame = API.createElement({ + type: "frame", + }); + + const [rectangle, text] = API.createTextContainer({ frameId: frame.id }); + + API.setElements([text, rectangle, frame]); + API.setSelectedElements([rectangle, frame]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: rectangle.id, frameId: frame.id }, + { id: text.id, containerId: rectangle.id, frameId: frame.id }, + { id: frame.id }, + { + type: "rectangle", + [ORIG_ID]: `${rectangle.id}`, + }, + { + [ORIG_ID]: `${text.id}`, + type: "text", + containerId: getCloneByOrigId(rectangle.id)?.id, + frameId: getCloneByOrigId(frame.id)?.id, + }, + { [ORIG_ID]: `${frame.id}`, type: "frame", selected: true }, + ]); + }); + }); + + describe("duplicating frame children", () => { + it("frame child selected", () => { + const frame = API.createElement({ + type: "frame", + }); + + const rectangle = API.createElement({ + type: "rectangle", + frameId: frame.id, + }); + + API.setElements([frame, rectangle]); + API.setSelectedElements([rectangle]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: rectangle.id, frameId: frame.id }, + { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true }, + ]); + }); + + it("frame text container selected (rectangle selected)", () => { + const frame = API.createElement({ + type: "frame", + }); + + const [rectangle, text] = API.createTextContainer({ frameId: frame.id }); + + API.setElements([frame, rectangle, text]); + API.setSelectedElements([rectangle]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: rectangle.id, frameId: frame.id }, + { id: text.id, containerId: rectangle.id, frameId: frame.id }, + { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true }, + { + [ORIG_ID]: text.id, + containerId: getCloneByOrigId(rectangle.id).id, + frameId: frame.id, + }, + ]); + }); + + it("frame bound text selected (container not selected)", () => { + const frame = API.createElement({ + type: "frame", + }); + + const [rectangle, text] = API.createTextContainer({ frameId: frame.id }); + + API.setElements([frame, rectangle, text]); + API.setSelectedElements([text]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: rectangle.id, frameId: frame.id }, + { id: text.id, containerId: rectangle.id, frameId: frame.id }, + { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true }, + { + [ORIG_ID]: text.id, + containerId: getCloneByOrigId(rectangle.id).id, + frameId: frame.id, + }, + ]); + }); + + it("frame text container selected (text not exists)", () => { + const frame = API.createElement({ + type: "frame", + }); + + const [rectangle] = API.createTextContainer({ frameId: frame.id }); + + API.setElements([frame, rectangle]); + API.setSelectedElements([rectangle]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: rectangle.id, frameId: frame.id }, + { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true }, + ]); + }); + + // shouldn't happen + it("frame bound text selected (container not exists)", () => { + const frame = API.createElement({ + type: "frame", + }); + + const [, text] = API.createTextContainer({ frameId: frame.id }); + + API.setElements([frame, text]); + API.setSelectedElements([text]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: text.id, frameId: frame.id }, + { [ORIG_ID]: text.id, frameId: frame.id }, + ]); + }); + + it("frame bound container selected (text has no frameId)", () => { + const frame = API.createElement({ + type: "frame", + }); + + const [rectangle, text] = API.createTextContainer({ + frameId: frame.id, + label: { frameId: null }, + }); + + API.setElements([frame, rectangle, text]); + API.setSelectedElements([rectangle]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: frame.id }, + { id: rectangle.id, frameId: frame.id }, + { id: text.id, containerId: rectangle.id }, + { [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true }, + { + [ORIG_ID]: text.id, + containerId: getCloneByOrigId(rectangle.id).id, + }, + ]); + }); + }); + + describe("duplicating multiple frames", () => { + it("multiple frames selected (no children)", () => { + const frame1 = API.createElement({ + type: "frame", + }); + + const rect1 = API.createElement({ + type: "rectangle", + frameId: frame1.id, + }); + + const frame2 = API.createElement({ + type: "frame", + }); + + const rect2 = API.createElement({ + type: "rectangle", + frameId: frame2.id, + }); + + const ellipse = API.createElement({ + type: "ellipse", + }); + + API.setElements([rect1, frame1, ellipse, rect2, frame2]); + API.setSelectedElements([frame1, frame2]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: rect1.id, frameId: frame1.id }, + { id: frame1.id }, + { [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id }, + { [ORIG_ID]: frame1.id, selected: true }, + { id: ellipse.id }, + { id: rect2.id, frameId: frame2.id }, + { id: frame2.id }, + { [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id }, + { [ORIG_ID]: frame2.id, selected: true }, + ]); + }); + + it("multiple frames selected (no children) + unrelated element", () => { + const frame1 = API.createElement({ + type: "frame", + }); + + const rect1 = API.createElement({ + type: "rectangle", + frameId: frame1.id, + }); + + const frame2 = API.createElement({ + type: "frame", + }); + + const rect2 = API.createElement({ + type: "rectangle", + frameId: frame2.id, + }); + + const ellipse = API.createElement({ + type: "ellipse", + }); + + API.setElements([rect1, frame1, ellipse, rect2, frame2]); + API.setSelectedElements([frame1, ellipse, frame2]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: rect1.id, frameId: frame1.id }, + { id: frame1.id }, + { [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id }, + { [ORIG_ID]: frame1.id, selected: true }, + { id: ellipse.id }, + { [ORIG_ID]: ellipse.id, selected: true }, + { id: rect2.id, frameId: frame2.id }, + { id: frame2.id }, + { [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id }, + { [ORIG_ID]: frame2.id, selected: true }, + ]); + }); + }); + + describe("duplicating containers/bound elements", () => { + it("labeled arrow (arrow selected)", () => { + const [arrow, text] = API.createLabeledArrow(); + + API.setElements([arrow, text]); + API.setSelectedElements([arrow]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: arrow.id }, + { id: text.id, containerId: arrow.id }, + { [ORIG_ID]: arrow.id, selected: true }, + { [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id }, + ]); + }); + + // shouldn't happen + it("labeled arrow (text selected)", () => { + const [arrow, text] = API.createLabeledArrow(); + + API.setElements([arrow, text]); + API.setSelectedElements([text]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: arrow.id }, + { id: text.id, containerId: arrow.id }, + { [ORIG_ID]: arrow.id, selected: true }, + { [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id }, + ]); + }); + }); + + describe("duplicating groups", () => { + it("duplicate group containing frame (children don't have groupIds set)", () => { + const frame = API.createElement({ + type: "frame", + groupIds: ["A"], + }); + + const [rectangle, text] = API.createTextContainer({ + frameId: frame.id, + }); + + const ellipse = API.createElement({ + type: "ellipse", + groupIds: ["A"], + }); + + API.setElements([rectangle, text, frame, ellipse]); + API.setSelectedElements([frame, ellipse]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: rectangle.id, frameId: frame.id }, + { id: text.id, frameId: frame.id }, + { id: frame.id }, + { id: ellipse.id }, + { [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id }, + { [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id }, + { [ORIG_ID]: frame.id, selected: true }, + { [ORIG_ID]: ellipse.id, selected: true }, + ]); + }); + + it("duplicate group containing frame (children have groupIds)", () => { + const frame = API.createElement({ + type: "frame", + groupIds: ["A"], + }); + + const [rectangle, text] = API.createTextContainer({ + frameId: frame.id, + groupIds: ["A"], + }); + + const ellipse = API.createElement({ + type: "ellipse", + groupIds: ["A"], + }); + + API.setElements([rectangle, text, frame, ellipse]); + API.setSelectedElements([frame, ellipse]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: rectangle.id, frameId: frame.id }, + { id: text.id, frameId: frame.id }, + { id: frame.id }, + { id: ellipse.id }, + { + [ORIG_ID]: rectangle.id, + frameId: getCloneByOrigId(frame.id)?.id, + // FIXME shouldn't be selected (in selectGroupsForSelectedElements) + selected: true, + }, + { + [ORIG_ID]: text.id, + frameId: getCloneByOrigId(frame.id)?.id, + // FIXME shouldn't be selected (in selectGroupsForSelectedElements) + selected: true, + }, + { [ORIG_ID]: frame.id, selected: true }, + { [ORIG_ID]: ellipse.id, selected: true }, + ]); + }); + + it("duplicating element nested in group", () => { + const ellipse = API.createElement({ + type: "ellipse", + groupIds: ["B"], + }); + const rect1 = API.createElement({ + type: "rectangle", + groupIds: ["A", "B"], + }); + const rect2 = API.createElement({ + type: "rectangle", + groupIds: ["A", "B"], + }); + + API.setElements([ellipse, rect1, rect2]); + API.setSelectedElements([ellipse], "B"); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: ellipse.id }, + { [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true }, + { id: rect1.id, groupIds: ["A", "B"] }, + { id: rect2.id, groupIds: ["A", "B"] }, + ]); + }); + }); +}); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx new file mode 100644 index 0000000..b28e831 --- /dev/null +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -0,0 +1,359 @@ +import { KEYS } from "../keys"; +import { register } from "./register"; +import type { ExcalidrawElement } from "../element/types"; +import { duplicateElement, getNonDeletedElements } from "../element"; +import { isSomeElementSelected } from "../scene"; +import { ToolButton } from "../components/ToolButton"; +import { t } from "../i18n"; +import { + arrayToMap, + castArray, + findLastIndex, + getShortcutKey, + invariant, +} from "../utils"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + selectGroupsForSelectedElements, + getSelectedGroupForElement, + getElementsInGroup, +} from "../groups"; +import type { AppState } from "../types"; +import { fixBindingsAfterDuplication } from "../element/binding"; +import type { ActionResult } from "./types"; +import { DEFAULT_GRID_SIZE } from "../constants"; +import { + bindTextToShapeAfterDuplication, + getBoundTextElement, + getContainerElement, +} from "../element/textElement"; +import { + hasBoundTextElement, + isBoundToContainer, + isFrameLikeElement, +} from "../element/typeChecks"; +import { normalizeElementOrder } from "../element/sortElements"; +import { DuplicateIcon } from "../components/icons"; +import { + bindElementsToFramesAfterDuplication, + getFrameChildren, +} from "../frame"; +import { + excludeElementsInFramesFromSelection, + getSelectedElements, +} from "../scene/selection"; +import { CaptureUpdateAction } from "../store"; + +export const actionDuplicateSelection = register({ + name: "duplicateSelection", + label: "labels.duplicateSelection", + icon: DuplicateIcon, + trackEvent: { category: "element" }, + perform: (elements, appState, formData, app) => { + // duplicate selected point(s) if editing a line + if (appState.editingLinearElement) { + // TODO: Invariants should be checked here instead of duplicateSelectedPoints() + try { + const newAppState = LinearElementEditor.duplicateSelectedPoints( + appState, + app.scene.getNonDeletedElementsMap(), + ); + + return { + elements, + appState: newAppState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } catch { + return false; + } + } + + const nextState = duplicateElements(elements, appState); + + if (app.props.onDuplicate && nextState.elements) { + const mappedElements = app.props.onDuplicate( + nextState.elements, + elements, + ); + if (mappedElements) { + nextState.elements = mappedElements; + } + } + + return { + ...nextState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, + PanelComponent: ({ elements, appState, updateData }) => ( + <ToolButton + type="button" + icon={DuplicateIcon} + title={`${t("labels.duplicateSelection")} — ${getShortcutKey( + "CtrlOrCmd+D", + )}`} + aria-label={t("labels.duplicateSelection")} + onClick={() => updateData(null)} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); + +const duplicateElements = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +): Partial<Exclude<ActionResult, false>> => { + // --------------------------------------------------------------------------- + + const groupIdMap = new Map(); + const newElements: ExcalidrawElement[] = []; + const oldElements: ExcalidrawElement[] = []; + const oldIdToDuplicatedId = new Map(); + const duplicatedElementsMap = new Map<string, ExcalidrawElement>(); + + const elementsMap = arrayToMap(elements); + + const duplicateAndOffsetElement = < + T extends ExcalidrawElement | ExcalidrawElement[], + >( + element: T, + ): T extends ExcalidrawElement[] + ? ExcalidrawElement[] + : ExcalidrawElement | null => { + const elements = castArray(element); + + const _newElements = elements.reduce( + (acc: ExcalidrawElement[], element) => { + if (processedIds.has(element.id)) { + return acc; + } + + processedIds.set(element.id, true); + + const newElement = duplicateElement( + appState.editingGroupId, + groupIdMap, + element, + { + x: element.x + DEFAULT_GRID_SIZE / 2, + y: element.y + DEFAULT_GRID_SIZE / 2, + }, + ); + + processedIds.set(newElement.id, true); + + duplicatedElementsMap.set(newElement.id, newElement); + oldIdToDuplicatedId.set(element.id, newElement.id); + + oldElements.push(element); + newElements.push(newElement); + + acc.push(newElement); + return acc; + }, + [], + ); + + return ( + Array.isArray(element) ? _newElements : _newElements[0] || null + ) as T extends ExcalidrawElement[] + ? ExcalidrawElement[] + : ExcalidrawElement | null; + }; + + elements = normalizeElementOrder(elements); + + const idsOfElementsToDuplicate = arrayToMap( + getSelectedElements(elements, appState, { + includeBoundTextElement: true, + includeElementsInFrames: true, + }), + ); + + // Ids of elements that have already been processed so we don't push them + // into the array twice if we end up backtracking when retrieving + // discontiguous group of elements (can happen due to a bug, or in edge + // cases such as a group containing deleted elements which were not selected). + // + // This is not enough to prevent duplicates, so we do a second loop afterwards + // to remove them. + // + // For convenience we mark even the newly created ones even though we don't + // loop over them. + const processedIds = new Map<ExcalidrawElement["id"], true>(); + + const elementsWithClones: ExcalidrawElement[] = elements.slice(); + + const insertAfterIndex = ( + index: number, + elements: ExcalidrawElement | null | ExcalidrawElement[], + ) => { + invariant(index !== -1, "targetIndex === -1 "); + + if (!Array.isArray(elements) && !elements) { + return; + } + + elementsWithClones.splice(index + 1, 0, ...castArray(elements)); + }; + + const frameIdsToDuplicate = new Set( + elements + .filter( + (el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el), + ) + .map((el) => el.id), + ); + + for (const element of elements) { + if (processedIds.has(element.id)) { + continue; + } + + if (!idsOfElementsToDuplicate.has(element.id)) { + continue; + } + + // groups + // ------------------------------------------------------------------------- + + const groupId = getSelectedGroupForElement(appState, element); + if (groupId) { + const groupElements = getElementsInGroup(elements, groupId).flatMap( + (element) => + isFrameLikeElement(element) + ? [...getFrameChildren(elements, element.id), element] + : [element], + ); + + const targetIndex = findLastIndex(elementsWithClones, (el) => { + return el.groupIds?.includes(groupId); + }); + + insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements)); + continue; + } + + // frame duplication + // ------------------------------------------------------------------------- + + if (element.frameId && frameIdsToDuplicate.has(element.frameId)) { + continue; + } + + if (isFrameLikeElement(element)) { + const frameId = element.id; + + const frameChildren = getFrameChildren(elements, frameId); + + const targetIndex = findLastIndex(elementsWithClones, (el) => { + return el.frameId === frameId || el.id === frameId; + }); + + insertAfterIndex( + targetIndex, + duplicateAndOffsetElement([...frameChildren, element]), + ); + continue; + } + + // text container + // ------------------------------------------------------------------------- + + if (hasBoundTextElement(element)) { + const boundTextElement = getBoundTextElement(element, elementsMap); + + const targetIndex = findLastIndex(elementsWithClones, (el) => { + return ( + el.id === element.id || + ("containerId" in el && el.containerId === element.id) + ); + }); + + if (boundTextElement) { + insertAfterIndex( + targetIndex, + duplicateAndOffsetElement([element, boundTextElement]), + ); + } else { + insertAfterIndex(targetIndex, duplicateAndOffsetElement(element)); + } + + continue; + } + + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + + const targetIndex = findLastIndex(elementsWithClones, (el) => { + return el.id === element.id || el.id === container?.id; + }); + + if (container) { + insertAfterIndex( + targetIndex, + duplicateAndOffsetElement([container, element]), + ); + } else { + insertAfterIndex(targetIndex, duplicateAndOffsetElement(element)); + } + + continue; + } + + // default duplication (regular elements) + // ------------------------------------------------------------------------- + + insertAfterIndex( + findLastIndex(elementsWithClones, (el) => el.id === element.id), + duplicateAndOffsetElement(element), + ); + } + + // --------------------------------------------------------------------------- + + bindTextToShapeAfterDuplication( + elementsWithClones, + oldElements, + oldIdToDuplicatedId, + ); + fixBindingsAfterDuplication( + elementsWithClones, + oldElements, + oldIdToDuplicatedId, + ); + bindElementsToFramesAfterDuplication( + elementsWithClones, + oldElements, + oldIdToDuplicatedId, + ); + + const nextElementsToSelect = + excludeElementsInFramesFromSelection(newElements); + + return { + elements: elementsWithClones, + appState: { + ...appState, + ...selectGroupsForSelectedElements( + { + editingGroupId: appState.editingGroupId, + selectedElementIds: nextElementsToSelect.reduce( + (acc: Record<ExcalidrawElement["id"], true>, element) => { + if (!isBoundToContainer(element)) { + acc[element.id] = true; + } + return acc; + }, + {}, + ), + }, + getNonDeletedElements(elementsWithClones), + appState, + null, + ), + }, + }; +}; diff --git a/packages/excalidraw/actions/actionElementLink.ts b/packages/excalidraw/actions/actionElementLink.ts new file mode 100644 index 0000000..91469fd --- /dev/null +++ b/packages/excalidraw/actions/actionElementLink.ts @@ -0,0 +1,110 @@ +import { copyTextToSystemClipboard } from "../clipboard"; +import { copyIcon, elementLinkIcon } from "../components/icons"; +import { + canCreateLinkFromElements, + defaultGetElementLinkFromSelection, + getLinkIdAndTypeFromSelection, +} from "../element/elementLink"; +import { t } from "../i18n"; +import { getSelectedElements } from "../scene"; +import { CaptureUpdateAction } from "../store"; +import { register } from "./register"; + +export const actionCopyElementLink = register({ + name: "copyElementLink", + label: "labels.copyElementLink", + icon: copyIcon, + trackEvent: { category: "element" }, + perform: async (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + try { + if (window.location) { + const idAndType = getLinkIdAndTypeFromSelection( + selectedElements, + appState, + ); + + if (idAndType) { + await copyTextToSystemClipboard( + app.props.generateLinkForSelection + ? app.props.generateLinkForSelection(idAndType.id, idAndType.type) + : defaultGetElementLinkFromSelection( + idAndType.id, + idAndType.type, + ), + ); + + return { + appState: { + toast: { + message: t("toast.elementLinkCopied"), + closable: true, + }, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + return { + appState, + elements, + app, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + } catch (error: any) { + console.error(error); + } + + return { + appState, + elements, + app, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + predicate: (elements, appState) => + canCreateLinkFromElements(getSelectedElements(elements, appState)), +}); + +export const actionLinkToElement = register({ + name: "linkToElement", + label: "labels.linkToElement", + icon: elementLinkIcon, + perform: (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + if ( + selectedElements.length !== 1 || + !canCreateLinkFromElements(selectedElements) + ) { + return { + elements, + appState, + app, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + + return { + appState: { + ...appState, + openDialog: { + name: "elementLinkSelector", + sourceElementId: getSelectedElements(elements, appState)[0].id, + }, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + predicate: (elements, appState, appProps, app) => { + const selectedElements = getSelectedElements(elements, appState); + + return ( + appState.openDialog?.name !== "elementLinkSelector" && + selectedElements.length === 1 && + canCreateLinkFromElements(selectedElements) + ); + }, + trackEvent: false, +}); diff --git a/packages/excalidraw/actions/actionElementLock.test.tsx b/packages/excalidraw/actions/actionElementLock.test.tsx new file mode 100644 index 0000000..d6dd3f7 --- /dev/null +++ b/packages/excalidraw/actions/actionElementLock.test.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { Excalidraw } from "../index"; +import { queryByTestId, fireEvent } from "@testing-library/react"; +import { render } from "../tests/test-utils"; +import { Pointer, UI } from "../tests/helpers/ui"; +import { API } from "../tests/helpers/api"; + +const { h } = window; +const mouse = new Pointer("mouse"); + +describe("element locking", () => { + it("should not show unlockAllElements action in contextMenu if no elements locked", async () => { + await render(<Excalidraw />); + + mouse.rightClickAt(0, 0); + + const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements"); + expect(item).toBe(null); + }); + + it("should unlock all elements and select them when using unlockAllElements action in contextMenu", async () => { + await render( + <Excalidraw + initialData={{ + elements: [ + API.createElement({ + x: 100, + y: 100, + width: 100, + height: 100, + locked: true, + }), + API.createElement({ + x: 100, + y: 100, + width: 100, + height: 100, + locked: true, + }), + API.createElement({ + x: 100, + y: 100, + width: 100, + height: 100, + locked: false, + }), + ], + }} + />, + ); + + mouse.rightClickAt(0, 0); + + expect(Object.keys(h.state.selectedElementIds).length).toBe(0); + expect(h.elements.map((el) => el.locked)).toEqual([true, true, false]); + + const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements"); + expect(item).not.toBe(null); + + fireEvent.click(item!.querySelector("button")!); + + expect(h.elements.map((el) => el.locked)).toEqual([false, false, false]); + // should select the unlocked elements + expect(h.state.selectedElementIds).toEqual({ + [h.elements[0].id]: true, + [h.elements[1].id]: true, + }); + }); +}); diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts new file mode 100644 index 0000000..eba21f2 --- /dev/null +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -0,0 +1,119 @@ +import { LockedIcon, UnlockedIcon } from "../components/icons"; +import { newElementWith } from "../element/mutateElement"; +import { isFrameLikeElement } from "../element/typeChecks"; +import type { ExcalidrawElement } from "../element/types"; +import { KEYS } from "../keys"; +import { getSelectedElements } from "../scene"; +import { CaptureUpdateAction } from "../store"; +import { arrayToMap } from "../utils"; +import { register } from "./register"; + +const shouldLock = (elements: readonly ExcalidrawElement[]) => + elements.every((el) => !el.locked); + +export const actionToggleElementLock = register({ + name: "toggleElementLock", + label: (elements, appState, app) => { + const selected = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: false, + }); + if (selected.length === 1 && !isFrameLikeElement(selected[0])) { + return selected[0].locked + ? "labels.elementLock.unlock" + : "labels.elementLock.lock"; + } + + return shouldLock(selected) + ? "labels.elementLock.lockAll" + : "labels.elementLock.unlockAll"; + }, + icon: (appState, elements) => { + const selectedElements = getSelectedElements(elements, appState); + return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon; + }, + trackEvent: { category: "element" }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + return ( + selectedElements.length > 0 && + !selectedElements.some((element) => element.locked && element.frameId) + ); + }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + includeElementsInFrames: true, + }); + + if (!selectedElements.length) { + return false; + } + + const nextLockState = shouldLock(selectedElements); + const selectedElementsMap = arrayToMap(selectedElements); + return { + elements: elements.map((element) => { + if (!selectedElementsMap.has(element.id)) { + return element; + } + + return newElementWith(element, { locked: nextLockState }); + }), + appState: { + ...appState, + selectedLinearElement: nextLockState + ? null + : appState.selectedLinearElement, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event, appState, elements, app) => { + return ( + event.key.toLocaleLowerCase() === KEYS.L && + event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: false, + }).length > 0 + ); + }, +}); + +export const actionUnlockAllElements = register({ + name: "unlockAllElements", + paletteName: "Unlock all elements", + trackEvent: { category: "canvas" }, + viewMode: false, + icon: UnlockedIcon, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length === 0 && + elements.some((element) => element.locked) + ); + }, + perform: (elements, appState) => { + const lockedElements = elements.filter((el) => el.locked); + + return { + elements: elements.map((element) => { + if (element.locked) { + return newElementWith(element, { locked: false }); + } + return element; + }), + appState: { + ...appState, + selectedElementIds: Object.fromEntries( + lockedElements.map((el) => [el.id, true]), + ), + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + label: "labels.elementLock.unlockAll", +}); diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx new file mode 100644 index 0000000..8d18acd --- /dev/null +++ b/packages/excalidraw/actions/actionExport.tsx @@ -0,0 +1,309 @@ +import { ExportIcon, questionCircle, saveAs } from "../components/icons"; +import { ProjectName } from "../components/ProjectName"; +import { ToolButton } from "../components/ToolButton"; +import { Tooltip } from "../components/Tooltip"; +import { DarkModeToggle } from "../components/DarkModeToggle"; +import { loadFromJSON, saveAsJSON } from "../data"; +import { resaveAsImageWithScene } from "../data/resave"; +import { t } from "../i18n"; +import { useDevice } from "../components/App"; +import { KEYS } from "../keys"; +import { register } from "./register"; +import { CheckboxItem } from "../components/CheckboxItem"; +import { getExportSize } from "../scene/export"; +import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants"; +import { getSelectedElements, isSomeElementSelected } from "../scene"; +import { getNonDeletedElements } from "../element"; +import { isImageFileHandle } from "../data/blob"; +import { nativeFileSystemSupported } from "../data/filesystem"; +import type { Theme } from "../element/types"; + +import "../components/ToolIcon.scss"; +import { CaptureUpdateAction } from "../store"; + +export const actionChangeProjectName = register({ + name: "changeProjectName", + label: "labels.fileTitle", + trackEvent: false, + perform: (_elements, appState, value) => { + return { + appState: { ...appState, name: value }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ appState, updateData, appProps, data, app }) => ( + <ProjectName + label={t("labels.fileTitle")} + value={app.getName()} + onChange={(name: string) => updateData(name)} + ignoreFocus={data?.ignoreFocus ?? false} + /> + ), +}); + +export const actionChangeExportScale = register({ + name: "changeExportScale", + label: "imageExportDialog.scale", + trackEvent: { category: "export", action: "scale" }, + perform: (_elements, appState, value) => { + return { + appState: { ...appState, exportScale: value }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ elements: allElements, appState, updateData }) => { + const elements = getNonDeletedElements(allElements); + const exportSelected = isSomeElementSelected(elements, appState); + const exportedElements = exportSelected + ? getSelectedElements(elements, appState) + : elements; + + return ( + <> + {EXPORT_SCALES.map((s) => { + const [width, height] = getExportSize( + exportedElements, + DEFAULT_EXPORT_PADDING, + s, + ); + + const scaleButtonTitle = `${t( + "imageExportDialog.label.scale", + )} ${s}x (${width}x${height})`; + + return ( + <ToolButton + key={s} + size="small" + type="radio" + icon={`${s}x`} + name="export-canvas-scale" + title={scaleButtonTitle} + aria-label={scaleButtonTitle} + id="export-canvas-scale" + checked={s === appState.exportScale} + onChange={() => updateData(s)} + /> + ); + })} + </> + ); + }, +}); + +export const actionChangeExportBackground = register({ + name: "changeExportBackground", + label: "imageExportDialog.label.withBackground", + trackEvent: { category: "export", action: "toggleBackground" }, + perform: (_elements, appState, value) => { + return { + appState: { ...appState, exportBackground: value }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ appState, updateData }) => ( + <CheckboxItem + checked={appState.exportBackground} + onChange={(checked) => updateData(checked)} + > + {t("imageExportDialog.label.withBackground")} + </CheckboxItem> + ), +}); + +export const actionChangeExportEmbedScene = register({ + name: "changeExportEmbedScene", + label: "imageExportDialog.tooltip.embedScene", + trackEvent: { category: "export", action: "embedScene" }, + perform: (_elements, appState, value) => { + return { + appState: { ...appState, exportEmbedScene: value }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ appState, updateData }) => ( + <CheckboxItem + checked={appState.exportEmbedScene} + onChange={(checked) => updateData(checked)} + > + {t("imageExportDialog.label.embedScene")} + <Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}> + <div className="excalidraw-tooltip-icon">{questionCircle}</div> + </Tooltip> + </CheckboxItem> + ), +}); + +export const actionSaveToActiveFile = register({ + name: "saveToActiveFile", + label: "buttons.save", + icon: ExportIcon, + trackEvent: { category: "export" }, + predicate: (elements, appState, props, app) => { + return ( + !!app.props.UIOptions.canvasActions.saveToActiveFile && + !!appState.fileHandle && + !appState.viewModeEnabled + ); + }, + perform: async (elements, appState, value, app) => { + const fileHandleExists = !!appState.fileHandle; + + try { + const { fileHandle } = isImageFileHandle(appState.fileHandle) + ? await resaveAsImageWithScene( + elements, + appState, + app.files, + app.getName(), + ) + : await saveAsJSON(elements, appState, app.files, app.getName()); + + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + fileHandle, + toast: fileHandleExists + ? { + message: fileHandle?.name + ? t("toast.fileSavedToFilename").replace( + "{filename}", + `"${fileHandle.name}"`, + ) + : t("toast.fileSaved"), + } + : null, + }, + }; + } catch (error: any) { + if (error?.name !== "AbortError") { + console.error(error); + } else { + console.warn(error); + } + return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; + } + }, + keyTest: (event) => + event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey, +}); + +export const actionSaveFileToDisk = register({ + name: "saveFileToDisk", + label: "exportDialog.disk_title", + icon: ExportIcon, + viewMode: true, + trackEvent: { category: "export" }, + perform: async (elements, appState, value, app) => { + try { + const { fileHandle } = await saveAsJSON( + elements, + { + ...appState, + fileHandle: null, + }, + app.files, + app.getName(), + ); + return { + captureUpdate: CaptureUpdateAction.EVENTUALLY, + appState: { + ...appState, + openDialog: null, + fileHandle, + toast: { message: t("toast.fileSaved") }, + }, + }; + } catch (error: any) { + if (error?.name !== "AbortError") { + console.error(error); + } else { + console.warn(error); + } + return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; + } + }, + keyTest: (event) => + event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD], + PanelComponent: ({ updateData }) => ( + <ToolButton + type="button" + icon={saveAs} + title={t("buttons.saveAs")} + aria-label={t("buttons.saveAs")} + showAriaLabel={useDevice().editor.isMobile} + hidden={!nativeFileSystemSupported} + onClick={() => updateData(null)} + data-testid="save-as-button" + /> + ), +}); + +export const actionLoadScene = register({ + name: "loadScene", + label: "buttons.load", + trackEvent: { category: "export" }, + predicate: (elements, appState, props, app) => { + return ( + !!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled + ); + }, + perform: async (elements, appState, _, app) => { + try { + const { + elements: loadedElements, + appState: loadedAppState, + files, + } = await loadFromJSON(appState, elements); + return { + elements: loadedElements, + appState: loadedAppState, + files, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } catch (error: any) { + if (error?.name === "AbortError") { + console.warn(error); + return false; + } + return { + elements, + appState: { ...appState, errorMessage: error.message }, + files: app.files, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, +}); + +export const actionExportWithDarkMode = register({ + name: "exportWithDarkMode", + label: "imageExportDialog.label.darkMode", + trackEvent: { category: "export", action: "toggleTheme" }, + perform: (_elements, appState, value) => { + return { + appState: { ...appState, exportWithDarkMode: value }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ appState, updateData }) => ( + <div + style={{ + display: "flex", + justifyContent: "flex-end", + marginTop: "-45px", + marginBottom: "10px", + }} + > + <DarkModeToggle + value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT} + onChange={(theme: Theme) => { + updateData(theme === THEME.DARK); + }} + title={t("imageExportDialog.label.darkMode")} + /> + </div> + ), +}); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx new file mode 100644 index 0000000..f2d8c01 --- /dev/null +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -0,0 +1,223 @@ +import { KEYS } from "../keys"; +import { isInvisiblySmallElement } from "../element"; +import { arrayToMap, updateActiveTool } from "../utils"; +import { ToolButton } from "../components/ToolButton"; +import { done } from "../components/icons"; +import { t } from "../i18n"; +import { register } from "./register"; +import { mutateElement } from "../element/mutateElement"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + maybeBindLinearElement, + bindOrUnbindLinearElement, +} from "../element/binding"; +import { isBindingElement, isLinearElement } from "../element/typeChecks"; +import type { AppState } from "../types"; +import { resetCursor } from "../cursor"; +import { CaptureUpdateAction } from "../store"; +import { pointFrom } from "@excalidraw/math"; +import { isPathALoop } from "../shapes"; + +export const actionFinalize = register({ + name: "finalize", + label: "", + trackEvent: false, + perform: (elements, appState, _, app) => { + const { interactiveCanvas, focusContainer, scene } = app; + + const elementsMap = scene.getNonDeletedElementsMap(); + + if (appState.editingLinearElement) { + const { elementId, startBindingElement, endBindingElement } = + appState.editingLinearElement; + const element = LinearElementEditor.getElement(elementId, elementsMap); + + if (element) { + if (isBindingElement(element)) { + bindOrUnbindLinearElement( + element, + startBindingElement, + endBindingElement, + elementsMap, + scene, + ); + } + return { + elements: + element.points.length < 2 || isInvisiblySmallElement(element) + ? elements.filter((el) => el.id !== element.id) + : undefined, + appState: { + ...appState, + cursorButton: "up", + editingLinearElement: null, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } + } + + let newElements = elements; + + const pendingImageElement = + appState.pendingImageElementId && + scene.getElement(appState.pendingImageElementId); + + if (pendingImageElement) { + mutateElement(pendingImageElement, { isDeleted: true }, false); + } + + if (window.document.activeElement instanceof HTMLElement) { + focusContainer(); + } + + const multiPointElement = appState.multiElement + ? appState.multiElement + : appState.newElement?.type === "freedraw" + ? appState.newElement + : null; + + if (multiPointElement) { + // pen and mouse have hover + if ( + multiPointElement.type !== "freedraw" && + appState.lastPointerDownWith !== "touch" + ) { + const { points, lastCommittedPoint } = multiPointElement; + if ( + !lastCommittedPoint || + points[points.length - 1] !== lastCommittedPoint + ) { + mutateElement(multiPointElement, { + points: multiPointElement.points.slice(0, -1), + }); + } + } + + if (isInvisiblySmallElement(multiPointElement)) { + // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want + newElements = newElements.filter( + (el) => el.id !== multiPointElement.id, + ); + } + + // If the multi point line closes the loop, + // set the last point to first point. + // This ensures that loop remains closed at different scales. + const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); + if ( + multiPointElement.type === "line" || + multiPointElement.type === "freedraw" + ) { + if (isLoop) { + const linePoints = multiPointElement.points; + const firstPoint = linePoints[0]; + mutateElement(multiPointElement, { + points: linePoints.map((p, index) => + index === linePoints.length - 1 + ? pointFrom(firstPoint[0], firstPoint[1]) + : p, + ), + }); + } + } + + if ( + isBindingElement(multiPointElement) && + !isLoop && + multiPointElement.points.length > 1 + ) { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + multiPointElement, + -1, + arrayToMap(elements), + ); + maybeBindLinearElement( + multiPointElement, + appState, + { x, y }, + elementsMap, + elements, + ); + } + } + + if ( + (!appState.activeTool.locked && + appState.activeTool.type !== "freedraw") || + !multiPointElement + ) { + resetCursor(interactiveCanvas); + } + + let activeTool: AppState["activeTool"]; + if (appState.activeTool.type === "eraser") { + activeTool = updateActiveTool(appState, { + ...(appState.activeTool.lastActiveTool || { + type: "selection", + }), + lastActiveToolBeforeEraser: null, + }); + } else { + activeTool = updateActiveTool(appState, { + type: "selection", + }); + } + + return { + elements: newElements, + appState: { + ...appState, + cursorButton: "up", + activeTool: + (appState.activeTool.locked || + appState.activeTool.type === "freedraw") && + multiPointElement + ? appState.activeTool + : activeTool, + activeEmbeddable: null, + newElement: null, + selectionElement: null, + multiElement: null, + editingTextElement: null, + startBoundElement: null, + suggestedBindings: [], + selectedElementIds: + multiPointElement && + !appState.activeTool.locked && + appState.activeTool.type !== "freedraw" + ? { + ...appState.selectedElementIds, + [multiPointElement.id]: true, + } + : appState.selectedElementIds, + // To select the linear element when user has finished mutipoint editing + selectedLinearElement: + multiPointElement && isLinearElement(multiPointElement) + ? new LinearElementEditor(multiPointElement) + : appState.selectedLinearElement, + pendingImageElementId: null, + }, + // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event, appState) => + (event.key === KEYS.ESCAPE && + (appState.editingLinearElement !== null || + (!appState.newElement && appState.multiElement === null))) || + ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && + appState.multiElement !== null), + PanelComponent: ({ appState, updateData, data }) => ( + <ToolButton + type="button" + icon={done} + title={t("buttons.done")} + aria-label={t("buttons.done")} + onClick={updateData} + visible={appState.multiElement != null} + size={data?.size || "medium"} + style={{ pointerEvents: "all" }} + /> + ), +}); diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx new file mode 100644 index 0000000..94e4721 --- /dev/null +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -0,0 +1,212 @@ +import React from "react"; +import { Excalidraw } from "../index"; +import { render } from "../tests/test-utils"; +import { API } from "../tests/helpers/api"; +import { pointFrom } from "@excalidraw/math"; +import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; + +const { h } = window; + +describe("flipping re-centers selection", () => { + it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => { + const elements = [ + API.createElement({ + type: "rectangle", + id: "rec1", + x: 100, + y: 100, + width: 100, + height: 100, + boundElements: [{ id: "arr", type: "arrow" }], + }), + API.createElement({ + type: "rectangle", + id: "rec2", + x: 220, + y: 250, + width: 100, + height: 100, + boundElements: [{ id: "arr", type: "arrow" }], + }), + API.createElement({ + type: "arrow", + id: "arr", + x: 149.9, + y: 95, + width: 156, + height: 239.9, + startBinding: { + elementId: "rec1", + focus: 0, + gap: 5, + fixedPoint: [0.49, -0.05], + }, + endBinding: { + elementId: "rec2", + focus: 0, + gap: 5, + fixedPoint: [-0.05, 0.49], + }, + startArrowhead: null, + endArrowhead: "arrow", + fixedSegments: null, + points: [ + pointFrom(0, 0), + pointFrom(0, -35), + pointFrom(-90, -35), + pointFrom(-90, 204), + pointFrom(66, 204), + ], + elbowed: true, + }), + ]; + await render(<Excalidraw initialData={{ elements }} />); + + API.setSelectedElements(elements); + + expect(Object.keys(h.state.selectedElementIds).length).toBe(3); + + API.executeAction(actionFlipHorizontal); + API.executeAction(actionFlipHorizontal); + API.executeAction(actionFlipHorizontal); + API.executeAction(actionFlipHorizontal); + + const rec1 = h.elements.find((el) => el.id === "rec1")!; + expect(rec1.x).toBeCloseTo(100, 0); + expect(rec1.y).toBeCloseTo(100, 0); + + const rec2 = h.elements.find((el) => el.id === "rec2")!; + expect(rec2.x).toBeCloseTo(220, 0); + expect(rec2.y).toBeCloseTo(250, 0); + }); +}); + +describe("flipping arrowheads", () => { + beforeEach(async () => { + await render(<Excalidraw />); + }); + + it("flipping bound arrow should flip arrowheads only", () => { + const rect = API.createElement({ + type: "rectangle", + boundElements: [{ type: "arrow", id: "arrow1" }], + }); + const arrow = API.createElement({ + type: "arrow", + id: "arrow1", + startArrowhead: "arrow", + endArrowhead: null, + endBinding: { + elementId: rect.id, + focus: 0.5, + gap: 5, + }, + }); + + API.setElements([rect, arrow]); + API.setSelectedElements([arrow]); + + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe(null); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe(null); + expect(API.getElement(arrow).endArrowhead).toBe("arrow"); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe(null); + + API.executeAction(actionFlipVertical); + expect(API.getElement(arrow).startArrowhead).toBe(null); + expect(API.getElement(arrow).endArrowhead).toBe("arrow"); + }); + + it("flipping bound arrow should flip arrowheads only 2", () => { + const rect = API.createElement({ + type: "rectangle", + boundElements: [{ type: "arrow", id: "arrow1" }], + }); + const rect2 = API.createElement({ + type: "rectangle", + boundElements: [{ type: "arrow", id: "arrow1" }], + }); + const arrow = API.createElement({ + type: "arrow", + id: "arrow1", + startArrowhead: "arrow", + endArrowhead: "circle", + startBinding: { + elementId: rect.id, + focus: 0.5, + gap: 5, + }, + endBinding: { + elementId: rect2.id, + focus: 0.5, + gap: 5, + }, + }); + + API.setElements([rect, rect2, arrow]); + API.setSelectedElements([arrow]); + + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe("circle"); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe("circle"); + expect(API.getElement(arrow).endArrowhead).toBe("arrow"); + + API.executeAction(actionFlipVertical); + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe("circle"); + }); + + it("flipping unbound arrow shouldn't flip arrowheads", () => { + const arrow = API.createElement({ + type: "arrow", + id: "arrow1", + startArrowhead: "arrow", + endArrowhead: "circle", + }); + + API.setElements([arrow]); + API.setSelectedElements([arrow]); + + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe("circle"); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe("circle"); + }); + + it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => { + const rect = API.createElement({ + type: "rectangle", + boundElements: [{ type: "arrow", id: "arrow1" }], + }); + const arrow = API.createElement({ + type: "arrow", + id: "arrow1", + startArrowhead: "arrow", + endArrowhead: null, + endBinding: { + elementId: rect.id, + focus: 0.5, + gap: 5, + }, + }); + + API.setElements([rect, arrow]); + API.setSelectedElements([rect, arrow]); + + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe(null); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe(null); + }); +}); diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts new file mode 100644 index 0000000..80149b8 --- /dev/null +++ b/packages/excalidraw/actions/actionFlip.ts @@ -0,0 +1,204 @@ +import { register } from "./register"; +import { getSelectedElements } from "../scene"; +import { getNonDeletedElements } from "../element"; +import type { + ExcalidrawArrowElement, + ExcalidrawElbowArrowElement, + ExcalidrawElement, + NonDeleted, + NonDeletedSceneElementsMap, +} from "../element/types"; +import { resizeMultipleElements } from "../element/resizeElements"; +import type { AppClassProperties, AppState } from "../types"; +import { arrayToMap } from "../utils"; +import { CODES, KEYS } from "../keys"; +import { + bindOrUnbindLinearElements, + isBindingEnabled, +} from "../element/binding"; +import { updateFrameMembershipOfSelectedElements } from "../frame"; +import { flipHorizontal, flipVertical } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; +import { + isArrowElement, + isElbowArrow, + isLinearElement, +} from "../element/typeChecks"; +import { mutateElement, newElementWith } from "../element/mutateElement"; +import { deepCopyElement } from "../element/newElement"; +import { getCommonBoundingBox } from "../element/bounds"; + +export const actionFlipHorizontal = register({ + name: "flipHorizontal", + label: "labels.flipHorizontal", + icon: flipHorizontal, + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + return { + elements: updateFrameMembershipOfSelectedElements( + flipSelectedElements( + elements, + app.scene.getNonDeletedElementsMap(), + appState, + "horizontal", + app, + ), + appState, + app, + ), + appState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => event.shiftKey && event.code === CODES.H, +}); + +export const actionFlipVertical = register({ + name: "flipVertical", + label: "labels.flipVertical", + icon: flipVertical, + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + return { + elements: updateFrameMembershipOfSelectedElements( + flipSelectedElements( + elements, + app.scene.getNonDeletedElementsMap(), + appState, + "vertical", + app, + ), + appState, + app, + ), + appState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD], +}); + +const flipSelectedElements = ( + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, + appState: Readonly<AppState>, + flipDirection: "horizontal" | "vertical", + app: AppClassProperties, +) => { + const selectedElements = getSelectedElements( + getNonDeletedElements(elements), + appState, + { + includeBoundTextElement: true, + includeElementsInFrames: true, + }, + ); + + const updatedElements = flipElements( + selectedElements, + elementsMap, + appState, + flipDirection, + app, + ); + + const updatedElementsMap = arrayToMap(updatedElements); + + return elements.map( + (element) => updatedElementsMap.get(element.id) || element, + ); +}; + +const flipElements = ( + selectedElements: NonDeleted<ExcalidrawElement>[], + elementsMap: NonDeletedSceneElementsMap, + appState: AppState, + flipDirection: "horizontal" | "vertical", + app: AppClassProperties, +): ExcalidrawElement[] => { + if ( + selectedElements.every( + (element) => + isArrowElement(element) && (element.startBinding || element.endBinding), + ) + ) { + return selectedElements.map((element) => { + const _element = element as ExcalidrawArrowElement; + return newElementWith(_element, { + startArrowhead: _element.endArrowhead, + endArrowhead: _element.startArrowhead, + }); + }); + } + + const { midX, midY } = getCommonBoundingBox(selectedElements); + + resizeMultipleElements( + selectedElements, + elementsMap, + "nw", + app.scene, + new Map( + Array.from(elementsMap.values()).map((element) => [ + element.id, + deepCopyElement(element), + ]), + ), + { + flipByX: flipDirection === "horizontal", + flipByY: flipDirection === "vertical", + shouldResizeFromCenter: true, + shouldMaintainAspectRatio: true, + }, + ); + + bindOrUnbindLinearElements( + selectedElements.filter(isLinearElement), + elementsMap, + app.scene.getNonDeletedElements(), + app.scene, + isBindingEnabled(appState), + [], + appState.zoom, + ); + + // --------------------------------------------------------------------------- + // flipping arrow elements (and potentially other) makes the selection group + // "move" across the canvas because of how arrows can bump against the "wall" + // of the selection, so we need to center the group back to the original + // position so that repeated flips don't accumulate the offset + + const { elbowArrows, otherElements } = selectedElements.reduce( + ( + acc: { + elbowArrows: ExcalidrawElbowArrowElement[]; + otherElements: ExcalidrawElement[]; + }, + element, + ) => + isElbowArrow(element) + ? { ...acc, elbowArrows: acc.elbowArrows.concat(element) } + : { ...acc, otherElements: acc.otherElements.concat(element) }, + { elbowArrows: [], otherElements: [] }, + ); + + const { midX: newMidX, midY: newMidY } = + getCommonBoundingBox(selectedElements); + const [diffX, diffY] = [midX - newMidX, midY - newMidY]; + otherElements.forEach((element) => + mutateElement(element, { + x: element.x + diffX, + y: element.y + diffY, + }), + ); + elbowArrows.forEach((element) => + mutateElement(element, { + x: element.x + diffX, + y: element.y + diffY, + }), + ); + // --------------------------------------------------------------------------- + + return selectedElements; +}; diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts new file mode 100644 index 0000000..ce05b53 --- /dev/null +++ b/packages/excalidraw/actions/actionFrame.ts @@ -0,0 +1,214 @@ +import { getCommonBounds, getNonDeletedElements } from "../element"; +import type { ExcalidrawElement } from "../element/types"; +import { addElementsToFrame, removeAllElementsFromFrame } from "../frame"; +import { getFrameChildren } from "../frame"; +import { KEYS } from "../keys"; +import type { AppClassProperties, AppState, UIAppState } from "../types"; +import { updateActiveTool } from "../utils"; +import { setCursorForShape } from "../cursor"; +import { register } from "./register"; +import { isFrameLikeElement } from "../element/typeChecks"; +import { frameToolIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; +import { getSelectedElements } from "../scene"; +import { newFrameElement } from "../element/newElement"; +import { getElementsInGroup } from "../groups"; +import { mutateElement } from "../element/mutateElement"; + +const isSingleFrameSelected = ( + appState: UIAppState, + app: AppClassProperties, +) => { + const selectedElements = app.scene.getSelectedElements(appState); + + return ( + selectedElements.length === 1 && isFrameLikeElement(selectedElements[0]) + ); +}; + +export const actionSelectAllElementsInFrame = register({ + name: "selectAllElementsInFrame", + label: "labels.selectAllElementsInFrame", + trackEvent: { category: "canvas" }, + perform: (elements, appState, _, app) => { + const selectedElement = + app.scene.getSelectedElements(appState).at(0) || null; + + if (isFrameLikeElement(selectedElement)) { + const elementsInFrame = getFrameChildren( + getNonDeletedElements(elements), + selectedElement.id, + ).filter((element) => !(element.type === "text" && element.containerId)); + + return { + elements, + appState: { + ...appState, + selectedElementIds: elementsInFrame.reduce((acc, element) => { + acc[element.id] = true; + return acc; + }, {} as Record<ExcalidrawElement["id"], true>), + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } + + return { + elements, + appState, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + predicate: (elements, appState, _, app) => + isSingleFrameSelected(appState, app), +}); + +export const actionRemoveAllElementsFromFrame = register({ + name: "removeAllElementsFromFrame", + label: "labels.removeAllElementsFromFrame", + trackEvent: { category: "history" }, + perform: (elements, appState, _, app) => { + const selectedElement = + app.scene.getSelectedElements(appState).at(0) || null; + + if (isFrameLikeElement(selectedElement)) { + return { + elements: removeAllElementsFromFrame(elements, selectedElement), + appState: { + ...appState, + selectedElementIds: { + [selectedElement.id]: true, + }, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + } + + return { + elements, + appState, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + predicate: (elements, appState, _, app) => + isSingleFrameSelected(appState, app), +}); + +export const actionupdateFrameRendering = register({ + name: "updateFrameRendering", + label: "labels.updateFrameRendering", + viewMode: true, + trackEvent: { category: "canvas" }, + perform: (elements, appState) => { + return { + elements, + appState: { + ...appState, + frameRendering: { + ...appState.frameRendering, + enabled: !appState.frameRendering.enabled, + }, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + checked: (appState: AppState) => appState.frameRendering.enabled, +}); + +export const actionSetFrameAsActiveTool = register({ + name: "setFrameAsActiveTool", + label: "toolBar.frame", + trackEvent: { category: "toolbar" }, + icon: frameToolIcon, + viewMode: false, + perform: (elements, appState, _, app) => { + const nextActiveTool = updateActiveTool(appState, { + type: "frame", + }); + + setCursorForShape(app.interactiveCanvas, { + ...appState, + activeTool: nextActiveTool, + }); + + return { + elements, + appState: { + ...appState, + activeTool: updateActiveTool(appState, { + type: "frame", + }), + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && + !event.shiftKey && + !event.altKey && + event.key.toLocaleLowerCase() === KEYS.F, +}); + +export const actionWrapSelectionInFrame = register({ + name: "wrapSelectionInFrame", + label: "labels.wrapSelectionInFrame", + trackEvent: { category: "element" }, + predicate: (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + return ( + selectedElements.length > 0 && + !selectedElements.some((element) => isFrameLikeElement(element)) + ); + }, + perform: (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + const [x1, y1, x2, y2] = getCommonBounds( + selectedElements, + app.scene.getNonDeletedElementsMap(), + ); + const PADDING = 16; + const frame = newFrameElement({ + x: x1 - PADDING, + y: y1 - PADDING, + width: x2 - x1 + PADDING * 2, + height: y2 - y1 + PADDING * 2, + }); + + // for a selected partial group, we want to remove it from the remainder of the group + if (appState.editingGroupId) { + const elementsInGroup = getElementsInGroup( + selectedElements, + appState.editingGroupId, + ); + + for (const elementInGroup of elementsInGroup) { + const index = elementInGroup.groupIds.indexOf(appState.editingGroupId); + + mutateElement( + elementInGroup, + { + groupIds: elementInGroup.groupIds.slice(0, index), + }, + false, + ); + } + } + + const nextElements = addElementsToFrame( + [...app.scene.getElementsIncludingDeleted(), frame], + selectedElements, + frame, + appState, + ); + + return { + elements: nextElements, + appState: { + selectedElementIds: { [frame.id]: true }, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx new file mode 100644 index 0000000..4e98c59 --- /dev/null +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -0,0 +1,308 @@ +import { KEYS } from "../keys"; +import { t } from "../i18n"; +import { arrayToMap, getShortcutKey } from "../utils"; +import { register } from "./register"; +import { UngroupIcon, GroupIcon } from "../components/icons"; +import { newElementWith } from "../element/mutateElement"; +import { isSomeElementSelected } from "../scene"; +import { + getSelectedGroupIds, + selectGroup, + selectGroupsForSelectedElements, + getElementsInGroup, + addToGroup, + removeFromSelectedGroups, + isElementInGroup, +} from "../groups"; +import { getNonDeletedElements } from "../element"; +import { randomId } from "../random"; +import { ToolButton } from "../components/ToolButton"; +import type { + ExcalidrawElement, + ExcalidrawTextElement, + OrderedExcalidrawElement, +} from "../element/types"; +import type { AppClassProperties, AppState } from "../types"; +import { isBoundToContainer } from "../element/typeChecks"; +import { + frameAndChildrenSelectedTogether, + getElementsInResizingFrame, + getFrameLikeElements, + getRootElements, + groupByFrameLikes, + removeElementsFromFrame, + replaceAllElementsInFrame, +} from "../frame"; +import { syncMovedIndices } from "../fractionalIndex"; +import { CaptureUpdateAction } from "../store"; + +const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { + if (elements.length >= 2) { + const groupIds = elements[0].groupIds; + for (const groupId of groupIds) { + if ( + elements.reduce( + (acc, element) => acc && isElementInGroup(element, groupId), + true, + ) + ) { + return true; + } + } + } + return false; +}; + +const enableActionGroup = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + app: AppClassProperties, +) => { + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + }); + + return ( + selectedElements.length >= 2 && + !allElementsInSameGroup(selectedElements) && + !frameAndChildrenSelectedTogether(selectedElements) + ); +}; + +export const actionGroup = register({ + name: "group", + label: "labels.group", + icon: (appState) => <GroupIcon theme={appState.theme} />, + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + const selectedElements = getRootElements( + app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + }), + ); + if (selectedElements.length < 2) { + // nothing to group + return { + appState, + elements, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + // if everything is already grouped into 1 group, there is nothing to do + const selectedGroupIds = getSelectedGroupIds(appState); + if (selectedGroupIds.length === 1) { + const selectedGroupId = selectedGroupIds[0]; + const elementIdsInGroup = new Set( + getElementsInGroup(elements, selectedGroupId).map( + (element) => element.id, + ), + ); + const selectedElementIds = new Set( + selectedElements.map((element) => element.id), + ); + const combinedSet = new Set([ + ...Array.from(elementIdsInGroup), + ...Array.from(selectedElementIds), + ]); + if (combinedSet.size === elementIdsInGroup.size) { + // no incremental ids in the selected ids + return { + appState, + elements, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + } + + let nextElements = [...elements]; + + // this includes the case where we are grouping elements inside a frame + // and elements outside that frame + const groupingElementsFromDifferentFrames = + new Set(selectedElements.map((element) => element.frameId)).size > 1; + // when it happens, we want to remove elements that are in the frame + // and are going to be grouped from the frame (mouthful, I know) + if (groupingElementsFromDifferentFrames) { + const frameElementsMap = groupByFrameLikes(selectedElements); + + frameElementsMap.forEach((elementsInFrame, frameId) => { + removeElementsFromFrame( + elementsInFrame, + app.scene.getNonDeletedElementsMap(), + ); + }); + } + + const newGroupId = randomId(); + const selectElementIds = arrayToMap(selectedElements); + + nextElements = nextElements.map((element) => { + if (!selectElementIds.get(element.id)) { + return element; + } + return newElementWith(element, { + groupIds: addToGroup( + element.groupIds, + newGroupId, + appState.editingGroupId, + ), + }); + }); + // keep the z order within the group the same, but move them + // to the z order of the highest element in the layer stack + const elementsInGroup = getElementsInGroup(nextElements, newGroupId); + const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; + const lastGroupElementIndex = nextElements.lastIndexOf( + lastElementInGroup as OrderedExcalidrawElement, + ); + const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1); + const elementsBeforeGroup = nextElements + .slice(0, lastGroupElementIndex) + .filter( + (updatedElement) => !isElementInGroup(updatedElement, newGroupId), + ); + const reorderedElements = syncMovedIndices( + [...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup], + arrayToMap(elementsInGroup), + ); + + return { + appState: { + ...appState, + ...selectGroup( + newGroupId, + { ...appState, selectedGroupIds: {} }, + getNonDeletedElements(nextElements), + ), + }, + elements: reorderedElements, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + predicate: (elements, appState, _, app) => + enableActionGroup(elements, appState, app), + keyTest: (event) => + !event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G, + PanelComponent: ({ elements, appState, updateData, app }) => ( + <ToolButton + hidden={!enableActionGroup(elements, appState, app)} + type="button" + icon={<GroupIcon theme={appState.theme} />} + onClick={() => updateData(null)} + title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`} + aria-label={t("labels.group")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + ></ToolButton> + ), +}); + +export const actionUngroup = register({ + name: "ungroup", + label: "labels.ungroup", + icon: (appState) => <UngroupIcon theme={appState.theme} />, + trackEvent: { category: "element" }, + perform: (elements, appState, _, app) => { + const groupIds = getSelectedGroupIds(appState); + const elementsMap = arrayToMap(elements); + + if (groupIds.length === 0) { + return { + appState, + elements, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + + let nextElements = [...elements]; + + const boundTextElementIds: ExcalidrawTextElement["id"][] = []; + nextElements = nextElements.map((element) => { + if (isBoundToContainer(element)) { + boundTextElementIds.push(element.id); + } + const nextGroupIds = removeFromSelectedGroups( + element.groupIds, + appState.selectedGroupIds, + ); + if (nextGroupIds.length === element.groupIds.length) { + return element; + } + return newElementWith(element, { + groupIds: nextGroupIds, + }); + }); + + const updateAppState = selectGroupsForSelectedElements( + appState, + getNonDeletedElements(nextElements), + appState, + null, + ); + + const selectedElements = app.scene.getSelectedElements(appState); + + const selectedElementFrameIds = new Set( + selectedElements + .filter((element) => element.frameId) + .map((element) => element.frameId!), + ); + + const targetFrames = getFrameLikeElements(elements).filter((frame) => + selectedElementFrameIds.has(frame.id), + ); + + targetFrames.forEach((frame) => { + if (frame) { + nextElements = replaceAllElementsInFrame( + nextElements, + getElementsInResizingFrame( + nextElements, + frame, + appState, + elementsMap, + ), + frame, + app, + ); + } + }); + + // remove binded text elements from selection + updateAppState.selectedElementIds = Object.entries( + updateAppState.selectedElementIds, + ).reduce( + (acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => { + if (selected && !boundTextElementIds.includes(id)) { + acc[id] = true; + } + return acc; + }, + {}, + ); + + return { + appState: { ...appState, ...updateAppState }, + elements: nextElements, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + event.shiftKey && + event[KEYS.CTRL_OR_CMD] && + event.key === KEYS.G.toUpperCase(), + predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, + + PanelComponent: ({ elements, appState, updateData }) => ( + <ToolButton + type="button" + hidden={getSelectedGroupIds(appState).length === 0} + icon={<UngroupIcon theme={appState.theme} />} + onClick={() => updateData(null)} + title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`} + aria-label={t("labels.ungroup")} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + ></ToolButton> + ), +}); diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx new file mode 100644 index 0000000..299a09f --- /dev/null +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -0,0 +1,128 @@ +import type { Action, ActionResult } from "./types"; +import { UndoIcon, RedoIcon } from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { t } from "../i18n"; +import type { History } from "../history"; +import { HistoryChangedEvent } from "../history"; +import type { AppClassProperties, AppState } from "../types"; +import { KEYS, matchKey } from "../keys"; +import { arrayToMap } from "../utils"; +import { isWindows } from "../constants"; +import type { SceneElementsMap } from "../element/types"; +import type { Store } from "../store"; +import { CaptureUpdateAction } from "../store"; +import { useEmitter } from "../hooks/useEmitter"; + +const executeHistoryAction = ( + app: AppClassProperties, + appState: Readonly<AppState>, + updater: () => [SceneElementsMap, AppState] | void, +): ActionResult => { + if ( + !appState.multiElement && + !appState.resizingElement && + !appState.editingTextElement && + !appState.newElement && + !appState.selectedElementsAreBeingDragged && + !appState.selectionElement && + !app.flowChartCreator.isCreatingChart + ) { + const result = updater(); + + if (!result) { + return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; + } + + const [nextElementsMap, nextAppState] = result; + const nextElements = Array.from(nextElementsMap.values()); + + return { + appState: nextAppState, + elements: nextElements, + captureUpdate: CaptureUpdateAction.NEVER, + }; + } + + return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; +}; + +type ActionCreator = (history: History, store: Store) => Action; + +export const createUndoAction: ActionCreator = (history, store) => ({ + name: "undo", + label: "buttons.undo", + icon: UndoIcon, + trackEvent: { category: "history" }, + viewMode: false, + perform: (elements, appState, value, app) => + executeHistoryAction(app, appState, () => + history.undo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, + PanelComponent: ({ updateData, data }) => { + const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( + history.onHistoryChangedEmitter, + new HistoryChangedEvent( + history.isUndoStackEmpty, + history.isRedoStackEmpty, + ), + ); + + return ( + <ToolButton + type="button" + icon={UndoIcon} + aria-label={t("buttons.undo")} + onClick={updateData} + size={data?.size || "medium"} + disabled={isUndoStackEmpty} + data-testid="button-undo" + /> + ); + }, +}); + +export const createRedoAction: ActionCreator = (history, store) => ({ + name: "redo", + label: "buttons.redo", + icon: RedoIcon, + trackEvent: { category: "history" }, + viewMode: false, + perform: (elements, appState, _, app) => + executeHistoryAction(app, appState, () => + history.redo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), + keyTest: (event) => + (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || + (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), + PanelComponent: ({ updateData, data }) => { + const { isRedoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent( + history.isUndoStackEmpty, + history.isRedoStackEmpty, + ), + ); + + return ( + <ToolButton + type="button" + icon={RedoIcon} + aria-label={t("buttons.redo")} + onClick={updateData} + size={data?.size || "medium"} + disabled={isRedoStackEmpty} + data-testid="button-redo" + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx new file mode 100644 index 0000000..1f05755 --- /dev/null +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -0,0 +1,77 @@ +import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { isElbowArrow, isLinearElement } from "../element/typeChecks"; +import type { ExcalidrawLinearElement } from "../element/types"; +import { CaptureUpdateAction } from "../store"; +import { register } from "./register"; +import { ToolButton } from "../components/ToolButton"; +import { t } from "../i18n"; +import { lineEditorIcon } from "../components/icons"; + +export const actionToggleLinearEditor = register({ + name: "toggleLinearEditor", + category: DEFAULT_CATEGORIES.elements, + label: (elements, appState, app) => { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + })[0] as ExcalidrawLinearElement | undefined; + + return selectedElement?.type === "arrow" + ? "labels.lineEditor.editArrow" + : "labels.lineEditor.edit"; + }, + keywords: ["line"], + trackEvent: { + category: "element", + }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + if ( + !appState.editingLinearElement && + selectedElements.length === 1 && + isLinearElement(selectedElements[0]) && + !isElbowArrow(selectedElements[0]) + ) { + return true; + } + return false; + }, + perform(elements, appState, _, app) { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + })[0] as ExcalidrawLinearElement; + + const editingLinearElement = + appState.editingLinearElement?.elementId === selectedElement.id + ? null + : new LinearElementEditor(selectedElement); + return { + appState: { + ...appState, + editingLinearElement, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + PanelComponent: ({ appState, updateData, app }) => { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + })[0] as ExcalidrawLinearElement; + + const label = t( + selectedElement.type === "arrow" + ? "labels.lineEditor.editArrow" + : "labels.lineEditor.edit", + ); + return ( + <ToolButton + type="button" + icon={lineEditorIcon} + title={label} + aria-label={label} + onClick={() => updateData(null)} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx new file mode 100644 index 0000000..beb95d2 --- /dev/null +++ b/packages/excalidraw/actions/actionLink.tsx @@ -0,0 +1,55 @@ +import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; +import { LinkIcon } from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { isEmbeddableElement } from "../element/typeChecks"; +import { t } from "../i18n"; +import { KEYS } from "../keys"; +import { getSelectedElements } from "../scene"; +import { CaptureUpdateAction } from "../store"; +import { getShortcutKey } from "../utils"; +import { register } from "./register"; + +export const actionLink = register({ + name: "hyperlink", + label: (elements, appState) => getContextMenuLabel(elements, appState), + icon: LinkIcon, + perform: (elements, appState) => { + if (appState.showHyperlinkPopup === "editor") { + return false; + } + + return { + elements, + appState: { + ...appState, + showHyperlinkPopup: "editor", + openMenu: null, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + trackEvent: { category: "hyperlink", action: "click" }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return selectedElements.length === 1; + }, + PanelComponent: ({ elements, appState, updateData }) => { + const selectedElements = getSelectedElements(elements, appState); + + return ( + <ToolButton + type="button" + icon={LinkIcon} + aria-label={t(getContextMenuLabel(elements, appState))} + title={`${ + isEmbeddableElement(elements[0]) + ? t("labels.link.labelEmbed") + : t("labels.link.label") + } - ${getShortcutKey("CtrlOrCmd+K")}`} + onClick={() => updateData(null)} + selected={selectedElements.length === 1 && !!selectedElements[0].link} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx new file mode 100644 index 0000000..9e71fe2 --- /dev/null +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -0,0 +1,81 @@ +import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { t } from "../i18n"; +import { showSelectedShapeActions, getNonDeletedElements } from "../element"; +import { register } from "./register"; +import { KEYS } from "../keys"; +import { CaptureUpdateAction } from "../store"; + +export const actionToggleCanvasMenu = register({ + name: "toggleCanvasMenu", + label: "buttons.menu", + trackEvent: { category: "menu" }, + perform: (_, appState) => ({ + appState: { + ...appState, + openMenu: appState.openMenu === "canvas" ? null : "canvas", + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }), + PanelComponent: ({ appState, updateData }) => ( + <ToolButton + type="button" + icon={HamburgerMenuIcon} + aria-label={t("buttons.menu")} + onClick={updateData} + selected={appState.openMenu === "canvas"} + /> + ), +}); + +export const actionToggleEditMenu = register({ + name: "toggleEditMenu", + label: "buttons.edit", + trackEvent: { category: "menu" }, + perform: (_elements, appState) => ({ + appState: { + ...appState, + openMenu: appState.openMenu === "shape" ? null : "shape", + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }), + PanelComponent: ({ elements, appState, updateData }) => ( + <ToolButton + visible={showSelectedShapeActions( + appState, + getNonDeletedElements(elements), + )} + type="button" + icon={palette} + aria-label={t("buttons.edit")} + onClick={updateData} + selected={appState.openMenu === "shape"} + /> + ), +}); + +export const actionShortcuts = register({ + name: "toggleShortcuts", + label: "welcomeScreen.defaults.helpHint", + icon: HelpIconThin, + viewMode: true, + trackEvent: { category: "menu", action: "toggleHelpDialog" }, + perform: (_elements, appState, _, { focusContainer }) => { + if (appState.openDialog?.name === "help") { + focusContainer(); + } + return { + appState: { + ...appState, + openDialog: + appState.openDialog?.name === "help" + ? null + : { + name: "help", + }, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + keyTest: (event) => event.key === KEYS.QUESTION_MARK, +}); diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx new file mode 100644 index 0000000..4803720 --- /dev/null +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -0,0 +1,133 @@ +import { getClientColor } from "../clients"; +import { Avatar } from "../components/Avatar"; +import type { GoToCollaboratorComponentProps } from "../components/UserList"; +import { + eyeIcon, + microphoneIcon, + microphoneMutedIcon, +} from "../components/icons"; +import { t } from "../i18n"; +import { CaptureUpdateAction } from "../store"; +import type { Collaborator } from "../types"; +import { register } from "./register"; +import clsx from "clsx"; + +export const actionGoToCollaborator = register({ + name: "goToCollaborator", + label: "Go to a collaborator", + viewMode: true, + trackEvent: { category: "collab" }, + perform: (_elements, appState, collaborator: Collaborator) => { + if ( + !collaborator.socketId || + appState.userToFollow?.socketId === collaborator.socketId || + collaborator.isCurrentUser + ) { + return { + appState: { + ...appState, + userToFollow: null, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + + return { + appState: { + ...appState, + userToFollow: { + socketId: collaborator.socketId, + username: collaborator.username || "", + }, + // Close mobile menu + openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + PanelComponent: ({ updateData, data, appState }) => { + const { socketId, collaborator, withName, isBeingFollowed } = + data as GoToCollaboratorComponentProps; + + const background = getClientColor(socketId, collaborator); + + const statusClassNames = clsx({ + "is-followed": isBeingFollowed, + "is-current-user": collaborator.isCurrentUser === true, + "is-speaking": collaborator.isSpeaking, + "is-in-call": collaborator.isInCall, + "is-muted": collaborator.isMuted, + }); + + const statusIconJSX = collaborator.isInCall ? ( + collaborator.isSpeaking ? ( + <div + className="UserList__collaborator-status-icon-speaking-indicator" + title={t("userList.hint.isSpeaking")} + > + <div /> + <div /> + <div /> + </div> + ) : collaborator.isMuted ? ( + <div + className="UserList__collaborator-status-icon-microphone-muted" + title={t("userList.hint.micMuted")} + > + {microphoneMutedIcon} + </div> + ) : ( + <div title={t("userList.hint.inCall")}>{microphoneIcon}</div> + ) + ) : null; + + return withName ? ( + <div + className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`} + style={{ [`--avatar-size` as any]: "1.5rem" }} + onClick={() => updateData<Collaborator>(collaborator)} + > + <Avatar + color={background} + onClick={() => {}} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> + <div className="UserList__collaborator-name"> + {collaborator.username} + </div> + <div className="UserList__collaborator-status-icons" aria-hidden> + {isBeingFollowed && ( + <div + className="UserList__collaborator-status-icon-is-followed" + title={t("userList.hint.followStatus")} + > + {eyeIcon} + </div> + )} + {statusIconJSX} + </div> + </div> + ) : ( + <div + className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`} + > + <Avatar + color={background} + onClick={() => { + updateData(collaborator); + }} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> + {statusIconJSX && ( + <div className="UserList__collaborator-status-icon"> + {statusIconJSX} + </div> + )} + </div> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionProperties.test.tsx b/packages/excalidraw/actions/actionProperties.test.tsx new file mode 100644 index 0000000..80d2ca2 --- /dev/null +++ b/packages/excalidraw/actions/actionProperties.test.tsx @@ -0,0 +1,168 @@ +import React from "react"; +import { Excalidraw } from "../index"; +import { queryByTestId } from "@testing-library/react"; +import { render } from "../tests/test-utils"; +import { UI } from "../tests/helpers/ui"; +import { API } from "../tests/helpers/api"; +import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors"; +import { FONT_FAMILY, STROKE_WIDTH } from "../constants"; + +describe("element locking", () => { + beforeEach(async () => { + await render(<Excalidraw />); + }); + + describe("properties when tool selected", () => { + it("should show active background top picks", () => { + UI.clickTool("rectangle"); + + const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1]; + + // just in case we change it in the future + expect(color).not.toBe(COLOR_PALETTE.transparent); + + API.setAppState({ + currentItemBackgroundColor: color, + }); + const activeColor = queryByTestId( + document.body, + `color-top-pick-${color}`, + ); + expect(activeColor).toHaveClass("active"); + }); + + it("should show fill style when background non-transparent", () => { + UI.clickTool("rectangle"); + + const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1]; + + // just in case we change it in the future + expect(color).not.toBe(COLOR_PALETTE.transparent); + + API.setAppState({ + currentItemBackgroundColor: color, + currentItemFillStyle: "hachure", + }); + const hachureFillButton = queryByTestId(document.body, `fill-hachure`); + + expect(hachureFillButton).toHaveClass("active"); + API.setAppState({ + currentItemFillStyle: "solid", + }); + const solidFillStyle = queryByTestId(document.body, `fill-solid`); + expect(solidFillStyle).toHaveClass("active"); + }); + + it("should not show fill style when background transparent", () => { + UI.clickTool("rectangle"); + + API.setAppState({ + currentItemBackgroundColor: COLOR_PALETTE.transparent, + currentItemFillStyle: "hachure", + }); + const hachureFillButton = queryByTestId(document.body, `fill-hachure`); + + expect(hachureFillButton).toBe(null); + }); + + it("should show horizontal text align for text tool", () => { + UI.clickTool("text"); + + API.setAppState({ + currentItemTextAlign: "right", + }); + + const centerTextAlign = queryByTestId(document.body, `align-right`); + expect(centerTextAlign).toBeChecked(); + }); + }); + + describe("properties when elements selected", () => { + it("should show active styles when single element selected", () => { + const rect = API.createElement({ + type: "rectangle", + backgroundColor: "red", + fillStyle: "cross-hatch", + }); + API.setElements([rect]); + API.setSelectedElements([rect]); + + const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`); + expect(crossHatchButton).toHaveClass("active"); + }); + + it("should not show fill style selected element's background is transparent", () => { + const rect = API.createElement({ + type: "rectangle", + backgroundColor: COLOR_PALETTE.transparent, + fillStyle: "cross-hatch", + }); + API.setElements([rect]); + API.setSelectedElements([rect]); + + const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`); + expect(crossHatchButton).toBe(null); + }); + + it("should highlight common stroke width of selected elements", () => { + const rect1 = API.createElement({ + type: "rectangle", + strokeWidth: STROKE_WIDTH.thin, + }); + const rect2 = API.createElement({ + type: "rectangle", + strokeWidth: STROKE_WIDTH.thin, + }); + API.setElements([rect1, rect2]); + API.setSelectedElements([rect1, rect2]); + + const thinStrokeWidthButton = queryByTestId( + document.body, + `strokeWidth-thin`, + ); + expect(thinStrokeWidthButton).toBeChecked(); + }); + + it("should not highlight any stroke width button if no common style", () => { + const rect1 = API.createElement({ + type: "rectangle", + strokeWidth: STROKE_WIDTH.thin, + }); + const rect2 = API.createElement({ + type: "rectangle", + strokeWidth: STROKE_WIDTH.bold, + }); + API.setElements([rect1, rect2]); + API.setSelectedElements([rect1, rect2]); + + expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null); + expect( + queryByTestId(document.body, `strokeWidth-thin`), + ).not.toBeChecked(); + expect( + queryByTestId(document.body, `strokeWidth-bold`), + ).not.toBeChecked(); + expect( + queryByTestId(document.body, `strokeWidth-extraBold`), + ).not.toBeChecked(); + }); + + it("should show properties of different element types when selected", () => { + const rect = API.createElement({ + type: "rectangle", + strokeWidth: STROKE_WIDTH.bold, + }); + const text = API.createElement({ + type: "text", + fontFamily: FONT_FAMILY["Comic Shanns"], + }); + API.setElements([rect, text]); + API.setSelectedElements([rect, text]); + + expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); + expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( + "active", + ); + }); + }); +}); 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> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts new file mode 100644 index 0000000..61c2780 --- /dev/null +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -0,0 +1,57 @@ +import { KEYS } from "../keys"; +import { register } from "./register"; +import { selectGroupsForSelectedElements } from "../groups"; +import { getNonDeletedElements, isTextElement } from "../element"; +import type { ExcalidrawElement } from "../element/types"; +import { isLinearElement } from "../element/typeChecks"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { selectAllIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; + +export const actionSelectAll = register({ + name: "selectAll", + label: "labels.selectAll", + icon: selectAllIcon, + trackEvent: { category: "canvas" }, + viewMode: false, + perform: (elements, appState, value, app) => { + if (appState.editingLinearElement) { + return false; + } + + const selectedElementIds = elements + .filter( + (element) => + !element.isDeleted && + !(isTextElement(element) && element.containerId) && + !element.locked, + ) + .reduce((map: Record<ExcalidrawElement["id"], true>, element) => { + map[element.id] = true; + return map; + }, {}); + + return { + appState: { + ...appState, + ...selectGroupsForSelectedElements( + { + editingGroupId: null, + selectedElementIds, + }, + getNonDeletedElements(elements), + appState, + app, + ), + selectedLinearElement: + // single linear element selected + Object.keys(selectedElementIds).length === 1 && + isLinearElement(elements[0]) + ? new LinearElementEditor(elements[0]) + : null, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A, +}); diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts new file mode 100644 index 0000000..01f8040 --- /dev/null +++ b/packages/excalidraw/actions/actionStyles.ts @@ -0,0 +1,167 @@ +import { + isTextElement, + isExcalidrawElement, + redrawTextBoundingBox, +} from "../element"; +import { CODES, KEYS } from "../keys"; +import { t } from "../i18n"; +import { register } from "./register"; +import { newElementWith } from "../element/mutateElement"; +import { + DEFAULT_FONT_SIZE, + DEFAULT_FONT_FAMILY, + DEFAULT_TEXT_ALIGN, +} from "../constants"; +import { getBoundTextElement } from "../element/textElement"; +import { + hasBoundTextElement, + canApplyRoundnessTypeToElement, + getDefaultRoundnessTypeForElement, + isFrameLikeElement, + isArrowElement, +} from "../element/typeChecks"; +import { getSelectedElements } from "../scene"; +import type { ExcalidrawTextElement } from "../element/types"; +import { paintIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; +import { getLineHeight } from "../fonts"; + +// `copiedStyles` is exported only for tests. +export let copiedStyles: string = "{}"; + +export const actionCopyStyles = register({ + name: "copyStyles", + label: "labels.copyStyles", + icon: paintIcon, + trackEvent: { category: "element" }, + perform: (elements, appState, formData, app) => { + const elementsCopied = []; + const element = elements.find((el) => appState.selectedElementIds[el.id]); + elementsCopied.push(element); + if (element && hasBoundTextElement(element)) { + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); + elementsCopied.push(boundTextElement); + } + if (element) { + copiedStyles = JSON.stringify(elementsCopied); + } + return { + appState: { + ...appState, + toast: { message: t("toast.copyStyles") }, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C, +}); + +export const actionPasteStyles = register({ + name: "pasteStyles", + label: "labels.pasteStyles", + icon: paintIcon, + trackEvent: { category: "element" }, + perform: (elements, appState, formData, app) => { + const elementsCopied = JSON.parse(copiedStyles); + const pastedElement = elementsCopied[0]; + const boundTextElement = elementsCopied[1]; + if (!isExcalidrawElement(pastedElement)) { + return { elements, captureUpdate: CaptureUpdateAction.EVENTUALLY }; + } + + const selectedElements = getSelectedElements(elements, appState, { + includeBoundTextElement: true, + }); + const selectedElementIds = selectedElements.map((element) => element.id); + return { + elements: elements.map((element) => { + if (selectedElementIds.includes(element.id)) { + let elementStylesToCopyFrom = pastedElement; + if (isTextElement(element) && element.containerId) { + elementStylesToCopyFrom = boundTextElement; + } + if (!elementStylesToCopyFrom) { + return element; + } + let newElement = newElementWith(element, { + backgroundColor: elementStylesToCopyFrom?.backgroundColor, + strokeWidth: elementStylesToCopyFrom?.strokeWidth, + strokeColor: elementStylesToCopyFrom?.strokeColor, + strokeStyle: elementStylesToCopyFrom?.strokeStyle, + fillStyle: elementStylesToCopyFrom?.fillStyle, + opacity: elementStylesToCopyFrom?.opacity, + roughness: elementStylesToCopyFrom?.roughness, + roundness: elementStylesToCopyFrom.roundness + ? canApplyRoundnessTypeToElement( + elementStylesToCopyFrom.roundness.type, + element, + ) + ? elementStylesToCopyFrom.roundness + : getDefaultRoundnessTypeForElement(element) + : null, + }); + + if (isTextElement(newElement)) { + const fontSize = + (elementStylesToCopyFrom as ExcalidrawTextElement).fontSize || + DEFAULT_FONT_SIZE; + const fontFamily = + (elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily || + DEFAULT_FONT_FAMILY; + newElement = newElementWith(newElement, { + fontSize, + fontFamily, + textAlign: + (elementStylesToCopyFrom as ExcalidrawTextElement).textAlign || + DEFAULT_TEXT_ALIGN, + lineHeight: + (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || + getLineHeight(fontFamily), + }); + let container = null; + if (newElement.containerId) { + container = + selectedElements.find( + (element) => + isTextElement(newElement) && + element.id === newElement.containerId, + ) || null; + } + redrawTextBoundingBox( + newElement, + container, + app.scene.getNonDeletedElementsMap(), + ); + } + + if ( + newElement.type === "arrow" && + isArrowElement(elementStylesToCopyFrom) + ) { + newElement = newElementWith(newElement, { + startArrowhead: elementStylesToCopyFrom.startArrowhead, + endArrowhead: elementStylesToCopyFrom.endArrowhead, + }); + } + + if (isFrameLikeElement(element)) { + newElement = newElementWith(newElement, { + roundness: null, + backgroundColor: "transparent", + }); + } + + return newElement; + } + return element; + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, +}); diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts new file mode 100644 index 0000000..29f54c9 --- /dev/null +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -0,0 +1,48 @@ +import { isTextElement } from "../element"; +import { newElementWith } from "../element/mutateElement"; +import { measureText } from "../element/textMeasurements"; +import { getSelectedElements } from "../scene"; +import { CaptureUpdateAction } from "../store"; +import type { AppClassProperties } from "../types"; +import { getFontString } from "../utils"; +import { register } from "./register"; + +export const actionTextAutoResize = register({ + name: "autoResize", + label: "labels.autoResize", + icon: null, + trackEvent: { category: "element" }, + predicate: (elements, appState, _: unknown, app: AppClassProperties) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length === 1 && + isTextElement(selectedElements[0]) && + !selectedElements[0].autoResize + ); + }, + perform: (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + return { + appState, + elements: elements.map((element) => { + if (element.id === selectedElements[0].id && isTextElement(element)) { + const metrics = measureText( + element.originalText, + getFontString(element), + element.lineHeight, + ); + + return newElementWith(element, { + autoResize: true, + width: metrics.width, + height: metrics.height, + text: element.originalText, + }); + } + return element; + }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx new file mode 100644 index 0000000..69d6570 --- /dev/null +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -0,0 +1,32 @@ +import { CODES, KEYS } from "../keys"; +import { register } from "./register"; +import type { AppState } from "../types"; +import { gridIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; + +export const actionToggleGridMode = register({ + name: "gridMode", + icon: gridIcon, + keywords: ["snap"], + label: "labels.toggleGrid", + viewMode: true, + trackEvent: { + category: "canvas", + predicate: (appState) => appState.gridModeEnabled, + }, + perform(elements, appState) { + return { + appState: { + ...appState, + gridModeEnabled: !this.checked!(appState), + objectsSnapModeEnabled: false, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + checked: (appState: AppState) => appState.gridModeEnabled, + predicate: (element, appState, props) => { + return props.gridModeEnabled === undefined; + }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, +}); diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx new file mode 100644 index 0000000..1ae3cbe --- /dev/null +++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx @@ -0,0 +1,31 @@ +import { magnetIcon } from "../components/icons"; +import { CODES, KEYS } from "../keys"; +import { CaptureUpdateAction } from "../store"; +import { register } from "./register"; + +export const actionToggleObjectsSnapMode = register({ + name: "objectsSnapMode", + label: "buttons.objectsSnapMode", + icon: magnetIcon, + viewMode: false, + trackEvent: { + category: "canvas", + predicate: (appState) => !appState.objectsSnapModeEnabled, + }, + perform(elements, appState) { + return { + appState: { + ...appState, + objectsSnapModeEnabled: !this.checked!(appState), + gridModeEnabled: false, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + checked: (appState) => appState.objectsSnapModeEnabled, + predicate: (elements, appState, appProps) => { + return typeof appProps.objectsSnapModeEnabled === "undefined"; + }, + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S, +}); diff --git a/packages/excalidraw/actions/actionToggleSearchMenu.ts b/packages/excalidraw/actions/actionToggleSearchMenu.ts new file mode 100644 index 0000000..9261e79 --- /dev/null +++ b/packages/excalidraw/actions/actionToggleSearchMenu.ts @@ -0,0 +1,55 @@ +import { KEYS } from "../keys"; +import { register } from "./register"; +import type { AppState } from "../types"; +import { searchIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; +import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants"; + +export const actionToggleSearchMenu = register({ + name: "searchMenu", + icon: searchIcon, + keywords: ["search", "find"], + label: "search.title", + viewMode: true, + trackEvent: { + category: "search_menu", + action: "toggle", + predicate: (appState) => appState.gridModeEnabled, + }, + perform(elements, appState, _, app) { + if ( + appState.openSidebar?.name === DEFAULT_SIDEBAR.name && + appState.openSidebar.tab === CANVAS_SEARCH_TAB + ) { + const searchInput = + app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>( + `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`, + ); + + if (searchInput?.matches(":focus")) { + return { + appState: { ...appState, openSidebar: null }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + } + + searchInput?.focus(); + searchInput?.select(); + return false; + } + + return { + appState: { + ...appState, + openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB }, + openDialog: null, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + checked: (appState: AppState) => appState.gridModeEnabled, + predicate: (element, appState, props) => { + return props.gridModeEnabled === undefined; + }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F, +}); diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx new file mode 100644 index 0000000..e28d099 --- /dev/null +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -0,0 +1,26 @@ +import { register } from "./register"; +import { CODES, KEYS } from "../keys"; +import { abacusIcon } from "../components/icons"; +import { CaptureUpdateAction } from "../store"; + +export const actionToggleStats = register({ + name: "stats", + label: "stats.fullTitle", + icon: abacusIcon, + paletteName: "Toggle stats", + viewMode: true, + trackEvent: { category: "menu" }, + keywords: ["edit", "attributes", "customize"], + perform(elements, appState) { + return { + appState: { + ...appState, + stats: { ...appState.stats, open: !this.checked!(appState) }, + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + checked: (appState) => appState.stats.open, + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH, +}); diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx new file mode 100644 index 0000000..cae3b09 --- /dev/null +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -0,0 +1,31 @@ +import { eyeIcon } from "../components/icons"; +import { CODES, KEYS } from "../keys"; +import { CaptureUpdateAction } from "../store"; +import { register } from "./register"; + +export const actionToggleViewMode = register({ + name: "viewMode", + label: "labels.viewMode", + paletteName: "Toggle view mode", + icon: eyeIcon, + viewMode: true, + trackEvent: { + category: "canvas", + predicate: (appState) => !appState.viewModeEnabled, + }, + perform(elements, appState) { + return { + appState: { + ...appState, + viewModeEnabled: !this.checked!(appState), + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + checked: (appState) => appState.viewModeEnabled, + predicate: (elements, appState, appProps) => { + return typeof appProps.viewModeEnabled === "undefined"; + }, + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, +}); diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx new file mode 100644 index 0000000..31afd3f --- /dev/null +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -0,0 +1,31 @@ +import { coffeeIcon } from "../components/icons"; +import { CODES, KEYS } from "../keys"; +import { CaptureUpdateAction } from "../store"; +import { register } from "./register"; + +export const actionToggleZenMode = register({ + name: "zenMode", + label: "buttons.zenMode", + icon: coffeeIcon, + paletteName: "Toggle zen mode", + viewMode: true, + trackEvent: { + category: "canvas", + predicate: (appState) => !appState.zenModeEnabled, + }, + perform(elements, appState) { + return { + appState: { + ...appState, + zenModeEnabled: !this.checked!(appState), + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, + checked: (appState) => appState.zenModeEnabled, + predicate: (elements, appState, appProps) => { + return typeof appProps.zenModeEnabled === "undefined"; + }, + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, +}); diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx new file mode 100644 index 0000000..4097b2d --- /dev/null +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -0,0 +1,153 @@ +import { + moveOneLeft, + moveOneRight, + moveAllLeft, + moveAllRight, +} from "../zindex"; +import { KEYS, CODES } from "../keys"; +import { t } from "../i18n"; +import { getShortcutKey } from "../utils"; +import { register } from "./register"; +import { + BringForwardIcon, + BringToFrontIcon, + SendBackwardIcon, + SendToBackIcon, +} from "../components/icons"; +import { isDarwin } from "../constants"; +import { CaptureUpdateAction } from "../store"; + +export const actionSendBackward = register({ + name: "sendBackward", + label: "labels.sendBackward", + keywords: ["move down", "zindex", "layer"], + icon: SendBackwardIcon, + trackEvent: { category: "element" }, + perform: (elements, appState) => { + return { + elements: moveOneLeft(elements, appState), + appState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyPriority: 40, + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && + !event.shiftKey && + event.code === CODES.BRACKET_LEFT, + PanelComponent: ({ updateData, appState }) => ( + <button + type="button" + className="zIndexButton" + onClick={() => updateData(null)} + title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`} + > + {SendBackwardIcon} + </button> + ), +}); + +export const actionBringForward = register({ + name: "bringForward", + label: "labels.bringForward", + keywords: ["move up", "zindex", "layer"], + icon: BringForwardIcon, + trackEvent: { category: "element" }, + perform: (elements, appState) => { + return { + elements: moveOneRight(elements, appState), + appState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyPriority: 40, + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && + !event.shiftKey && + event.code === CODES.BRACKET_RIGHT, + PanelComponent: ({ updateData, appState }) => ( + <button + type="button" + className="zIndexButton" + onClick={() => updateData(null)} + title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`} + > + {BringForwardIcon} + </button> + ), +}); + +export const actionSendToBack = register({ + name: "sendToBack", + label: "labels.sendToBack", + keywords: ["move down", "zindex", "layer"], + icon: SendToBackIcon, + trackEvent: { category: "element" }, + perform: (elements, appState) => { + return { + elements: moveAllLeft(elements, appState), + appState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + isDarwin + ? event[KEYS.CTRL_OR_CMD] && + event.altKey && + event.code === CODES.BRACKET_LEFT + : event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + event.code === CODES.BRACKET_LEFT, + PanelComponent: ({ updateData, appState }) => ( + <button + type="button" + className="zIndexButton" + onClick={() => updateData(null)} + title={`${t("labels.sendToBack")} — ${ + isDarwin + ? getShortcutKey("CtrlOrCmd+Alt+[") + : getShortcutKey("CtrlOrCmd+Shift+[") + }`} + > + {SendToBackIcon} + </button> + ), +}); + +export const actionBringToFront = register({ + name: "bringToFront", + label: "labels.bringToFront", + keywords: ["move up", "zindex", "layer"], + icon: BringToFrontIcon, + trackEvent: { category: "element" }, + + perform: (elements, appState) => { + return { + elements: moveAllRight(elements, appState), + appState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, + keyTest: (event) => + isDarwin + ? event[KEYS.CTRL_OR_CMD] && + event.altKey && + event.code === CODES.BRACKET_RIGHT + : event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + event.code === CODES.BRACKET_RIGHT, + PanelComponent: ({ updateData, appState }) => ( + <button + type="button" + className="zIndexButton" + onClick={(event) => updateData(null)} + title={`${t("labels.bringToFront")} — ${ + isDarwin + ? getShortcutKey("CtrlOrCmd+Alt+]") + : getShortcutKey("CtrlOrCmd+Shift+]") + }`} + > + {BringToFrontIcon} + </button> + ), +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts new file mode 100644 index 0000000..a556bfb --- /dev/null +++ b/packages/excalidraw/actions/index.ts @@ -0,0 +1,92 @@ +export { actionDeleteSelected } from "./actionDeleteSelected"; +export { + actionBringForward, + actionBringToFront, + actionSendBackward, + actionSendToBack, +} from "./actionZindex"; +export { actionSelectAll } from "./actionSelectAll"; +export { actionDuplicateSelection } from "./actionDuplicateSelection"; +export { + actionChangeStrokeColor, + actionChangeBackgroundColor, + actionChangeStrokeWidth, + actionChangeFillStyle, + actionChangeSloppiness, + actionChangeOpacity, + actionChangeFontSize, + actionChangeFontFamily, + actionChangeTextAlign, + actionChangeVerticalAlign, +} from "./actionProperties"; + +export { + actionChangeViewBackgroundColor, + actionClearCanvas, + actionZoomIn, + actionZoomOut, + actionResetZoom, + actionZoomToFit, + actionToggleTheme, +} from "./actionCanvas"; + +export { actionFinalize } from "./actionFinalize"; + +export { + actionChangeProjectName, + actionChangeExportBackground, + actionSaveToActiveFile, + actionSaveFileToDisk, + actionLoadScene, +} from "./actionExport"; + +export { actionCopyStyles, actionPasteStyles } from "./actionStyles"; +export { + actionToggleCanvasMenu, + actionToggleEditMenu, + actionShortcuts, +} from "./actionMenu"; + +export { actionGroup, actionUngroup } from "./actionGroup"; + +export { actionGoToCollaborator } from "./actionNavigate"; + +export { actionAddToLibrary } from "./actionAddToLibrary"; + +export { + actionAlignTop, + actionAlignBottom, + actionAlignLeft, + actionAlignRight, + actionAlignVerticallyCentered, + actionAlignHorizontallyCentered, +} from "./actionAlign"; + +export { + distributeHorizontally, + distributeVertically, +} from "./actionDistribute"; + +export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; + +export { + actionCopy, + actionCut, + actionCopyAsPng, + actionCopyAsSvg, + copyText, +} from "./actionClipboard"; + +export { actionToggleGridMode } from "./actionToggleGridMode"; +export { actionToggleZenMode } from "./actionToggleZenMode"; +export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; + +export { actionToggleStats } from "./actionToggleStats"; +export { actionUnbindText, actionBindText } from "./actionBoundText"; +export { actionLink } from "./actionLink"; +export { actionToggleElementLock } from "./actionElementLock"; +export { actionToggleLinearEditor } from "./actionLinearEditor"; + +export { actionToggleSearchMenu } from "./actionToggleSearchMenu"; + +export { actionToggleCropEditor } from "./actionCropEditor"; diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx new file mode 100644 index 0000000..f378438 --- /dev/null +++ b/packages/excalidraw/actions/manager.tsx @@ -0,0 +1,194 @@ +import React from "react"; +import type { + Action, + UpdaterFn, + ActionName, + ActionResult, + PanelComponentProps, + ActionSource, +} from "./types"; +import type { + ExcalidrawElement, + OrderedExcalidrawElement, +} from "../element/types"; +import type { AppClassProperties, AppState } from "../types"; +import { trackEvent } from "../analytics"; +import { isPromiseLike } from "../utils"; + +const trackAction = ( + action: Action, + source: ActionSource, + appState: Readonly<AppState>, + elements: readonly ExcalidrawElement[], + app: AppClassProperties, + value: any, +) => { + if (action.trackEvent) { + try { + if (typeof action.trackEvent === "object") { + const shouldTrack = action.trackEvent.predicate + ? action.trackEvent.predicate(appState, elements, value) + : true; + if (shouldTrack) { + trackEvent( + action.trackEvent.category, + action.trackEvent.action || action.name, + `${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`, + ); + } + } + } catch (error) { + console.error("error while logging action:", error); + } + } +}; + +export class ActionManager { + actions = {} as Record<ActionName, Action>; + + updater: (actionResult: ActionResult | Promise<ActionResult>) => void; + + getAppState: () => Readonly<AppState>; + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[]; + app: AppClassProperties; + + constructor( + updater: UpdaterFn, + getAppState: () => AppState, + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[], + app: AppClassProperties, + ) { + this.updater = (actionResult) => { + if (isPromiseLike(actionResult)) { + actionResult.then((actionResult) => { + return updater(actionResult); + }); + } else { + return updater(actionResult); + } + }; + this.getAppState = getAppState; + this.getElementsIncludingDeleted = getElementsIncludingDeleted; + this.app = app; + } + + registerAction(action: Action) { + this.actions[action.name] = action; + } + + registerAll(actions: readonly Action[]) { + actions.forEach((action) => this.registerAction(action)); + } + + handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) { + const canvasActions = this.app.props.UIOptions.canvasActions; + const data = Object.values(this.actions) + .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) + .filter( + (action) => + (action.name in canvasActions + ? canvasActions[action.name as keyof typeof canvasActions] + : true) && + action.keyTest && + action.keyTest( + event, + this.getAppState(), + this.getElementsIncludingDeleted(), + this.app, + ), + ); + + if (data.length !== 1) { + if (data.length > 1) { + console.warn("Canceling as multiple actions match this shortcut", data); + } + return false; + } + + const action = data[0]; + + if (this.getAppState().viewModeEnabled && action.viewMode !== true) { + return false; + } + + const elements = this.getElementsIncludingDeleted(); + const appState = this.getAppState(); + const value = null; + + trackAction(action, "keyboard", appState, elements, this.app, null); + + event.preventDefault(); + event.stopPropagation(); + this.updater(data[0].perform(elements, appState, value, this.app)); + return true; + } + + executeAction<T extends Action>( + action: T, + source: ActionSource = "api", + value: Parameters<T["perform"]>[2] = null, + ) { + const elements = this.getElementsIncludingDeleted(); + const appState = this.getAppState(); + + trackAction(action, source, appState, elements, this.app, value); + + this.updater(action.perform(elements, appState, value, this.app)); + } + + /** + * @param data additional data sent to the PanelComponent + */ + renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => { + const canvasActions = this.app.props.UIOptions.canvasActions; + + if ( + this.actions[name] && + "PanelComponent" in this.actions[name] && + (name in canvasActions + ? canvasActions[name as keyof typeof canvasActions] + : true) + ) { + const action = this.actions[name]; + const PanelComponent = action.PanelComponent!; + PanelComponent.displayName = "PanelComponent"; + const elements = this.getElementsIncludingDeleted(); + const appState = this.getAppState(); + const updateData = (formState?: any) => { + trackAction(action, "ui", appState, elements, this.app, formState); + + this.updater( + action.perform( + this.getElementsIncludingDeleted(), + this.getAppState(), + formState, + this.app, + ), + ); + }; + + return ( + <PanelComponent + elements={this.getElementsIncludingDeleted()} + appState={this.getAppState()} + updateData={updateData} + appProps={this.app.props} + app={this.app} + data={data} + /> + ); + } + + return null; + }; + + isActionEnabled = (action: Action) => { + const elements = this.getElementsIncludingDeleted(); + const appState = this.getAppState(); + + return ( + !action.predicate || + action.predicate(elements, appState, this.app.props, this.app) + ); + }; +} diff --git a/packages/excalidraw/actions/register.ts b/packages/excalidraw/actions/register.ts new file mode 100644 index 0000000..7c841e3 --- /dev/null +++ b/packages/excalidraw/actions/register.ts @@ -0,0 +1,10 @@ +import type { Action } from "./types"; + +export let actions: readonly Action[] = []; + +export const register = <T extends Action>(action: T) => { + actions = actions.concat(action); + return action as T & { + keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"]; + }; +}; diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts new file mode 100644 index 0000000..451609d --- /dev/null +++ b/packages/excalidraw/actions/shortcuts.ts @@ -0,0 +1,125 @@ +import { isDarwin } from "../constants"; +import { t } from "../i18n"; +import type { SubtypeOf } from "../utility-types"; +import { getShortcutKey } from "../utils"; +import type { ActionName } from "./types"; + +export type ShortcutName = + | SubtypeOf< + ActionName, + | "toggleTheme" + | "loadScene" + | "clearCanvas" + | "cut" + | "copy" + | "paste" + | "copyStyles" + | "pasteStyles" + | "selectAll" + | "deleteSelectedElements" + | "duplicateSelection" + | "sendBackward" + | "bringForward" + | "sendToBack" + | "bringToFront" + | "copyAsPng" + | "group" + | "ungroup" + | "gridMode" + | "zenMode" + | "objectsSnapMode" + | "stats" + | "addToLibrary" + | "viewMode" + | "flipHorizontal" + | "flipVertical" + | "hyperlink" + | "toggleElementLock" + | "resetZoom" + | "zoomOut" + | "zoomIn" + | "zoomToFit" + | "zoomToFitSelectionInViewport" + | "zoomToFitSelection" + | "toggleEraserTool" + | "toggleHandTool" + | "setFrameAsActiveTool" + | "saveFileToDisk" + | "saveToActiveFile" + | "toggleShortcuts" + | "wrapSelectionInFrame" + > + | "saveScene" + | "imageExport" + | "commandPalette" + | "searchMenu"; + +const shortcutMap: Record<ShortcutName, string[]> = { + toggleTheme: [getShortcutKey("Shift+Alt+D")], + saveScene: [getShortcutKey("CtrlOrCmd+S")], + loadScene: [getShortcutKey("CtrlOrCmd+O")], + clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")], + imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")], + commandPalette: [ + getShortcutKey("CtrlOrCmd+/"), + getShortcutKey("CtrlOrCmd+Shift+P"), + ], + cut: [getShortcutKey("CtrlOrCmd+X")], + copy: [getShortcutKey("CtrlOrCmd+C")], + paste: [getShortcutKey("CtrlOrCmd+V")], + copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")], + pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")], + selectAll: [getShortcutKey("CtrlOrCmd+A")], + deleteSelectedElements: [getShortcutKey("Delete")], + duplicateSelection: [ + getShortcutKey("CtrlOrCmd+D"), + getShortcutKey(`Alt+${t("helpDialog.drag")}`), + ], + sendBackward: [getShortcutKey("CtrlOrCmd+[")], + bringForward: [getShortcutKey("CtrlOrCmd+]")], + sendToBack: [ + isDarwin + ? getShortcutKey("CtrlOrCmd+Alt+[") + : getShortcutKey("CtrlOrCmd+Shift+["), + ], + bringToFront: [ + isDarwin + ? getShortcutKey("CtrlOrCmd+Alt+]") + : getShortcutKey("CtrlOrCmd+Shift+]"), + ], + copyAsPng: [getShortcutKey("Shift+Alt+C")], + group: [getShortcutKey("CtrlOrCmd+G")], + ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")], + gridMode: [getShortcutKey("CtrlOrCmd+'")], + zenMode: [getShortcutKey("Alt+Z")], + objectsSnapMode: [getShortcutKey("Alt+S")], + stats: [getShortcutKey("Alt+/")], + addToLibrary: [], + flipHorizontal: [getShortcutKey("Shift+H")], + flipVertical: [getShortcutKey("Shift+V")], + viewMode: [getShortcutKey("Alt+R")], + hyperlink: [getShortcutKey("CtrlOrCmd+K")], + toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")], + resetZoom: [getShortcutKey("CtrlOrCmd+0")], + zoomOut: [getShortcutKey("CtrlOrCmd+-")], + zoomIn: [getShortcutKey("CtrlOrCmd++")], + zoomToFitSelection: [getShortcutKey("Shift+3")], + zoomToFit: [getShortcutKey("Shift+1")], + zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")], + toggleEraserTool: [getShortcutKey("E")], + toggleHandTool: [getShortcutKey("H")], + setFrameAsActiveTool: [getShortcutKey("F")], + saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], + saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], + toggleShortcuts: [getShortcutKey("?")], + searchMenu: [getShortcutKey("CtrlOrCmd+F")], + wrapSelectionInFrame: [], +}; + +export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { + const shortcuts = shortcutMap[name]; + // if multiple shortcuts available, take the first one + return shortcuts && shortcuts.length > 0 + ? shortcuts[idx] || shortcuts[0] + : ""; +}; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts new file mode 100644 index 0000000..71ac9f4 --- /dev/null +++ b/packages/excalidraw/actions/types.ts @@ -0,0 +1,207 @@ +import type React from "react"; +import type { + ExcalidrawElement, + OrderedExcalidrawElement, +} from "../element/types"; +import type { + AppClassProperties, + AppState, + ExcalidrawProps, + BinaryFiles, + UIAppState, +} from "../types"; +import type { CaptureUpdateActionType } from "../store"; + +export type ActionSource = + | "ui" + | "keyboard" + | "contextMenu" + | "api" + | "commandPalette"; + +/** if false, the action should be prevented */ +export type ActionResult = + | { + elements?: readonly ExcalidrawElement[] | null; + appState?: Partial<AppState> | null; + files?: BinaryFiles | null; + captureUpdate: CaptureUpdateActionType; + replaceFiles?: boolean; + } + | false; + +type ActionFn = ( + elements: readonly OrderedExcalidrawElement[], + appState: Readonly<AppState>, + formData: any, + app: AppClassProperties, +) => ActionResult | Promise<ActionResult>; + +export type UpdaterFn = (res: ActionResult) => void; +export type ActionFilterFn = (action: Action) => void; + +export type ActionName = + | "copy" + | "cut" + | "paste" + | "copyAsPng" + | "copyAsSvg" + | "copyText" + | "sendBackward" + | "bringForward" + | "sendToBack" + | "bringToFront" + | "copyStyles" + | "selectAll" + | "pasteStyles" + | "gridMode" + | "zenMode" + | "objectsSnapMode" + | "stats" + | "changeStrokeColor" + | "changeBackgroundColor" + | "changeFillStyle" + | "changeStrokeWidth" + | "changeStrokeShape" + | "changeSloppiness" + | "changeStrokeStyle" + | "changeArrowhead" + | "changeArrowType" + | "changeOpacity" + | "changeFontSize" + | "toggleCanvasMenu" + | "toggleEditMenu" + | "undo" + | "redo" + | "finalize" + | "changeProjectName" + | "changeExportBackground" + | "changeExportEmbedScene" + | "changeExportScale" + | "saveToActiveFile" + | "saveFileToDisk" + | "loadScene" + | "duplicateSelection" + | "deleteSelectedElements" + | "changeViewBackgroundColor" + | "clearCanvas" + | "zoomIn" + | "zoomOut" + | "resetZoom" + | "zoomToFit" + | "zoomToFitSelection" + | "zoomToFitSelectionInViewport" + | "changeFontFamily" + | "changeTextAlign" + | "changeVerticalAlign" + | "toggleFullScreen" + | "toggleShortcuts" + | "group" + | "ungroup" + | "goToCollaborator" + | "addToLibrary" + | "changeRoundness" + | "alignTop" + | "alignBottom" + | "alignLeft" + | "alignRight" + | "alignVerticallyCentered" + | "alignHorizontallyCentered" + | "distributeHorizontally" + | "distributeVertically" + | "flipHorizontal" + | "flipVertical" + | "viewMode" + | "exportWithDarkMode" + | "toggleTheme" + | "increaseFontSize" + | "decreaseFontSize" + | "unbindText" + | "hyperlink" + | "bindText" + | "unlockAllElements" + | "toggleElementLock" + | "toggleLinearEditor" + | "toggleEraserTool" + | "toggleHandTool" + | "selectAllElementsInFrame" + | "removeAllElementsFromFrame" + | "updateFrameRendering" + | "setFrameAsActiveTool" + | "setEmbeddableAsActiveTool" + | "createContainerFromText" + | "wrapTextInContainer" + | "commandPalette" + | "autoResize" + | "elementStats" + | "searchMenu" + | "copyElementLink" + | "linkToElement" + | "cropEditor" + | "wrapSelectionInFrame"; + +export type PanelComponentProps = { + elements: readonly ExcalidrawElement[]; + appState: AppState; + updateData: <T = any>(formData?: T) => void; + appProps: ExcalidrawProps; + data?: Record<string, any>; + app: AppClassProperties; +}; + +export interface Action { + name: ActionName; + label: + | string + | (( + elements: readonly ExcalidrawElement[], + appState: Readonly<AppState>, + app: AppClassProperties, + ) => string); + keywords?: string[]; + icon?: + | React.ReactNode + | (( + appState: UIAppState, + elements: readonly ExcalidrawElement[], + ) => React.ReactNode); + PanelComponent?: React.FC<PanelComponentProps>; + perform: ActionFn; + keyPriority?: number; + keyTest?: ( + event: React.KeyboardEvent | KeyboardEvent, + appState: AppState, + elements: readonly ExcalidrawElement[], + app: AppClassProperties, + ) => boolean; + predicate?: ( + elements: readonly ExcalidrawElement[], + appState: AppState, + appProps: ExcalidrawProps, + app: AppClassProperties, + ) => boolean; + checked?: (appState: Readonly<AppState>) => boolean; + trackEvent: + | false + | { + category: + | "toolbar" + | "element" + | "canvas" + | "export" + | "history" + | "menu" + | "collab" + | "hyperlink" + | "search_menu"; + action?: string; + predicate?: ( + appState: Readonly<AppState>, + elements: readonly ExcalidrawElement[], + value: any, + ) => boolean; + }; + /** if set to `true`, allow action to be performed in viewMode. + * Defaults to `false` */ + viewMode?: boolean; +} |
