feat: image cropping (#8613)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di
2024-10-22 04:26:52 +08:00
committed by GitHub
parent eb09b48ae6
commit e957c8e9ee
36 changed files with 2199 additions and 92 deletions
+179 -57
View File
@@ -54,6 +54,7 @@ import oc from "open-color";
import {
isElbowArrow,
isFrameLikeElement,
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
@@ -62,6 +63,7 @@ import type {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
GroupId,
@@ -307,38 +309,42 @@ const renderBindingHighlightForSuggestedPointBinding = (
});
};
type ElementSelectionBorder = {
angle: number;
x1: number;
y1: number;
x2: number;
y2: number;
selectionColors: string[];
dashed?: boolean;
cx: number;
cy: number;
activeEmbeddable: boolean;
padding?: number;
};
const renderSelectionBorder = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
elementProperties: {
angle: number;
elementX1: number;
elementY1: number;
elementX2: number;
elementY2: number;
selectionColors: string[];
dashed?: boolean;
cx: number;
cy: number;
activeEmbeddable: boolean;
},
elementProperties: ElementSelectionBorder,
) => {
const {
angle,
elementX1,
elementY1,
elementX2,
elementY2,
x1,
y1,
x2,
y2,
selectionColors,
cx,
cy,
dashed,
activeEmbeddable,
} = elementProperties;
const elementWidth = elementX2 - elementX1;
const elementHeight = elementY2 - elementY1;
const elementWidth = x2 - x1;
const elementHeight = y2 - y1;
const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
const padding =
elementProperties.padding ?? DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
const linePadding = padding / appState.zoom.value;
const lineWidth = 8 / appState.zoom.value;
@@ -360,8 +366,8 @@ const renderSelectionBorder = (
context.lineDashOffset = (lineWidth + spaceWidth) * index;
strokeRectWithRotation(
context,
elementX1 - linePadding,
elementY1 - linePadding,
x1 - linePadding,
y1 - linePadding,
elementWidth + linePadding * 2,
elementHeight + linePadding * 2,
cx,
@@ -433,18 +439,17 @@ const renderElementsBoxHighlight = (
);
const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(elements);
const [x1, y1, x2, y2] = getCommonBounds(elements);
return {
angle: 0,
elementX1,
elementX2,
elementY1,
elementY2,
x1,
x2,
y1,
y2,
selectionColors: ["rgb(0,118,255)"],
dashed: false,
cx: elementX1 + (elementX2 - elementX1) / 2,
cy: elementY1 + (elementY2 - elementY1) / 2,
cx: x1 + (x2 - x1) / 2,
cy: y1 + (y2 - y1) / 2,
activeEmbeddable: false,
};
};
@@ -594,6 +599,111 @@ const renderTransformHandles = (
});
};
const renderCropHandles = (
context: CanvasRenderingContext2D,
renderConfig: InteractiveCanvasRenderConfig,
appState: InteractiveCanvasAppState,
croppingElement: ExcalidrawImageElement,
elementsMap: ElementsMap,
): void => {
const [x1, y1, , , cx, cy] = getElementAbsoluteCoords(
croppingElement,
elementsMap,
);
const LINE_WIDTH = 3;
const LINE_LENGTH = 20;
const ZOOMED_LINE_WIDTH = LINE_WIDTH / appState.zoom.value;
const ZOOMED_HALF_LINE_WIDTH = ZOOMED_LINE_WIDTH / 2;
const HALF_WIDTH = cx - x1 + ZOOMED_LINE_WIDTH;
const HALF_HEIGHT = cy - y1 + ZOOMED_LINE_WIDTH;
const HORIZONTAL_LINE_LENGTH = Math.min(
LINE_LENGTH / appState.zoom.value,
HALF_WIDTH,
);
const VERTICAL_LINE_LENGTH = Math.min(
LINE_LENGTH / appState.zoom.value,
HALF_HEIGHT,
);
context.save();
context.fillStyle = renderConfig.selectionColor;
context.strokeStyle = renderConfig.selectionColor;
context.lineWidth = ZOOMED_LINE_WIDTH;
const handles: Array<
[
[number, number],
[number, number],
[number, number],
[number, number],
[number, number],
]
> = [
[
// x, y
[-HALF_WIDTH, -HALF_HEIGHT],
// horizontal line: first start and to
[0, ZOOMED_HALF_LINE_WIDTH],
[HORIZONTAL_LINE_LENGTH, ZOOMED_HALF_LINE_WIDTH],
// vertical line: second start and to
[ZOOMED_HALF_LINE_WIDTH, 0],
[ZOOMED_HALF_LINE_WIDTH, VERTICAL_LINE_LENGTH],
],
[
[HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, -HALF_HEIGHT],
[ZOOMED_HALF_LINE_WIDTH, ZOOMED_HALF_LINE_WIDTH],
[
-HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH,
ZOOMED_HALF_LINE_WIDTH,
],
[0, 0],
[0, VERTICAL_LINE_LENGTH],
],
[
[-HALF_WIDTH, HALF_HEIGHT],
[0, -ZOOMED_HALF_LINE_WIDTH],
[HORIZONTAL_LINE_LENGTH, -ZOOMED_HALF_LINE_WIDTH],
[ZOOMED_HALF_LINE_WIDTH, 0],
[ZOOMED_HALF_LINE_WIDTH, -VERTICAL_LINE_LENGTH],
],
[
[HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, HALF_HEIGHT],
[ZOOMED_HALF_LINE_WIDTH, -ZOOMED_HALF_LINE_WIDTH],
[
-HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH,
-ZOOMED_HALF_LINE_WIDTH,
],
[0, 0],
[0, -VERTICAL_LINE_LENGTH],
],
];
handles.forEach((handle) => {
const [[x, y], [x1s, y1s], [x1t, y1t], [x2s, y2s], [x2t, y2t]] = handle;
context.save();
context.translate(cx, cy);
context.rotate(croppingElement.angle);
context.beginPath();
context.moveTo(x + x1s, y + y1s);
context.lineTo(x + x1t, y + y1t);
context.stroke();
context.beginPath();
context.moveTo(x + x2s, y + y2s);
context.lineTo(x + x2t, y + y2t);
context.stroke();
context.restore();
});
context.restore();
};
const renderTextBox = (
text: NonDeleted<ExcalidrawTextElement>,
context: CanvasRenderingContext2D,
@@ -671,7 +781,7 @@ const _renderInteractiveScene = ({
}
// Paint selection element
if (appState.selectionElement) {
if (appState.selectionElement && !appState.isCropping) {
try {
renderSelectionElement(
appState.selectionElement,
@@ -783,18 +893,7 @@ const _renderInteractiveScene = ({
// Optimisation for finding quickly relevant element ids
const locallySelectedIds = arrayToMap(selectedElements);
const selections: {
angle: number;
elementX1: number;
elementY1: number;
elementX2: number;
elementY2: number;
selectionColors: string[];
dashed?: boolean;
cx: number;
cy: number;
activeEmbeddable: boolean;
}[] = [];
const selections: ElementSelectionBorder[] = [];
for (const element of elementsMap.values()) {
const selectionColors = [];
@@ -833,14 +932,17 @@ const _renderInteractiveScene = ({
}
if (selectionColors.length) {
const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
getElementAbsoluteCoords(element, elementsMap, true);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
selections.push({
angle: element.angle,
elementX1,
elementY1,
elementX2,
elementY2,
x1,
y1,
x2,
y2,
selectionColors,
dashed: !!remoteClients,
cx,
@@ -848,24 +950,28 @@ const _renderInteractiveScene = ({
activeEmbeddable:
appState.activeEmbeddable?.element === element &&
appState.activeEmbeddable.state === "active",
padding:
element.id === appState.croppingElementId ||
isImageElement(element)
? 0
: undefined,
});
}
}
const addSelectionForGroupId = (groupId: GroupId) => {
const groupElements = getElementsInGroup(elementsMap, groupId);
const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(groupElements);
const [x1, y1, x2, y2] = getCommonBounds(groupElements);
selections.push({
angle: 0,
elementX1,
elementX2,
elementY1,
elementY2,
x1,
x2,
y1,
y2,
selectionColors: [oc.black],
dashed: true,
cx: elementX1 + (elementX2 - elementX1) / 2,
cy: elementY1 + (elementY2 - elementY1) / 2,
cx: x1 + (x2 - x1) / 2,
cy: y1 + (y2 - y1) / 2,
activeEmbeddable: false,
});
};
@@ -900,7 +1006,9 @@ const _renderInteractiveScene = ({
!appState.viewModeEnabled &&
showBoundingBox &&
// do not show transform handles when text is being edited
!isTextElement(appState.editingTextElement)
!isTextElement(appState.editingTextElement) &&
// do not show transform handles when image is being cropped
!appState.croppingElementId
) {
renderTransformHandles(
context,
@@ -910,6 +1018,20 @@ const _renderInteractiveScene = ({
selectedElements[0].angle,
);
}
if (appState.croppingElementId && !appState.isCropping) {
const croppingElement = elementsMap.get(appState.croppingElementId);
if (croppingElement && isImageElement(croppingElement)) {
renderCropHandles(
context,
renderConfig,
appState,
croppingElement,
elementsMap,
);
}
}
} else if (selectedElements.length > 1 && !appState.isRotating) {
const dashedLinePadding =
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;