Browse Source

Merge 99ebc29f97 into f06484c6ab

pull/9993/merge
Bartosz Legięć 2 days ago committed by GitHub
parent
commit
66128cc505
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/common/src/utils.ts
  2. 273
      packages/element/src/applyFilterToImage.ts
  3. 1
      packages/element/src/index.ts
  4. 19
      packages/element/src/renderElement.ts
  5. 24
      packages/excalidraw/renderer/staticScene.ts

4
packages/common/src/utils.ts

@ -1311,3 +1311,7 @@ export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>( @@ -1311,3 +1311,7 @@ export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
console.error("unable to set feature flag", e);
}
};
export function isCanvasFilterSupported() {
return "filter" in (CanvasRenderingContext2D.prototype || {});
}

273
packages/element/src/applyFilterToImage.ts

@ -0,0 +1,273 @@ @@ -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<CanvasImageSource, CanvasImageSource>();
/**
* 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;
}
}
}

1
packages/element/src/index.ts

@ -96,3 +96,4 @@ export * from "./transformHandles"; @@ -96,3 +96,4 @@ export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./applyFilterToImage";

19
packages/element/src/renderElement.ts

@ -22,6 +22,7 @@ import { @@ -22,6 +22,7 @@ import {
isRTL,
getVerticalOffset,
invariant,
isCanvasFilterSupported,
} from "@excalidraw/common";
import type {
@ -66,6 +67,8 @@ import { getCornerRadius } from "./utils"; @@ -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 = ( @@ -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,

24
packages/excalidraw/renderer/staticScene.ts

@ -1,5 +1,11 @@ @@ -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 = ({ @@ -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 */

Loading…
Cancel
Save