Skip to content

Commit

Permalink
feat: image cropping (excalidraw#8613)
Browse files Browse the repository at this point in the history
Co-authored-by: dwelle <[email protected]>
  • Loading branch information
ryan-di and dwelle authored Oct 21, 2024
1 parent eb09b48 commit e957c8e
Show file tree
Hide file tree
Showing 36 changed files with 2,199 additions and 92 deletions.
55 changes: 55 additions & 0 deletions packages/excalidraw/actions/actionCropEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { register } from "./register";
import { cropIcon } from "../components/icons";
import { StoreAction } 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,
},
storeAction: StoreAction.CAPTURE,
};
},
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)}
/>
);
},
});
2 changes: 2 additions & 0 deletions packages/excalidraw/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,5 @@ export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";

export { actionToggleSearchMenu } from "./actionToggleSearchMenu";

export { actionToggleCropEditor } from "./actionCropEditor";
3 changes: 2 additions & 1 deletion packages/excalidraw/actions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ export type ActionName =
| "commandPalette"
| "autoResize"
| "elementStats"
| "searchMenu";
| "searchMenu"
| "cropEditor";

export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
Expand Down
4 changes: 4 additions & 0 deletions packages/excalidraw/appState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [],
};
};
Expand Down Expand Up @@ -237,6 +239,8 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
});

Expand Down
33 changes: 32 additions & 1 deletion packages/excalidraw/change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isImageElement,
isTextElement,
} from "./element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./element/types";
Expand Down Expand Up @@ -626,6 +629,18 @@ export class AppStateChange implements Change<AppState> {
);

break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);

if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId":
const editingGroupId = nextAppState[key];

Expand Down Expand Up @@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds,
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
...standaloneProps
} = delta as ObservedAppState;

Expand All @@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
}
}

type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
ElementUpdate<Ordered<T>>,
"seed"
>;

/**
* Elements change is a low level primitive to capture a change between two sets of elements.
Expand Down Expand Up @@ -1216,6 +1235,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
});
}

if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}

if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
Expand Down
7 changes: 7 additions & 0 deletions packages/excalidraw/components/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { trackEvent } from "../analytics";
import {
hasBoundTextElement,
isElbowArrow,
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
Expand Down Expand Up @@ -127,6 +128,11 @@ export const SelectedShapeActions = ({
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);

const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);

return (
<div className="panelColumn">
<div>
Expand Down Expand Up @@ -245,6 +251,7 @@ export const SelectedShapeActions = ({
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</div>
</fieldset>
Expand Down
Loading

0 comments on commit e957c8e

Please sign in to comment.