-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgitlab-image-diff.js
More file actions
158 lines (138 loc) · 6.53 KB
/
Copy pathgitlab-image-diff.js
File metadata and controls
158 lines (138 loc) · 6.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
/**
* GitLab image diff bookmarklet
*
* Highlights pixel-level differences in GitLab MR image diffs.
*
* What it does:
* 1. Processes any image diffs already visible in the viewport.
* 2. Installs a MutationObserver that fires whenever GitLab's virtual scrolling
* renders a new diff file. For each image diff it finds, it:
* - Expands collapsed files
* - Ensures 2-up view mode is active
* - Injects a pixel diff panel: new image in greyscale as context, with
* |new − old| colour-dodged over it — grey = unchanged, colour = changed
*
* How to install:
* See README.md — copy the bookmarklet URL into a new bookmark, or regenerate
* it from source using the script in that file.
*
* How the diff rendering works:
* Both images are stacked in a position:relative container.
* The new image sits on top with mix-blend-mode:difference, which computes
* |new − old| per pixel. filter:brightness(10) saturate(4) on the container
* amplifies even tiny differences to be clearly visible.
* The filter creates an isolated compositing group so blending happens
* before amplification — no canvas or CORS needed.
*/
(function () {
const PANEL_ATTR = "data-gl-diff-panel";
function processViewers() {
// Expand files hidden behind title-bar chevron
document
.querySelectorAll('button[aria-label="Show file contents"]')
.forEach((b) => b.click());
// Expand files hidden by "large file" warning
document
.querySelectorAll('[data-testid="expand-button"]')
.forEach((b) => b.click());
// Switch any viewer not in 2-up mode
document.querySelectorAll(".view-modes-menu li").forEach((li) => {
if (li.textContent.trim() === "2-up" && !li.classList.contains("active"))
li.click();
});
// Inject diff panels into newly visible viewers
document.querySelectorAll(".diff-viewer .image").forEach((viewer) => {
if (viewer.querySelector("[" + PANEL_ATTR + "]")) return; // already has panel
const oldImg = viewer.querySelector(".frame.deleted img");
const newImg = viewer.querySelector(".frame.added img");
if (!oldImg || !newImg) return;
injectDiffPanel(viewer, oldImg.src, newImg.src);
});
}
// Debounced MutationObserver re-processes whenever the DOM changes
// (handles virtual-scroll re-renders when scrolling back to a file)
let debTimer;
const obs = new MutationObserver(() => {
clearTimeout(debTimer);
debTimer = setTimeout(processViewers, 200);
});
obs.observe(document.body, {childList: true, subtree: true});
// Process whatever is already visible, then let the MutationObserver
// handle the rest as the user scrolls through GitLab's virtual list.
processViewers();
function injectDiffPanel(viewer, oldSrc, newSrc) {
const panel = document.createElement("div");
panel.setAttribute(PANEL_ATTR, "1");
panel.style.cssText =
"margin:0;padding:10px 12px 14px;background:#0d0d0d;border-top:3px solid #fc6d26;";
const label = document.createElement("div");
label.textContent = "▶ Pixel diff — grey = unchanged · colour = changed";
label.style.cssText =
"color:#fc6d26;font:700 11px/2.2 monospace;letter-spacing:.05em;margin-bottom:4px;";
// Layer 1: new image in greyscale + low contrast — visual context for the diff.
// Layer 2: amplified |new − old| diff, colour-dodged over layer 1.
// filter on diffGroup creates an isolated compositing group so that:
// (a) difference blend computes |new − old| per pixel inside the group,
// (b) brightness(10) + saturate(4) is applied to the whole group result,
// then mix-blend-mode:color-dodge composites it onto the grey base:
// diff = 0 (unchanged) → color-dodge(grey, 0) = grey shows through
// diff > 0 (changed) → non-linear boost pushes grey toward bright colour
//
// The old and new images may have different dimensions. We wait for both to
// load, then size everything explicitly with uniform scaling so both images
// sit at their natural sizes, aligned at the top-left corner.
const outerWrap = document.createElement("div");
outerWrap.style.cssText = "position:relative;line-height:0;";
const baseImg = new Image();
baseImg.src = newSrc;
baseImg.style.cssText =
"display:block;filter:grayscale(1) contrast(0.3) brightness(1.0);";
const diffGroup = document.createElement("div");
diffGroup.style.cssText =
"position:absolute;top:0;left:0;" +
"filter:brightness(10) saturate(4);" +
"mix-blend-mode:color-dodge;";
const img1 = new Image();
img1.src = oldSrc;
img1.style.cssText = "display:block;";
const img2 = new Image();
img2.src = newSrc;
img2.style.cssText =
"position:absolute;top:0;left:0;mix-blend-mode:difference;";
diffGroup.append(img1, img2);
outerWrap.append(baseImg, diffGroup);
panel.append(label, outerWrap);
viewer.appendChild(panel);
// Set explicit pixel dimensions after both images have loaded.
// img.decode() is race-free (unlike the onload pattern) and resolves after decode.
Promise.all([
img1.decode().catch(() => {}),
img2.decode().catch(() => {}),
]).then(() => {
const oldW = img1.naturalWidth,
oldH = img1.naturalHeight;
const newW = img2.naturalWidth,
newH = img2.naturalHeight;
if (!oldW || !newW) return;
const maxW = Math.max(oldW, newW);
const maxH = Math.max(oldH, newH);
// Scale both images by the same factor to fit the panel's content width.
const style = getComputedStyle(panel);
const availW =
panel.clientWidth -
parseFloat(style.paddingLeft) -
parseFloat(style.paddingRight);
if (availW <= 0) return;
const scale = maxW > availW ? availW / maxW : 1;
outerWrap.style.width = maxW * scale + "px";
outerWrap.style.height = maxH * scale + "px";
// diffGroup fills outerWrap via percentage — no need to repeat the pixel values.
diffGroup.style.width = "100%";
diffGroup.style.height = "100%";
// Keep cssText consistent across all three images; carry the filter for baseImg.
baseImg.style.cssText = `display:block;width:${newW * scale}px;height:${newH * scale}px;filter:grayscale(1) contrast(0.3) brightness(1.0);`;
img1.style.cssText = `display:block;width:${oldW * scale}px;height:${oldH * scale}px;`;
img2.style.cssText = `position:absolute;top:0;left:0;width:${newW * scale}px;height:${newH * scale}px;mix-blend-mode:difference;`;
});
}
})();