Skip to content
Merged
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
90 changes: 88 additions & 2 deletions dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"hash/fnv"
"image"
"io"
"math"
"sort"
"strconv"
"time"
Expand Down Expand Up @@ -47,6 +48,9 @@ type layoutMetrics struct {
vpW, vpH int
scale float64 // device px per CSS px; 0 means 1 (see scaled)

box *layout.Box // the tree idx was built from
boxes map[*dom.Node]*layout.Box // element → its block-level box, built on first use

// Forced synchronous layout (see liveMetrics / Refresh): the document, the
// signature of the DOM the current geometry was laid out from, and how to
// lay it out again. relayout is nil for a static snapshot.
Expand All @@ -56,7 +60,7 @@ type layoutMetrics struct {
}

func newLayoutMetrics(box *layout.Box, sm css.StyleMap, vpW, vpH int) *layoutMetrics {
return &layoutMetrics{idx: layout.BuildIndex(box), sm: sm, vpW: vpW, vpH: vpH}
return &layoutMetrics{idx: layout.BuildIndex(box), sm: sm, vpW: vpW, vpH: vpH, box: box}
}

// liveMetrics is the Metrics a script session reads: the pass rp just laid
Expand Down Expand Up @@ -90,7 +94,7 @@ func (m *layoutMetrics) Refresh() {
return
}
box, sm := m.relayout()
m.idx, m.sm, m.sig = layout.BuildIndex(box), sm, sig
m.idx, m.sm, m.box, m.boxes, m.sig = layout.BuildIndex(box), sm, box, nil, sig
}

// treeSignature hashes everything layout depends on in the DOM — structure,
Expand Down Expand Up @@ -145,6 +149,88 @@ func treeSignature(root *dom.Node) uint64 {
return h.Sum64()
}

// boxOf returns n's block-level box in the current tree, or nil for an
// element that is inline-level or not laid out.
func (m *layoutMetrics) boxOf(n *dom.Node) *layout.Box {
if m.boxes == nil {
m.boxes = map[*dom.Node]*layout.Box{}
var visit func(b *layout.Box)
visit = func(b *layout.Box) {
if b == nil {
return
}
if b.Node != nil && m.boxes[b.Node] == nil {
m.boxes[b.Node] = b
}
for _, c := range b.Children {
visit(c)
}
}
visit(m.box)
}
return m.boxes[n]
}

// paddingRect is a box's padding box (border box less its borders).
func paddingRect(b *layout.Box) (x, y, w, h float64) {
bw := css.Edges{}
if b.Style != nil {
bw = b.Style.Border.Widths()
}
return b.X + bw.Left, b.Y + bw.Top, math.Max(b.W-bw.Left-bw.Right, 0), math.Max(b.H-bw.Top-bw.Bottom, 0)
}

// ClientSize implements js.BoxMetrics: the padding box (CSSOM View
// clientWidth/clientHeight; this engine renders no scrollbars). An element
// with no box, or an inline-level one, reports 0.
func (m *layoutMetrics) ClientSize(n *dom.Node) (w, h float64, ok bool) {
b := m.boxOf(n)
if b == nil {
return 0, 0, true
}
_, _, pw, ph := paddingRect(b)
return m.css(pw), m.css(ph), true
}

// ScrollSize implements js.BoxMetrics: the size of the element's scrolling
// area (CSSOM View scrollWidth/scrollHeight), i.e. its scrollable overflow
// area (CSS Overflow 3 §3.3): the union of its padding box, the line boxes
// and inline content it contains, and its descendants' border boxes —
// descending into a descendant only when that descendant does not clip its
// own overflow — measured from the padding box's top-left corner. An
// inline-level element reports its border box; one with no box, 0.
func (m *layoutMetrics) ScrollSize(n *dom.Node) (w, h float64, ok bool) {
b := m.boxOf(n)
if b == nil {
if r, ok := m.idx[n]; ok {
return m.css(r.W), m.css(r.H), true
}
return 0, 0, true
}
px, py, pw, ph := paddingRect(b)
right, bottom := px+pw, py+ph
grow := func(r, bt float64) {
right, bottom = math.Max(right, r), math.Max(bottom, bt)
}
var visit func(b *layout.Box)
visit = func(b *layout.Box) {
for _, ln := range b.Lines {
grow(ln.X+ln.W, ln.Y+ln.H)
for _, it := range ln.Items {
grow(it.X+it.Width, it.Y+it.LineHeight)
}
}
for _, c := range b.Children {
grow(c.X+c.W, c.Y+c.H)
if c.Style == nil || (c.Style.OverflowX == css.OverflowVisible && c.Style.OverflowY == css.OverflowVisible) {
visit(c)
}
}
}
visit(b)
return m.css(right - px), m.css(bottom - py), true
}

// Rect returns n's used border-box rect (document coords).
func (m *layoutMetrics) Rect(n *dom.Node) (x, y, w, h float64, ok bool) {
r, ok := m.idx[n]
Expand Down
18 changes: 18 additions & 0 deletions forced_layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,21 @@ func TestTreeSignature(t *testing.T) {
}
(&layoutMetrics{}).Refresh() // a static snapshot: no-op
}

// TestScrollAndClientSize: scrollWidth is the width of the element's
// scrolling area — its padding box plus any content overflowing it (CSSOM
// View §scrollWidth) — and clientWidth the padding box (no borders).
func TestScrollAndClientSize(t *testing.T) {
got := scriptTitle(t, `<!DOCTYPE html><html><head><style>body{margin:0}</style></head><body>`+
`<h1 id="h" style="white-space:nowrap;width:10px;margin:0;font-size:20px">Short text here</h1>`+
`<div id="b" style="width:50px;height:20px;border:5px solid;padding:2px"><div style="width:200px;height:100px"></div></div>`+
`<div id="c" style="width:50px;height:20px;padding:3px"><div style="width:10px;height:5px"></div></div>`+
`<script>var h=document.getElementById('h'),b=document.getElementById('b'),c=document.getElementById('c');`+
`document.title=[h.scrollWidth>50,h.clientWidth,b.offsetWidth,b.clientWidth,b.scrollWidth,b.scrollHeight,c.scrollWidth,c.scrollHeight].join(',')</script></body></html>`)
// b: border box 50+2*2+2*5 = 64; padding box 54; content 200 wide starting
// at the padding edge + 2 → scroll width 2+200 = 202 (CSSOM View counts
// the start padding); height 2+100 = 102. c: no overflow → padding box.
if got != "true,10,64,54,202,102,56,26" {
t.Errorf("got %q, want true,10,64,54,202,102,56,26", got)
}
}
8 changes: 4 additions & 4 deletions js/dom.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,10 +525,10 @@ func (b *binder) defineElement(o *goja.Object, n *dom.Node) {
})
b.accessor(o, "offsetWidth", func() goja.Value { return b.vm.ToValue(b.borderW(n)) }, nil)
b.accessor(o, "offsetHeight", func() goja.Value { return b.vm.ToValue(b.borderH(n)) }, nil)
b.accessor(o, "clientWidth", func() goja.Value { return b.vm.ToValue(b.borderW(n)) }, nil)
b.accessor(o, "clientHeight", func() goja.Value { return b.vm.ToValue(b.borderH(n)) }, nil)
b.accessor(o, "scrollWidth", func() goja.Value { return b.vm.ToValue(b.borderW(n)) }, nil)
b.accessor(o, "scrollHeight", func() goja.Value { return b.vm.ToValue(b.borderH(n)) }, nil)
b.accessor(o, "clientWidth", func() goja.Value { w, _ := b.clientSize(n); return b.vm.ToValue(w) }, nil)
b.accessor(o, "clientHeight", func() goja.Value { _, h := b.clientSize(n); return b.vm.ToValue(h) }, nil)
b.accessor(o, "scrollWidth", func() goja.Value { w, _ := b.scrollSize(n); return b.vm.ToValue(w) }, nil)
b.accessor(o, "scrollHeight", func() goja.Value { _, h := b.scrollSize(n); return b.vm.ToValue(h) }, nil)
b.accessor(o, "offsetTop", func() goja.Value { return b.vm.ToValue(b.offsetTop(n)) }, nil)
b.accessor(o, "offsetLeft", func() goja.Value { return b.vm.ToValue(b.offsetLeft(n)) }, nil)
// scrollTop/scrollLeft: this engine has no real scroll/clip model (a
Expand Down
43 changes: 43 additions & 0 deletions js/dynamic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,49 @@ func TestSessionRefreshBeforeGeometryReads(t *testing.T) {
}
}

// boxFakeMetrics adds BoxMetrics to refreshFakeMetrics.
type boxFakeMetrics struct {
refreshFakeMetrics
client, scroll map[*dom.Node][2]float64
}

func (m *boxFakeMetrics) ClientSize(n *dom.Node) (w, h float64, ok bool) {
v, ok := m.client[n]
return v[0], v[1], ok
}
func (m *boxFakeMetrics) ScrollSize(n *dom.Node) (w, h float64, ok bool) {
v, ok := m.scroll[n]
return v[0], v[1], ok
}

// TestSessionBoxMetrics: client* and scroll* come from BoxMetrics when the
// source provides them (0 for a node it does not know), after a Refresh.
func TestSessionBoxMetrics(t *testing.T) {
root, err := dom.Parse(`<html><body><div id="d"></div><b id="x"></b><script>
var d=document.getElementById('d'),x=document.getElementById('x');
console.log('box='+[d.offsetWidth,d.clientWidth,d.clientHeight,d.scrollWidth,d.scrollHeight,x.clientWidth,x.scrollWidth].join(','));
</script></body></html>`)
if err != nil {
t.Fatal(err)
}
d := dom.Find(root, "div")
m := &boxFakeMetrics{
refreshFakeMetrics: refreshFakeMetrics{fakeMetrics: fakeMetrics{rect: map[*dom.Node][4]float64{d: {0, 0, 64, 34}}}},
client: map[*dom.Node][2]float64{d: {54, 24}},
scroll: map[*dom.Node][2]float64{d: {202.4, 102}},
}
var logs []string
sess := Begin(root, Options{PageURL: "https://demo.test/", Timeout: 3 * time.Second,
Log: func(l string) { logs = append(logs, l) }})
sess.SetMetrics(m)
sess.RunInitial()
sess.Close()
mustHaveJS(t, logs, "box=64,54,24,202,102,0,0")
if m.refreshes != 7 {
t.Errorf("Refresh called %d times, want 7 (one per read)", m.refreshes)
}
}

// TestSessionRunPending proves a <script> injected by a running script is picked
// up and executed by the next RunPending, in the same runtime — and only once.
func TestSessionRunPending(t *testing.T) {
Expand Down
25 changes: 25 additions & 0 deletions js/geometry.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ func (b *binder) refreshLayout() {
}
}

// clientSize / scrollSize return n's client and scroll area sizes, rounded
// as the integer IDL attributes are, falling back to the border box when the
// Metrics source does not provide them (see BoxMetrics).
func (b *binder) clientSize(n *dom.Node) (int, int) {
if bm, ok := b.metrics.(BoxMetrics); ok {
b.refreshLayout()
if w, h, ok := bm.ClientSize(n); ok {
return int(math.Round(w)), int(math.Round(h))
}
return 0, 0
}
return b.borderW(n), b.borderH(n)
}

func (b *binder) scrollSize(n *dom.Node) (int, int) {
if bm, ok := b.metrics.(BoxMetrics); ok {
b.refreshLayout()
if w, h, ok := bm.ScrollSize(n); ok {
return int(math.Round(w)), int(math.Round(h))
}
return 0, 0
}
return b.borderW(n), b.borderH(n)
}

// boundingRect builds a DOMRect object for n from its laid-out geometry. An
// unlaid-out node yields an all-zero rect (matching a display:none element).
func (b *binder) boundingRect(n *dom.Node) goja.Value {
Expand Down
10 changes: 10 additions & 0 deletions js/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ type Refresher interface {
Refresh()
}

// BoxMetrics is an optional Metrics extension giving the two boxes CSSOM View
// distinguishes from the border box: the client area (the padding box, no
// borders and no scrollbar — this engine draws none) and the scrolling area
// (the padding box extended by any content overflowing it). Without it,
// client* and scroll* fall back to the border box.
type BoxMetrics interface {
ClientSize(n *dom.Node) (w, h float64, ok bool)
ScrollSize(n *dom.Node) (w, h float64, ok bool)
}

// Session is a live page-script execution bound to one DOM tree and one goja
// runtime. Unlike the one-shot Run, it stays resident across layout passes: the
// engine lays the page out, feeds the geometry in via SetMetrics, runs scripts
Expand Down
Loading