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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 3536 additions and 618 deletions

View file

@ -5,11 +5,13 @@ import {
Arrowhead,
NonDeletedExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
} from "../element/types";
import {
isTextElement,
isLinearElement,
isFreeDrawElement,
isInitializedImageElement,
} from "../element/typeChecks";
import {
getDiamondPoints,
@ -21,22 +23,23 @@ import { Drawable, Options } from "roughjs/bin/core";
import { RoughSVG } from "roughjs/bin/svg";
import { RoughGenerator } from "roughjs/bin/generator";
import { SceneState } from "../scene/types";
import {
SVG_NS,
distance,
getFontString,
getFontFamilyString,
isRTL,
} from "../utils";
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
import { isPathALoop } from "../math";
import rough from "roughjs/bin/rough";
import { Zoom } from "../types";
import { AppState, BinaryFiles, Zoom } from "../types";
import { getDefaultAppState } from "../appState";
import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand";
import { MAX_DECIMALS_FOR_SVG_EXPORT } from "../constants";
const defaultAppState = getDefaultAppState();
const isPendingImageElement = (
element: ExcalidrawElement,
sceneState: SceneState,
) =>
isInitializedImageElement(element) &&
!sceneState.imageCache.has(element.fileId);
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
@ -47,6 +50,7 @@ const getCanvasPadding = (element: ExcalidrawElement) =>
export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement;
theme: SceneState["theme"];
canvasZoom: Zoom["value"];
canvasOffsetX: number;
canvasOffsetY: number;
@ -55,6 +59,7 @@ export interface ExcalidrawElementWithCanvas {
const generateElementCanvas = (
element: NonDeletedExcalidrawElement,
zoom: Zoom,
sceneState: SceneState,
): ExcalidrawElementWithCanvas => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
@ -111,21 +116,73 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas);
drawElementOnCanvas(element, rc, context);
if (
sceneState.theme === "dark" &&
isInitializedImageElement(element) &&
!isPendingImageElement(element, sceneState) &&
sceneState.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
) {
// 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
// color scheme (it's still not quite there and the clors look slightly
// desaturing/black is not as black, but...)
context.filter = "invert(100%) hue-rotate(180deg) saturate(1.25)";
}
drawElementOnCanvas(element, rc, context, sceneState);
context.restore();
return {
element,
canvas,
theme: sceneState.theme,
canvasZoom: zoom.value,
canvasOffsetX,
canvasOffsetY,
};
};
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
)}`;
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
)}`;
const drawImagePlaceholder = (
element: ExcalidrawImageElement,
context: CanvasRenderingContext2D,
zoomValue: AppState["zoom"]["value"],
) => {
context.fillStyle = "#E7E7E7";
context.fillRect(0, 0, element.width, element.height);
const imageMinWidthOrHeight = Math.min(element.width, element.height);
const size = Math.min(
imageMinWidthOrHeight,
Math.min(imageMinWidthOrHeight * 0.4, 100),
);
context.drawImage(
element.status === "error"
? IMAGE_ERROR_PLACEHOLDER_IMG
: IMAGE_PLACEHOLDER_IMG,
element.width / 2 - size / 2,
element.height / 2 - size / 2,
size,
size,
);
};
const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
sceneState: SceneState,
) => {
context.globalAlpha = element.opacity / 100;
switch (element.type) {
@ -160,6 +217,23 @@ const drawElementOnCanvas = (
context.restore();
break;
}
case "image": {
const img = isInitializedImageElement(element)
? sceneState.imageCache.get(element.fileId)?.image
: undefined;
if (img != null && !(img instanceof Promise)) {
context.drawImage(
img,
0 /* hardcoded for the selection box*/,
0,
element.width,
element.height,
);
} else {
drawImagePlaceholder(element, context, sceneState.zoom.value);
}
break;
}
default: {
if (isTextElement(element)) {
const rtl = isRTL(element.text);
@ -254,6 +328,7 @@ export const generateRoughOptions = (
switch (element.type) {
case "rectangle":
case "diamond":
case "image":
case "ellipse": {
options.fillStyle = element.fillStyle;
options.fill =
@ -459,7 +534,8 @@ const generateElementShape = (
shape = [];
break;
}
case "text": {
case "text":
case "image": {
// just to ensure we don't regenerate element.canvas on rerenders
shape = [];
break;
@ -471,7 +547,7 @@ const generateElementShape = (
const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement,
sceneState?: SceneState,
sceneState: SceneState,
) => {
const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
const prevElementWithCanvas = elementWithCanvasCache.get(element);
@ -479,8 +555,13 @@ const generateElementWithCanvas = (
prevElementWithCanvas &&
prevElementWithCanvas.canvasZoom !== zoom.value &&
!sceneState?.shouldCacheIgnoreZoom;
if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) {
const elementWithCanvas = generateElementCanvas(element, zoom);
if (
!prevElementWithCanvas ||
shouldRegenerateBecauseZoom ||
prevElementWithCanvas.theme !== sceneState.theme
) {
const elementWithCanvas = generateElementCanvas(element, zoom, sceneState);
elementWithCanvasCache.set(element, elementWithCanvas);
@ -509,10 +590,25 @@ const drawElementFromCanvas = (
const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
const _isPendingImageElement = isPendingImageElement(element, sceneState);
const scaleXFactor =
"scale" in elementWithCanvas.element && !_isPendingImageElement
? elementWithCanvas.element.scale[0]
: 1;
const scaleYFactor =
"scale" in elementWithCanvas.element && !_isPendingImageElement
? elementWithCanvas.element.scale[1]
: 1;
context.save();
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
context.translate(cx, cy);
context.rotate(element.angle);
context.scale(
(1 / window.devicePixelRatio) * scaleXFactor,
(1 / window.devicePixelRatio) * scaleYFactor,
);
context.translate(cx * scaleXFactor, cy * scaleYFactor);
context.rotate(element.angle * scaleXFactor * scaleYFactor);
context.drawImage(
elementWithCanvas.canvas!,
@ -567,7 +663,7 @@ export const renderElement = (
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context);
drawElementOnCanvas(element, rc, context, sceneState);
context.restore();
}
@ -578,6 +674,7 @@ export const renderElement = (
case "ellipse":
case "line":
case "arrow":
case "image":
case "text": {
generateElementShape(element, generator);
if (renderOptimizations) {
@ -596,7 +693,7 @@ export const renderElement = (
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context);
drawElementOnCanvas(element, rc, context, sceneState);
context.restore();
}
break;
@ -628,6 +725,7 @@ export const renderElementToSvg = (
element: NonDeletedExcalidrawElement,
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
offsetX?: number,
offsetY?: number,
) => {
@ -723,6 +821,44 @@ export const renderElementToSvg = (
svgRoot.appendChild(node);
break;
}
case "image": {
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (fileData) {
const symbolId = `image-${fileData.id}`;
let symbol = svgRoot.querySelector(`#${symbolId}`);
if (!symbol) {
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
symbol.id = symbolId;
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL);
symbol.appendChild(image);
svgRoot.prepend(symbol);
}
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
use.setAttribute("href", `#${symbolId}`);
use.setAttribute("width", `${Math.round(element.width)}`);
use.setAttribute("height", `${Math.round(element.height)}`);
use.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
svgRoot.appendChild(use);
}
break;
}
default: {
if (isTextElement(element)) {
const opacity = element.opacity / 100;