Migrate image-diff plugin to TypeScript Migrated the image-diff plugin to TypeScript to align with modern PolyGerrit plugin standards. - Replaced outdated WCT and HTML tests with TypeScript via @open-wc/testing. - Migrated code out of root namespace into standard `web/` structure. - Refactored Vanilla JavaScript LitElement into Lit components using decorators. - Updated `BUILD` architecture to compile and package through ts_project. - Cleaned up obsolete npm dependencies. Bug: b/267985258 Release-Notes: skip Change-Id: I1503a171bb16aefbfaf4b34f1d68cef3ce1de571
diff --git a/BUILD b/BUILD index 1dbb10a..067f856 100644 --- a/BUILD +++ b/BUILD
@@ -1,26 +1,6 @@ load("//tools/bzl:js.bzl", "polygerrit_plugin") -load("@npm//@bazel/rollup:index.bzl", "rollup_bundle") - polygerrit_plugin( name = "image-diff", - app = "plugin-bundle.js", -) - -rollup_bundle( - name = "plugin-bundle", - srcs = glob([ - "**/*.js", - ]) + ["@plugins_npm//:node_modules"], - args = [ - "--bundleConfigAsCjs=true", - ], - config_file = "rollup.config.js", - entry_point = "plugin.js", - format = "iife", - rollup_bin = "//tools/node_tools:rollup-bin", - sourcemap = "hidden", - deps = [ - "@tools_npm//@rollup/plugin-node-resolve", - ], + app = "//plugins/image-diff/web:image_diff.js", )
diff --git a/gr-image-diff-tool/gr-image-diff-tool.js b/gr-image-diff-tool/gr-image-diff-tool.js deleted file mode 100644 index e5593fe..0000000 --- a/gr-image-diff-tool/gr-image-diff-tool.js +++ /dev/null
@@ -1,166 +0,0 @@ -/** - * @license - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {html, css, LitElement} from 'lit'; -import '../gr-opacity-diff-mode/gr-opacity-diff-mode.js'; -import '../gr-resemble-diff-mode/gr-resemble-diff-mode.js'; - -const DiffModes = { - OPACITY: 'opacity', - RESEMBLE: 'resemble', -}; - -class ImageDiffTool extends LitElement { - static get is() { - return 'gr-image-diff-tool'; - } - - static get styles() { - return [ - css` - :host { - background-color: var(--table-header-background-color, #fafafa); - display: block; - font-family: var(--font-family); - } - #header { - align-items: center; - border-bottom: 1px solid var(--border-color, #ddd); - border-top: 1px solid var(--border-color, #ddd); - display: inline-flex; - padding: .5em; - width: 100%; - } - h3 { - padding: 0 .5em; - } - #dropdown { - background-color: var(--view-background-color); - border: 1px solid var(--border-color); - border-radius: 2px; - color: var(--primary-text-color); - font-size: var(--font-size-normal); - height: 2em; - margin-left: 1em; - padding: 0 .15em; - } - .diffmode { - align-items: center; - display: flex; - justify-content: center; - } - ` - ]; - } - - render() { - return html` - <div id="header"> - <h3>Image diff</h3> - <select .value=${this._observeMode} @change=${this._handleSelectChange} id="dropdown"> - <option value="resemble" title="Scale the images to the same size and compute a diff with highlights">Highlight differences</option> - <option value="opacity" title="Overlay the new image over the old and use an opacity control to view the differences">Onion skin</option> - </select> - </div> - <div class="diffmode"> - ${this._showResembleMode ? html` - <gr-resemble-diff-mode - .baseImage=${this.baseImage} - .revisionImage=${this.revisionImage}></gr-resemble-diff-mode> - ` : ''} - </div> - <div class="diffmode"> - ${this._showOpacityMode ? html` - <gr-opacity-diff-mode - .baseImage=${this.baseImage} - .revisionImage=${this.revisionImage}></gr-opacity-diff-mode> - ` : ''} - </div> - `; - } - - static get properties() { - return { - baseImage: {type: Object}, - revisionImage: {type: Object}, - hidden: {type: Boolean, reflect: true}, - _showResembleMode: {type: Boolean}, - _showOpacityMode: {type: Boolean}, - _observeMode: {type: String}, - }; - } - - constructor() { - super(); - this.hidden = false; - this._showResembleMode = false; - this._showOpacityMode = false; - this._observeMode = ''; - } - - updated(changedProperties) { - if (changedProperties.has('_observeMode')) { - this._handleSelect(this._observeMode); - } - } - - connectedCallback() { - super.connectedCallback(); - if (!this.baseImage || !this.revisionImage) { - // No need to show the diff tool if there are no images. - this.hidden = true; - } - const diff_mode = this._getMode(); - diff_mode === DiffModes.OPACITY - ? this._displayOpacityMode() - : this._displayResembleMode(); - } - - _getMode() { - return window.localStorage.getItem('image-diff-mode'); - } - - _setMode(mode) { - window.localStorage.setItem('image-diff-mode', mode); - } - - _handleSelectChange(e) { - this._observeMode = e.target.value; - } - - _handleSelect(mode) { - mode === DiffModes.OPACITY - ? this._displayOpacityMode() - : this._displayResembleMode(); - } - - _displayResembleMode() { - this._observeMode = DiffModes.RESEMBLE; - this._showResembleMode = true; - this._showOpacityMode = false; - this._setMode(DiffModes.RESEMBLE); - } - - _displayOpacityMode() { - this._observeMode = DiffModes.OPACITY; - this._showResembleMode = false; - this._showOpacityMode = true; - this._setMode(DiffModes.OPACITY); - } -} - -customElements.define(ImageDiffTool.is, ImageDiffTool); \ No newline at end of file
diff --git a/gr-image-diff-tool/gr-image-diff-tool_test.html b/gr-image-diff-tool/gr-image-diff-tool_test.html deleted file mode 100644 index 06d86a1..0000000 --- a/gr-image-diff-tool/gr-image-diff-tool_test.html +++ /dev/null
@@ -1,79 +0,0 @@ -<!-- -Copyright (C) 2018 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> - -<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> -<meta charset="utf-8"> -<title>image-diff-tool</title> -<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script> -<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script> -<script src="../bower_components/web-component-tester/browser.js"></script> -<script src="../node_modules/resemblejs/resemble.js"></script> - -<test-fixture id="basicFixture"> - <template> - <gr-image-diff-tool> - </gr-image-diff-tool> - </template> -</test-fixture> - -<script type="module"> - import '../test/common-test-setup.js'; - import "./gr-image-diff-tool.js"; - suite('gr-image-diff-tool tests', async () => { - let element; - let sandbox; - - setup(async () => { - sandbox = sinon.sandbox.create(); - element = fixture('basicFixture'); - await element.updateComplete; - }); - - teardown(async () => { sandbox.restore(); }); - - test('test opacity mode', async () => { - element._observeMode = 'opacity'; - assert.isTrue(element._showOpacityMode); - assert.isFalse(element._showResembleMode); - await element.updateComplete; - - assert.ok(element.shadowRoot.querySelector('gr-opacity-diff-mode')); - assert.equal(element.shadowRoot.querySelector('gr-resemble-diff-mode'), null); - }); - - test('test resemble mode', async () => { - element._observeMode = 'resemble'; - assert.isTrue(element._showResembleMode); - assert.isFalse(element._showOpacityMode); - await element.updateComplete; - - assert.ok(element.shadowRoot.querySelector('gr-resemble-diff-mode')); - assert.equal(element.shadowRoot.querySelector('gr-opacity-diff-mode'), null); - }); - - test('localStorage persists', async () => { - sandbox.stub(element, '_setMode'); - sandbox.stub(element, '_getMode'); - element.connectedCallback(); - assert.equal(element._getMode.callCount, 1); - assert.equal(element._setMode.callCount, 1); - element._observeMode = 'opacity'; - assert.equal(element._setMode.callCount, 2); - element._observeMode = 'resemble'; - assert.equal(element._setMode.callCount, 3); - }); - }); -</script>
diff --git a/gr-opacity-diff-mode/gr-opacity-diff-mode.js b/gr-opacity-diff-mode/gr-opacity-diff-mode.js deleted file mode 100644 index 96eb7f2..0000000 --- a/gr-opacity-diff-mode/gr-opacity-diff-mode.js +++ /dev/null
@@ -1,181 +0,0 @@ -/** - * @license - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {html, css, LitElement} from 'lit'; - -class OpacityDiffMode extends LitElement { - static get is() { - return 'gr-opacity-diff-mode'; - } - - static get styles() { - return [ - css` - :host { - display: block; - } - .wrapper { - box-shadow: 0 1px 3px rgba(0, 0, 0, .3); - margin: 1em 0; - } - img { - display: block; - height: var(--img-height); - margin: auto; - position: absolute; - width: var(--img-width); - } - #imageRevision { - opacity: var(--my-opacity-value); - z-index: 0.5; - } - #imageDiffContainer { - height: var(--div-height); - margin: auto; - width: var(--div-width); - } - #controlsContainer { - border-top: 1px solid var(--border-color, #ddd); - display: flex; - } - #controlsBox { - display: flex; - justify-content: space-between; - margin: 0 .5em; - min-width: 32em; - width: 100%; - } - label { - align-items: center; - display: flex; - padding: 1em .5em; - } - input { - margin: .5em; - } - #opacitySlider { - width: 10em; - } - ` - ]; - } - - render() { - return html` - <div class="wrapper" style=" - --my-opacity-value: ${this.opacityValue}; - --img-width: ${this._scaledWidth ? this._scaledWidth + 'px' : 'initial'}; - --img-height: ${this._scaledHeight ? this._scaledHeight + 'px' : 'initial'}; - --div-width: ${this._maxWidth ? this._maxWidth + 'px' : 'initial'}; - --div-height: ${this._maxHeight ? this._maxHeight + 'px' : 'initial'}; - "> - <div id="imageDiffContainer"> - <img - @load=${this._onImageLoad} - id="imageBase" - src=${this.computeSrcString(this.baseImage)} /> - <img - @load=${this._onImageLoad} - data-opacity=${this.opacityValue} - id="imageRevision" - src=${this.computeSrcString(this.revisionImage)} /> - </div> - <div id="controlsContainer"> - <div id="controlsBox"> - <label> - <input - id="scaleSizesToggle" - @click=${this.handleScaleSizesToggle} - type="checkbox"> - Scale to same size - </label> - <label> - Revision image opacity - <input - id="opacitySlider" - max="1.0" - min="0.0" - @input=${this.handleOpacityChange} - step=".01" - type="range" - .value=${this.opacityValue || '0.5'}/> - </label> - </div> - </div> - </div> - `; - } - - static get properties() { - return { - baseImage: {type: Object}, - revisionImage: {type: Object}, - opacityValue: {type: Number}, - _maxHeight: {type: Number}, - _maxWidth: {type: Number}, - _scaledWidth: {type: Number}, - _scaledHeight: {type: Number}, - }; - } - - constructor() { - super(); - this._maxHeight = 0; - this._maxWidth = 0; - this.opacityValue = 0.5; - } - - updated(changedProperties) { - if (changedProperties.has('baseImage') || changedProperties.has('revisionImage')) { - this._handleImageChange(); - } - } - - _onImageLoad(e) { - const target = e.target; - this._maxHeight = Math.max(this._maxHeight, target.naturalHeight); - this._maxWidth = Math.max(this._maxWidth, target.naturalWidth); - } - - _handleImageChange() { - if (this.baseImage === undefined || this.revisionImage === undefined) return; - this.handleOpacityChange(); - } - - handleOpacityChange(e) { - const value = e ? e.target.value : this.opacityValue; - this.opacityValue = Number(value); - } - - computeSrcString(image) { - if (!image) return ''; - return 'data:' + image['type'] + ';base64, ' + image['body']; - } - - handleScaleSizesToggle(e) { - const isChecked = e ? e.target.checked : false; - if (isChecked) { - this._scaledWidth = this._maxWidth; - this._scaledHeight = this._maxHeight; - } else { - this._scaledWidth = null; - this._scaledHeight = null; - } - } -} - -customElements.define(OpacityDiffMode.is, OpacityDiffMode);
diff --git a/gr-opacity-diff-mode/gr-opacity-diff-mode_test.html b/gr-opacity-diff-mode/gr-opacity-diff-mode_test.html deleted file mode 100644 index d1fc5ed..0000000 --- a/gr-opacity-diff-mode/gr-opacity-diff-mode_test.html +++ /dev/null
@@ -1,96 +0,0 @@ -<!DOCTYPE html> -<!-- -@license -Copyright (C) 2018 The Android Open Source Project -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> -<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> -<meta charset="utf-8"> -<title>gr-opacity-diff-mode</title> -<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script> -<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script> -<script src="../bower_components/web-component-tester/browser.js"></script> - -<test-fixture id='opacity-diff-fixture'> - <template> - <gr-opacity-diff-mode></gr-opacity-diff-mode> - </template> -</test-fixture> - -<script type="module"> - import '../test/common-test-setup.js'; - import "./gr-opacity-diff-mode.js"; - function getComputedStyleValue(name, el) { - let style; - if (window.ShadyCSS) { - style = ShadyCSS.getComputedStyleValue(el, name); - } else if (el.getComputedStyleValue) { - style = el.getComputedStyleValue(name); - } else { - style = getComputedStyle(el).getPropertyValue(name); - } - return style; - }; - - suite('gr-opacity-diff-tool tests', () => { - let element; - - setup(() => { - element= fixture('opacity-diff-fixture'); - }); - - test('slider changes opacity of image', () => { - const arrayOfNumbers = [0.73, 0.5, 0.0]; - arrayOfNumbers.forEach(number => { - assert.notEqual( - getComputedStyleValue('--my-opacity-value', element), - number); - element.$.opacitySlider.value = number; - element.handleOpacityChange(); - assert.equal( - getComputedStyleValue('--my-opacity-value', element), - number); - }); - }); - - test('create the src string for images', () => { - element.revisionImage = { - type: 'IMG/src', - body: 'Zx3Cgffk=', - }; - const expectedOutcome = 'data:IMG/src;base64, Zx3Cgffk='; - assert.equal(element.computeSrcString(element.revisionImage), - expectedOutcome); - }); - - test('size scaling', () => { - element._maxWidth = 200; - element._maxHeight = 400; - - assert.equal(getComputedStyleValue('--img-width', element), ''); - assert.equal(getComputedStyleValue('--img-height', element), ''); - MockInteractions.tap(element.$.scaleSizesToggle); - flushAsynchronousOperations(); - - assert.equal(getComputedStyleValue('--img-width', element), '200px'); - assert.equal(getComputedStyleValue('--img-height', element), '400px'); - }); - - test('resize the div container for base & revision image comparison', () => { - assert.equal(getComputedStyleValue('--div-height', element), ''); - element._maxHeight = 500; - element._maxWidth = 300; - flushAsynchronousOperations(); - - assert.equal(getComputedStyleValue('--div-height', element), '500px'); - }); - }); -</script>
diff --git a/gr-resemble-diff-mode/gr-resemble-diff-mode.js b/gr-resemble-diff-mode/gr-resemble-diff-mode.js deleted file mode 100644 index f0c895f..0000000 --- a/gr-resemble-diff-mode/gr-resemble-diff-mode.js +++ /dev/null
@@ -1,275 +0,0 @@ -/** - * @license - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {html, css, LitElement} from 'lit'; - -const DEFAULT_SETTING = { - errorType: 'flat', - largeImageThreshold: 1200, -}; - -class ResembleDiffMode extends LitElement { - static get is() { - return 'gr-resemble-diff-mode'; - } - - static get styles() { - return [ - css` - :host { - display: block; - } - h2 { - display: none; - } - :host([loading]) #imageDiff { - display: none; - } - :host([loading]) h2 { - display: inline; - padding: 1em 0; - } - .toggle { - padding: .5em; - } - #controlsContainer { - align-items: center; - border-top: 1px solid var(--border-color, #ddd); - display: flex; - justify-content: space-between; - padding: 1em; - white-space: nowrap; - width: 100%; - } - #diffContainer { - display: block; - width: 100%; - } - #color { - border: 1px solid var(--border-color, #ddd); - border-radius: 2px; - } - #fullscreen { - border: 1px solid var(--border-color, #ddd); - border-radius: 2px; - color: var(--primary-text-color, #000); - padding: .5em; - } - #controlsContainer, - #color, - #fullscreen { - background-color: var(--table-header-background-color, #fafafa); - } - #color:hover, - #fullscreen:hover { - background-color: var(--header-background-color, #eeeeee); - } - #imageDiff { - display: block; - height: auto; - margin: auto; - max-width: 50em; - } - #modeContainer { - box-shadow: 0 1px 3px rgba(0, 0, 0, .3); - display: block; - margin: 1em 0em; - width: 50em; - } - ` - ]; - } - - render() { - return html` - <div id="modeContainer"> - <div id="diffContainer"> - <h2>Loading...</h2> - <img id="imageDiff" src=${this._diffImageSrc || ''}> - </div> - <div id="controlsContainer"> - <div>${this._difference}% changed</div> - <label class="toggle"> - <input - id="ignoreColorsToggle" - type="checkbox" - .checked=${this._ignoreColors} - @click=${this._handleIgnoreColorsToggle}> - Ignore colors - </label> - <label class="toggle"> - <input - id="transparentToggle" - type="checkbox" - .checked=${this._transparent} - @click=${this._handleTransparentToggle}> - Transparent - </label> - <input - id="color" - type="color" - .value=${this._colorValue} - @change=${this._handleColorChange}> - <button - id="fullscreen" - @click=${this._handleFullScreen}> - View full sized - </button> - </div> - </div> - `; - } - - static get properties() { - return { - baseImage: {type: Object}, - revisionImage: {type: Object}, - _colorValue: {type: String}, - _difference: {type: Number}, - _ignoreColors: {type: Boolean}, - _transparent: {type: Boolean}, - _diffImageSrc: {type: String}, - loading: {type: Boolean, reflect: true}, - }; - } - - constructor() { - super(); - this._colorValue = '#00ffff'; - this._difference = 0; - this._ignoreColors = false; - this._transparent = false; - this._diffImageSrc = ''; - this.loading = false; - } - - updated(changedProperties) { - if (changedProperties.has('baseImage') || changedProperties.has('revisionImage')) { - this._handleImageDiff(this.baseImage, this.revisionImage); - } - } - - connectedCallback() { - super.connectedCallback(); - window.resemble.outputSettings(DEFAULT_SETTING); - } - - _handleImageDiff(baseImage, revisionImage) { - if ([baseImage, revisionImage].includes(undefined)) { - return; - } - this.reload(); - } - - _setImageDiffSrc(src) { - this._diffImageSrc = src; - } - - _setDifferenceValue(percentage) { - this._difference = percentage; - } - - _getDataUrl(image) { - return 'data:' + image['type'] + ';base64,' + image['body']; - } - - _maybeIgnoreColors(diffProcess, ignoreColors) { - ignoreColors ? diffProcess.ignoreColors() : diffProcess.ignoreNothing(); - return diffProcess; - } - - _createDiffProcess(base, rev, ignoreColors) { - window.resemble.outputSettings(this._setOutputSetting()); - const process = window.resemble(base).compareTo(rev); - return this._maybeIgnoreColors(process, ignoreColors); - } - - _setOutputSetting() { - const rgb = this._hexToRGB(this._colorValue); - return { - transparency: this._transparent ? 0.1 : 1, - errorColor: { - red: rgb.r, - green: rgb.g, - blue: rgb.b, - }, - }; - } - - /** - * Reloads the diff. Resemble 1.2.1 seems to have an issue with successive - * reloads via the repaint() function, so this implementation creates a - * fresh diff each time it is called. - * - * @return {Promise} resolves if and when the reload succeeds. - */ - reload() { - this.loading = true; - if (this.baseImage && this.revisionImage) { - const base = this._getDataUrl(this.baseImage); - const rev = this._getDataUrl(this.revisionImage); - - return new Promise((resolve, reject) => { - this._createDiffProcess(base, rev, this._ignoreColors).onComplete( - data => { - this._setImageDiffSrc(data.getImageDataUrl()); - this._setDifferenceValue(data.misMatchPercentage); - this.loading = false; - resolve(); - } - ); - }); - } - this.loading = false; - } - - _handleIgnoreColorsToggle(e) { - this._ignoreColors = e.target.checked; - this.reload(); - } - - _handleTransparentToggle(e) { - this._transparent = e.target.checked; - this.reload(); - } - - _handleColorChange(e) { - this._colorValue = e.target.value; - this.reload(); - } - - _hexToRGB(hex) { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } - : null; - } - - _handleFullScreen() { - const w = window.open('about:blank', '_blank'); - const imageDiff = this.shadowRoot.getElementById('imageDiff'); - if (imageDiff) { - w.document.body.appendChild(imageDiff.cloneNode(true)); - } - } -} - -customElements.define(ResembleDiffMode.is, ResembleDiffMode); \ No newline at end of file
diff --git a/gr-resemble-diff-mode/gr-resemble-diff-mode_test.html b/gr-resemble-diff-mode/gr-resemble-diff-mode_test.html deleted file mode 100644 index 18cf465..0000000 --- a/gr-resemble-diff-mode/gr-resemble-diff-mode_test.html +++ /dev/null
@@ -1,156 +0,0 @@ -<!-- -Copyright (C) 2018 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> - -<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> -<meta charset="utf-8"> -<title>resemble-diff-mode</title> -<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script> -<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script> -<script src="../bower_components/web-component-tester/browser.js"></script> -<script src="../node_modules/resemblejs/resemble.js"></script> - -<test-fixture id="basicFixture"> - <template> - <gr-resemble-diff-mode> - </gr-resemble-diff-mode> - </template> -</test-fixture> - -<script type="module"> - import '../test/common-test-setup.js'; - import "./gr-resemble-diff-mode.js"; - suite('gr-resemble-diff-mode tests', () => { - let element; - let sandbox; - - setup(() => { - sandbox = sinon.sandbox.create(); - element = fixture('basicFixture'); - }); - - teardown(() => { sandbox.restore(); }); - - test('test initialization', () => { - assert.notEqual(element, null); - assert.equal(element.baseImage, null); - assert.equal(element.revisionImage, null); - }); - - test('_setImageDiffSrc', () => { - const img = element.$.imageDiff; - const src = 'data:image/png;base64,SGVsbG8='; - assert.notOk(img.getAttribute('src')); - element._setImageDiffSrc(src); - assert.equal(img.getAttribute('src'), src); - }); - - test('_setDifferenceValue', () => { - const expected = 0.15; - element._setDifferenceValue(expected); - assert.equal(element._difference, expected); - }); - - test('_maybeIgnoreColors', () => { - const dummyProcess = { - ignoreColors: sandbox.stub(), - ignoreNothing: sandbox.stub(), - }; - element._maybeIgnoreColors(dummyProcess, false); - assert.isFalse(dummyProcess.ignoreColors.called); - assert.isTrue(dummyProcess.ignoreNothing.called); - element._maybeIgnoreColors(dummyProcess, true); - assert.isTrue(dummyProcess.ignoreColors.called); - }); - - test('_setOutputSetting', () => { - sandbox.stub(window.resemble, 'outputSettings'); - element._transparent = true; - element._colorValue = '#00ffff'; - const expectedResult = { - transparency: 0.1, - errorColor: { - red: 0, - green: 255, - blue: 255, - }, - }; - element._createDiffProcess(); - flushAsynchronousOperations(); - - assert.isTrue(window.resemble.outputSettings.called); - sinon.assert.calledWith(window.resemble.outputSettings, expectedResult); - }); - - test('_handleIgnoreColorsToggle', () => { - sandbox.stub(element, 'reload'); - element._ignoreColors = false; - assert.isFalse(element.$.ignoreColorsToggle.checked); - MockInteractions.tap(element.$.ignoreColorsToggle); - flushAsynchronousOperations(); - - assert.isTrue(element._ignoreColors); - assert.isTrue(element.reload.called); - }); - - test('_handleTransparentToggle', () => { - sandbox.stub(element, 'reload'); - element._transparent = false; - assert.isFalse(element.$.transparentToggle.checked); - MockInteractions.tap(element.$.transparentToggle); - flushAsynchronousOperations(); - - assert.isTrue(element._transparent); - assert.isTrue(element.reload.called); - }); - - test('_handleColorChange', () => { - sandbox.stub(element, 'reload'); - element._colorValue = ''; - flushAsynchronousOperations(); - - assert.isTrue(element.reload.called); - }); - - test('_hexToRGB', () => { - const expected = { r: 0, g: 255, b: 255 }; - const input = '#00ffff'; - const output = element._hexToRGB(input); - assert.equal(output.r, expected.r); - assert.equal(output.g, expected.g); - assert.equal(output.b, expected.b); - }); - - test('_handleFullScreen', () => { - sandbox.stub(element, '_handleFullScreen'); - MockInteractions.tap(element.$.fullscreen); - flushAsynchronousOperations(); - - assert.isTrue(element._handleFullScreen.called); - }); - - test('calls reload', () => { - sandbox.stub(element, 'reload'); - element.baseImage = {}; - assert.equal(element.reload.callCount, 0); - element.revisionImage = {}; - assert.equal(element.reload.callCount, 1); - MockInteractions.tap(element.$.ignoreColorsToggle); - assert.equal(element.reload.callCount, 2); - MockInteractions.tap(element.$.transparentToggle); - assert.equal(element.reload.callCount, 3); - }); - }); -</script> \ No newline at end of file
diff --git a/package.json b/package.json index 07056cd..1cf90d6 100644 --- a/package.json +++ b/package.json
@@ -1,26 +1,14 @@ { - "name": "image-diff", - "browser": true, - "scripts": { - "test": "polymer serve", - "eslint": "npx eslint --ext .js ./", - "eslintfix": "npm run eslint -- --fix", - "wct-test": "npm i && npx bower i && npx polymer test -l chrome" - }, - "dependencies": { - "resemblejs": "^5.0.0" - }, - "devDependencies": { - "@polymer/iron-test-helpers": "^3.0.1", - "@webcomponents/webcomponentsjs": "^1.3.3", - "bower": "^1.8.8", - "es6-promise": "^3.3.1", - "eslint": "^6.6.0", - "eslint-config-google": "^0.13.0", - "eslint-plugin-html": "^6.0.0", - "eslint-plugin-import": "^2.20.1", - "eslint-plugin-jsdoc": "^19.2.0" - }, - "license": "Apache-2.0", - "private": true + "name": "image-diff", + "dependencies": { + "@gerritcodereview/typescript-api": "3.13.0", + "lit": "^3.3.1", + "resemblejs": "^5.0.0" + }, + "devDependencies": { + "@open-wc/testing": "^4.0.0", + "sinon": "^20.0.0" + }, + "license": "Apache-2.0", + "private": true }
diff --git a/test/common-test-setup.js b/test/common-test-setup.js deleted file mode 100644 index f460703..0000000 --- a/test/common-test-setup.js +++ /dev/null
@@ -1,60 +0,0 @@ -/** - * @license - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -/* eslint-disable */ -import 'polymer-bridges/polymer/lib/utils/boot_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/resolve-url_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/settings_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/mixin_bridge.js'; -import 'polymer-bridges/polymer/lib/elements/dom-module_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/style-gather_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/path_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/case-map_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/async_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/wrap_bridge.js'; -import 'polymer-bridges/polymer/lib/mixins/properties-changed_bridge.js'; -import 'polymer-bridges/polymer/lib/mixins/property-accessors_bridge.js'; -import 'polymer-bridges/polymer/lib/mixins/template-stamp_bridge.js'; -import 'polymer-bridges/polymer/lib/mixins/property-effects_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/telemetry_bridge.js'; -import 'polymer-bridges/polymer/lib/mixins/properties-mixin_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/debounce_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/gestures_bridge.js'; -import 'polymer-bridges/polymer/lib/mixins/gesture-event-listeners_bridge.js'; -import 'polymer-bridges/polymer/lib/mixins/dir-mixin_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/render-status_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/unresolved_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/array-splice_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/flattened-nodes-observer_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/flush_bridge.js'; -import 'polymer-bridges/polymer/lib/legacy/polymer.dom_bridge.js'; -import 'polymer-bridges/polymer/lib/legacy/legacy-element-mixin_bridge.js'; -import 'polymer-bridges/polymer/lib/legacy/class_bridge.js'; -import 'polymer-bridges/polymer/lib/legacy/polymer-fn_bridge.js'; -import 'polymer-bridges/polymer/lib/mixins/mutable-data_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/templatize_bridge.js'; -import 'polymer-bridges/polymer/lib/legacy/templatizer-behavior_bridge.js'; -import 'polymer-bridges/polymer/lib/elements/dom-bind_bridge.js'; -import 'polymer-bridges/polymer/lib/utils/html-tag_bridge.js'; -import 'polymer-bridges/polymer/polymer-element_bridge.js'; -import 'polymer-bridges/polymer/lib/elements/dom-repeat_bridge.js'; -import 'polymer-bridges/polymer/lib/elements/dom-if_bridge.js'; -import 'polymer-bridges/polymer/lib/elements/array-selector_bridge.js'; -import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js'; -import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js'; -import 'polymer-bridges/polymer/polymer-legacy_bridge.js'; - -import '@polymer/iron-test-helpers/iron-test-helpers.js';
diff --git a/test/index.html b/test/index.html deleted file mode 100644 index 7f5487d..0000000 --- a/test/index.html +++ /dev/null
@@ -1,34 +0,0 @@ -<!DOCTYPE html> -<!-- -@license -Copyright (C) 2018 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> - -<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> -<title>Test Runner</title> -<meta charset="utf-8"> -<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> -<script src="../bower_components/web-component-tester/browser.js"></script> - -<script> - const suites = [ - '../gr-image-diff-tool/gr-image-diff-tool_test.html', - '../gr-opacity-diff-mode/gr-opacity-diff-mode_test.html', - '../gr-resemble-diff-mode/gr-resemble-diff-mode_test.html', - ]; - - WCT.loadSuites(suites); - WCT.loadSuites(suites.map(suite => `${suite}?dom=shadow`)); -</script> \ No newline at end of file
diff --git a/web/BUILD b/web/BUILD new file mode 100644 index 0000000..01494fc --- /dev/null +++ b/web/BUILD
@@ -0,0 +1,67 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("//tools/bzl:js.bzl", "gerrit_js_bundle", "web_test_runner") +load("//tools/js:eslint.bzl", "plugin_eslint") + +package_group( + name = "visibility", + packages = ["//plugins/image-diff/..."], +) + +package(default_visibility = [":visibility"]) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//plugins:tsconfig-plugins-base.json", + ], +) + +ts_project( + name = "image-diff-ts", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*test*"], + ), + incremental = True, + out_dir = "_bazel_ts_out", + tsc = "//tools/node_tools:tsc-bin", + tsconfig = ":tsconfig", + deps = [ + "@plugins_npm//@gerritcodereview/typescript-api", + "@plugins_npm//lit", + ], +) + +ts_project( + name = "image-diff-ts-tests", + srcs = glob(["**/*.ts"]), + incremental = True, + out_dir = "_bazel_ts_out_tests", + tsc = "//tools/node_tools:tsc-bin", + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], + deps = [ + "@plugins_npm//:node_modules", + "@ui_dev_npm//:node_modules", + ], +) + +gerrit_js_bundle( + name = "image_diff", + srcs = [":image-diff-ts"], + entry_point = "_bazel_ts_out/plugin.js", +) + +web_test_runner( + name = "karma_test", + srcs = ["karma_test.sh"], + data = [ + ":image-diff-ts-tests", + ":tsconfig", + "@plugins_npm//:node_modules", + "@ui_dev_npm//:node_modules", + ], +) + +plugin_eslint()
diff --git a/web/gr-image-diff-tool/gr-image-diff-tool.ts b/web/gr-image-diff-tool/gr-image-diff-tool.ts new file mode 100644 index 0000000..e11bbdd --- /dev/null +++ b/web/gr-image-diff-tool/gr-image-diff-tool.ts
@@ -0,0 +1,186 @@ +/** + * @license + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {html, css, LitElement, PropertyValues} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {ImageInfo} from '../gr-opacity-diff-mode/gr-opacity-diff-mode'; +import '../gr-opacity-diff-mode/gr-opacity-diff-mode'; +import '../gr-resemble-diff-mode/gr-resemble-diff-mode'; + +const DiffModes = { + OPACITY: 'opacity', + RESEMBLE: 'resemble', +}; + +@customElement('gr-image-diff-tool') +export class ImageDiffTool extends LitElement { + static override get styles() { + return [ + css` + :host { + background-color: var(--table-header-background-color, #fafafa); + display: block; + font-family: var(--font-family); + } + #header { + align-items: center; + border-bottom: 1px solid var(--border-color, #ddd); + border-top: 1px solid var(--border-color, #ddd); + display: inline-flex; + padding: 0.5em; + width: 100%; + } + h3 { + padding: 0 0.5em; + } + #dropdown { + background-color: var(--view-background-color); + border: 1px solid var(--border-color); + border-radius: 2px; + color: var(--primary-text-color); + font-size: var(--font-size-normal); + height: 2em; + margin-left: 1em; + padding: 0 0.15em; + } + .diffmode { + align-items: center; + display: flex; + justify-content: center; + } + ` + ]; + } + + @property({type: Object}) + baseImage?: ImageInfo; + + @property({type: Object}) + revisionImage?: ImageInfo; + + @property({type: Boolean, reflect: true}) + override hidden = false; + + @state() + protected showResembleMode = false; + + @state() + protected showOpacityMode = false; + + @state() + protected observeMode = ''; + + override render() { + return html` + <div id="header"> + <h3>Image diff</h3> + <select + .value=${this.observeMode} + @change=${this.handleSelectChange} + id="dropdown" + > + <option + value="resemble" + title="Scale the images to the same size and compute a diff with highlights" + >Highlight differences</option + > + <option + value="opacity" + title="Overlay the new image over the old and use an opacity control to view the differences" + >Onion skin</option + > + </select> + </div> + <div class="diffmode"> + ${this.showResembleMode + ? html` + <gr-resemble-diff-mode + .baseImage=${this.baseImage} + .revisionImage=${this.revisionImage} + ></gr-resemble-diff-mode> + ` + : ''} + </div> + <div class="diffmode"> + ${this.showOpacityMode + ? html` + <gr-opacity-diff-mode + .baseImage=${this.baseImage} + .revisionImage=${this.revisionImage} + ></gr-opacity-diff-mode> + ` + : ''} + </div> + `; + } + + override updated(changedProperties: PropertyValues) { + if (changedProperties.has('observeMode')) { + this.handleSelect(this.observeMode); + } + } + + override connectedCallback() { + super.connectedCallback(); + if (!this.baseImage || !this.revisionImage) { + // No need to show the diff tool if there are no images. + this.hidden = true; + } + const diff_mode = this.getMode(); + diff_mode === DiffModes.OPACITY + ? this.displayOpacityMode() + : this.displayResembleMode(); + } + + protected getMode() { + return window.localStorage.getItem('image-diff-mode'); + } + + protected setMode(mode: string) { + window.localStorage.setItem('image-diff-mode', mode); + } + + protected handleSelectChange(e: Event) { + this.observeMode = (e.target as HTMLSelectElement).value; + } + + protected handleSelect(mode: string) { + mode === DiffModes.OPACITY + ? this.displayOpacityMode() + : this.displayResembleMode(); + } + + protected displayResembleMode() { + this.observeMode = DiffModes.RESEMBLE; + this.showResembleMode = true; + this.showOpacityMode = false; + this.setMode(DiffModes.RESEMBLE); + } + + protected displayOpacityMode() { + this.observeMode = DiffModes.OPACITY; + this.showResembleMode = false; + this.showOpacityMode = true; + this.setMode(DiffModes.OPACITY); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gr-image-diff-tool': ImageDiffTool; + } +}
diff --git a/web/gr-image-diff-tool/gr-image-diff-tool_test.ts b/web/gr-image-diff-tool/gr-image-diff-tool_test.ts new file mode 100644 index 0000000..c763123 --- /dev/null +++ b/web/gr-image-diff-tool/gr-image-diff-tool_test.ts
@@ -0,0 +1,85 @@ +/** + * @license + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import './gr-image-diff-tool'; +import {ImageDiffTool} from './gr-image-diff-tool'; +import {fixture, html, assert} from '@open-wc/testing'; +import sinon from 'sinon'; + +suite('gr-image-diff-tool tests', () => { + let element: ImageDiffTool; + let sandbox: sinon.SinonSandbox; + + setup(async () => { + sandbox = sinon.createSandbox(); + (window as any).resemble = { + outputSettings: sandbox.stub(), + compareTo: sandbox.stub().returnsThis(), + onComplete: sandbox.stub(), + }; + const resembleStub: any = sandbox.stub().returns((window as any).resemble); + resembleStub.outputSettings = (window as any).resemble.outputSettings; + (window as any).resemble = resembleStub; + element = await fixture<ImageDiffTool>( + html`<gr-image-diff-tool></gr-image-diff-tool>` + ); + await element.updateComplete; + }); + + teardown(() => { + sandbox.restore(); + }); + + test('test opacity mode', async () => { + (element as any).observeMode = 'opacity'; + await element.updateComplete; + await element.updateComplete; + assert.isTrue((element as any).showOpacityMode); + assert.isFalse((element as any).showResembleMode); + + assert.isOk(element.shadowRoot!.querySelector('gr-opacity-diff-mode')); + assert.isNull(element.shadowRoot!.querySelector('gr-resemble-diff-mode')); + }); + + test('test resemble mode', async () => { + (element as any).observeMode = 'resemble'; + await element.updateComplete; + await element.updateComplete; + assert.isTrue((element as any).showResembleMode); + assert.isFalse((element as any).showOpacityMode); + + assert.isOk(element.shadowRoot!.querySelector('gr-resemble-diff-mode')); + assert.isNull(element.shadowRoot!.querySelector('gr-opacity-diff-mode')); + }); + + test('localStorage persists', async () => { + sandbox.stub(element as any, 'setMode'); + sandbox.stub(element as any, 'getMode'); + element.connectedCallback(); + assert.equal((element as any).getMode.callCount, 1); + assert.equal((element as any).setMode.callCount, 1); + (element as any).observeMode = 'opacity'; + await element.updateComplete; + await element.updateComplete; + assert.equal((element as any).setMode.callCount, 2); + (element as any).observeMode = 'resemble'; + await element.updateComplete; + await element.updateComplete; + assert.equal((element as any).setMode.callCount, 3); + }); +});
diff --git a/web/gr-opacity-diff-mode/gr-opacity-diff-mode.ts b/web/gr-opacity-diff-mode/gr-opacity-diff-mode.ts new file mode 100644 index 0000000..4280139 --- /dev/null +++ b/web/gr-opacity-diff-mode/gr-opacity-diff-mode.ts
@@ -0,0 +1,199 @@ +/** + * @license + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {html, css, LitElement, PropertyValues} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; + +export interface ImageInfo { + type: string; + body: string; +} + +@customElement('gr-opacity-diff-mode') +export class OpacityDiffMode extends LitElement { + static override get styles() { + return [ + css` + :host { + display: block; + } + .wrapper { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + margin: 1em 0; + } + img { + display: block; + height: var(--img-height); + margin: auto; + position: absolute; + width: var(--img-width); + } + #imageRevision { + opacity: var(--my-opacity-value); + z-index: 0.5; + } + #imageDiffContainer { + height: var(--div-height); + margin: auto; + width: var(--div-width); + } + #controlsContainer { + border-top: 1px solid var(--border-color, #ddd); + display: flex; + } + #controlsBox { + display: flex; + justify-content: space-between; + margin: 0 0.5em; + min-width: 32em; + width: 100%; + } + label { + align-items: center; + display: flex; + padding: 1em 0.5em; + } + input { + margin: 0.5em; + } + #opacitySlider { + width: 10em; + } + ` + ]; + } + + @property({type: Object}) + baseImage?: ImageInfo; + + @property({type: Object}) + revisionImage?: ImageInfo; + + @property({type: Number}) + opacityValue = 0.5; + + @state() + protected maxHeight = 0; + + @state() + protected maxWidth = 0; + + @state() + protected scaledWidth: number | null = null; + + @state() + protected scaledHeight: number | null = null; + + override render() { + return html` + <div + class="wrapper" + style=" + --my-opacity-value: ${this.opacityValue}; + --img-width: ${this.scaledWidth ? this.scaledWidth + 'px' : 'initial'}; + --img-height: ${this.scaledHeight ? this.scaledHeight + 'px' : 'initial'}; + --div-width: ${this.maxWidth ? this.maxWidth + 'px' : 'initial'}; + --div-height: ${this.maxHeight ? this.maxHeight + 'px' : 'initial'}; + " + > + <div id="imageDiffContainer"> + <img + @load=${this.onImageLoad} + id="imageBase" + src=${this.computeSrcString(this.baseImage)} + /> + <img + @load=${this.onImageLoad} + data-opacity=${this.opacityValue} + id="imageRevision" + src=${this.computeSrcString(this.revisionImage)} + /> + </div> + <div id="controlsContainer"> + <div id="controlsBox"> + <label> + <input + id="scaleSizesToggle" + @click=${this.handleScaleSizesToggle} + type="checkbox" + /> + Scale to same size + </label> + <label> + Revision image opacity + <input + id="opacitySlider" + max="1.0" + min="0.0" + @input=${this.handleOpacityChange} + step=".01" + .value=${String(this.opacityValue ?? 0.5)} + /> + </label> + </div> + </div> + </div> + `; + } + + override updated(changedProperties: PropertyValues) { + if ( + changedProperties.has('baseImage') || + changedProperties.has('revisionImage') + ) { + this.handleImageChange(); + } + } + + protected onImageLoad(e: Event) { + const target = e.target as HTMLImageElement; + this.maxHeight = Math.max(this.maxHeight, target.naturalHeight); + this.maxWidth = Math.max(this.maxWidth, target.naturalWidth); + } + + protected handleImageChange() { + if (this.baseImage === undefined || this.revisionImage === undefined) return; + this.handleOpacityChange(); + } + + handleOpacityChange(e?: Event) { + const value = e ? (e.target as HTMLInputElement).value : this.opacityValue; + this.opacityValue = Number(value); + } + + computeSrcString(image?: ImageInfo) { + if (!image) return ''; + return 'data:' + image.type + ';base64, ' + image.body; + } + + handleScaleSizesToggle(e?: Event) { + const isChecked = e ? (e.target as HTMLInputElement).checked : false; + if (isChecked) { + this.scaledWidth = this.maxWidth; + this.scaledHeight = this.maxHeight; + } else { + this.scaledWidth = null; + this.scaledHeight = null; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gr-opacity-diff-mode': OpacityDiffMode; + } +}
diff --git a/web/gr-opacity-diff-mode/gr-opacity-diff-mode_test.ts b/web/gr-opacity-diff-mode/gr-opacity-diff-mode_test.ts new file mode 100644 index 0000000..613b578 --- /dev/null +++ b/web/gr-opacity-diff-mode/gr-opacity-diff-mode_test.ts
@@ -0,0 +1,100 @@ +/** + * @license + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import './gr-opacity-diff-mode'; +import {OpacityDiffMode} from './gr-opacity-diff-mode'; +import {fixture, html, assert} from '@open-wc/testing'; + +function getComputedStyleValue(name: string, el: HTMLElement) { + let style; + if ((window as any).ShadyCSS) { + style = (window as any).ShadyCSS.getComputedStyleValue(el, name); + } else if ((el as any).getComputedStyleValue) { + style = ((el as any).getComputedStyleValue(name)); + } else { + style = getComputedStyle(el).getPropertyValue(name); + } + return style; +} + +suite('gr-opacity-diff-tool tests', () => { + let element: OpacityDiffMode; + + setup(async () => { + element = await fixture<OpacityDiffMode>( + html`<gr-opacity-diff-mode></gr-opacity-diff-mode>` + ); + }); + + test('slider changes opacity of image', async () => { + const arrayOfNumbers = [0.73, 0.5, 0.0]; + const opacitySlider = element.shadowRoot!.querySelector( + '#opacitySlider' + ) as HTMLInputElement; + const wrapper = element.shadowRoot!.querySelector('.wrapper') as HTMLElement; + for (const number of arrayOfNumbers) { + assert.notEqual(getComputedStyleValue('--my-opacity-value', wrapper), String(number)); + opacitySlider.value = String(number); + // Mock firing input event + opacitySlider.dispatchEvent(new Event('input')); + element.handleOpacityChange({target: opacitySlider} as any); + await element.updateComplete; + assert.equal(getComputedStyleValue('--my-opacity-value', wrapper).trim(), String(number)); + } + }); + + test('create the src string for images', () => { + element.revisionImage = { + type: 'IMG/src', + body: 'Zx3Cgffk=', + }; + const expectedOutcome = 'data:IMG/src;base64, Zx3Cgffk='; + assert.equal(element.computeSrcString(element.revisionImage), expectedOutcome); + }); + + test('size scaling', async () => { + (element as any).maxWidth = 200; + (element as any).maxHeight = 400; + await element.updateComplete; + + const wrapper = element.shadowRoot!.querySelector('.wrapper') as HTMLElement; + assert.equal(getComputedStyleValue('--img-width', wrapper).trim(), ''); + assert.equal(getComputedStyleValue('--img-height', wrapper).trim(), ''); + const scaleSizesToggle = element.shadowRoot!.querySelector( + '#scaleSizesToggle' + ) as HTMLInputElement; + + scaleSizesToggle.checked = true; + scaleSizesToggle.dispatchEvent(new Event('click')); + element.handleScaleSizesToggle({target: scaleSizesToggle} as any); + await element.updateComplete; + + assert.equal(getComputedStyleValue('--img-width', wrapper).trim(), '200px'); + assert.equal(getComputedStyleValue('--img-height', wrapper).trim(), '400px'); + }); + + test('resize the div container for base & revision image comparison', async () => { + const wrapper = element.shadowRoot!.querySelector('.wrapper') as HTMLElement; + assert.equal(getComputedStyleValue('--div-height', wrapper).trim(), ''); + (element as any).maxHeight = 500; + (element as any).maxWidth = 300; + await element.updateComplete; + + assert.equal(getComputedStyleValue('--div-height', wrapper).trim(), '500px'); + }); +});
diff --git a/web/gr-resemble-diff-mode/gr-resemble-diff-mode.ts b/web/gr-resemble-diff-mode/gr-resemble-diff-mode.ts new file mode 100644 index 0000000..5a289df --- /dev/null +++ b/web/gr-resemble-diff-mode/gr-resemble-diff-mode.ts
@@ -0,0 +1,286 @@ +/** + * @license + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {html, css, LitElement, PropertyValues} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {ImageInfo} from '../gr-opacity-diff-mode/gr-opacity-diff-mode'; + +const DEFAULT_SETTING = { + errorType: 'flat', + largeImageThreshold: 1200, +}; + +@customElement('gr-resemble-diff-mode') +export class ResembleDiffMode extends LitElement { + static override get styles() { + return [ + css` + :host { + display: block; + } + h2 { + display: none; + } + :host([loading]) #imageDiff { + display: none; + } + :host([loading]) h2 { + display: inline; + padding: 1em 0; + } + .toggle { + padding: 0.5em; + } + #controlsContainer { + align-items: center; + border-top: 1px solid var(--border-color, #ddd); + display: flex; + justify-content: space-between; + padding: 1em; + white-space: nowrap; + width: 100%; + } + #diffContainer { + display: block; + width: 100%; + } + #color { + border: 1px solid var(--border-color, #ddd); + border-radius: 2px; + } + #fullscreen { + border: 1px solid var(--border-color, #ddd); + border-radius: 2px; + color: var(--primary-text-color, #000); + padding: 0.5em; + } + #controlsContainer, + #color, + #fullscreen { + background-color: var(--table-header-background-color, #fafafa); + } + #color:hover, + #fullscreen:hover { + background-color: var(--header-background-color, #eeeeee); + } + #imageDiff { + display: block; + height: auto; + margin: auto; + max-width: 50em; + } + #modeContainer { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + display: block; + margin: 1em 0em; + width: 50em; + } + ` + ]; + } + + @property({type: Object}) + baseImage?: ImageInfo; + + @property({type: Object}) + revisionImage?: ImageInfo; + + @state() + protected colorValue = '#00ffff'; + + @state() + protected difference = 0; + + @state() + protected ignoreColors = false; + + @state() + protected transparent = false; + + @state() + protected diffImageSrc = ''; + + @property({type: Boolean, reflect: true}) + loading = false; + + override render() { + return html` + <div id="modeContainer"> + <div id="diffContainer"> + <h2>Loading...</h2> + <img id="imageDiff" src=${this.diffImageSrc || ''} /> + </div> + <div id="controlsContainer"> + <div>${this.difference}% changed</div> + <label class="toggle"> + <input + id="ignoreColorsToggle" + type="checkbox" + .checked=${this.ignoreColors} + @click=${this.handleIgnoreColorsToggle} + /> + Ignore colors + </label> + <label class="toggle"> + <input + id="transparentToggle" + type="checkbox" + .checked=${this.transparent} + @click=${this.handleTransparentToggle} + /> + Transparent + </label> + <input + id="color" + type="color" + .value=${this.colorValue} + @change=${this.handleColorChange} + /> + <button id="fullscreen" @click=${this.handleFullScreen}> + View full sized + </button> + </div> + </div> + `; + } + + override updated(changedProperties: PropertyValues) { + if ( + changedProperties.has('baseImage') || + changedProperties.has('revisionImage') + ) { + this.handleImageDiff(this.baseImage, this.revisionImage); + } + } + + override connectedCallback() { + super.connectedCallback(); + window.resemble.outputSettings(DEFAULT_SETTING); + } + + protected handleImageDiff(baseImage?: ImageInfo, revisionImage?: ImageInfo) { + if (baseImage === undefined || revisionImage === undefined) { + return; + } + this.reload(); + } + + protected setImageDiffSrc(src: string) { + this.diffImageSrc = src; + } + + protected setDifferenceValue(percentage: number) { + this.difference = percentage; + } + + protected getDataUrl(image: ImageInfo) { + return 'data:' + image.type + ';base64,' + image.body; + } + + protected maybeIgnoreColors(diffProcess: any, ignoreColors: boolean) { + ignoreColors ? diffProcess.ignoreColors() : diffProcess.ignoreNothing(); + return diffProcess; + } + + protected createDiffProcess(base: string, rev: string, ignoreColors: boolean) { + window.resemble.outputSettings(this.setOutputSetting()); + const process = window.resemble(base).compareTo(rev); + return this.maybeIgnoreColors(process, ignoreColors); + } + + protected setOutputSetting() { + const rgb = this.hexToRGB(this.colorValue); + return { + transparency: this.transparent ? 0.1 : 1, + errorColor: { + red: rgb?.r ?? 0, + green: rgb?.g ?? 255, + blue: rgb?.b ?? 255, + }, + }; + } + + /** + * Reloads the diff. Resemble 1.2.1 seems to have an issue with successive + * reloads via the repaint() function, so this implementation creates a + * fresh diff each time it is called. + * + * @return resolves if and when the reload succeeds. + */ + reload(): Promise<void> | void { + this.loading = true; + if (this.baseImage && this.revisionImage) { + const base = this.getDataUrl(this.baseImage); + const rev = this.getDataUrl(this.revisionImage); + + return new Promise((resolve) => { + this.createDiffProcess(base, rev, this.ignoreColors).onComplete( + (data: any) => { + this.setImageDiffSrc(data.getImageDataUrl()); + this.setDifferenceValue(data.misMatchPercentage); + this.loading = false; + resolve(); + } + ); + }); + } + this.loading = false; + } + + protected handleIgnoreColorsToggle(e: Event) { + this.ignoreColors = (e.target as HTMLInputElement).checked; + this.reload(); + } + + protected handleTransparentToggle(e: Event) { + this.transparent = (e.target as HTMLInputElement).checked; + this.reload(); + } + + protected handleColorChange(e: Event) { + this.colorValue = (e.target as HTMLInputElement).value; + this.reload(); + } + + protected hexToRGB(hex: string) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; + } + + protected handleFullScreen() { + const w = window.open('about:blank', '_blank'); + const imageDiff = this.shadowRoot?.getElementById('imageDiff'); + if (imageDiff && w) { + w.document.body.appendChild(imageDiff.cloneNode(true)); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gr-resemble-diff-mode': ResembleDiffMode; + } + interface Window { + resemble: any; + } +}
diff --git a/web/gr-resemble-diff-mode/gr-resemble-diff-mode_test.ts b/web/gr-resemble-diff-mode/gr-resemble-diff-mode_test.ts new file mode 100644 index 0000000..d3de197 --- /dev/null +++ b/web/gr-resemble-diff-mode/gr-resemble-diff-mode_test.ts
@@ -0,0 +1,172 @@ +/** + * @license + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import './gr-resemble-diff-mode'; +import {ResembleDiffMode} from './gr-resemble-diff-mode'; +import {fixture, html, assert} from '@open-wc/testing'; +import sinon from 'sinon'; + +suite('gr-resemble-diff-mode tests', () => { + let element: ResembleDiffMode; + let sandbox: sinon.SinonSandbox; + + setup(async () => { + sandbox = sinon.createSandbox(); + (window as any).resemble = { + outputSettings: sandbox.stub(), + compareTo: sandbox.stub().returnsThis(), + onComplete: sandbox.stub(), + }; + const resembleStub: any = sandbox.stub().returns((window as any).resemble); + resembleStub.outputSettings = (window as any).resemble.outputSettings; + (window as any).resemble = resembleStub; + element = await fixture<ResembleDiffMode>( + html`<gr-resemble-diff-mode></gr-resemble-diff-mode>` + ); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('test initialization', () => { + assert.isOk(element); + assert.isUndefined(element.baseImage); + assert.isUndefined(element.revisionImage); + }); + + test('setImageDiffSrc', async () => { + const img = element.shadowRoot!.querySelector('#imageDiff') as HTMLImageElement; + const src = 'data:image/png;base64,SGVsbG8='; + assert.notOk(img.getAttribute('src')); + (element as any).setImageDiffSrc(src); + await element.updateComplete; + assert.equal(img.getAttribute('src'), src); + }); + + test('setDifferenceValue', () => { + const expected = 0.15; + (element as any).setDifferenceValue(expected); + assert.equal((element as any).difference, expected); + }); + + test('maybeIgnoreColors', () => { + const dummyProcess = { + ignoreColors: sandbox.stub(), + ignoreNothing: sandbox.stub(), + }; + (element as any).maybeIgnoreColors(dummyProcess, false); + assert.isFalse(dummyProcess.ignoreColors.called); + assert.isTrue(dummyProcess.ignoreNothing.called); + (element as any).maybeIgnoreColors(dummyProcess, true); + assert.isTrue(dummyProcess.ignoreColors.called); + }); + + test('setOutputSetting', async () => { + (element as any).transparent = true; + (element as any).colorValue = '#00ffff'; + const expectedResult = { + transparency: 0.1, + errorColor: { + red: 0, + green: 255, + blue: 255, + }, + }; + try { + (element as any).createDiffProcess('base', 'rev', false); + } catch(e) { /* might throw because of dummy image data, we only care about outputSettings call */ } + await element.updateComplete; + + assert.isTrue((window.resemble.outputSettings as any).called); + sinon.assert.calledWith(window.resemble.outputSettings as any, expectedResult); + }); + + test('handleIgnoreColorsToggle', async () => { + sandbox.stub(element, 'reload'); + (element as any).ignoreColors = false; + const ignoreColorsToggle = element.shadowRoot!.getElementById('ignoreColorsToggle') as HTMLInputElement; + assert.isFalse(ignoreColorsToggle.checked); + ignoreColorsToggle.checked = true; + ignoreColorsToggle.dispatchEvent(new Event('click')); + (element as any).handleIgnoreColorsToggle({target: ignoreColorsToggle} as any); + await element.updateComplete; + + assert.isTrue((element as any).ignoreColors); + assert.isTrue((element.reload as any).called); + }); + + test('handleTransparentToggle', async () => { + sandbox.stub(element, 'reload'); + (element as any).transparent = false; + const transparentToggle = element.shadowRoot!.getElementById('transparentToggle') as HTMLInputElement; + assert.isFalse(transparentToggle.checked); + transparentToggle.checked = true; + transparentToggle.dispatchEvent(new Event('click')); + (element as any).handleTransparentToggle({target: transparentToggle} as any); + await element.updateComplete; + + assert.isTrue((element as any).transparent); + assert.isTrue((element.reload as any).called); + }); + + test('handleColorChange', async () => { + sandbox.stub(element, 'reload'); + (element as any).colorValue = ''; + await element.updateComplete; + + (element as any).handleColorChange({target: {value: '#ffffff'}} as any); + assert.isTrue((element.reload as any).called); + }); + + test('hexToRGB', () => { + const expected = {r: 0, g: 255, b: 255}; + const input = '#00ffff'; + const output = (element as any).hexToRGB(input); + assert.equal(output.r, expected.r); + assert.equal(output.g, expected.g); + assert.equal(output.b, expected.b); + }); + + test('handleFullScreen', async () => { + const windowOpenStub = sandbox.stub(window, 'open').returns(null); + const fullscreenButton = element.shadowRoot!.getElementById('fullscreen') as HTMLButtonElement; + fullscreenButton.click(); + await element.updateComplete; + + assert.isTrue(windowOpenStub.calledWith('about:blank', '_blank')); + }); + + test('calls reload', async () => { + sandbox.stub(element, 'reload'); + element.baseImage = {type: 'IMG/src', body: ''}; + await element.updateComplete; + assert.equal((element.reload as any).callCount, 0); + element.revisionImage = {type: 'IMG/src', body: ''}; + await element.updateComplete; + assert.equal((element.reload as any).callCount, 1); + const ignoreColorsToggle = element.shadowRoot!.getElementById('ignoreColorsToggle') as HTMLInputElement; + ignoreColorsToggle.click(); + await element.updateComplete; + assert.equal((element.reload as any).callCount, 2); + const transparentToggle = element.shadowRoot!.getElementById('transparentToggle') as HTMLInputElement; + transparentToggle.click(); + await element.updateComplete; + assert.equal((element.reload as any).callCount, 3); + }); +});
diff --git a/web/karma_test.sh b/web/karma_test.sh new file mode 100755 index 0000000..8d4a34d --- /dev/null +++ b/web/karma_test.sh
@@ -0,0 +1,6 @@ +#!/bin/bash +export PATH="/opt/homebrew/bin:$PATH" +set -euo pipefail +./$1 --config $2 \ + --test-files 'plugins/image-diff/web/_bazel_ts_out_tests/**/*_test.js' \ + --ts-config="plugins/image-diff/web/tsconfig.json"
diff --git a/plugin.js b/web/plugin.ts similarity index 73% rename from plugin.js rename to web/plugin.ts index 83c7b61..c02ceae 100644 --- a/plugin.js +++ b/web/plugin.ts
@@ -1,6 +1,6 @@ /** * @license - * Copyright (C) 2020 The Android Open Source Project + * Copyright (C) 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import '@gerritcodereview/typescript-api/gerrit'; +import './gr-image-diff-tool/gr-image-diff-tool'; -import 'resemblejs/resemble.js'; // eslint-disable-line -import './gr-image-diff-tool/gr-image-diff-tool.js'; +import 'resemblejs/resemble.js'; -Gerrit.install(plugin => { +window.Gerrit?.install(plugin => { plugin.registerCustomComponent('image-diff', 'gr-image-diff-tool'); -}); \ No newline at end of file +});
diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d8ebe28 --- /dev/null +++ b/web/tsconfig.json
@@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig-plugins-base.json", + "compilerOptions": { + "outDir": "../../../.ts-out/plugins/image-diff" /* overridden by bazel */ + }, + "include": [ + "**/*.ts" + ] +}