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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2199 additions and 92 deletions

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;

View file

@ -17,6 +17,7 @@ import {
isArrowElement,
hasBoundTextElement,
isMagicFrameElement,
isImageElement,
} from "../element/typeChecks";
import { getElementAbsoluteCoords } from "../element/bounds";
import type { RoughCanvas } from "roughjs/bin/canvas";
@ -61,6 +62,7 @@ import { ShapeCache } from "../scene/ShapeCache";
import { getVerticalOffset } from "../fonts";
import { isRightAngleRads } from "../../math";
import { getCornerRadius } from "../shapes";
import { getUncroppedImageElement } from "../element/cropElement";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
@ -434,8 +436,22 @@ const drawElementOnCanvas = (
);
context.clip();
}
const { x, y, width, height } = element.crop
? element.crop
: {
x: 0,
y: 0,
width: img.naturalWidth,
height: img.naturalHeight,
};
context.drawImage(
img,
x,
y,
width,
height,
0 /* hardcoded for the selection box*/,
0,
element.width,
@ -921,14 +937,53 @@ export const renderElement = (
context.imageSmoothingEnabled = false;
}
drawElementFromCanvas(
elementWithCanvas,
context,
if (
element.id === appState.croppingElementId &&
isImageElement(elementWithCanvas.element) &&
elementWithCanvas.element.crop !== null
) {
context.save();
context.globalAlpha = 0.1;
const uncroppedElementCanvas = generateElementCanvas(
getUncroppedImageElement(elementWithCanvas.element, elementsMap),
allElementsMap,
appState.zoom,
renderConfig,
appState,
);
if (uncroppedElementCanvas) {
drawElementFromCanvas(
uncroppedElementCanvas,
context,
renderConfig,
appState,
allElementsMap,
);
}
context.restore();
}
const _elementWithCanvas = generateElementCanvas(
elementWithCanvas.element,
allElementsMap,
appState.zoom,
renderConfig,
appState,
allElementsMap,
);
if (_elementWithCanvas) {
drawElementFromCanvas(
_elementWithCanvas,
context,
renderConfig,
appState,
allElementsMap,
);
}
// reset
context.imageSmoothingEnabled = currentImageSmoothingStatus;
}

View file

@ -37,6 +37,7 @@ import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
import { getVerticalOffset } from "../fonts";
import { getCornerRadius, isPathALoop } from "../shapes";
import { getUncroppedWidthAndHeight } from "../element/cropElement";
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,
@ -417,12 +418,28 @@ const renderElementToSvg = (
symbol.id = symbolId;
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL);
image.setAttribute("preserveAspectRatio", "none");
if (element.crop) {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
symbol.setAttribute(
"viewBox",
`${
element.crop.x / (element.crop.naturalWidth / uncroppedWidth)
} ${
element.crop.y / (element.crop.naturalHeight / uncroppedHeight)
} ${width} ${height}`,
);
image.setAttribute("width", `${uncroppedWidth}`);
image.setAttribute("height", `${uncroppedHeight}`);
} else {
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
}
symbol.appendChild(image);
root.prepend(symbol);