Skip to content

Commit

Permalink
refactor: use proxy to draw individually
Browse files Browse the repository at this point in the history
and to apply filters on each draw
  • Loading branch information
davidenke committed Aug 14, 2024
1 parent 6ce40b0 commit b44c924
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 10 deletions.
1 change: 1 addition & 0 deletions src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
declare global {
// TODO: remove
interface HTMLCanvasElement {
__currentPathMirror?: CanvasRenderingContext2D;
__skipFilterPatch?: boolean;
Expand Down
53 changes: 53 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@
white-space: nowrap;
width: 100%;
}
.debug {
display: flex;
min-height: 350px;
flex-flow: row nowrap;
align-items: start;
justify-content: space-between;
background-color: #242424;
}
#clones {
position: relative;
}
.clone {
perspective: 100vw;
position: absolute;
right: calc(var(--i) * 25px);
top: calc(var(--i) * 25px);
border: 1px solid red;
transform: perspective(100vw) rotateY(-30deg);
}
</style>
<script>
// reload page on file change
Expand Down Expand Up @@ -299,5 +318,39 @@
ctxClear.fillRect(10, 10, 130, 130);
ctxClear.clearRect(35, 35, 80, 80);
</script>

<div class="debug">
<canvas height="200" width="300"></canvas>
<section id="clones"></section>
<script>
const ctxDebug = document
.querySelector('.debug canvas')
.getContext('2d');

ctxDebug.filter = 'blur(5px)';
ctxDebug.fillStyle = 'red';
ctxDebug.fillRect(50, 50, 200, 100);

ctxDebug.filter = 'invert(1)';
ctxDebug.fillText('Hello World', 30, 30);

ctxDebug.filter = 'invert(1) opacity(.25)';
ctxDebug.strokeStyle = 'black';
ctxDebug.beginPath();
ctxDebug.moveTo(20, 20);
ctxDebug.lineTo(20, 100);
ctxDebug.lineTo(70, 100);
ctxDebug.lineWidth = 10;
ctxDebug.lineCap = 'round';
ctxDebug.lineJoin = 'round';
ctxDebug.stroke();

ctxDebug.strokeStyle = 'yellow';
//ctxDebug.beginPath();
//ctxDebug.moveTo(70, 100);
ctxDebug.lineTo(20, 20);
ctxDebug.stroke();
</script>
</div>
</body>
</html>
69 changes: 60 additions & 9 deletions src/polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,65 @@ import './filters/saturate.filter.js';
import './filters/sepia.filter.js';

import { applyFilter } from './utils/filter.utils.js';
import { applyProxy } from './utils/proxy.utils.js';

Object.defineProperty(CanvasRenderingContext2D.prototype, 'filter', {
set: function (filter: string) {
this.__filter = filter;
applyFilter(this, this.__filter);
},
get: function () {
return this.__filter;
},
configurable: true,
/**
* Applies the given command history to the given context.
*/
function applyHistory(
context: CanvasRenderingContext2D,
history: CanvasRenderingContext2DHistory,
) {
history.forEach(({ type, prop, value, args }) => {
switch (type) {
case 'set':
// @ts-expect-error - naaaah, it'll work
context[prop] = value;
break;
case 'apply':
// @ts-expect-error - sure
context[prop](...args);
break;
}
});
}

let cloned = 0;
applyProxy((ctx, drawFn, drawArgs) => {
// prevent recursive loop on cloned contexts
if (ctx.__cloned) {
ctx.__skipNextDraw = true;
// @ts-expect-error - all good things come in threes
ctx[drawFn](...drawArgs);
return;
}

// prepare a clone of the canvas to to adopt its settings
const _canvas = ctx.canvas.cloneNode() as HTMLCanvasElement;
const clone = _canvas.getContext('2d')!;
clone.__cloned = true;

// apply the history of the original context to the clone
applyHistory(clone, ctx.canvas.__history);

// apply the draw function itself
// @ts-expect-error - all good things come in threes
clone[drawFn](...drawArgs);

// now apply the latest filter to the clone (if any)
const filter = ctx.canvas.__history.findLast(({ prop }) => prop === 'filter');
if (filter?.value) {
applyFilter(clone, filter.value as string);
}

// add the result to the original canvas
ctx.__skipNextDraw = true;
ctx.drawImage(clone.canvas, 0, 0);

if (ctx.canvas.parentElement?.classList.contains('debug')) {
++cloned;
clone.canvas.classList.add('clone');
clone.canvas.style.setProperty('--i', `${cloned}`);
document.getElementById('clones')?.appendChild(clone.canvas);
}
});
1 change: 0 additions & 1 deletion src/utils/filter.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export const applyFilter = (
// apply all filters
.reduce((input, [filter, options]) => {
// do we have a appropriate filter implementation?
console.log(filter, SUPPORTED_FILTERS.has(filter));
if (SUPPORTED_FILTERS.has(filter)) {
// then filter and return the result
return SUPPORTED_FILTERS.get(filter)!(
Expand Down
136 changes: 136 additions & 0 deletions src/utils/proxy.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 1. proxy everything from 2d context into a mirror
// 2. on every draw function, replicate into a canvas copy
// 3. apply latest filter to current copy

declare global {
type CanvasRenderingContext2DHistoryEntry = {
type: 'set' | 'apply' | 'draw';
prop: string | symbol;
value?: unknown;
args?: unknown[];
};
type CanvasRenderingContext2DHistory = CanvasRenderingContext2DHistoryEntry[];
interface CanvasRenderingContext2D {
__cloned: boolean;
__skipNextDraw: boolean;
}

interface HTMLCanvasElement {
__history: CanvasRenderingContext2DHistory;
}
}

// a list of all drawing functions in CanvasRenderingContext2D
const DRAWING_FN_PROPS: Array<keyof CanvasRenderingContext2D> = [
'clearRect',
'clip',
'drawImage',
'putImageData',
'fill',
'fillRect',
'fillText',
'stroke',
'strokeRect',
'strokeText',
'reset',
'restore',
];

export function addHistoryEntry(
context: CanvasRenderingContext2D,
entry: CanvasRenderingContext2DHistoryEntry,
) {
if (!context.canvas.__history) context.canvas.__history = [];
context.canvas.__history.push(entry);
}

export function applyProxy(
onDraw: (
context: CanvasRenderingContext2D,
drawFn: string,
args?: unknown[],
) => void,
) {
const mirror = {
__cloned: false,
__clearRect(
this: CanvasRenderingContext2D,
...args: Parameters<CanvasRenderingContext2D['clearRect']>
) {
this.__skipNextDraw = true;
this.clearRect(...args);
this.__skipNextDraw = false;
},
__drawImage(
this: CanvasRenderingContext2D,
...args: Parameters<CanvasRenderingContext2D['drawImage']>
) {
this.__skipNextDraw = true;
this.drawImage(...args);
this.__skipNextDraw = false;
},
} as unknown as CanvasRenderingContext2D;

// create a mirror of the 2d context
const properties = Object.getOwnPropertyDescriptors(
CanvasRenderingContext2D.prototype,
);
Object.defineProperties(mirror, properties);
Object.keys(properties).forEach(property => {
// @ts-expect-error - we're doing nasty things here
delete CanvasRenderingContext2D.prototype[property];
});

Object.setPrototypeOf(
CanvasRenderingContext2D.prototype,
new Proxy<CanvasRenderingContext2D>(mirror, {
get(
target,
prop: keyof CanvasRenderingContext2D,
receiver: CanvasRenderingContext2D,
) {
// handle function calls: a.b(...args)
if (prop in properties && 'value' in properties[prop as string]) {
// use the default implementation if needed
if (receiver.__skipNextDraw) {
receiver.__skipNextDraw = false;
return Reflect.get(target, prop, receiver);
}

// provide a wrapper function to intercept arguments
return (...args: unknown[]) => {
// skip drawing functions, apply our own magic
if (DRAWING_FN_PROPS.includes(prop)) {
onDraw(receiver, prop, args);
addHistoryEntry(receiver, { type: 'draw', prop, args });
return;
}

addHistoryEntry(receiver, { type: 'apply', prop, args });
// @ts-expect-error - it's a function, we checked!
return target[prop].apply(receiver, args);
};
}

// handle property access: a.b
return Reflect.get(target, prop, receiver);
},
set(
target: CanvasRenderingContext2D,
prop: keyof CanvasRenderingContext2D,
value: unknown,
receiver: CanvasRenderingContext2D,
) {
// update history
addHistoryEntry(receiver, { type: 'set', prop, value });

// skip eventually native implementation;
// else the filter will be applied twice
if (prop === 'filter') return false;

// handle property set: a.b = value
return Reflect.set(target, prop, value, receiver);
},
}),
);
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"strict": true,
"target": "esnext"
},
"exclude": ["coverage", "dist", "node_modules", "reports"],
"ts-node": {
"esm": true,
"files": true
Expand Down

0 comments on commit b44c924

Please sign in to comment.