renderdiff: add viewer for image differences (#8768)
- Modify the compare script to output more details of a comparison. This will include the source/golden directory, the comparison directory (the new renderings), and a file path to difference images if the golden does not match the rendered image. - The image_diff script can now output a TIFF that is the difference of two input TIFFs. - Add a viewer for examining the differences between rendered output and golden images. - The viewer consists of a simple server of web API endpoints for querying difference results (along with rendered images in TIFF). - And a web-based (html + lit-element) UI for looking at the rendered images and differences.
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
// Copyright (C) 2025 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 { LitElement, html, css } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js";
|
||||
import './tools.js';
|
||||
import './tiff-viewer.js';
|
||||
|
||||
function getGoldenUrl(testResult) {
|
||||
return '/g/' + testResult.name;
|
||||
}
|
||||
|
||||
function getCompUrl(testResult) {
|
||||
return '/c/' + testResult.name;
|
||||
}
|
||||
|
||||
function getDiffUrl(testResult) {
|
||||
return '/d/' + testResult.diff;
|
||||
}
|
||||
|
||||
const DIFF_VIEW = 'diff'
|
||||
const GOLDEN_VIEW = 'golden'
|
||||
const RENDERED_VIEW = 'rendered'
|
||||
|
||||
class ExpandedPassedResult extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
content: { type: Object, attribute: 'content' },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.content = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.content) {
|
||||
const url = getGoldenUrl(this.content);
|
||||
return html`<tiff-viewer fileurl="${url}"></tiff-viewer>`;
|
||||
}
|
||||
return html``;
|
||||
}
|
||||
}
|
||||
customElements.define('expanded-passed-result', ExpandedPassedResult);
|
||||
|
||||
class ExpandedFailedResult extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
content: { type: Object, attribute: 'content' },
|
||||
leftViewSelected: { type: String },
|
||||
rightViewSelected: { type: String },
|
||||
};
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.viewer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.viewer {
|
||||
margin: 0 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.content = null;
|
||||
this.leftViewSelected = GOLDEN_VIEW;
|
||||
this.rightViewSelected = DIFF_VIEW;
|
||||
this.addEventListener('radio-change', (ev) => {
|
||||
if (ev.detail.radioId == 'right') {
|
||||
this.rightViewSelected = ev.detail.value;
|
||||
}
|
||||
if (ev.detail.radioId == 'left') {
|
||||
this.leftViewSelected = ev.detail.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_viewer(name, choices, current) {
|
||||
const url = {
|
||||
[GOLDEN_VIEW]: getGoldenUrl,
|
||||
[RENDERED_VIEW]: getCompUrl,
|
||||
[DIFF_VIEW]: getDiffUrl,
|
||||
}[current](this.content);
|
||||
return html`
|
||||
<div>
|
||||
<radio-button-group id="${name}" .choices=${choices} value=${current}></radio-button-group>
|
||||
<tiff-viewer class="viewer" fileurl="${url}"></tiff-viewer>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.content) {
|
||||
return html``;
|
||||
}
|
||||
const v1 = this._viewer("left", [GOLDEN_VIEW, RENDERED_VIEW], this.leftViewSelected);
|
||||
const v2 = this._viewer("right", [RENDERED_VIEW, DIFF_VIEW], this.rightViewSelected);
|
||||
return html`
|
||||
<div class="viewer-container">
|
||||
${v1}
|
||||
${v2}
|
||||
</div>
|
||||
`;
|
||||
return html``;
|
||||
}
|
||||
}
|
||||
customElements.define('expanded-failed-result', ExpandedFailedResult);
|
||||
|
||||
class App extends LitElement {
|
||||
static styles = css`
|
||||
.test-label {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.test-item {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid black;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
align-items: center;
|
||||
margin: 5px;
|
||||
}
|
||||
.test-item:hover {
|
||||
box-shadow: 2px 3px 2px 0px rgba(40, 40, 40, .5);
|
||||
cursor: pointer;
|
||||
}
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.results {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.results-container {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid black;
|
||||
border-radius: 10px;
|
||||
background: #cbf1c9;
|
||||
padding: 5px;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.container-label {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.failed-tiffs {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.tiff-wrapper {
|
||||
margin: 0 4px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.tiff-wrapper-label {
|
||||
margin: 4px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
.dialog-header {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
tests: {type: Array},
|
||||
dialogContent: {type: Object},
|
||||
};
|
||||
|
||||
async _init() {
|
||||
const config = await ((await fetch("/r/")).json());
|
||||
config['results'] = config['results'].sort((a, b) => a.name < b.name ? -1 : (a.name > b.name ? 1 : 0));
|
||||
this.tests = config['results']
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.tests = [];
|
||||
this.dialogContent = null;
|
||||
this._init();
|
||||
|
||||
this.addEventListener('dialog-closed', () => {
|
||||
this.dialogContent = null;
|
||||
});
|
||||
}
|
||||
|
||||
updated(props) {
|
||||
if (props.has('dialogContent')) {
|
||||
let dialog = this.shadowRoot.querySelector("#dialog");
|
||||
dialog.open = !!this.dialogContent;
|
||||
}
|
||||
}
|
||||
|
||||
_onClick(testDetail) {
|
||||
this.dialogContent = testDetail;
|
||||
this.shadowRoot.querySelector("#dialog").open = true;
|
||||
}
|
||||
|
||||
render() {
|
||||
let passed = this.tests.filter((t) => t.result == 'ok');
|
||||
let failed = this.tests.filter((t) => t.result != 'ok');
|
||||
const singleTiff = (url) => {
|
||||
return html`<tiff-viewer style="max-width:100px" fileurl="${url}"></tiff-viewer>`;
|
||||
};
|
||||
const buildTiffs = (ts) => ts.map((t) => {
|
||||
const printName = t.name.replace('.tif', '');
|
||||
const tiff = singleTiff(getGoldenUrl(t));
|
||||
return html`
|
||||
<div class="test-item" @click="${()=>this._onClick(t)}">
|
||||
<span class="test-label">${printName}</span>
|
||||
${tiff}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
const buildFailedTiffs = (ts) => ts.map((t) => {
|
||||
const printName = t.name.replace('.tif', '');
|
||||
const goldenUrl = getGoldenUrl(t);
|
||||
const compUrl = getCompUrl(t);
|
||||
const diffUrl = getDiffUrl(t);
|
||||
const wrap = (tif, label) => {
|
||||
return html`
|
||||
<div class="tiff-wrapper">
|
||||
${tif}
|
||||
<div class="tiff-wrapper-label">${label}</div>
|
||||
</div>`;
|
||||
}
|
||||
const tiffs = [
|
||||
[goldenUrl, 'golden'],
|
||||
[compUrl, 'rendered'],
|
||||
[diffUrl, 'diff']
|
||||
].map((a) => [singleTiff(a[0]), a[1]])
|
||||
.map((a) => wrap(...a));
|
||||
return html`
|
||||
<div class="test-item" @click="${()=>this._onClick(t)}">
|
||||
<span class="test-label">${printName}</span>
|
||||
<div class="failed-tiffs">
|
||||
${tiffs}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
const passedTiffs = buildTiffs(passed);
|
||||
const failedTiffs = buildFailedTiffs(failed);
|
||||
|
||||
let dialogSlot = null;
|
||||
let dialogHeader = '';
|
||||
if (this.dialogContent) {
|
||||
dialogSlot = (() => {
|
||||
if (this.dialogContent.result == 'ok') {
|
||||
return html`<expanded-passed-result .content=${this.dialogContent}></expanded-passed-result>`;
|
||||
}
|
||||
return html`<expanded-failed-result .content=${this.dialogContent}></expanded-failed-result>`;
|
||||
})();
|
||||
dialogHeader = this.dialogContent.name;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="app">
|
||||
<div class="results-container" style="background:#f5a5a4">
|
||||
<span class="container-label">Failed</span>
|
||||
<div class="results">
|
||||
${failedTiffs}
|
||||
</div>
|
||||
</div>
|
||||
<div class="results-container">
|
||||
<span class="container-label">Passed</span>
|
||||
<div class="results">
|
||||
${passedTiffs}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<modal-dialog id="dialog">
|
||||
<div class="dialog-header" slot="header">${dialogHeader}</div>
|
||||
${dialogSlot}
|
||||
</modal-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('diff-app', App);
|
||||
@@ -0,0 +1,36 @@
|
||||
<!--
|
||||
Copyright (C) 2025 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.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Diff Viewer</title>
|
||||
<script type="module" src="./app.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.min.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700" rel="stylesheet">
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: "Open Sans";
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<diff-app></diff-app>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (C) 2025 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 { LitElement, html, css } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js";
|
||||
|
||||
// Generated by Gemini with some modifications
|
||||
export class TiffViewer extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
canvas {
|
||||
border: 1px solid #ccc;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
fileurl: {type: String, attribute: 'fileurl'},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.fileurl = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<canvas id="tiffCanvas"></canvas>`;
|
||||
}
|
||||
|
||||
updated(props) {
|
||||
if (props.has('fileurl') && this.fileurl) {
|
||||
this._updateImage(this.fileurl);
|
||||
}
|
||||
}
|
||||
|
||||
async _updateImage(fileurl) {
|
||||
const fileblob = await ((await fetch(this.fileurl)).arrayBuffer());
|
||||
const canvas = this.shadowRoot.getElementById('tiffCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
try {
|
||||
const arrayBuffer = fileblob;
|
||||
const ifds = UTIF.decode(arrayBuffer); // Parse TIFF data
|
||||
|
||||
if (!ifds || ifds.length === 0) {
|
||||
console.error('Could not decode TIFF file. No image data found.');
|
||||
this._clearCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
// By default, render the first image in the TIFF (IFD)
|
||||
const firstImage = ifds[0];
|
||||
UTIF.decodeImage(arrayBuffer, firstImage, ifds); // Decode the actual pixel data
|
||||
|
||||
const rgba = UTIF.toRGBA8(firstImage); // Convert to RGBA
|
||||
canvas.width = firstImage.width;
|
||||
canvas.height = firstImage.height;
|
||||
|
||||
// The default mode would set alpha to 1 so that RGB differences would be displayed as non-transparent
|
||||
for (let i = 0; i < firstImage.width; i++) {
|
||||
for (let j = 0; j < firstImage.height; j++) {
|
||||
rgba[(j * firstImage.width * 4) + (i * 4) + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = new ImageData(new Uint8ClampedArray(rgba), firstImage.width, firstImage.height);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
} catch (error) {
|
||||
console.error('Error processing TIFF file:', error);
|
||||
this._clearCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
_clearCanvas() {
|
||||
const canvas = this.shadowRoot.getElementById('tiffCanvas');
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tiff-viewer', TiffViewer);
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (C) 2025 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 { LitElement, html, css } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js";
|
||||
|
||||
// Generated by Gemini
|
||||
export class RadioButtonGroup extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
.radio-group-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
label:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
input[type="radio"] {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
/* Custom radio button appearance */
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 50%;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
input[type="radio"]:checked {
|
||||
border-color: #656565;
|
||||
background-color: #656565; /* Optional: fill color when checked */
|
||||
}
|
||||
input[type="radio"]:checked::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 3px; /* Adjust to center the dot */
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input[type="radio"]:focus {
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
.label-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
`;
|
||||
static properties = {
|
||||
/**
|
||||
* An array of strings representing the choices for the radio buttons.
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
choices: { type: Array },
|
||||
|
||||
/**
|
||||
* The name for the radio button group. This is important for accessibility
|
||||
* and ensuring only one radio button in the group can be selected.
|
||||
* @type {string}
|
||||
*/
|
||||
name: { type: String },
|
||||
|
||||
/**
|
||||
* The currently selected value.
|
||||
* @type {string}
|
||||
*/
|
||||
value: { type: String, reflect: true },
|
||||
|
||||
/**
|
||||
* The label or title for the radio group.
|
||||
* @type {string}
|
||||
*/
|
||||
groupLabel: { type: String }
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.choices = [];
|
||||
this.name = 'radio-group'; // Default name
|
||||
this.value = '';
|
||||
this.groupLabel = '';
|
||||
}
|
||||
|
||||
_handleChange(event) {
|
||||
const selectedValue = event.target.value;
|
||||
if (this.value !== selectedValue) {
|
||||
this.value = selectedValue;
|
||||
// Dispatch a custom event with the new value
|
||||
this.dispatchEvent(new CustomEvent('radio-change', {
|
||||
detail: { value: this.value, radioId: this.id },
|
||||
bubbles: true, // Allows the event to bubble up through the DOM
|
||||
composed: true // Allows the event to cross shadow DOM boundaries
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="radio-group-container" role="radiogroup" aria-labelledby="group-label">
|
||||
${this.groupLabel ? html`<span id="group-label" class="group-label">${this.groupLabel}</span>` : ''}
|
||||
${this.choices.map(choice => html`
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name=${this.name}
|
||||
.value=${choice}
|
||||
.checked=${choice === this.value}
|
||||
@change=${this._handleChange}
|
||||
>
|
||||
<span class="label-text">${choice}</span>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('radio-button-group', RadioButtonGroup);
|
||||
|
||||
// Generated by Gemini with some modifications
|
||||
class ModalDialog extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: none; /* Hidden by default */
|
||||
}
|
||||
|
||||
:host([open]) {
|
||||
display: block; /* Show when open attribute is present */
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent black */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000; /* Ensure it's on top */
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
min-width: 300px; /* Or your desired width */
|
||||
z-index: 1001; /* Above the backdrop */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0 15px;
|
||||
}
|
||||
`;
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
open: { type: Boolean, reflect: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
_handleBackdropClick(event) {
|
||||
// Close only if the click is directly on the backdrop, not on the dialog itself
|
||||
if (event.target === this.shadowRoot.querySelector('.backdrop')) {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new CustomEvent('dialog-closed', { bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
|
||||
_handleDialogClick(event) {
|
||||
// Prevent clicks inside the dialog from bubbling up to the backdrop
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.open) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click="${this._handleBackdropClick}">
|
||||
<div class="dialog" @click="${this._handleDialogClick}">
|
||||
<slot name="header"><h2>Default Header</h2></slot>
|
||||
<slot>
|
||||
<p>This is the default content of the modal.</p>
|
||||
</slot>
|
||||
<slot name="footer">
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('modal-dialog', ModalDialog);
|
||||
Reference in New Issue
Block a user