Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions fixtures/html/dom/document_hit_testing_on_overflow.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8" />
<title>Overflow Hit Testing Test</title>
<style>
html,
body {
height: 100%;
margin: 0;
font-family: sans-serif;
}

.panel {
box-sizing: border-box;
width: 100%;
}

.top {
height: 50%;
padding: 16px;
background: #fafafa;
border-bottom: 1px solid #ddd;
}

.bottom {
height: 50%;
padding: 12px 16px;
overflow: auto;
background: #fff;
}

#container {
position: relative;
/* establish positioning context */
width: 100%;
height: 60px;
background: #333;
color: #fff;
overflow: visible;
padding: 1rem;
/* explicit */
outline: 1px dashed #999;
}

#normalBtn {
margin-bottom: 20px;
padding: 10px 20px;
font-size: 16px;
height: 30px;
background: #28a745;
color: #fff;
border: none;
cursor: pointer;
display: block;
}

#normalBtn:hover {
background: #218838;
}

#staticBtn {
position: static;
top: 80px;
background: #8fdaff;
color: #fff;
border: none;
padding: 10px 20px;
cursor: pointer;
display: block;
}

#staticBtn:hover {
background: #010f17;
}

.overflow-btn {
/* make top effective */
position: absolute;
/* intentionally beyond parent height (60px) */
margin-top: 150px;
background: #007cba;
color: #fff;
border: none;
padding: 10px 20px;
cursor: pointer;
display: block;
}

.overflow-btn:hover {
background: #005a87;
}

.test-result {
margin: 6px 0;
padding: 6px 8px;
border: 1px solid #ccc;
font-size: 14px;
}

.pass {
background: #d4edda;
border-color: #c3e6cb;
color: #155724;
}

.fail {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}

#manual-area {
margin-top: 12px;
padding: 8px;
background: #f7f7f7;
}

code {
background: #eee;
padding: 2px 4px;
}
</style>
</head>

<body>
<div class="top panel">
<h1>Overflow HitTest Test</h1>
<p>Goal: verify an overflowed child (overflow:visible on parent) remains hit-testable outside the parent's
visual bounds.</p>

<button id="normalBtn">Normal Button</button>
<div id="container">
<p>Container</p>
<button id="staticBtn" class="static-btn">Static Button</button>
<button id="overflowBtn" class="overflow-btn">Overflow Button</button>
</div>
</div>

<div class="bottom panel">
<h2>Logs / Results</h2>
<div id="results"></div>
<div id="manual-area">
<strong>Manual test steps:</strong>
<ol>
<li>Move the pointer onto the "Overflow Button" text (rendered below the parent).</li>
<li>Click it: console should show <code>[CLICK] overflow button</code> and a PASS entry "Manual click
event fired".</li>
<li>If it cannot be clicked, overflow visible hit-testing is broken.</li>
</ol>
</div>
</div>

<script>
function logResult(message, passed) {
const resultsDiv = document.getElementById('results');
const div = document.createElement('div');
div.className = 'test-result ' + (passed ? 'pass' : 'fail');
div.textContent = (passed ? '✅ PASS: ' : '❌ FAIL: ') + message;
resultsDiv.appendChild(div);
console[(passed ? 'info' : 'warn')](message + ' => ' + (passed ? 'PASS' : 'FAIL'));
}

// Basic nodes
const container = document.getElementById('container');
const overflowBtn = document.getElementById('overflowBtn');
const staticBtn = document.getElementById('staticBtn');
const normalBtn = document.getElementById('normalBtn');

// Programmatic click test (verifies listener, not real hit-testing)
overflowBtn?.addEventListener('click', () => {
console.log('[CLICK] overflow button');
logResult('Manual click event fired', true);
});
staticBtn?.addEventListener('click', () => {
console.log('[CLICK] static button');
logResult('Static button click detected', true);
});
normalBtn?.addEventListener('click', () => {
console.log('[CLICK] normal button');
logResult('Normal button click detected', true);
});
</script>
</body>

</html>
58 changes: 57 additions & 1 deletion src/client/layout/layout_box.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,29 @@ namespace client_layout

bool LayoutBox::hasHitTestableOverflow() const
{
// We only consider hit-testable overflow when both axes are 'visible'.
// `hasNonVisibleOverflow()` is set when either overflow-x or overflow-y is NOT visible (i.e. establishes a clip).
// For overflow:visible we must allow descendants that paint (and therefore can be hit) outside the border box
// per CSS 2.1 visual overflow / hit-testing semantics.
if (hasNonVisibleOverflow())
return false;

// Fast path: if there are no element children, nothing can extend beyond us.
auto selfBBox = physicalBorderBoxRect();
const glm::vec3 selfMin = selfBBox.minimumWorld;
const glm::vec3 selfMax = selfBBox.maximumWorld;

for (const auto &childBox : getChildBoxes())
{
auto childBBox = childBox->physicalBorderBoxRect();
const glm::vec3 cMin = childBBox.minimumWorld;
const glm::vec3 cMax = childBBox.maximumWorld;

// If any component lies outside our border box, we have hit-testable overflow.
if (cMin.x < selfMin.x || cMin.y < selfMin.y || cMin.z < selfMin.z ||
cMax.x > selfMax.x || cMax.y > selfMax.y || cMax.z > selfMax.z)
return true;
}
return false;
}

Expand All @@ -315,7 +338,26 @@ namespace client_layout
optional<geometry::BoundingBox> overflowBox = nullopt;
if (hasHitTestableOverflow())
{
// TODO(yorkie): handle the hit test for the box with overflow.
// overflow:visible => union of self + children (minimal implementation)
auto selfBBox = physicalBorderBoxRect();
glm::vec3 unionMin = selfBBox.minimumWorld;
glm::vec3 unionMax = selfBBox.maximumWorld;

for (const auto &childBox : getChildBoxes())
{
auto childBBox = childBox->physicalBorderBoxRect();
unionMin.x = std::min(unionMin.x, childBBox.minimumWorld.x);
unionMin.y = std::min(unionMin.y, childBBox.minimumWorld.y);
unionMin.z = std::min(unionMin.z, childBBox.minimumWorld.z);
unionMax.x = std::max(unionMax.x, childBBox.maximumWorld.x);
unionMax.y = std::max(unionMax.y, childBBox.maximumWorld.y);
unionMax.z = std::max(unionMax.z, childBBox.maximumWorld.z);
}

// Apply accumulated offset (same semantics as clipped path below)
unionMin += accumulatedOffset;
unionMax += accumulatedOffset;
return ray.intersectsBoxMinMax(unionMin, unionMax);
}
else
{
Expand Down Expand Up @@ -405,4 +447,18 @@ namespace client_layout
setHasValidCachedGeometry(false);
// TODO(yorkie): invalidate the cached geometry of the parent.
}

vector<shared_ptr<LayoutBox>> LayoutBox::getChildBoxes() const
{
auto children = virtualChildren();
if (!children)
return {};
std::vector<std::shared_ptr<LayoutBox>> childBoxes;
for (const auto &child : *children)
{
if (child->isBox())
childBoxes.push_back(static_pointer_cast<LayoutBox>(child));
}
return childBoxes;
}
}
2 changes: 2 additions & 0 deletions src/client/layout/layout_box.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ namespace client_layout
}

glm::vec3 computeSize() const;
vector<std::shared_ptr<LayoutBox>> getChildBoxes() const;

void invalidateCachedGeometry();

protected:
Expand Down