chromium/tools/visual_debugger/app.html

<!DOCTYPE html>
<html lang="en">
<!--
 Copyright 2022 The Chromium Authors
 Use of this source code is governed by a BSD-style license that can be
 found in the LICENSE file.
  -->

<head>
  <style>
    #scrubberframe::-webkit-slider-thumb {
      appearance: none;
      width: 20px;
      height: 20px;
      background-color: grey;
    }

    #scrubberframe {
      margin-top: 10px;
      flex-grow: 1;
      appearance: none;
      background-color: #f0f0f0;
      width: 100%;
    }

    .scrubber::-webkit-slider-thumb {
      appearance: none;
      width: 20px;
      height: 20px;
      background-color: grey;
    }

    .minMaxScrubber {
      width: 100%;
      display: flex;
    }

    .minMaxScrubber > .scrubber {
      appearance: none;
      background-color: #f0f0f0;
      margin: 0px;
      flex-grow: 1;
      flex-basis: 20px; /* width of slider-thumb */
      min-width: 0px;
    }

    #url {
      font-family: monospace;
      font-size: smaller;
    }

    #controls>#buttons {
      display: flex;
    }

    #log {
      min-height: 100px;
      max-height: 100%;
      font-family: monospace;
      overflow: auto;
    }

    #connectionPanel,
    #saveload {
      display: inline-block;
    }

    #connectionPanel,
    #saveload,
    #topPanel {
      padding-bottom: 10px;
    }

    #topPanel {
      display: flex;
    }

    #settings {
      display: flex;
      flex-direction: column;
      font-size: small;
    }

    .panelSection {
      margin-right: 20px;
    }

    .panelSection:last-child {
      margin-right: 0px;
      flex-grow: 1;
    }

    #connection-status {
      color: limegreen;
      font-size: large;
      padding-right: 5px;
    }

    #connection-status.disconnected {
      color: orange;
    }
  </style>
  <link href="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css" rel="stylesheet">
  <script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.js"></script>
  <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined">
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" />

  <link rel='stylesheet' href='style.css'>
  <script src='filter.js'></script>
  <script src='filter-ui.js'></script>
  <script src='frame.js'></script>
  <script src='connection.js'></script>
  <script src='thread.js'></script>
  <script src='thread-ui.js'></script>
</head>

<div id='connectionPanel'>
  <div class='sectionTitle'>
    <font class='disconnected' id='connection-status'>&#9679;</font>Connection
  </div>
  <div class='section'>
    <div title="Expert usage only. Autoconnect should provide the dev tools websocket through /json/version discovery.">
      <input id='url' name='url' size=60 type="text"
        placeholder='WebSocket URL or leave empty for autoconnect...'></input>
    </div>
    <button class="mdc-button mdc-button--outline" id='connect'>
      <div class="mdc-button__ripple"></div>
      <span class="mdc-button__label">Connect</span>
    </button>
    <button class="mdc-button mdc-button--outline" id='disconnect' disabled=true>
      <div class="mdc-button__ripple"></div>
      <span class="mdc-button__label">Disconnect</span>
    </button>
    <input type="checkbox" id="autoconnect" name="autoconnect" checked="true">
    <label for="autoconnect" style="font-size:small; font:Roboto" > Autoconnect</label><br>
  </div>
</div>

<div id='saveload'>
  <div class='sectionTitle'>Save/Load ...</div>
  <div class='section'>
    <button id='demo' class="mdc-button mdc-button--outline">
      <div class="mdc-button__ripple"></div>
      <span class="mdc-button__label">Load demo data</span>
    </button>
    <button id='savedata' class="mdc-button mdc-button--outline"
      title="Serializes current session stream to json file.">
      <div class="mdc-button__ripple"></div>
      <span class="mdc-button__label">Save to disk</span>
    </button>
    <button id='loaddata' class="mdc-button mdc-button--outline"
      title="Deserializes json file of previous session and imports these frames into the App.">
      <div class="mdc-button__ripple"></div>
      <span class="mdc-button__label">Load from disk</span>
    </button>
  </div>
</div>

<div id='logsPanel'>
  <div class='panelSection collapsible'>
    <div class='sectionTitle'>
      Logs
      [<span class="plus">+</span><span class="minus">-</span>]
      <!--input size=40 placeholder='Comma separated filter ...'>(TODO)</input-->
    </div>
    <div class='section'>
      <div id='log'></div>
    </div>
  </div>
</div>

<div class='panelSection collapsible'>
  <div class='sectionTitle'
    title="Filter debug data stream. Filter operations occur in a left to right order with first match being applied.">
    <i class="material-icons-outlined">filter_list</i> Filters
    [<span class="plus">+</span><span class="minus">-</span>]
  </div>
  <div class='section'>
    <div id='filters' class="mdc-chip-set mdc-chip-set--filter" role="grid"
      title="Filter debug data stream. Filter operations occur in a left to right order with first match being applied.">
    </div>
    <button class="mdc-button mdc-button--outline" id='createfilter'>
      <div class="mdc-button__ripple"></div>
      <span class="mdc-button__label"><i class="material-icons-outlined">add_box</i> Add new filter</span>
    </button>
  </div>
</div>

<div class='panelSection collapsible'>
  <div class='sectionTitle'>
    <i class="material-symbols-outlined">
      airwave
    </i>
    Threads
    [<span class="plus">+</span><span class="minus">-</span>]
  </div>

  <div id='threads' class='section'></div>
</div>

<div class='panelSection collapsible'>
  <div class='sectionTitle'>
    Viewer Controls
    [<span class="plus">+</span><span class="minus">-</span>]
  </div>
  <div class='section' id='controls'>
    <div id='buttons'>
      <button class="mdc-button mdc-button--outline" id='prev'>
        <span class="mdc-button__label"><i class="material-icons-outlined">skip_previous</i> Previous frame</span>
      </button>
      <button class="mdc-button mdc-button--outline" id='play'>
        <div class="mdc-button__ripple"></div>
        <span class="mdc-button__label"><i class="material-icons-outlined">play_circle_outline</i> Play</span>
      </button>
      <button class="mdc-button mdc-button--outline" id='pause'>
        <div class="mdc-button__ripple"></div>
        <span class="mdc-button__label"><i class="material-icons-outlined">pause_circle_outline</i> Pause</span>
      </button>
      <button class="mdc-button mdc-button--outline" id='next'>
        <span class="mdc-button__label">Next frame <i class="material-icons-outlined">skip_next</i></span>
      </button>
      <button class="mdc-button mdc-button--raised" id='live'>
        <div class="mdc-button__ripple"></div>
        <span class="mdc-button__label">Live <i class="material-icons-outlined">fast_forward</i></span>
      </button>

    </div>
    <div>
      <div class='sectionTitle'>Frame Selection</div>
      <input type='range' min='0' max='0' value='0' id='scrubberframe'></input>
    </div>
    <div>
      <div class='sectionTitle'>Draw Selection<span id="drawRange"></span></div>
      <div class="minMaxScrubber">
        <input type="range" id='minDrawScrubber' class="scrubber"
            min="0" max="0" value="0" step="1"></input>
        <input type="range" id='maxDrawScrubber' class="scrubber"
            min="0" max="1" value="1" step="1"></input>
      </div>
    </div>
  </div>
</div>

<div class="panelSection collapsible">
  <div class='sectionTitle'>
    Viewer
    [<span class="plus">+</span><span class="minus">-</span>]
  </div>
  <div style='float: right' class='sectionTitle'>
    Scale
    <select id="viewerscale">
      <option id="100pct">100%</option>
      <option id="50pct">50%</option>
      <option id="200pct">200%</option>
      <option id="freeCam" disabled>Free Camera</option>
    </select>
  </div>

  <div style='float: right' class='sectionTitle'>
  Orientation
    <select id="viewerorientation">
      <option id="0deg">0 deg clockwise</option>
      <option id="90deg">90 deg clockwise</option>
      <option id="180deg">180 deg clockwise</option>
      <option id="270deg">270 deg clockwise</option>
      <option id="hFlip">Horizontal Flip</option>
      <option id="vFlip">Vertical Flip</option>
    </select>
  </div>

  <div class='section'>
    <!--We need to fix viewer size to avoid scroll position change when
      multiple displays are present. crbug.com/1358526-->
    <div style="height:4000px;  border: 1px dotted gray; background-color: #f0f0f0">
        <canvas id='canvas' style="top :0px"></canvas>
    </div>
  </div>

  <div class='modalContainer'></div>
</div>

<div id='importtracing'>
  <div class='sectionTitle'>Tracing (Prototype)...</div>
  <div class='section'>
    <button id='importtracebutton' class="mdc-button mdc-button--outline"
      title="Import tracing data (json) into visual debugger app.">
      <div class="mdc-button__ripple"></div>
      <span class="mdc-button__label">Import Trace</span>
    </button>
  </div>
</div>

<script>
  function processIncomingFrame(json) {
    if (!json) return;

    new DrawFrame(json);
    Player.instance.onNewFrame();
  }

  async function testAnimate() {
    const f = await fetch('demo.json');
    const text = await f.text();
    const json = JSON.parse(text);
    for (const frame of json) {
      processIncomingFrame(frame);
    }
  }

  async function saveDemoDataToDisk() {
    const text = JSON.stringify(DrawFrame.frameBuffer.instances.map(d => d.toJSON()));
    const blob = new Blob([text], { type: 'text/plain' });
    const link = document.createElement('a');
    link.download = 'cvd-stream.json';
    link.href = window.URL.createObjectURL(blob);
    link.click();
  }

  window.onload = function () {

    Connection.initialize();

    const addFilterButton = document.querySelector('#createfilter');
    addFilterButton.addEventListener('click', () => {
      showCreateFilterPopup(addFilterButton);
    });

    const container = document.querySelector('.modalContainer');
    container.addEventListener('click', (event) => {
      if (event.target == container)
        hideModal();
    });

    const demo = document.querySelector('#demo');
    demo.addEventListener('click', testAnimate);

    const savedata = document.querySelector('#savedata');
    savedata.addEventListener('click', saveDemoDataToDisk);

    const loaddata = document.querySelector('#loaddata');
    loaddata.addEventListener('click', () => {
      const f = document.createElement('input');
      f.type = 'file';
      f.addEventListener('change', () => {
        const file = new FileReader(f.files[0]);
        file.addEventListener('load', () => {
          const json = JSON.parse(file.result);
          for (const frame of json) {
            processIncomingFrame(frame);
          }
        });
        file.readAsText(f.files[0]);
      });
      f.click();
    });

    const importtracedata = document.querySelector('#importtracebutton');
    importtracedata.addEventListener('click', () => {
      const f = document.createElement('input');
      f.type = 'file';
      f.addEventListener('change', () => {
        async function handleBlob(blob) {
          if (blob.type === 'application/x-gzip') {
            // If the blob is gzipped, decompress it and recurse.
            const ds = new DecompressionStream("gzip");
            const decompressedBlob = blob.stream().pipeThrough(ds);
            handleBlob(await new Response(decompressedBlob).blob());
          } else {
            try {
              const json = JSON.parse(await blob.text());
              handleImportTraceJson(json);
            } catch (e) {
              if (e instanceof SyntaxError) {
                // Not all JSON blobs have the right mimetype, so we just try
                // and fail to check.
                alert("Not valid JSON: " + blob.message);
              } else {
                throw e;
              }
            }
          }
        }

        handleBlob(f.files[0]);
      });
      f.click();

      function handleImportTraceJson(json) {
        const traceEvents = json.traceEvents;

        curr_frame = "0";
        curr_draws = [];
        sources_this_frame = []
        global_sources_mapping = []
        // Get the source index for an annotation, updating `global_sources_mapping` as needed.
        let getSourceIndex = (anno) => {
          let found_source_index = global_sources_mapping.indexOf(anno);
          if (found_source_index === -1) {
            global_sources_mapping.push(anno);
            found_source_index = global_sources_mapping.length - 1;
            sources_this_frame.push({
              "anno": anno,
              "file": "none",
              "func": "none",
              "index": found_source_index,
              "line": -1,
            });
          }
          return found_source_index;
        };

        threads_this_frame = new Set();
        global_threads = {};
        global_processes = {};
        // Return a faux thread ID that also includes the process ID.
        // VizDebugger only tracks a thread ID, but we know the process ID as well in this case.
        let trackThreadAndProcessId = (event) => {
          // We're assuming the thread ID is not going to exceed u16.
          let thread_id = (event.pid * 65536) + event.tid;
          threads_this_frame.add({
            "thread_id": thread_id,
            "thread_name": `${global_processes[event.pid]}/${global_threads[event.tid]}`,
          })
          return thread_id;
        }
        let resolveThreadNamesAndResetThreadIdsThisFrame = () => {
          let threads = [];
          for (const thread of threads_this_frame) {
            threads.push(thread);
          }
          threads_this_frame.clear();
          return threads;
        };


        for (const event of traceEvents) {
          if (event.name === "thread_name") {
            global_threads[event.tid] = event.args.name;
          } else if (event.name === "process_name") {
            global_processes[event.pid] = event.args.name;
          } else if (event.cat.includes("viz.visual_debugger")) {
            if (event.name == "visual_debugger_sync") {
              single_frame = { "drawcalls": [], "frame": "none", "logs": [], "new_sources": [], "time": "0", "version": 1, "windowx": 2400, "windowy": 1600, "threads": [{ "thread_id": "1", "thread_name": "allthreads" }] };
              single_frame.drawcalls = curr_draws;
              curr_frame = event.args.last_presented_trace_id;
              single_frame.frame = curr_frame;
              single_frame.new_sources = sources_this_frame;
              single_frame.windowx = parseInt(event.args.display_size.split("x")[0]);
              single_frame.windowy = parseInt(event.args.display_size.split("x")[1]);
              processIncomingFrame(single_frame);
              curr_draws = [];
              sources_this_frame = [];
              threads_this_frame.clear();
            }
            else {
              const single_call = { "drawindex": curr_draws.length, "option": { "alpha": 5, "color": "#ff0000" }, "pos": [-1, -1], "size": [-1, -1], "source_index": -1, "thread_id": 1, "buff_id": -1 };

              single_call.pos[0] = parseFloat(event.args.args.pos_x);
              single_call.pos[1] = parseFloat(event.args.args.pos_y);
              single_call.size[0] = parseFloat(event.args.args.size_x);
              single_call.size[1] = parseFloat(event.args.args.size_y);
              single_call.text = event.args.args.text;
              single_call.source_index = getSourceIndex(event.name);
              curr_draws.push(single_call);
            }
          } else if (event.cat.includes("viz.quads")) {
            if (event.name === "cc::LayerTreeHostImpl") {
              let draw_calls = [];
              let logs = [];

              let render_pass_count = event.args.snapshot.frame.render_passes.length;
              for (let i = 0; i < render_pass_count; i++) {
                let render_pass = event.args.snapshot.frame.render_passes[i];

                logs.push({
                  "source_index": getSourceIndex("frame.render_pass.meta"),
                  "drawindex": draw_calls.length + logs.length,
                  "option": { "alpha": 0, "color": "#0000ff" },
                  "thread_id": trackThreadAndProcessId(event),
                  "value": `Render pass id=${render_pass.id}, output_rect=${render_pass.output_rect}, damage_rect=${render_pass.damage_rect}, quad_list.size=${render_pass.quad_list.length}, copy_requests=${render_pass.copy_requests}`,
                });

                if (i < render_pass_count - 1) {
                  // Skip non-root render pass quads to reduce visual noise.
                  continue;
                }

                draw_calls.push({
                  "source_index": getSourceIndex("frame.render_pass.output_rect"),
                  "drawindex": draw_calls.length + logs.length,
                  "option": { "alpha": 5, "color": "#000000" },
                  "pos": [render_pass.output_rect[0], render_pass.output_rect[1]],
                  "size": [render_pass.output_rect[2], render_pass.output_rect[3]],
                  "thread_id": trackThreadAndProcessId(event),
                  "buff_id": -1,
                });

                draw_calls.push({
                  "source_index": getSourceIndex("frame.render_pass.damage"),
                  "drawindex": draw_calls.length + logs.length,
                  "option": { "alpha": 5, "color": "#000000" },
                  "pos": [render_pass.damage_rect[0], render_pass.damage_rect[1]],
                  "size": [render_pass.damage_rect[2], render_pass.damage_rect[3]],
                  "thread_id": trackThreadAndProcessId(event),
                  "buff_id": -1,
                });

                for (let quad of render_pass.quad_list) {
                  draw_calls.push({
                    "source_index": getSourceIndex("frame.render_pass.quad"),
                    "drawindex": draw_calls.length + logs.length,
                    "option": { "alpha": 5, "color": "#000000" },
                    "pos": [
                      quad.rect_as_target_space_quad[0],
                      quad.rect_as_target_space_quad[1],
                    ],
                    "size": [
                      quad.rect_as_target_space_quad[2] - quad.rect_as_target_space_quad[0],
                      quad.rect_as_target_space_quad[5] - quad.rect_as_target_space_quad[1],
                    ],
                    "thread_id": trackThreadAndProcessId(event),
                    "buff_id": -1,
                  });

                  draw_calls.push({
                    "source_index": getSourceIndex("frame.render_pass.material"),
                    "drawindex": draw_calls.length + logs.length,
                    "option": { "alpha": 0, "color": "#00ff00" },
                    "pos": [
                      quad.rect_as_target_space_quad[0],
                      quad.rect_as_target_space_quad[1],
                    ],
                    "size": [0, 0],
                    "text": `${quad.material}`,
                    "thread_id": trackThreadAndProcessId(event),
                    "buff_id": -1,
                  });
                }
              }

              // This event contains a snapshot of the layer tree, so we can process a full frame immediately.
              processIncomingFrame({
                "drawcalls": draw_calls,
                "frame": event.tts,
                "logs": logs,
                "new_sources": sources_this_frame,
                "time": event.ts,
                "version": 1,
                "windowx": event.args.snapshot.device_viewport_size.width,
                "windowy": event.args.snapshot.device_viewport_size.height,
                "threads": resolveThreadNamesAndResetThreadIdsThisFrame(),
              });
              sources_this_frame = [];
            }
          }
        }
      }
    });

    document.querySelectorAll('.panelSection.collapsible').forEach(
        (section) => {
          let title = section.querySelector('.sectionTitle');
          title.addEventListener('click', () => {
            section.classList.toggle('collapsed');
          });
        });

    setUpPlayer();
    restoreFilters();
  }

  /**
   * Gets value from localStorage if it exists, and sets the <option> with that
   * id to be selected in options_el.
   * @param {string} key: The localStorage key to get if present.
   * @param {Element} options_el: The <select> Element to be updated.
   * @returns {boolean} Whether a value from localStorage was used to change the
   *     the selected option.
   */
  function getStoredOptionsValue(key, options_el) {
    let storedId = localStorage.getItem(key);
    if (storedId != null) {
      let option = options_el.namedItem(storedId);
      if (option == null) {
        console.error(`No option with id ${storedId} found on ${options_el.id}`);
        localStorage.removeItem(key);
      } else {
        options_el.selectedIndex = option.index;
        return true;
      }
    }

    return false;
  }

  function updateDrawScrubberSizes(minIndex, maxIndex, nDraws) {
    const scrubberMin = document.querySelector('#minDrawScrubber');
    const scrubberMax = document.querySelector('#maxDrawScrubber');
    const drawRange = document.querySelector('#drawRange');

    // The point where the sliders meet will be halway between the two values.
    // This means the slider you select when clicking will always be the one
    // closest to your mouse.
    let mid = Math.floor((minIndex + maxIndex) / 2);
    scrubberMin.max = mid;
    scrubberMax.min = mid;
    // Update the size of each slider to its fraction of the total range.
    scrubberMin.style = `flex-grow: ${mid}`;
    scrubberMax.style = `flex-grow: ${nDraws - mid}`;
  }

  function setDrawScrubbers(minIndex, maxIndex, nDraws) {
    const scrubberMin = document.querySelector('#minDrawScrubber');
    const scrubberMax = document.querySelector('#maxDrawScrubber');
    const drawRange = document.querySelector('#drawRange');

    scrubberMax.max = nDraws;
    scrubberMin.value = minIndex;
    scrubberMax.value = maxIndex;
    drawRange.textContent = ` [${minIndex}, ${maxIndex})`;

    updateDrawScrubberSizes(minIndex, maxIndex, nDraws);
  }

  function updateFrameScrubber(oldest, newest, current) {
    const scrubberFrame = document.querySelector('#scrubberframe');
    scrubberFrame.min = oldest;
    scrubberFrame.max = newest;
    scrubberFrame.value = current;
  }

  function setUpPlayer() {
    // First, set up the viewer.
    const canvas = document.querySelector('#canvas');
    const logContainer = document.querySelector('#log');
    const viewer = new Viewer(canvas, logContainer);
    // Now create the player for the viewer.
    const player = new Player(viewer, (frame) => {
      updateFrameScrubber(
          DrawFrame.frameBuffer.oldestIndex(),
          DrawFrame.frameBuffer.newestIndex(),
          player.currentFrameIndex);
      setDrawScrubbers(
          frame.minIndex(), frame.maxIndex(), frame.submissionCount());
    });

    document.querySelector('#pause').addEventListener('click', () => {
      player.pause();
      pause.setAttribute('style', 'background:#000000;color:white');
    });

    document.querySelector('#play').addEventListener('click', () => {
      player.play();
      pause.removeAttribute('style');
    });

    document.querySelector('#prev').addEventListener('click', () => {
      player.rewind();
    });

    document.querySelector('#next').addEventListener('click', () => {
      player.forward();
    });

    document.querySelector('#live').addEventListener('click', () => {
      player.live();
      has_disconnected = false;
      pause.removeAttribute('style');
    });

    const scrubberFrame = document.querySelector('#scrubberframe');
    scrubberFrame.addEventListener('input', () => {
      player.freezeFrame(parseInt(scrubberFrame.value));
      pause.setAttribute('style', 'background:#000000;color:white');
    });

    const scrubberDrawMin = document.querySelector('#minDrawScrubber');
    const scrubberDrawMax = document.querySelector('#maxDrawScrubber');
    function drawScrubberChanged() {
      let min = parseInt(scrubberDrawMin.value);
      let max = parseInt(scrubberDrawMax.value);
      player.freezeFrame(parseInt(scrubberFrame.value), min, max);
      updateDrawScrubberSizes(min, max, parseInt(scrubberDrawMax.max));
    }

    scrubberDrawMin.addEventListener('input', () => {
      drawScrubberChanged();
    });
    scrubberDrawMax.addEventListener('input', () => {
      drawScrubberChanged()
    });

    let currentMouseX = 0;
    let currentMouseY = 0;
    let zoom = 100;
    const viewerScale = document.querySelector("#viewerscale");

    function scaleChanged() {
      let selected = viewerScale.selectedOptions[0];
      if (selected.id != "freeCam") {
        player.setViewerScale(viewerScale.value);
        zoom = parseInt(viewerScale.value);
      }
      else {
        player.setViewerScale(zoom);
        canvas.addEventListener('mouseenter', () => {
          canvas.addEventListener('mousemove', function (event) {
            currentMouseX = Math.round(event.offsetX);
            currentMouseY = Math.round(event.offsetY);
          });
        });
        canvas.addEventListener('wheel', function(e) {
          e.preventDefault();
          var delta = e.deltaY;

          viewer.zoomToMouse(currentMouseX, currentMouseY, delta);

        }, {passive:false});
      }
    }
    const scaleKey = "scale";
    if (getStoredOptionsValue(scaleKey, viewerScale)) {
      scaleChanged();
    }
    viewerScale.addEventListener('input', () => {
      scaleChanged();
      localStorage.setItem(scaleKey, viewerScale.selectedOptions[0].id);
    });

    const viewerOrientation = document.querySelector("#viewerorientation");

    function orientationChanged() {
      let selected = viewerOrientation.selectedOptions[0];
      // Change dropdown style when setting non-zero orientation.
      if (selected.id != '0deg') {
        viewerOrientation.classList.add("nonzero-orientation");
      } else {
        viewerOrientation.classList.remove("nonzero-orientation");
      }
      player.setViewerOrientation(viewerOrientation.value);
    }
    const orientationKey = 'orientation';
    if (getStoredOptionsValue(orientationKey, viewerOrientation)) {
      orientationChanged();
    }
    viewerOrientation.addEventListener('change', () => {
      orientationChanged();
      localStorage.setItem(orientationKey, viewerOrientation.selectedOptions[0].id);
    });

  }


  function showModal(element, focusSelector) {
    const container = document.querySelector('.modalContainer');
    container.appendChild(element);
    container.style.display = 'block';
    element.querySelector(focusSelector).focus();
  }

  function hideModal() {
    const container = document.querySelector('.modalContainer');
    container.style.display = 'none';
    container.textContent = '';
  }

</script>

</html>