HTML Web 组件中的客户端元素大小调整问题

Client element resizing issue in HTML web component

最近,我正在实施一个轻量级 vanilla-JS 库,其中包含 HTML Web 组件,仅供 in-company 使用。

我在 JavaScript 中遇到有关 parent 容器中客户端元素大小调整的行为问题。

这是我 test-HTML-file 在小型测试场景中重现行为的方法:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Client resize behavior test in different container implementations</title>
  <style>
    * {
      position: relative;
      box-sizing: border-box;
    }
    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
    }
    .container {
      height: 400px;
      width: 600px;
      border: 3px solid black;
      background-color: lightgrey;
      overflow: visible;
    }
    .title {
      position: absolute;
    }
    .outer {
      height: 100%;
      width: 100%;
      padding: 20px;
      padding-top: 50px;
    }
    .inner {
      height: 100%;
      width: 100%;
      border: 3px solid blue;
      background-color: lightblue;
    }
    .client {
      position: absolute;
      border: 3px solid red;
      background-color: lightcoral;
      opacity: .5;
      height: 100%;
      width: 100%;
    }
    button {
      margin: 10px;
    }
  </style>
  <script type="module">
    customElements.define("test-container", class extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: "open" }).innerHTML = `
        <style>
          * {
            position: relative;
            box-sizing: border-box;
          }
          :host {
            contain: content;
            display: block;
          }
          .shadow-outer {
            height: 100%;
            width: 100%;
            padding: 20px;
            padding-top: 50px;
          }
          .shadow-inner {
            height: 100%;
            width: 100%;
            border: 3px solid blue;
            background-color: lightblue;
          }
        </style>
        <div style="position:absolute;">State-of-the-art HTML web component container with nested DIVS in the shadow-DOM</div>
        <div class="shadow-outer">
          <div class="shadow-inner">
            <slot>
            </slot>
          </div>
        </div>
      `;
      }
    });
    const setClientSizeToParentClientSize = (client, button) => {
      const parent = client.parentElement;
      client.style.position = "absolute";
      client.style.height = `${parent.clientHeight}px`;
      client.style.width = `${parent.clientWidth}px`;
      client.innerHTML += " resized";
      button.disabled = true;
    };
    document.getElementById("set-client1").addEventListener("click", function () {
      setClientSizeToParentClientSize(document.getElementById("client1"), this);
    });
    document.getElementById("set-client2").addEventListener("click", function () {
      setClientSizeToParentClientSize(document.getElementById("client2"), this);
    });
  </script>
</head>
<body>
  <div>
    <div class="container" id="container1">
      <div style="position:absolute;">Plain old light-DOM container with nested DIVs in the light-DOM</div>
      <div class="outer">
        <div class="inner">
          <div class="client" id="client1">Client 1</div>
        </div>
      </div>
    </div>
    <button id="set-client1">Set client 1 size in JavaScript</button>
  </div>
  <div>
    <test-container id="container2" class="container">
      <div class="client" id="client2">Client 2</div>
    </test-container>
    <button id="set-client2">Set client 2 size in JavaScript</button>
  </div>
</body>
</html>

我也创建了对应的JS fiddle.

容器包含两个嵌套的 DIV 元素以在容器的外部边界和内部(客户端)边界之间创建一种 hard-coded 边距。

当使用 JavaScript 通过按容器下方的调整大小按钮来调整客户端 (child) 元素时,HTML Web 组件实现的行为与经典 (light-DOM 仅)实施。

我认为这与JavaScript确定的parent元素有关。对于经典实现,客户端的 parent 将是内部的 DIV。但是对于HTML web component方法,好像是web component本身...

我可以在 JavaScript 中做些什么来让我的 HTML 网络组件的开槽 child 元素使用 JavaScript 关于它们 shadow-DOM 的(重新)大小parent 在 Web 组件中而不是 light-dom parent(作为 Web 组件本身)?

编辑:

我想我需要稍微澄清一下我的问题的背景。

我容器中的客户端将是可拖动的(使用拖动手柄元素,类似于标题栏)和可调整大小(使用调整手柄,如右下角的三角形)。

拖动和调整大小应选择性地绑定到容器的客户区(= 内部 DIV 的客户区)。如果 "bound" 选项为真,则不允许客户端跨越容器的(内部)边界。为此,拖动和调整大小行为的 mousemove 事件处理程序将需要根据容器的内部客户区域在客户边界上执行计算。

所有这些拖动和调整大小的逻辑已经到位,并且仅适用于经典 light-DOM 解决方案,但是当在 HTML Web 组件容器实现中为客户端元素实现此逻辑时,事件处理不识别 shadow-DOM 的内部 DIV 容器作为客户端的 parent 进行边界检查;它使用整个容器的客户区域。

在我的示例中,我试图尽可能地隔离和简化这个技术问题。

我示例中的客户端元素最初已经正确最大化到容器客户端区域的 100% 高度和 100% 宽度(使用指定的 CSS 类)。

我的测试示例中的按钮只是添加了一些具有绝对值的覆盖内联 CSS 样式,这应该会导致视觉上相同的 "maximized" 客户端大小。

这个逻辑似乎适用于普通的旧 light-DOM 解决方案,但不适用于 HTML 网络组件的 shadow-DOM 解决方案。在后一种情况下,JavaScript 调整大小逻辑不会分配 Web 组件的内部 DIV 的客户端宽度和 - 高度尺寸,而是分配整个 HTML Web 组件的客户端宽度和 - 高度尺寸,这过大,导致明显溢出

所以我需要更正按钮事件处理程序中的 JavaScript 逻辑,使新的 HTML Web 组件容器实现中的客户端能够正确调整大小:设置内联 CSS 绝对值不应导致任何视觉尺寸变化!

容器的实现和样式可能会动态变化,因此 JavaScript 解决方案不应依赖于容器的特定视觉 and/or 功能设计。

编辑 2:

为了更加清楚,我想在此处包含一个更准确地模仿我的实际应用程序的代码示例。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Draggable and resizable client in a custom container element</title>
  <style>
    * {
      position: relative;
      box-sizing: border-box;
    }
    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
    }
    .container {
      height: 80%;
      width: 80%;
      border: 3px solid black;
      background-color: lightgrey;
      overflow: visible;
    }
    .outer {
      height: 100%;
      width: 100%;
      padding: 20px;
      padding-top: 50px;
    }
    .inner {
      height: 100%;
      width: 100%;
      border: 3px solid blue;
      background-color: lightblue;
    }
    .client {
      position: absolute;
      border: 3px solid red;
      background-color: lightcoral;
      opacity: .5;
      height: 30%;
      width: 30%;
      min-height: 2rem;
      min-width: 4rem;
    }
    .title {
      background-color: firebrick;
      color: lightyellow;
      cursor: move;
    }
    button {
      margin: 10px;
    }
  </style>
  <script type="module">
    customElements.define("resize-handle", class extends HTMLElement {
      constructor() {
        super();

        this.attachShadow({ mode: "open" }).innerHTML = `
          <style>
            :host {
              display: block;
              contain: content;
              position: absolute !important;
              right: 0 !important;
              bottom: 0 !important;
              top: unset !important;
              left: unset !important;
              width: 0;
              height: 0;
              border: 0;
              border-left: 1rem solid transparent;
              border-bottom: 1rem solid rgba(255, 255, 255, .2);
              cursor: nw-resize;
              z-index: 1;
            }
            :host(.move) {
              top: 0 !important;
              left: 0 !important;
              width: unset !important;
              height: unset !important;
              border: 0;
              background: rgba(255, 255, 255, .2) !important;
            }
          </style>
        `;

        this.mouseDownEventListener = (event) => this.handleMouseDown(event);
        this.mouseUpEventListener = (event) => this.handleMouseUp(event);

        this.addEventListener("mousedown", this.mouseDownEventListener);
      }

      handleMouseDown(event) {
        if (event.buttons !== 0x1 || event.shiftKey || event.ctrlKey || event.altKey) {
          return;
        }

        this.classList.add("move");

        document.addEventListener("mouseup", this.mouseUpEventListener);
      }

      handleMouseUp(event) {
        if ((event.buttons & 0x1) === 0x1) {
          return;
        }

        this.classList.remove("move");

        document.removeEventListener("mouseup", this.mouseUpEventListener);
      }
    });

    customElements.define("test-container", class extends HTMLElement {
      constructor() {
        super();

        this.attachShadow({ mode: "open" }).innerHTML = `
          <style>
            * {
              position: relative;
              box-sizing: border-box;
            }
            :host {
              contain: content;
              display: block;
            }
            .shadow-outer {
              height: 100%;
              width: 100%;
              padding: 20px;
              padding-top: 50px;
            }
            .shadow-inner {
              height: 100%;
              width: 100%;
              border: 3px solid blue;
              background-color: lightblue;
            }
          </style>
          <div style="position:absolute;">Container (&lt;test-container&gt; HTML web component)</div>
          <div class="shadow-outer">
            <div class="shadow-inner">
              <slot>
              </slot>
            </div>
          </div>
        `;

        this.innerDiv = this.shadowRoot.querySelector(".shadow-inner");
      }

      get containerClientHeight() {
        return this.innerDiv.clientHeight;
      }

      get containerClientWidth() {
        return this.innerDiv.clientWidth;
      }
    });

    class Drag {
      constructor(element, handle, options) {
        this.element = element;
        this.handle = handle;
        this.options = {
          bounds: options && options.bounds != null ? options.bounds : true
        };

        this.x = 0;
        this.y = 0;
        this.left = 0;
        this.top = 0;
        this.dragging = false;

        this.mouseDownEventListener = (event) => this.handleMouseDown(event);
        this.mouseMoveEventListener = (event) => this.handleMouseMove(event);
        this.mouseUpEventListener = (event) => this.handleMouseUp(event);

        this.handle.addEventListener("mousedown", this.mouseDownEventListener);
      }

      handleMouseDown(event) {
        if (this.dragging) {
          return;
        }

        if (event.buttons !== 0x1 || event.shiftKey || event.ctrlKey || event.altKey) {
          return;
        }

        event.preventDefault();

        this.x = event.clientX;
        this.y = event.clientY;
        this.left = this.element.offsetLeft;
        this.top = this.element.offsetTop;
        this.dragging = true;

        document.addEventListener("mousemove", this.mouseMoveEventListener);
        document.addEventListener("mouseup", this.mouseUpEventListener);
      }

      handleMouseMove(event) {
        if (!this.dragging) {
          document.removeEventListener("mousemove", this.mouseMoveEventListener);
          document.removeEventListener("mouseup", this.mouseUpEventListener);
          return;
        }

        let left = this.left + event.clientX - this.x;
        let top = this.top + event.clientY - this.y;

        if (this.options.bounds) {
          const parent = this.element.parentElement || document.body;

          let clientWidth = parent.containerClientWidth !== undefined ? parent.containerClientWidth : parent.clientWidth;
          let clientHeight = parent.containerClientHeight !== undefined ? parent.containerClientHeight : parent.clientHeight;

          // HACK - NOT FOR PRODUCTION
          if (document.querySelector("#oldbehavior").checked) {
            clientWidth = parent.clientWidth;
            clientHeight = parent.clientHeight;
          }

          if (left > clientWidth - this.element.offsetWidth) {
            left = clientWidth - this.element.offsetWidth;
          }

          if (left <= 0) {
            left = 0;
          }

          if (top > clientHeight - this.element.offsetHeight) {
            top = clientHeight - this.element.offsetHeight;
          }

          if (top <= 0) {
            top = 0;
          }
        }

        this.element.style.left = `${left}px`;
        this.element.style.top = `${top}px`;
      }

      handleMouseUp(event) {
        if ((event.buttons & 0x1) === 0x1) {
          return;
        }

        document.removeEventListener("mousemove", this.mouseMoveEventListener);
        document.removeEventListener("mouseup", this.mouseUpEventListener);

        this.dragging = false;
      }
    }

    class Resize {
      constructor(element, handle, options) {
        this.element = element;
        this.handle = handle;
        this.options = {
          bounds: options && options.bounds != null ? options.bounds : true
        };

        this.x = 0;
        this.y = 0;
        this.width = 0;
        this.height = 0;
        this.resizing = false;

        this.mouseDownEventListener = (event) => this.handleMouseDown(event);
        this.mouseMoveEventListener = (event) => this.handleMouseMove(event);
        this.mouseUpEventListener = (event) => this.handleMouseUp(event);

        this.handle.addEventListener("mousedown", this.mouseDownEventListener);
      }

      handleMouseDown(event) {
        if (this.resizing) {
          return;
        }

        if (event.buttons !== 0x1 || event.shiftKey || event.ctrlKey || event.altKey) {
          return;
        }

        event.preventDefault();

        const clientRect = this.element.getBoundingClientRect();
        this.x = event.clientX;
        this.y = event.clientY;
        this.width = clientRect.width;
        this.height = clientRect.height;
        this.resizing = true;

        document.addEventListener("mousemove", this.mouseMoveEventListener);
        document.addEventListener("mouseup", this.mouseUpEventListener);
      }

      handleMouseMove(event) {
        if (!this.resizing) {
          document.removeEventListener("mousemove", this.mouseMoveEventListener);
          document.removeEventListener("mouseup", this.mouseUpEventListener);
          return;
        }

        let width = this.width + event.clientX - this.x;
        let height = this.height + event.clientY - this.y;

        if (this.options.bounds) {
          const parent = this.element.parentElement || document.body;

          let clientWidth = parent.containerClientWidth !== undefined ? parent.containerClientWidth : parent.clientWidth;
          let clientHeight = parent.containerClientHeight !== undefined ? parent.containerClientHeight : parent.clientHeight;

          // HACK - NOT FOR PRODUCTION
          if (document.querySelector("#oldbehavior").checked) {
            clientWidth = parent.clientWidth;
            clientHeight = parent.clientHeight;
          }

          if (width > clientWidth - this.element.offsetLeft) {
            width = clientWidth - this.element.offsetLeft;
          }

          if (height > clientHeight - this.element.offsetTop) {
            height = clientHeight - this.element.offsetTop;
          }
        }

        this.element.style.width = `${width}px`;
        this.element.style.height = `${height}px`;
      }

      handleMouseUp(event) {
        if ((event.buttons & 0x1) === 0x1) {
          return;
        }

        document.removeEventListener("mousemove", this.mouseMoveEventListener);
        document.removeEventListener("mouseup", this.mouseUpEventListener);

        this.resizing = false;
      }
    }

    const client = document.querySelector(".client");
    const title = document.querySelector(".title");
    const handle = document.querySelector("resize-handle");

    const bounds = document.getElementById("bounds");
    const oldbehavior = document.getElementById("oldbehavior");

    const drag = new Drag(client, title, { bounds: bounds.checked });
    const resize = new Resize(client, handle, { bounds: bounds.checked });

    document.getElementById("bounds").addEventListener("click", function () {
      drag.options.bounds = this.checked;
      resize.options.bounds = this.checked;
      oldbehavior.disabled = !this.checked;
    });
  </script>
</head>
<body>
  <div>
    <input type="checkbox" id="bounds" checked />
    <label for="bounds" title="Deny the client to cross boundaries.">Bounds checking</label>
  </div>
  <div>
    <input type="checkbox" id="oldbehavior" />
    <label for="checkbox" title="The old behavior does not get the correct client region of the container, thus allowing slight overflow.">Old behavior</label>
  </div>
  <test-container class="container">
    <div class="client">
      <div class="title">
        <span>Client</span>
      </div>
      <resize-handle></resize-handle>
    </div>
  </test-container>
</body>
</html>

复选框 "Bounds checking" 将允许 disable/enable 边界检查。

复选框 "Old behavior" 切换边界检查行为。检查后,它会回退到原始问题。未选中时,它使用我自己的答案中提供的解决方案。

我还不是很满意,所以我会继续寻找其他解决方案。请让我知道 determining/calculating 容器在 JavaScript 内的有效客户区域是否有更好的方法。提前致谢。

光源 DOM 中的元素继承了主文档上的 CSS 样式,即使它插入阴影 DOM 和 <slot>

在您的示例中,#client2 的父元素是灰色 <div>,因此如果您使用父元素的值强制 CSS 宽度和高度,您将获得此行为。

为了继承插槽,您需要使用 ::slotted() 结合 Shadow !important DOM CSS 样式:

::slotted( div.client ) {
    max-height: 100% !important ;
    max-width: 100% !important ;
}

::slotted(div.client) 将 select 通过 <slot>.

插入 <div class="client">

!important 将否决灯 DOM CSS unless 将获得优先权。

max-widthmax-height: 100% 会将 div.client selection 限制在其 <slot> 容器的大小内。

Fiddle: https://jsfiddle.net/sugfdqt4/

我找到了答案,@Supersharp 在评论中也指出了这一点。其实很简单。

HTML 网络组件容器实现应该只获取一些 read-only 属性(例如 containerClientHeightcontainerClientWidth),return 它的 shadow-DOM的内部DIV的客户维度。这些属性可以在按钮单击事件处理程序中使用。

这是我的最终工作代码:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Client resize behavior test in different container implementations</title>
  <style>
    * {
      position: relative;
      box-sizing: border-box;
    }
    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
    }
    .container {
      height: 400px;
      width: 600px;
      border: 3px solid black;
      background-color: lightgrey;
      overflow: visible;
    }
    .title {
      position: absolute;
    }
    .outer {
      height: 100%;
      width: 100%;
      padding: 20px;
      padding-top: 50px;
    }
    .inner {
      height: 100%;
      width: 100%;
      border: 3px solid blue;
      background-color: lightblue;
    }
    .client {
      position: absolute;
      border: 3px solid red;
      background-color: lightcoral;
      opacity: .5;
      height: 100%;
      width: 100%;
    }
    button {
      margin: 10px;
    }
  </style>
  <script type="module">
    customElements.define("test-container", class extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: "open" }).innerHTML = `
          <style>
            * {
              position: relative;
              box-sizing: border-box;
            }
            :host {
              contain: content;
              display: block;
            }
            .shadow-outer {
              height: 100%;
              width: 100%;
              padding: 20px;
              padding-top: 50px;
            }
            .shadow-inner {
              height: 100%;
              width: 100%;
              border: 3px solid blue;
              background-color: lightblue;
            }
          </style>
          <div style="position:absolute;">State-of-the-art HTML web component container with nested DIVS in the shadow-DOM</div>
          <div class="shadow-outer">
            <div class="shadow-inner">
              <slot>
              </slot>
            </div>
          </div>
        `;
        this.innerDiv = this.shadowRoot.querySelector(".shadow-inner");
      }
      get containerClientHeight() {
        return this.innerDiv.clientHeight;
      }
      get containerClientWidth() {
        return this.innerDiv.clientWidth;
      }
    });
    const setClientSizeToParentClientSize = (client, button) => {
      const parent = client.parentElement;
      client.style.position = "absolute";
      client.style.height = `${parent.containerClientHeight !== undefined ? parent.containerClientHeight : parent.clientHeight}px`;
      client.style.width = `${parent.containerClientWidth !== undefined ? parent.containerClientWidth : parent.clientWidth}px`;
      client.innerHTML += " resized";
      button.disabled = true;
    };
    document.getElementById("set-client1").addEventListener("click", function () {
      setClientSizeToParentClientSize(document.getElementById("client1"), this);
    });
    document.getElementById("set-client2").addEventListener("click", function () {
      setClientSizeToParentClientSize(document.getElementById("client2"), this);
    });
  </script>
</head>
<body>
  <div>
    <div class="container" id="container1">
      <div style="position:absolute;">Plain old light-DOM container with nested DIVs in the light-DOM</div>
      <div class="outer">
        <div class="inner">
          <div class="client" id="client1">Client 1</div>
        </div>
      </div>
    </div>
    <button id="set-client1">Set client 1 size in JavaScript</button>
  </div>
  <div>
    <test-container id="container2" class="container">
      <div class="client" id="client2">Client 2</div>
    </test-container>
    <button id="set-client2">Set client 2 size in JavaScript</button>
  </div>
</body>
</html>

这两个按钮现在都为目标客户添加了绝对尺寸的内联 CSS 样式,使它们与实际容器的客户区域相匹配。这两种实现都不会再导致客户端溢出。 (按下按钮时不会有视觉变化。)