diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 356f951521..14e77bde6f 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1311,3 +1311,7 @@ export const setFeatureFlag = ( console.error("unable to set feature flag", e); } }; + +export function isCanvasFilterSupported() { + return "filter" in (CanvasRenderingContext2D.prototype || {}); +} diff --git a/packages/element/src/applyFilterToImage.ts b/packages/element/src/applyFilterToImage.ts new file mode 100644 index 0000000000..d95a4547c5 --- /dev/null +++ b/packages/element/src/applyFilterToImage.ts @@ -0,0 +1,273 @@ +export function applyFiltersToImage( + image: CanvasImageSource, + imageWidth: number, + imageHeight: number, + filter: string, +): CanvasImageSource { + const cached = cache.get(image); + if (cached) { + return cached; + } + + const canvas = document.createElement("canvas"); + canvas.width = imageWidth; + canvas.height = imageHeight; + + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0, imageWidth, imageHeight); + + applyFilter(ctx, filter); + + cache.set(image, canvas); + return canvas; +} + +const cache = new WeakMap(); + +/** + * Taken from @davidenke's context-filter-polyfill + * @see {@link https://github.com/davidenke/context-filter-polyfill/blob/e1a04c24b8f31a0608f5d05155d29544a6d6429a/src/filters/invert.filter.ts} + */ +function invert(context: CanvasRenderingContext2D, stringAmount = "0"): void { + let amount = normalizeNumberPercentage(stringAmount); + + // do not manipulate without proper amount + if (amount <= 0) { + return; + } + + // a maximum of 100% + if (amount > 1) { + amount = 1; + } + + const { height, width } = context.canvas; + const imageData = context.getImageData(0, 0, width, height); + const { data } = imageData; + const { length } = data; + + for (let i = 0; i < length; i += 4) { + data[i + 0] = Math.abs(data[i + 0] - 255 * amount); + data[i + 1] = Math.abs(data[i + 1] - 255 * amount); + data[i + 2] = Math.abs(data[i + 2] - 255 * amount); + } + + context.putImageData(imageData, 0, 0); +} + +/** + * Taken from @davidenke's context-filter-polyfill + * @see {@link https://github.com/davidenke/context-filter-polyfill/blob/e1a04c24b8f31a0608f5d05155d29544a6d6429a/src/filters/hue-rotate.filter.ts} + */ +function hueRotate(context: CanvasRenderingContext2D, rotation = "0deg"): void { + const amount = normalizeAngle(rotation); + + // do not manipulate without proper amount + if (amount <= 0) { + return; + } + + const { height, width } = context.canvas; + const imageData = context.getImageData(0, 0, width, height); + const { data } = imageData; + + // in rgba world, every + // n * 4 + 0 is red, + // n * 4 + 1 green and + // n * 4 + 2 is blue + // the fourth can be skipped as it's the alpha channel + // https://github.com/makoConstruct/canvas-hue-rotate/blob/master/hueShiftCanvas.js + const h = ((amount % 1) + 1) % 1; // wraps the angle to unit interval, even when negative + const th = h * 3; + const thr = Math.floor(th); + const d = th - thr; + const b = 1 - d; + let ma; + let mb; + let mc; + let md; + let me; + let mf; + let mg; + let mh; + let mi; + + switch (thr) { + default: + ma = mb = mc = md = me = mf = mg = mh = mi = 0; + break; + case 0: + ma = b; + mb = 0; + mc = d; + md = d; + me = b; + mf = 0; + mg = 0; + mh = d; + mi = b; + break; + case 1: + ma = 0; + mb = d; + mc = b; + md = b; + me = 0; + mf = d; + mg = d; + mh = b; + mi = 0; + break; + case 2: + ma = d; + mb = b; + mc = 0; + md = 0; + me = d; + mf = b; + mg = b; + mh = 0; + mi = d; + break; + } + // do the pixels + let place = 0; + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + place = 4 * (y * width + x); + + const ir = data[place + 0]; + const ig = data[place + 1]; + const ib = data[place + 2]; + + data[place + 0] = Math.floor(ma * ir + mb * ig + mc * ib); + data[place + 1] = Math.floor(md * ir + me * ig + mf * ib); + data[place + 2] = Math.floor(mg * ir + mh * ig + mi * ib); + } + } + + // set back image data to context + context.putImageData(imageData, 0, 0); +} + +/** + * Taken from @davidenke's context-filter-polyfill + * @see {@link https://github.com/davidenke/context-filter-polyfill/blob/e1a04c24b8f31a0608f5d05155d29544a6d6429a/src/filters/saturate.filter.ts} + */ +function saturate(context: CanvasRenderingContext2D, saturation = "1"): void { + let amount = normalizeNumberPercentage(saturation); + + // do not manipulate without proper amount + if (amount === 1) { + return; + } + + // align minimum + if (amount < 0) { + amount = 0; + } + + const { height, width } = context.canvas; + const imageData = context.getImageData(0, 0, width, height); + const { data } = imageData; + const lumR = (1 - amount) * 0.213; + const lumG = (1 - amount) * 0.715; + const lumB = (1 - amount) * 0.072; + // tslint:disable-next-line no-bitwise + const shiftW = width << 2; + + for (let j = 0; j < height; j++) { + const offset = j * shiftW; + for (let i = 0; i < width; i++) { + // tslint:disable-next-line no-bitwise + const pos = offset + (i << 2); + const r = data[pos + 0]; + const g = data[pos + 1]; + const b = data[pos + 2]; + + data[pos + 0] = (lumR + amount) * r + lumG * g + lumB * b; + data[pos + 1] = lumR * r + (lumG + amount) * g + lumB * b; + data[pos + 2] = lumR * r + lumG * g + (lumB + amount) * b; + } + } + + // set back image data to context + context.putImageData(imageData, 0, 0); +} + +function normalizeNumberPercentage(percentage: string): number { + let normalized = parseFloat(percentage); + + // check for percentages and divide by a hundred + if (/%\s*?$/i.test(percentage)) { + normalized /= 100; + } + + return normalized; +} + +function normalizeAngle(angle: string): number { + let normalized = parseFloat(angle); + const unit = angle.slice(normalized.toString().length); + + // check for units and align accordingly + switch (unit) { + case "deg": + normalized /= 360; + break; + case "grad": + normalized /= 400; + break; + case "rad": + normalized /= 2 * Math.PI; + break; + } + + return normalized; +} + +/** + * Taken from @davidenke's context-filter-polyfill + * @see {@link https://github.com/davidenke/context-filter-polyfill/blob/e1a04c24b8f31a0608f5d05155d29544a6d6429a/src/utils/filter.utils.ts#L5} + */ +function parseFilterString( + filterString: string, +): [filterName: string, filterValue: string][] { + // filters are separated by whitespace + const match = filterString.match(/([-a-z]+)(?:\(([\w\d\s.%-]*)\))?/gim); + + if (!match) { + return []; + } + + return ( + match + // filters may have options within appended brackets + ?.map( + (filter) => + filter.match(/([-a-z]+)(?:\((.*)\))?/i)?.slice(1, 3) as [ + string, + string, + ], + ) + ); +} + +function applyFilter(ctx: CanvasRenderingContext2D, filterString: string) { + const filters = parseFilterString(filterString); + for (const [filterName, filterValue] of filters) { + switch (filterName) { + case "invert": + invert(ctx, filterValue); + break; + case "hue-rotate": + hueRotate(ctx, filterValue); + break; + case "saturate": + saturate(ctx, filterValue); + break; + default: + break; + } + } +} diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index a365c517de..df027e4d3d 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -96,3 +96,4 @@ export * from "./transformHandles"; export * from "./typeChecks"; export * from "./utils"; export * from "./zindex"; +export * from "./applyFilterToImage"; diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 6a49d4202f..0d867f8a6a 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -22,6 +22,7 @@ import { isRTL, getVerticalOffset, invariant, + isCanvasFilterSupported, } from "@excalidraw/common"; import type { @@ -66,6 +67,8 @@ import { getCornerRadius } from "./utils"; import { ShapeCache } from "./shape"; +import { applyFiltersToImage } from "./applyFilterToImage"; + import type { ExcalidrawElement, ExcalidrawTextElement, @@ -478,8 +481,22 @@ const drawElementOnCanvas = ( height: img.naturalHeight, }; + const shouldInvertImage = + !isCanvasFilterSupported() && + shouldResetImageFilter(element, renderConfig, appState); + + let imageData: CanvasImageSource = img; + if (shouldInvertImage) { + imageData = applyFiltersToImage( + img, + img.naturalWidth, + img.naturalHeight, + IMAGE_INVERT_FILTER, + ); + } + context.drawImage( - img, + imageData, x, y, width, diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index 866eb3da11..26872356c0 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -1,5 +1,11 @@ -import { FRAME_STYLE, throttleRAF } from "@excalidraw/common"; -import { isElementLink } from "@excalidraw/element"; +import { + FRAME_STYLE, + isCanvasFilterSupported, + THEME, + THEME_FILTER, + throttleRAF, +} from "@excalidraw/common"; +import { applyFiltersToImage, isElementLink } from "@excalidraw/element"; import { createPlaceholderEmbeddableLabel } from "@excalidraw/element"; import { getBoundTextElement } from "@excalidraw/element"; import { @@ -458,6 +464,20 @@ const _renderStaticScene = ({ console.error(error); } }); + + if ( + isExporting && + appState.theme === THEME.DARK && + !isCanvasFilterSupported() + ) { + const invertedCanvas = applyFiltersToImage( + canvas, + normalizedWidth, + normalizedHeight, + THEME_FILTER, + ); + context.drawImage(invertedCanvas, 0, 0, normalizedWidth, normalizedHeight); + } }; /** throttled to animation framerate */