feat: image support (#4011)

Co-authored-by: Emil Atanasov <heitara@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
This commit is contained in:
David Luzar
2021-10-21 22:05:48 +02:00
committed by GitHub
parent 0f0244224d
commit 163ad1f4c4
85 changed files with 3536 additions and 618 deletions

View File

@ -23,6 +23,7 @@ import {
ExcalidrawEllipseElement,
NonDeleted,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@ -30,6 +31,7 @@ import { Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { isImageElement } from "./typeChecks";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@ -47,8 +49,7 @@ const isElementDraggableFromInside = (
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside;
return isDraggableFromInside || isImageElement(element);
};
export const hitTest = (
@ -161,6 +162,7 @@ type HitTestArgs = {
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
case "ellipse":
@ -195,6 +197,7 @@ export const distanceToBindableElement = (
): number => {
switch (element.type) {
case "rectangle":
case "image":
case "text":
return distanceToRectangle(element, point);
case "diamond":
@ -224,7 +227,8 @@ const distanceToRectangle = (
element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement,
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -486,6 +490,7 @@ export const determineFocusDistance = (
const nabs = Math.abs(n);
switch (element.type) {
case "rectangle":
case "image":
case "text":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
@ -516,6 +521,7 @@ export const determineFocusPoint = (
let point;
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
@ -565,6 +571,7 @@ const getSortedElementLineIntersections = (
let intersections: GA.Point[];
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "diamond":
const corners = getCorners(element);
@ -598,6 +605,7 @@ const getSortedElementLineIntersections = (
const getCorners = (
element:
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
scale: number = 1,
@ -606,6 +614,7 @@ const getCorners = (
const hy = (scale * element.height) / 2;
switch (element.type) {
case "rectangle":
case "image":
case "text":
return [
GA.point(hx, hy),
@ -747,6 +756,7 @@ export const findFocusPointForEllipse = (
export const findFocusPointForRectangulars = (
element:
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
// Between -1 and 1 for how far away should the focus point be relative

View File

@ -62,25 +62,32 @@ export const dragNewElement = (
y: number,
width: number,
height: number,
isResizeWithSidesSameLength: boolean,
isResizeCenterPoint: boolean,
shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter: boolean,
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null,
) => {
if (isResizeWithSidesSameLength) {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
if (shouldMaintainAspectRatio) {
if (widthAspectRatio) {
height = width / widthAspectRatio;
} else {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
if (height < 0) {
height = -height;
if (height < 0) {
height = -height;
}
}
}
let newX = x < originX ? originX - width : originX;
let newY = y < originY ? originY - height : originY;
if (isResizeCenterPoint) {
if (shouldResizeFromCenter) {
width += width;
height += height;
newX = originX - width / 2;

111
src/element/image.ts Normal file
View File

@ -0,0 +1,111 @@
// -----------------------------------------------------------------------------
// ExcalidrawImageElement & related helpers
// -----------------------------------------------------------------------------
import { MIME_TYPES, SVG_NS } from "../constants";
import { t } from "../i18n";
import { AppClassProperties, DataURL, BinaryFiles } from "../types";
import { isInitializedImageElement } from "./typeChecks";
import {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
} from "./types";
export const loadHTMLImageElement = (dataURL: DataURL) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve(image);
};
image.onerror = (error) => {
reject(error);
};
image.src = dataURL;
});
};
/** NOTE: updates cache even if already populated with given image. Thus,
* you should filter out the images upstream if you want to optimize this. */
export const updateImageCache = async ({
fileIds,
files,
imageCache,
}: {
fileIds: FileId[];
files: BinaryFiles;
imageCache: AppClassProperties["imageCache"];
}) => {
const updatedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
await Promise.all(
fileIds.reduce((promises, fileId) => {
const fileData = files[fileId as string];
if (fileData && !updatedFiles.has(fileId)) {
updatedFiles.set(fileId, true);
return promises.concat(
(async () => {
try {
if (fileData.mimeType === MIME_TYPES.binary) {
throw new Error("Only images can be added to ImageCache");
}
const imagePromise = loadHTMLImageElement(fileData.dataURL);
const data = {
image: imagePromise,
mimeType: fileData.mimeType,
} as const;
// store the promise immediately to indicate there's an in-progress
// initialization
imageCache.set(fileId, data);
const image = await imagePromise;
imageCache.set(fileId, { ...data, image });
} catch (error) {
erroredFiles.set(fileId, true);
}
})(),
);
}
return promises;
}, [] as Promise<any>[]),
);
return {
imageCache,
/** includes errored files because they cache was updated nonetheless */
updatedFiles,
/** files that failed when creating HTMLImageElement */
erroredFiles,
};
};
export const getInitializedImageElements = (
elements: readonly ExcalidrawElement[],
) =>
elements.filter((element) =>
isInitializedImageElement(element),
) as InitializedExcalidrawImageElement[];
export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
// lower-casing due to XML/HTML convention differences
// https://johnresig.com/blog/nodename-case-sensitivity
return node?.nodeName.toLowerCase() === "svg";
};
export const normalizeSVG = async (SVGString: string) => {
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror");
if (errorNode || !isHTMLSVGElement(svg)) {
throw new Error(t("errors.invalidSVGString"));
} else {
if (!svg.hasAttribute("xmlns")) {
svg.setAttribute("xmlns", SVG_NS);
}
return svg.outerHTML;
}
};

View File

@ -11,6 +11,7 @@ export {
newTextElement,
updateTextElement,
newLinearElement,
newImageElement,
duplicateElement,
} from "./newElement";
export {
@ -93,6 +94,10 @@ const _clearElements = (
: element,
);
export const clearElementsForDatabase = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForExport = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);

View File

@ -17,12 +17,13 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
) => {
informMutation = true,
): TElement => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points } = updates as any;
const { points, fileId } = updates as any;
if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
@ -33,13 +34,23 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update in case its deep prop was mutated
(typeof value !== "object" || value === null || key === "groupIds")
// if object, always update because its attrs could have changed
// (except for specific keys we handle below)
(typeof value !== "object" ||
value === null ||
key === "groupIds" ||
key === "scale")
) {
continue;
}
if (key === "points") {
if (key === "scale") {
const prevScale = (element as any)[key];
const nextScale = value;
if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
continue;
}
} else if (key === "points") {
const prevPoints = (element as any)[key];
const nextPoints = value;
if (prevPoints.length === nextPoints.length) {
@ -66,14 +77,14 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
didChange = true;
}
}
if (!didChange) {
return;
return element;
}
if (
typeof updates.height !== "undefined" ||
typeof updates.width !== "undefined" ||
typeof fileId != "undefined" ||
typeof points !== "undefined"
) {
invalidateShapeForElement(element);
@ -81,7 +92,12 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version++;
element.versionNonce = randomInteger();
Scene.getScene(element)?.informMutation();
if (informMutation) {
Scene.getScene(element)?.informMutation();
}
return element;
};
export const newElementWith = <TElement extends ExcalidrawElement>(
@ -94,8 +110,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update in case its deep prop was mutated
(typeof value !== "object" || value === null || key === "groupIds")
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
}

View File

@ -1,5 +1,6 @@
import {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
ExcalidrawGenericElement,
@ -248,6 +249,22 @@ export const newLinearElement = (
};
};
export const newImageElement = (
opts: {
type: ExcalidrawImageElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => {
return {
..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements,
// and `transparent` will likely mean "use original colors of the image"
strokeColor: "transparent",
status: "pending",
fileId: null,
scale: [1, 1],
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
//

View File

@ -47,9 +47,9 @@ export const transformElements = (
transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[],
resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean,
isResizeCenterPoint: boolean,
shouldKeepSidesRatio: boolean,
shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean,
pointerX: number,
pointerY: number,
centerX: number,
@ -62,7 +62,7 @@ export const transformElements = (
element,
pointerX,
pointerY,
isRotateWithDiscreteAngle,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element);
} else if (
@ -76,7 +76,7 @@ export const transformElements = (
reshapeSingleTwoPointElement(
element,
resizeArrowDirection,
isRotateWithDiscreteAngle,
shouldRotateWithDiscreteAngle,
pointerX,
pointerY,
);
@ -90,7 +90,7 @@ export const transformElements = (
resizeSingleTextElement(
element,
transformHandleType,
isResizeCenterPoint,
shouldResizeFromCenter,
pointerX,
pointerY,
);
@ -98,10 +98,10 @@ export const transformElements = (
} else if (transformHandleType) {
resizeSingleElement(
pointerDownState.originalElements.get(element.id) as typeof element,
shouldKeepSidesRatio,
shouldMaintainAspectRatio,
element,
transformHandleType,
isResizeCenterPoint,
shouldResizeFromCenter,
pointerX,
pointerY,
);
@ -115,7 +115,7 @@ export const transformElements = (
selectedElements,
pointerX,
pointerY,
isRotateWithDiscreteAngle,
shouldRotateWithDiscreteAngle,
centerX,
centerY,
);
@ -142,13 +142,13 @@ const rotateSingleElement = (
element: NonDeletedExcalidrawElement,
pointerX: number,
pointerY: number,
isRotateWithDiscreteAngle: boolean,
shouldRotateWithDiscreteAngle: boolean,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
if (isRotateWithDiscreteAngle) {
if (shouldRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
}
@ -187,7 +187,7 @@ const getPerfectElementSizeWithRotation = (
export const reshapeSingleTwoPointElement = (
element: NonDeleted<ExcalidrawLinearElement>,
resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean,
shouldRotateWithDiscreteAngle: boolean,
pointerX: number,
pointerY: number,
) => {
@ -212,7 +212,7 @@ export const reshapeSingleTwoPointElement = (
element.x + element.points[1][0] - rotatedX,
element.y + element.points[1][1] - rotatedY,
];
if (isRotateWithDiscreteAngle) {
if (shouldRotateWithDiscreteAngle) {
[width, height] = getPerfectElementSizeWithRotation(
element.type,
width,
@ -281,28 +281,28 @@ const measureFontSizeFromWH = (
const getSidesForTransformHandle = (
transformHandleType: TransformHandleType,
isResizeFromCenter: boolean,
shouldResizeFromCenter: boolean,
) => {
return {
n:
/^(n|ne|nw)$/.test(transformHandleType) ||
(isResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
s:
/^(s|se|sw)$/.test(transformHandleType) ||
(isResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
w:
/^(w|nw|sw)$/.test(transformHandleType) ||
(isResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
e:
/^(e|ne|se)$/.test(transformHandleType) ||
(isResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
};
};
const resizeSingleTextElement = (
element: NonDeleted<ExcalidrawTextElement>,
transformHandleType: "nw" | "ne" | "sw" | "se",
isResizeFromCenter: boolean,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
@ -361,7 +361,7 @@ const resizeSingleTextElement = (
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
const [nextElementX, nextElementY] = adjustXYWithRotation(
getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
element.x,
element.y,
element.angle,
@ -383,10 +383,10 @@ const resizeSingleTextElement = (
export const resizeSingleElement = (
stateAtResizeStart: NonDeletedExcalidrawElement,
shouldKeepSidesRatio: boolean,
shouldMaintainAspectRatio: boolean,
element: NonDeletedExcalidrawElement,
transformHandleDirection: TransformHandleDirection,
isResizeFromCenter: boolean,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
@ -444,13 +444,13 @@ export const resizeSingleElement = (
let eleNewHeight = element.height * scaleY;
// adjust dimensions for resizing from center
if (isResizeFromCenter) {
if (shouldResizeFromCenter) {
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
}
// adjust dimensions to keep sides ratio
if (shouldKeepSidesRatio) {
if (shouldMaintainAspectRatio) {
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
if (transformHandleDirection.length === 1) {
@ -495,7 +495,7 @@ export const resizeSingleElement = (
}
// Keeps opposite handle fixed during resize
if (shouldKeepSidesRatio) {
if (shouldMaintainAspectRatio) {
if (["s", "n"].includes(transformHandleDirection)) {
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
@ -523,7 +523,7 @@ export const resizeSingleElement = (
}
}
if (isResizeFromCenter) {
if (shouldResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
}
@ -558,6 +558,18 @@ export const resizeSingleElement = (
...rescaledPoints,
};
if ("scale" in element && "scale" in stateAtResizeStart) {
mutateElement(element, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
stateAtResizeStart.scale[0],
(Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
stateAtResizeStart.scale[1],
],
});
}
if (
resizedElement.width !== 0 &&
resizedElement.height !== 0 &&
@ -692,13 +704,13 @@ const rotateMultipleElements = (
elements: readonly NonDeletedExcalidrawElement[],
pointerX: number,
pointerY: number,
isRotateWithDiscreteAngle: boolean,
shouldRotateWithDiscreteAngle: boolean,
centerX: number,
centerY: number,
) => {
let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
if (isRotateWithDiscreteAngle) {
if (shouldRotateWithDiscreteAngle) {
centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}

View File

@ -5,6 +5,8 @@ import {
ExcalidrawBindableElement,
ExcalidrawGenericElement,
ExcalidrawFreeDrawElement,
InitializedExcalidrawImageElement,
ExcalidrawImageElement,
} from "./types";
export const isGenericElement = (
@ -19,6 +21,18 @@ export const isGenericElement = (
);
};
export const isInitializedImageElement = (
element: ExcalidrawElement | null,
): element is InitializedExcalidrawImageElement => {
return !!element && element.type === "image" && !!element.fileId;
};
export const isImageElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawImageElement => {
return !!element && element.type === "image";
};
export const isTextElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => {

View File

@ -63,6 +63,21 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
type: "ellipse";
};
export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{
type: "image";
fileId: FileId | null;
/** whether respective file is persisted */
status: "pending" | "saved" | "error";
/** X and Y scale factors <-1, 1>, used for image axis flipping */
scale: [number, number];
}>;
export type InitializedExcalidrawImageElement = MarkNonNullable<
ExcalidrawImageElement,
"fileId"
>;
/**
* These are elements that don't have any additional properties.
*/
@ -81,10 +96,11 @@ export type ExcalidrawElement =
| ExcalidrawGenericElement
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement;
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: false;
isDeleted: boolean;
};
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
@ -104,7 +120,8 @@ export type ExcalidrawBindableElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement;
| ExcalidrawTextElement
| ExcalidrawImageElement;
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
@ -133,3 +150,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
simulatePressure: boolean;
lastCommittedPoint: Point | null;
}>;
export type FileId = string & { _brand: "FileId" };