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:
Powei Feng
2025-06-02 12:37:46 -07:00
committed by GitHub
parent 60036c75fe
commit cd22478e4f
11 changed files with 821 additions and 37 deletions

View File

@@ -4,26 +4,54 @@ import sys
import pprint
import json
from utils import execute, ArgParseImpl, important_print
from image_diff import same_image
from utils import execute, ArgParseImpl, important_print, mkdir_p
from image_diff import same_image, output_image_diff
from results import RESULT_OK, RESULT_FAILED, RESULT_MISSING
def _compare_goldens(base_dir, comparison_dir):
render_results = {}
base_files = glob.glob(os.path.join(base_dir, "./**/*.tif"))
for golden_file in base_files:
base_fname = os.path.abspath(golden_file)
test_case = base_fname.replace(f'{os.path.abspath(base_dir)}/', '')
comp_fname = os.path.abspath(os.path.join(comparison_dir, test_case))
if not os.path.exists(comp_fname):
print(f'file name not found: {comp_fname}')
render_results[test_case] = RESULT_MISSING
continue
if not same_image(base_fname, comp_fname):
render_results[test_case] = RESULT_FAILED
else:
render_results[test_case] = RESULT_OK
return render_results
def _compare_goldens(base_dir, comparison_dir, out_dir=None):
all_files = glob.glob(os.path.join(base_dir, "./**/*.tif"), recursive=True)
test_dirs = set(os.path.abspath(os.path.dirname(f)).replace(os.path.abspath(base_dir) + '/', '') \
for f in all_files)
all_results = []
for test_dir in test_dirs:
results_meta = {}
results = []
output_test_dir = None if not out_dir else os.path.join(out_dir, test_dir)
if output_test_dir:
mkdir_p(output_test_dir)
base_test_dir = os.path.abspath(os.path.join(base_dir, test_dir))
comp_test_dir = os.path.abspath(os.path.join(comparison_dir, test_dir))
results_meta['base_dir'] = base_test_dir
results_meta['comparison_dir'] = comp_test_dir
for golden_file in \
glob.glob(os.path.join(base_test_dir, "*.tif")):
base_fname = os.path.abspath(golden_file)
test_case = base_fname.replace(f'{base_test_dir}/', '')
comp_fname = os.path.join(comp_test_dir, test_case)
result = {
'name': test_case,
}
if not os.path.exists(comp_fname):
print(f'file name not found: {comp_fname}')
result['result'] = RESULT_MISSING
elif not same_image(base_fname, comp_fname):
result['result'] = RESULT_FAILED
if output_test_dir:
# just the file name
diff_fname = f"{test_case.replace('.tif', '_diff.tif')}"
output_image_diff(base_fname, comp_fname, os.path.join(output_test_dir, diff_fname))
result['diff'] = diff_fname
else:
result['result'] = RESULT_OK
results.append(result)
if output_test_dir:
results_meta['results'] = results
output_fname = os.path.join(output_test_dir, "compare_results.json")
with open(output_fname, 'w') as f:
f.write(json.dumps(results_meta, indent=2))
important_print(f'Written comparison results for {test_dir} to \n {output_fname}')
all_results += results
return all_results
if __name__ == '__main__':
parser = ArgParseImpl()
@@ -39,14 +67,9 @@ if __name__ == '__main__':
dest = os.path.join(os.getcwd(), './out/renderdiff_tests')
assert os.path.exists(dest), f"Destination folder={dest} does not exist."
results = _compare_goldens(args.src, dest)
results = _compare_goldens(args.src, dest, out_dir=args.out)
if args.out:
assert os.path.exists(arg.out), f"Output folder={dest} does not exist."
with open(os.path.join(args.out, "compare_results.json", 'w')) as f:
f.write(json.dumps(results))
failed = [f" {k}" for k in results.keys() if results[k] != RESULT_OK]
failed = [f" {k['name']}" for k in results if k['result'] != RESULT_OK]
success_count = len(results) - len(failed)
important_print(f'Successfully compared {success_count} / {len(results)} images' +
('\nFailed:\n' + ('\n'.join(failed)) if len(failed) > 0 else ''))

View File

@@ -13,7 +13,7 @@
# limitations under the License.
import tifffile
import numpy
import numpy as np
def same_image(tiff_file_a, tiff_file_b):
try:
@@ -27,7 +27,7 @@ def same_image(tiff_file_a, tiff_file_b):
return False
# numpy.array_equal() checks if two arrays have the same shape and elements.
if numpy.array_equal(img1_data, img2_data):
if np.array_equal(img1_data, img2_data):
return True
else:
return False
@@ -41,3 +41,27 @@ def same_image(tiff_file_a, tiff_file_b):
except Exception as e:
print(f"An unexpected error occurred: {e}")
return False
def output_image_diff(tiff_file_a, tiff_file_b, output_path):
try:
img1_data = tifffile.imread(tiff_file_a)
img2_data = tifffile.imread(tiff_file_b)
# If the dimensions (height, width, number of channels, number of pages/frames)
# are different, the images are not the same.
if img1_data.shape != img2_data.shape:
raise RuntimeError(f"Images {tiff_file_a} and {tiff_file_b} have different shapes: {img1_data.shape} vs {img2_data.shape}")
diff_img = np.abs(img1_data.astype(np.int16) - img2_data.astype(np.int16))
diff_img = diff_img.astype(np.uint8)
tifffile.imwrite(output_path, diff_img)
except FileNotFoundError:
print(f"Error: One or both files not found ('{file_path1}', '{file_path2}').")
return False
except tifffile.TiffFileError as e:
print(f"Error: One or both files are not valid TIFF files or could not be read. Details: {e}")
return False
except Exception as e:
print(f"An unexpected error occurred: {e}")
return False

View File

@@ -16,7 +16,9 @@
# Sets up the environment for scripts in test/renderdiff/
OUTPUT_DIR="$(pwd)/out/renderdiff_tests"
RENDER_OUTPUT_DIR="$(pwd)/out/renderdiff/renders"
DIFF_OUTPUT_DIR="$(pwd)/out/renderdiff/diffs"
GOLDEN_OUTPUT_DIR="$(pwd)/out/renderdiff/goldens"
RENDERDIFF_TEST_DIR="$(pwd)/test/renderdiff"
MESA_DIR="$(pwd)/mesa/out/"
VENV_DIR="$(pwd)/venv"
@@ -33,6 +35,7 @@ else
fi
function start_() {
mkdir -p ${RENDER_OUTPUT_DIR} ${DIFF_OUTPUT_DIR} ${GOLDEN_OUTPUT_DIR}
if [[ "$GITHUB_WORKFLOW" ]]; then
set -ex
fi

View File

@@ -102,7 +102,7 @@ if __name__ == "__main__":
vk_icd=args.vk_icd)
with open(f'{output_dir}/render_results.json', 'w') as f:
f.write(json.dumps(results))
f.write(json.dumps(results, indent=2))
shutil.copy2(args.test, f'{output_dir}/test.json')

View File

@@ -0,0 +1,81 @@
# 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 os
import sys
import flask
import pathlib
import json
from utils import ArgParseImpl
from flask import Flask, request, make_response, send_from_directory
DIR = pathlib.Path(__file__).parent.absolute()
HTML_DIR = os.path.join(DIR, "viewer_html")
def _create_app(config):
app = Flask(__name__)
client_config = config.copy()
base_dir = client_config['base_dir']
comparison_dir = client_config['comparison_dir']
diff_dir = client_config['diff_dir']
del client_config['base_dir']
del client_config['comparison_dir']
del client_config['diff_dir']
@app.route('/r/', methods=['GET'])
def get_r():
return json.dumps(client_config)
@app.route('/g/<path:filepath>', methods=['GET'])
def get_g(filepath):
return send_from_directory(base_dir, filepath)
@app.route('/d/<path:filepath>', methods=['GET'])
def get_d(filepath):
return send_from_directory(diff_dir, filepath)
@app.route('/c/<path:filepath>', methods=['GET'])
def get_c(filepath):
return send_from_directory(comparison_dir, filepath)
@app.route('/<path:filepath>')
def get_static_file(filepath):
return send_from_directory(HTML_DIR, filepath)
@app.route('/')
def get_index():
return send_from_directory(HTML_DIR, 'index.html')
app.url_map.strict_slashes = False
return app
if __name__ == '__main__':
PORT = 8901
parser = ArgParseImpl()
parser.add_argument('--diff', help='Diff directory', required=True)
args, _ = parser.parse_known_args(sys.argv[1:])
with open(os.path.join(args.diff, 'compare_results.json'), 'r') as f:
config = json.loads(f.read())
config['diff_dir'] = os.path.abspath(args.diff)
app = _create_app(config)
from waitress import serve
print(f'Point your browser to http://localhost:{PORT} to see the diff results')
serve(app, host="127.0.0.1", port=PORT)

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);