Skip to content

Commit 876da49

Browse files
Add canvas change detetion
1 parent b06b1f6 commit 876da49

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { createCanvasManager } from './canvasManager'
2+
3+
describe('createCanvasManager', () => {
4+
it('tracks whether a canvas is dirty', () => {
5+
const canvasManager = createCanvasManager()
6+
const canvas = document.createElement('canvas')
7+
8+
expect(canvasManager.isCanvasDirty(canvas)).toBeFalse()
9+
10+
canvasManager.markCanvasDirty(canvas)
11+
expect(canvasManager.isCanvasDirty(canvas)).toBeTrue()
12+
13+
canvasManager.markCanvasClean(canvas)
14+
expect(canvasManager.isCanvasDirty(canvas)).toBeFalse()
15+
})
16+
17+
it('tracks canvases independently', () => {
18+
const canvasManager = createCanvasManager()
19+
const dirtyCanvas = document.createElement('canvas')
20+
const cleanCanvas = document.createElement('canvas')
21+
22+
canvasManager.markCanvasDirty(dirtyCanvas)
23+
24+
expect(canvasManager.isCanvasDirty(dirtyCanvas)).toBeTrue()
25+
expect(canvasManager.isCanvasDirty(cleanCanvas)).toBeFalse()
26+
})
27+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export interface CanvasManager {
2+
isCanvasDirty: (canvas: HTMLCanvasElement) => boolean
3+
markCanvasClean: (canvas: HTMLCanvasElement) => void
4+
markCanvasDirty: (canvas: HTMLCanvasElement) => void
5+
}
6+
7+
export function createCanvasManager(): CanvasManager {
8+
const dirtyCanvases = new WeakSet<HTMLCanvasElement>()
9+
10+
return {
11+
isCanvasDirty: (canvas) => dirtyCanvases.has(canvas),
12+
markCanvasClean: (canvas) => dirtyCanvases.delete(canvas),
13+
markCanvasDirty: (canvas) => dirtyCanvases.add(canvas),
14+
}
15+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { mockClock, registerCleanupTask } from '@datadog/browser-core/test'
2+
import type { Clock } from '@datadog/browser-core/test'
3+
import type { Tracker } from './tracker.types'
4+
import { trackCanvas2DMutations } from './trackCanvas'
5+
6+
describe('trackCanvas2DMutations', () => {
7+
let canvas: HTMLCanvasElement
8+
let context: CanvasRenderingContext2D
9+
let markCanvasDirtySpy: jasmine.Spy<(canvas: HTMLCanvasElement) => void>
10+
let tracker: Tracker | undefined
11+
12+
beforeEach(() => {
13+
canvas = document.createElement('canvas')
14+
context = canvas.getContext('2d')!
15+
markCanvasDirtySpy = jasmine.createSpy()
16+
17+
registerCleanupTask(() => tracker?.stop())
18+
})
19+
20+
it('marks the canvas dirty after drawing operations', () => {
21+
tracker = trackCanvas2DMutations(markCanvasDirtySpy)
22+
const imageData = context.createImageData(1, 1)
23+
const drawingOperations = [
24+
() => context.clearRect(0, 0, 1, 1),
25+
() => context.fillRect(0, 0, 1, 1),
26+
() => context.strokeRect(0, 0, 1, 1),
27+
() => context.fill(),
28+
() => context.stroke(),
29+
() => context.fillText('foo', 0, 0),
30+
() => context.strokeText('foo', 0, 0),
31+
() => context.drawImage(canvas, 0, 0),
32+
() => context.putImageData(imageData, 0, 0),
33+
() => context.drawFocusIfNeeded(canvas),
34+
() => context.reset(),
35+
]
36+
37+
drawingOperations.forEach((draw) => {
38+
markCanvasDirtySpy.calls.reset()
39+
draw()
40+
expect(markCanvasDirtySpy).toHaveBeenCalledOnceWith(canvas)
41+
})
42+
})
43+
44+
it('does not mark the canvas dirty for non-drawing operations', () => {
45+
tracker = trackCanvas2DMutations(markCanvasDirtySpy)
46+
47+
context.beginPath()
48+
context.moveTo(0, 0)
49+
context.lineTo(1, 1)
50+
51+
expect(markCanvasDirtySpy).not.toHaveBeenCalled()
52+
})
53+
54+
it('does not mark the canvas dirty when a drawing operation throws', () => {
55+
tracker = trackCanvas2DMutations(markCanvasDirtySpy)
56+
57+
expect(() => context.putImageData(null as unknown as ImageData, 0, 0)).toThrow()
58+
expect(markCanvasDirtySpy).not.toHaveBeenCalled()
59+
})
60+
61+
it('marks the canvas dirty when it is resized', () => {
62+
const clock: Clock = mockClock()
63+
tracker = trackCanvas2DMutations(markCanvasDirtySpy)
64+
65+
canvas.width = 100
66+
canvas.height = 50
67+
clock.tick(0)
68+
69+
expect(markCanvasDirtySpy).toHaveBeenCalledTimes(2)
70+
expect(markCanvasDirtySpy.calls.argsFor(0)[0]).toBe(canvas)
71+
expect(markCanvasDirtySpy.calls.argsFor(1)[0]).toBe(canvas)
72+
})
73+
74+
it('stops tracking drawing operations', () => {
75+
const clock: Clock = mockClock()
76+
tracker = trackCanvas2DMutations(markCanvasDirtySpy)
77+
tracker.stop()
78+
79+
context.fillRect(0, 0, 1, 1)
80+
canvas.width = 100
81+
clock.tick(0)
82+
83+
expect(markCanvasDirtySpy).not.toHaveBeenCalled()
84+
})
85+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { instrumentMethod, instrumentSetter } from '@datadog/browser-core'
2+
import type { Tracker } from './tracker.types'
3+
4+
export type MarkCanvasDirty = (canvas: HTMLCanvasElement) => void
5+
6+
type Canvas2DDrawingMethod =
7+
| 'clearRect'
8+
| 'fillRect'
9+
| 'strokeRect'
10+
| 'fill'
11+
| 'stroke'
12+
| 'fillText'
13+
| 'strokeText'
14+
| 'drawImage'
15+
| 'putImageData'
16+
| 'drawFocusIfNeeded'
17+
| 'reset'
18+
19+
const CANVAS_2D_DRAWING_METHODS: readonly Canvas2DDrawingMethod[] = [
20+
'clearRect',
21+
'fillRect',
22+
'strokeRect',
23+
'fill',
24+
'stroke',
25+
'fillText',
26+
'strokeText',
27+
'drawImage',
28+
'putImageData',
29+
'drawFocusIfNeeded',
30+
'reset',
31+
]
32+
33+
export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracker {
34+
const instrumentationStoppers: Tracker[] = []
35+
36+
if (typeof CanvasRenderingContext2D !== 'undefined') {
37+
CANVAS_2D_DRAWING_METHODS.forEach((method) => {
38+
instrumentationStoppers.push(
39+
instrumentMethod(CanvasRenderingContext2D.prototype, method, ({ target: context, onPostCall }) => {
40+
onPostCall(() => markCanvasDirty(context.canvas))
41+
})
42+
)
43+
})
44+
}
45+
46+
if (typeof HTMLCanvasElement !== 'undefined') {
47+
instrumentationStoppers.push(
48+
instrumentSetter(HTMLCanvasElement.prototype, 'width', markCanvasDirty),
49+
instrumentSetter(HTMLCanvasElement.prototype, 'height', markCanvasDirty)
50+
)
51+
}
52+
53+
return {
54+
stop: () => instrumentationStoppers.forEach((stopper) => stopper.stop()),
55+
}
56+
}

0 commit comments

Comments
 (0)