├── .prittierrc ├── Cargo.lock ├── Cargo.toml ├── README.md ├── ext ├── background.js ├── content.js ├── diff.wasm ├── dom.js ├── icons │ ├── icon128.png │ ├── icon16.png │ └── icon48.png └── manifest.json ├── images ├── 1280x2000.png ├── 2000x1280.png ├── after.png ├── before.png └── example1.png ├── makefile ├── package.json ├── screenshot.gif └── src └── main.rs /.prittierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 140, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "jsxBracketSameLine": false, 8 | "parser": "flow", 9 | "semi": true, 10 | "rcVerbose": true 11 | } -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "base64" 3 | version = "0.9.0" 4 | source = "registry+https://github.com/rust-lang/crates.io-index" 5 | dependencies = [ 6 | "byteorder 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", 7 | "safemem 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", 8 | ] 9 | 10 | [[package]] 11 | name = "byteorder" 12 | version = "1.2.1" 13 | source = "registry+https://github.com/rust-lang/crates.io-index" 14 | 15 | [[package]] 16 | name = "dtoa" 17 | version = "0.4.2" 18 | source = "registry+https://github.com/rust-lang/crates.io-index" 19 | 20 | [[package]] 21 | name = "itoa" 22 | version = "0.3.4" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | 25 | [[package]] 26 | name = "lcs-diff" 27 | version = "0.1.1" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | 30 | [[package]] 31 | name = "lcs-image-diff-js" 32 | version = "0.1.0" 33 | dependencies = [ 34 | "base64 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", 35 | "lcs-diff 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", 36 | "serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)", 37 | "serde_derive 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)", 38 | "serde_json 1.0.9 (registry+https://github.com/rust-lang/crates.io-index)", 39 | ] 40 | 41 | [[package]] 42 | name = "num-traits" 43 | version = "0.1.43" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | dependencies = [ 46 | "num-traits 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", 47 | ] 48 | 49 | [[package]] 50 | name = "num-traits" 51 | version = "0.2.0" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | 54 | [[package]] 55 | name = "quote" 56 | version = "0.3.15" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | 59 | [[package]] 60 | name = "safemem" 61 | version = "0.2.0" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | 64 | [[package]] 65 | name = "serde" 66 | version = "1.0.27" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | 69 | [[package]] 70 | name = "serde_derive" 71 | version = "1.0.27" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | dependencies = [ 74 | "quote 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)", 75 | "serde_derive_internals 0.19.0 (registry+https://github.com/rust-lang/crates.io-index)", 76 | "syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)", 77 | ] 78 | 79 | [[package]] 80 | name = "serde_derive_internals" 81 | version = "0.19.0" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | dependencies = [ 84 | "syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)", 85 | "synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)", 86 | ] 87 | 88 | [[package]] 89 | name = "serde_json" 90 | version = "1.0.9" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | dependencies = [ 93 | "dtoa 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", 94 | "itoa 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", 95 | "num-traits 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)", 96 | "serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)", 97 | ] 98 | 99 | [[package]] 100 | name = "syn" 101 | version = "0.11.11" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | dependencies = [ 104 | "quote 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)", 105 | "synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)", 106 | "unicode-xid 0.0.4 (registry+https://github.com/rust-lang/crates.io-index)", 107 | ] 108 | 109 | [[package]] 110 | name = "synom" 111 | version = "0.11.3" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | dependencies = [ 114 | "unicode-xid 0.0.4 (registry+https://github.com/rust-lang/crates.io-index)", 115 | ] 116 | 117 | [[package]] 118 | name = "unicode-xid" 119 | version = "0.0.4" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | 122 | [metadata] 123 | "checksum base64 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "229d032f1a99302697f10b27167ae6d03d49d032e6a8e2550e8d3fc13356d2b4" 124 | "checksum byteorder 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "652805b7e73fada9d85e9a6682a4abd490cb52d96aeecc12e33a0de34dfd0d23" 125 | "checksum dtoa 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "09c3753c3db574d215cba4ea76018483895d7bff25a31b49ba45db21c48e50ab" 126 | "checksum itoa 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "8324a32baf01e2ae060e9de58ed0bc2320c9a2833491ee36cd3b4c414de4db8c" 127 | "checksum lcs-diff 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "c172ea7099cef89eb5a1a6e1f55d79a753823a0201d362f6ec5508028efcf4ed" 128 | "checksum num-traits 0.1.43 (registry+https://github.com/rust-lang/crates.io-index)" = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31" 129 | "checksum num-traits 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e7de20f146db9d920c45ee8ed8f71681fd9ade71909b48c3acbd766aa504cf10" 130 | "checksum quote 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)" = "7a6e920b65c65f10b2ae65c831a81a073a89edd28c7cce89475bff467ab4167a" 131 | "checksum safemem 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e27a8b19b835f7aea908818e871f5cc3a5a186550c30773be987e155e8163d8f" 132 | "checksum serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)" = "db99f3919e20faa51bb2996057f5031d8685019b5a06139b1ce761da671b8526" 133 | "checksum serde_derive 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)" = "f4ba7591cfe93755e89eeecdbcc668885624829b020050e6aec99c2a03bd3fd0" 134 | "checksum serde_derive_internals 0.19.0 (registry+https://github.com/rust-lang/crates.io-index)" = "6e03f1c9530c3fb0a0a5c9b826bdd9246a5921ae995d75f512ac917fc4dd55b5" 135 | "checksum serde_json 1.0.9 (registry+https://github.com/rust-lang/crates.io-index)" = "c9db7266c7d63a4c4b7fe8719656ccdd51acf1bed6124b174f933b009fb10bcb" 136 | "checksum syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)" = "d3b891b9015c88c576343b9b3e41c2c11a51c219ef067b264bd9c8aa9b441dad" 137 | "checksum synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)" = "a393066ed9010ebaed60b9eafa373d4b1baac186dd7e008555b0f702b51945b6" 138 | "checksum unicode-xid 0.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "8c1f860d7d29cf02cb2f3f359fd35991af3d30bac52c57d265a3c461074cb4dc" 139 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lcs-image-diff-js" 3 | version = "0.1.0" 4 | authors = ["bokuweb "] 5 | 6 | [dependencies] 7 | lcs-diff = "*" 8 | serde_derive = "^1.0.21" 9 | serde = "^1.0.21" 10 | serde_json = "^1.0.6" 11 | base64 = "*" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-image-diff 2 | :octocat: A chrome extension to check github image difference. 3 | 4 | ## Demo 5 | ![screenshot](https://github.com/bokuweb/github-image-diff/blob/master/screenshot.gif?raw=true) 6 | 7 | ## Install 8 | 9 | ### Webstore 10 | 11 | Please install below URL 12 | 13 | https://chrome.google.com/webstore/detail/ilbghodjnphlcjbknbahhoimjabkngii 14 | 15 | ### Github 16 | 17 | - Clone this repository. 18 | - Read `ext` directory with chrome. 19 | 20 | ## Related 21 | 22 | - [lcs-image-diff-rs](https://github.com/bokuweb/lcs-image-diff-rs) 23 | - [lcs-diff-rs](https://github.com/bokuweb/lcs-diff-rs) 24 | 25 | ## Credit 26 | 27 |
Icons made by UIUXER from www.flaticon.com is licensed by CC 3.0 BY
28 | 29 | ## License 30 | 31 | The MIT License (MIT) 32 | 33 | Copyright (c) 2018 bokuweb 34 | 35 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 36 | 37 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 38 | 39 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 40 | -------------------------------------------------------------------------------- /ext/background.js: -------------------------------------------------------------------------------- 1 | const VER = 100; 2 | const SCALE_THRESHOLD = 800; 3 | const MINIMUM_SCALE = 0.5; 4 | const url = "./diff.wasm"; 5 | let q = []; 6 | 7 | const memory = new WebAssembly.Memory({ initial: 200 }); 8 | const imports = { js: { mem: memory } }; 9 | 10 | chrome.webNavigation.onDOMContentLoaded.addListener(() => { 11 | q = []; 12 | chrome.tabs.query({ currentWindow: true, active: true }, tabArray => { 13 | if (!tabArray[0] || typeof tabArray[0].id === "undefined") return; 14 | chrome.tabs.sendMessage(tabArray[0].id, { type: "loaded" }, () => {}); 15 | }); 16 | }); 17 | 18 | chrome.runtime.onMessage.addListener((msg, sender) => { 19 | if (q.find(q => q.index === msg.index)) return; 20 | Promise.all(msg.urls.map(url => fetchImage(url))).then(images => { 21 | convertImages(images).then(convertedImages => { 22 | q.push({ 23 | index: msg.index, 24 | before: convertedImages[0], 25 | after: convertedImages[1], 26 | scale: convertedImages[0].scale 27 | }); 28 | if (!self.mod) return; 29 | run(sender.tab.id, convertedImages); 30 | }); 31 | }); 32 | }); 33 | 34 | instantiateCachedURL(VER, url, imports).then(instance => { 35 | self.mod = instance; 36 | console.log(self.mod.exports.memory.buffer, "instantiated!!"); 37 | }); 38 | 39 | function convertImages(images) { 40 | return Promise.all( 41 | images.map( 42 | image => 43 | new Promise(resolve => { 44 | const org = image.src; 45 | image.src = null; 46 | image.addEventListener("load", () => { 47 | const scale = 48 | SCALE_THRESHOLD > image.naturalHeight 49 | ? 1 50 | : SCALE_THRESHOLD / image.naturalHeight; 51 | resolve({ image, scale: Math.max(scale, MINIMUM_SCALE) }); 52 | }); 53 | image.src = org; 54 | }) 55 | ) 56 | ).then(res => { 57 | const min = Math.min(res[0].scale, res[1].scale); 58 | const scale = min > 1 ? 1 : min; 59 | return res.map(({ image }) => { 60 | const canvas = document.createElement("canvas"); 61 | const ctx = canvas.getContext("2d"); 62 | canvas.width = image.naturalWidth; 63 | canvas.height = image.naturalHeight; 64 | ctx.scale(scale, scale); 65 | ctx.drawImage(image, 0, 0); 66 | return { 67 | url: image.src, 68 | data: ctx.getImageData( 69 | 0, 70 | 0, 71 | canvas.width * scale, 72 | canvas.height * scale 73 | ).data, 74 | width: image.naturalWidth, 75 | height: image.naturalHeight, 76 | scale 77 | }; 78 | }); 79 | }); 80 | } 81 | 82 | function run(id, images) { 83 | if (typeof id === "undefined") return; 84 | console.log("[log] run"); 85 | q.forEach(d => { 86 | console.time("diff"); 87 | console.log("[log] data", d); 88 | const result = diff(d.before, d.after); 89 | console.timeEnd("diff"); 90 | console.log("[log] diff result", result); 91 | const scale = d.scale; 92 | const data = { 93 | type: "diff", 94 | index: d.index, 95 | before: { 96 | width: images[0].width, 97 | height: images[0].height 98 | }, 99 | after: { 100 | width: images[1].width, 101 | height: images[1].height 102 | }, 103 | diff: { 104 | before: result.before.map(r => 105 | r.map((s, i) => +((+s.toFixed() + i) / scale).toFixed()) 106 | ), 107 | after: result.after.map(r => 108 | r.map((s, i) => +((+s.toFixed() + i) / scale).toFixed()) 109 | ) 110 | }, 111 | scale 112 | }; 113 | console.log("[log]result", data); 114 | chrome.tabs.sendMessage(id, data); 115 | }); 116 | q = []; 117 | } 118 | 119 | function diff(before, after) { 120 | const beforePtr = self.mod.exports.alloc(before.data.length); 121 | const afterPtr = self.mod.exports.alloc(after.data.length); 122 | const heap = new Uint8Array(self.mod.exports.memory.buffer); 123 | heap.set(before.data, beforePtr); 124 | heap.set(after.data, afterPtr); 125 | 126 | const resultPtr = self.mod.exports.diff( 127 | beforePtr, 128 | before.data.length, 129 | before.width, 130 | afterPtr, 131 | after.data.length, 132 | after.width 133 | ); 134 | self.mod.exports.free(beforePtr, before.data.length); 135 | self.mod.exports.free(afterPtr, after.data.length); 136 | const resultBuf = new Uint8Array(self.mod.exports.memory.buffer, resultPtr); 137 | const getSize = buf => { 138 | let i = 0; 139 | while (buf[i] !== 0) i++; 140 | return i; 141 | }; 142 | const resultSize = getSize(resultBuf); 143 | const json = String.fromCharCode.apply(null, resultBuf.slice(0, resultSize)); 144 | const result = JSON.parse(json); 145 | self.mod.exports.free(resultPtr, resultSize); 146 | return result; 147 | } 148 | 149 | function fetchImage(url) { 150 | return fetch(url) 151 | .then(img => img.blob()) 152 | .then(blob => { 153 | const objectURL = URL.createObjectURL(blob); 154 | const image = new Image(); 155 | image.src = objectURL; 156 | return image; 157 | }); 158 | } 159 | 160 | // 1. +++ fetchAndInstantiate() +++ // 161 | 162 | // This library function fetches the wasm module at 'url', instantiates it with 163 | // the given 'importObject', and returns the instantiated object instance 164 | 165 | function fetchAndInstantiate(url, importObject) { 166 | return fetch(url) 167 | .then(response => response.arrayBuffer()) 168 | .then(bytes => WebAssembly.instantiate(bytes, importObject)) 169 | .then(results => results.instance); 170 | } 171 | 172 | // 2. +++ instantiateCachedURL() +++ // 173 | 174 | // This library function fetches the wasm Module at 'url', instantiates it with 175 | // the given 'importObject', and returns a Promise resolving to the finished 176 | // wasm Instance. Additionally, the function attempts to cache the compiled wasm 177 | // Module in IndexedDB using 'url' as the key. The entire site's wasm cache (not 178 | // just the given URL) is versioned by dbVersion and any change in dbVersion on 179 | // any call to instantiateCachedURL() will conservatively clear out the entire 180 | // cache to avoid stale modules. 181 | function instantiateCachedURL(dbVersion, url, importObject) { 182 | const dbName = "wasm-cache"; 183 | const storeName = "wasm-cache"; 184 | 185 | // This helper function Promise-ifies the operation of opening an IndexedDB 186 | // database and clearing out the cache when the version changes. 187 | function openDatabase() { 188 | return new Promise((resolve, reject) => { 189 | var request = indexedDB.open(dbName, dbVersion); 190 | request.onerror = reject.bind(null, "Error opening wasm cache database"); 191 | request.onsuccess = () => { 192 | resolve(request.result); 193 | }; 194 | request.onupgradeneeded = event => { 195 | var db = request.result; 196 | if (db.objectStoreNames.contains(storeName)) { 197 | console.log(`Clearing out version ${event.oldVersion} wasm cache`); 198 | db.deleteObjectStore(storeName); 199 | } 200 | console.log(`Creating version ${event.newVersion} wasm cache`); 201 | db.createObjectStore(storeName); 202 | }; 203 | }); 204 | } 205 | 206 | // This helper function Promise-ifies the operation of looking up 'url' in the 207 | // given IDBDatabase. 208 | function lookupInDatabase(db) { 209 | return new Promise((resolve, reject) => { 210 | var store = db.transaction([storeName]).objectStore(storeName); 211 | var request = store.get(url); 212 | request.onerror = reject.bind(null, `Error getting wasm module ${url}`); 213 | request.onsuccess = event => { 214 | if (request.result) resolve(request.result); 215 | else reject(`Module ${url} was not found in wasm cache`); 216 | }; 217 | }); 218 | } 219 | 220 | // This helper function fires off an async operation to store the given wasm 221 | // Module in the given IDBDatabase. 222 | function storeInDatabase(db, module) { 223 | var store = db.transaction([storeName], "readwrite").objectStore(storeName); 224 | try { 225 | var request = store.put(module, url); 226 | request.onerror = err => { 227 | console.log(`Failed to store in wasm cache: ${err}`); 228 | }; 229 | request.onsuccess = err => { 230 | console.log(`Successfully stored ${url} in wasm cache`); 231 | }; 232 | } catch (e) { 233 | console.warn("An error was thrown... in storing wasm cache..."); 234 | console.warn(e); 235 | } 236 | } 237 | 238 | // This helper function fetches 'url', compiles it into a Module, 239 | // instantiates the Module with the given import object. 240 | function fetchAndInstantiate() { 241 | return fetch(url) 242 | .then(response => { 243 | return response.arrayBuffer(); 244 | }) 245 | .then(buffer => { 246 | return WebAssembly.instantiate(buffer, importObject); 247 | }); 248 | } 249 | 250 | // With all the Promise helper functions defined, we can now express the core 251 | // logic of an IndexedDB cache lookup. We start by trying to open a database. 252 | return openDatabase().then( 253 | db => { 254 | // Now see if we already have a compiled Module with key 'url' in 'db': 255 | return lookupInDatabase(db).then( 256 | module => { 257 | // We do! Instantiate it with the given import object. 258 | console.log(`Found ${url} in wasm cache`); 259 | return WebAssembly.instantiate(module, importObject); 260 | }, 261 | errMsg => { 262 | // Nope! Compile from scratch and then store the compiled Module in 'db' 263 | // with key 'url' for next time. 264 | console.log(errMsg); 265 | return fetchAndInstantiate().then(results => { 266 | setTimeout(() => storeInDatabase(db, results.module), 0); 267 | return results.instance; 268 | }); 269 | } 270 | ); 271 | }, 272 | errMsg => { 273 | // If opening the database failed (due to permissions or quota), fall back 274 | // to simply fetching and compiling the module and don't try to store the 275 | // results. 276 | console.log(errMsg); 277 | return fetchAndInstantiate().then(results => results.instance); 278 | } 279 | ); 280 | } 281 | -------------------------------------------------------------------------------- /ext/content.js: -------------------------------------------------------------------------------- 1 | const results = {}; 2 | let subscribed = false; 3 | 4 | (async () => { 5 | if (subscribed) return; 6 | subscribed = true; 7 | chrome.runtime.onMessage.addListener(async req => { 8 | switch (req.type) { 9 | case "diff": 10 | return onDiffComplete(req); 11 | case "loaded": 12 | return onLoad(); 13 | default: 14 | return; 15 | } 16 | }); 17 | })(); 18 | 19 | function onDiffComplete({ index, before, after, diff, scale }) { 20 | if (!results[location.href]) results[location.href] = []; 21 | if (!results[location.href][index]) return; 22 | results[location.href][index] = { 23 | before: { 24 | ...before, 25 | ...results[location.href][index].before 26 | }, 27 | after: { 28 | ...after, 29 | ...results[location.href][index].after 30 | }, 31 | diff, 32 | scale, 33 | }; 34 | enableLinkByIndex(index); 35 | } 36 | 37 | function onLoad() { 38 | let backdrop = findBackdrop(); 39 | if (!backdrop) appendBackdrop(); 40 | window.onpopstate = disableBackdrop; 41 | findFiles().forEach(async (file, index) => { 42 | const iframe = findRenderFromFile(file); 43 | if (!iframe) return; 44 | const text = await fetch(iframe.src).then(src => src.text()); 45 | const urls = getImageUrls(text); 46 | if (urls.length < 2) return; 47 | if (!hasLinkInFile(file)) appendLinkToFile(file); 48 | addLinkEventListener(file, addDiffImages.bind(this, index)); 49 | const result = results[location.href]; 50 | if (result && result[index]) { 51 | if (result[index].diff) enableLinkInFile(file); 52 | return; 53 | } 54 | if (!result) results[location.href] = []; 55 | results[location.href][index] = { 56 | index, 57 | before: { url: urls[0] }, 58 | after: { url: urls[1] } 59 | }; 60 | chrome.runtime.sendMessage({ index, urls }); 61 | }); 62 | } 63 | 64 | function addDiffImages(index, e) { 65 | e.preventDefault(); 66 | enableBackdrop(); 67 | const result = results[location.href][index]; 68 | if (!result) return; 69 | appendImage({ 70 | url: result.before.url, 71 | differences: result.diff.before, 72 | height: result.before.height, 73 | marginRight: 20, 74 | rgba: "255, 119, 119, 0.6", 75 | scale: result.scale, 76 | }); 77 | appendImage({ 78 | url: result.after.url, 79 | differences: result.diff.after, 80 | height: result.after.height, 81 | marginRight: 20, 82 | rgba: "99, 195, 99, 0.6", 83 | scale: result.scale, 84 | }); 85 | const afterRatio = result.after.width / result.after.height; 86 | const beforeRatio = result.before.width / result.before.height; 87 | if (afterRatio > 1.5 && beforeRatio > 1.5) { 88 | setColumnStyle(); 89 | } else { 90 | setRowStyle(); 91 | } 92 | } 93 | 94 | function getImageUrls(text) { 95 | const lines = text.split("\n"); 96 | const matches = []; 97 | for (line of lines) { 98 | const m = line.trim().match(/^data-file\d\s+=\s\"(.+)\"/); 99 | if (m) matches.push(m[1]); 100 | if (matches.length > 0 && !m) break; 101 | } 102 | return matches; 103 | } 104 | 105 | 106 | -------------------------------------------------------------------------------- /ext/diff.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/ext/diff.wasm -------------------------------------------------------------------------------- /ext/dom.js: -------------------------------------------------------------------------------- 1 | const LINK_CLASS_NAME = "__diff"; 2 | const FILE_CLASS_NAME = "js-file"; 3 | const FILE_ACTIONS_CLASS_NAME = "file-actions"; 4 | const BACKDROP_CLASS_NAME = "__backdrop"; 5 | const RENDER_CLASS_NAME = "render-viewer"; 6 | 7 | function enableLinkByIndex(index) { 8 | const files = findFiles(); 9 | const link = files[index].querySelector(`.${LINK_CLASS_NAME}`); 10 | enableLink(link); 11 | } 12 | 13 | function enableBackdrop() { 14 | const backdrop = findBackdrop(); 15 | if (!backdrop) return; 16 | backdrop.textContent = null; 17 | backdrop.style.display = "flex"; 18 | document.body.style.overflow = "hidden"; 19 | } 20 | 21 | function findFiles() { 22 | return document.querySelectorAll(`.${FILE_CLASS_NAME}`) || []; 23 | } 24 | 25 | function findRenderFromFile(file) { 26 | return file.querySelector(`.${RENDER_CLASS_NAME}`); 27 | } 28 | 29 | function enableLink(link) { 30 | link.style.pointerEvents = "auto"; 31 | link.style.opacity = 1; 32 | } 33 | 34 | function hasLinkInFile(file) { 35 | return !!file.querySelector(`.${LINK_CLASS_NAME}`); 36 | } 37 | 38 | function enableLinkInFile(file) { 39 | const link = file.querySelector(`a.${LINK_CLASS_NAME}`); 40 | enableLink(link); 41 | } 42 | 43 | function appendLinkToFile(file) { 44 | const link = document.createElement("a"); 45 | link.setAttribute("aria-label", "Diff"); 46 | link.style.pointerEvents = "none"; 47 | link.style.opacity = 0.4; 48 | link.classList.add( 49 | "btn", 50 | "btn-sm", 51 | "tooltipped", 52 | "tooltipped-nw", 53 | LINK_CLASS_NAME 54 | ); 55 | link.innerText = "Diff"; 56 | const actions = file.querySelector(`.${FILE_ACTIONS_CLASS_NAME}`); 57 | actions.insertBefore(link, actions.firstChild); 58 | } 59 | 60 | function findBackdrop() { 61 | return document.querySelector(`div.${BACKDROP_CLASS_NAME}`); 62 | } 63 | 64 | function appendBackdrop() { 65 | backdrop = document.createElement("div"); 66 | backdrop.classList.add(BACKDROP_CLASS_NAME); 67 | backdrop.style.position = "fixed"; 68 | backdrop.style.top = 0; 69 | backdrop.style.left = 0; 70 | backdrop.style.minWidth = "100%"; 71 | backdrop.style.height = "100%"; 72 | backdrop.style.display = "none"; 73 | backdrop.style.flexDirection = "row"; 74 | backdrop.style.justifyContent = "center"; 75 | backdrop.style.padding = "100px 5% 0 5%"; 76 | backdrop.style.zIndex = 9999; 77 | backdrop.style.overflow = "scroll"; 78 | backdrop.style.cursor = "pointer"; 79 | backdrop.style.background = "rgba(0,0,0,0.5)"; 80 | backdrop.addEventListener("click", () => { 81 | backdrop.style.display = "none"; 82 | document.body.style.overflow = "auto"; 83 | }); 84 | document.body.appendChild(backdrop); 85 | } 86 | 87 | function disableBackdrop() { 88 | const backdrop = findBackdrop(); 89 | if (!backdrop) return; 90 | backdrop.style.display = "none"; 91 | document.body.style.overflow = "auto"; 92 | } 93 | 94 | function addLinkEventListener(file, cb) { 95 | const link = file.querySelector(`a.${LINK_CLASS_NAME}`); 96 | if (!link) return; 97 | link.addEventListener("click", cb); 98 | } 99 | 100 | function appendImage({ url, differences, height, marginRight, rgba, scale }) { 101 | const backdrop = findBackdrop(); 102 | if (!backdrop) return; 103 | backdrop.style.flexDirection = "row"; 104 | const wrapper = document.createElement("div"); 105 | wrapper.style.maxWidth = "40%"; 106 | wrapper.style.marginRight = `${marginRight}px`; 107 | wrapper.style.marginBottom = "20px"; 108 | const inner = document.createElement("div"); 109 | inner.style.position = "relative"; 110 | const img = document.createElement("img"); 111 | img.src = url; 112 | img.style.width = "100%"; 113 | img.style.height = "auto"; 114 | img.style.verticalAlign = "bottom"; 115 | img.addEventListener("click", e => e.stopPropagation()); 116 | inner.appendChild(img); 117 | wrapper.appendChild(inner); 118 | backdrop.appendChild(wrapper); 119 | differences.forEach(diff => { 120 | const top = `${diff[0] / height * 100 / scale}%`; 121 | const markHeight = `${(diff[1] - diff[0]) / height * 100 / scale}%`; 122 | const mark = document.createElement("div"); 123 | mark.style.position = "absolute"; 124 | mark.style.top = top; 125 | mark.style.width = "100%"; 126 | mark.style.height = markHeight; 127 | mark.style.background = `rgba(${rgba})`; 128 | inner.appendChild(mark); 129 | }); 130 | } 131 | 132 | function setColumnStyle() { 133 | const backdrop = findBackdrop(); 134 | if (!backdrop) return; 135 | backdrop.style.flexDirection = "column"; 136 | backdrop.style.justifyContent = "initial"; 137 | backdrop.style.alignItems = "center"; 138 | const wrappers = backdrop.querySelectorAll(`.${BACKDROP_CLASS_NAME} > div`); 139 | wrappers.forEach(w => (w.style.maxWidth = "60%")); 140 | } 141 | 142 | function setRowStyle() { 143 | const backdrop = findBackdrop(); 144 | if (!backdrop) return; 145 | backdrop.style.flexDirection = "row"; 146 | backdrop.style.justifyContent = "center"; 147 | backdrop.style.alignItems = "initial"; 148 | } 149 | -------------------------------------------------------------------------------- /ext/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/ext/icons/icon128.png -------------------------------------------------------------------------------- /ext/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/ext/icons/icon16.png -------------------------------------------------------------------------------- /ext/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/ext/icons/icon48.png -------------------------------------------------------------------------------- /ext/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Github image diff", 4 | "version": "0.1.2", 5 | "background": { 6 | "scripts": ["background.js"], 7 | "persistent": false 8 | }, 9 | "icons": { 10 | "16": "icons/icon16.png", 11 | "48": "icons/icon48.png", 12 | "128": "icons/icon128.png" 13 | }, 14 | "content_security_policy": 15 | "script-src 'self' 'unsafe-eval'; object-src 'self'", 16 | "permissions": [ 17 | "tabs", 18 | "webNavigation", 19 | "https://render.githubusercontent.com/*" 20 | ], 21 | "content_scripts": [ 22 | { 23 | "matches": ["https://github.com/*"], 24 | "js": ["content.js", "dom.js"], 25 | "all_frames": true 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /images/1280x2000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/images/1280x2000.png -------------------------------------------------------------------------------- /images/2000x1280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/images/2000x1280.png -------------------------------------------------------------------------------- /images/after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/images/after.png -------------------------------------------------------------------------------- /images/before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/images/before.png -------------------------------------------------------------------------------- /images/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/images/example1.png -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | build: 2 | mkdir -p wasm 3 | cargo +nightly build --target wasm32-unknown-unknown --release 4 | wasm-gc target/wasm32-unknown-unknown/release/lcs-image-diff-js.wasm ext/diff.wasm -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lcs-image-diff-js", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/bokuweb/lcs-image-diff-js.git", 6 | "author": "bokuweb ", 7 | "license": "MIT" 8 | } 9 | -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bokuweb/github-image-diff/fa424e0d5c7034f68bf2898e6ecf23acaf4c8b7e/screenshot.gif -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate serde_derive; 3 | extern crate serde; 4 | extern crate serde_json; 5 | extern crate lcs_diff; 6 | extern crate base64; 7 | 8 | use std::os::raw::{c_char, c_void}; 9 | use std::mem; 10 | use std::ffi::CString; 11 | use base64::encode; 12 | 13 | #[no_mangle] 14 | pub fn alloc(size: usize) -> *mut c_void { 15 | let mut buf = Vec::with_capacity(size); 16 | let ptr = buf.as_mut_ptr(); 17 | mem::forget(buf); 18 | return ptr as *mut c_void; 19 | } 20 | 21 | #[no_mangle] 22 | pub fn free(ptr: *mut c_void, size: usize) { 23 | unsafe { 24 | let _buf = Vec::from_raw_parts(ptr, 0, size); 25 | } 26 | } 27 | 28 | fn compute_range(r: &Vec) -> Vec<(usize, usize)> { 29 | let mut i = 0; 30 | let mut j = 0; 31 | let mut acc: usize; 32 | let mut y1: usize; 33 | let mut ranges: Vec<(usize, usize)> = Vec::new(); 34 | while i < r.len() { 35 | y1 = r[i]; 36 | acc = y1; 37 | i += 1; 38 | loop { 39 | if i >= r.len() { 40 | break; 41 | } 42 | let index = r[i]; 43 | if acc + 1 != index { 44 | break; 45 | } 46 | acc = index; 47 | i += 1; 48 | j += 1; 49 | } 50 | let y2 = y1 + j; 51 | j = 0; 52 | ranges.push((y1, y2)); 53 | } 54 | ranges 55 | } 56 | 57 | fn create_encoded_rows(buf: &[u8], width: usize) -> Vec { 58 | buf.chunks(width * 4) 59 | .map(|chunk| encode(chunk)) 60 | .collect() 61 | } 62 | 63 | #[derive(Serialize)] 64 | #[serde(rename_all = "camelCase")] 65 | struct Result { 66 | before: Vec<(usize, usize)>, 67 | after: Vec<(usize, usize)>, 68 | } 69 | 70 | fn main() {} 71 | 72 | #[no_mangle] 73 | pub fn diff(before_ptr: *mut u8, 74 | before_len: usize, 75 | before_width: usize, 76 | after_ptr: *mut u8, 77 | after_len: usize, 78 | after_width: usize) 79 | -> *mut c_char { 80 | let before_buf: &[u8] = unsafe { std::slice::from_raw_parts_mut(before_ptr, before_len) }; 81 | let after_buf: &[u8] = unsafe { std::slice::from_raw_parts_mut(after_ptr, after_len) }; 82 | let imga = create_encoded_rows(before_buf, before_width); 83 | let imgb = create_encoded_rows(after_buf, after_width); 84 | let result = lcs_diff::diff(&imga, &imgb); 85 | let mut added: Vec = Vec::new(); 86 | let mut removed: Vec = Vec::new(); 87 | for d in result.iter() { 88 | match d { 89 | &lcs_diff::DiffResult::Added(ref a) => added.push(a.new_index.unwrap()), 90 | &lcs_diff::DiffResult::Removed(ref r) => removed.push(r.old_index.unwrap()), 91 | _ => (), 92 | } 93 | } 94 | 95 | let result = Result { 96 | after: compute_range(&added), 97 | before: compute_range(&removed), 98 | }; 99 | 100 | let res = serde_json::to_string(&result).unwrap(); 101 | let c_str = CString::new(res).unwrap(); 102 | c_str.into_raw() 103 | } 104 | 105 | #[cfg(test)] 106 | mod tests { 107 | #[test] 108 | fn it_works() { 109 | assert_eq!(2 + 2, 4); 110 | } 111 | } 112 | --------------------------------------------------------------------------------