最近我正在实现一个带有 HTML Web 组件的轻量级 vanilla-JS 库,仅供公司内部使用。
我在 JavaScript 中有一个关于在父容器中调整客户端元素大小的行为问题。
这是我的测试 HTML 文件,用于在小型测试场景中重现该行为:
<!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 元素,以在容器的外部边界和内部(客户端)边界之间创建一种硬编码的边距。
当使用 JavaScript 通过按下容器下方的调整大小按钮来调整客户端(子)元素的大小时,HTML Web 组件实现的行为与经典(仅 light-DOM)实现不同。
我认为这与 JavaScript 确定的父元素有关。对于经典实现,客户端的父级将是内部 DIV。但是对于 HTML web 组件的方法,它似乎是 web 组件本身......
我可以在 JavaScript 中做些什么来让我的 HTML Web 组件的开槽子元素使用 JavaScript 关于 Web 组件中的 shadow-DOM 父级而不是 light-dom 父级(作为 Web 组件本身)来(重新)调整大小?
编辑:
我想我需要稍微澄清一下我的问题的背景。
我容器中的客户端将是可拖动的(使用拖动手柄元素,如标题栏)和可调整大小(使用调整大小手柄,如右下角的三角形)。
拖动和调整大小应该可选地绑定到容器的客户区域(= 内部 DIV 的客户区域)。如果“bound”选项为真,则不允许客户端越过容器的(内部)边界。为此,拖动和调整大小行为的 mousemove 事件处理程序将需要在客户端边界上相对于容器的内部客户端区域执行计算。
所有这些拖动和调整大小的逻辑已经到位并适用于仅适用于经典 light-DOM 的解决方案,但是当在 HTML Web 组件容器实现中为客户端元素实现此逻辑时,事件处理不会将 shadow-DOM 的内部 DIV 容器识别为客户的父母进行边界检查;它改为使用整个容器的客户区域。
在我的示例中,我试图尽可能地隔离和简化这个技术问题。
我的示例中的客户端元素最初已经正确最大化到容器客户端区域的 100% 高度和 100% 宽度(使用分配的 CSS 类)。
我的测试示例中的按钮只是添加了一些具有绝对值的覆盖内联 CSS 样式,这应该会在视觉上产生相同的“最大化”客户端大小。
这种逻辑似乎适用于普通的旧 light-DOM 解决方案,但不适用于 HTML Web 组件的 shadow-DOM 解决方案。在后一种情况下,JavaScript 大小调整逻辑不会分配 Web 组件内部 DIV 的 clientwidth 和 -height 尺寸,而是整个 HTML Web 组件的 clientwidth 和 -height 尺寸,太大,导致明显溢出。
所以我需要以这样的方式更正按钮事件处理程序中的 JavaScript 逻辑,它会导致新 HTML Web 组件容器实现中的客户端正确调整大小:设置内联 CSS 绝对值不应导致任何视觉大小变化!
容器的实现和样式可能会动态变化,因此 JavaScript 解决方案不应依赖于容器的特定视觉和/或功能设计。
编辑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 (<test-container> 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>
复选框“边界检查”将允许完全禁用/启用边界检查。
复选框“旧行为”切换边界检查行为。选中后,它会退回到原始问题。未选中时,它使用我自己的答案中提供的解决方案。
我还没有完全满意,所以我会在短时间内继续寻找其他解决方案。如果有更好的方法来确定/计算 JavaScript 中容器的有效客户端区域,请告诉我。提前致谢。