diff --git a/dynamic.go b/dynamic.go index 9edcbfe..8769c6a 100644 --- a/dynamic.go +++ b/dynamic.go @@ -8,6 +8,8 @@ import ( "fmt" "hash/fnv" "image" + "io" + "sort" "strconv" "time" @@ -44,12 +46,105 @@ type layoutMetrics struct { sm css.StyleMap vpW, vpH int scale float64 // device px per CSS px; 0 means 1 (see scaled) + + // 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. + root *dom.Node + sig uint64 + relayout func() (*layout.Box, css.StyleMap) } func newLayoutMetrics(box *layout.Box, sm css.StyleMap, vpW, vpH int) *layoutMetrics { return &layoutMetrics{idx: layout.BuildIndex(box), sm: sm, vpW: vpW, vpH: vpH} } +// liveMetrics is the Metrics a script session reads: the pass rp just laid +// out, plus the means to lay the document out again when a script has +// mutated it since — so geometry read right after a mutation (Marp's fitting +// headings wrap a heading's content in a new span and measure it at once) is +// the geometry of the mutated DOM, as CSSOM View's getBoundingClientRect / +// offset* / client* / scroll* require. The re-layout is private to the +// metrics: the settle loop still decides what the render's own pass is. +func (e *Engine) liveMetrics(doc *Document, rp *renderPass, fonts *paint.Fonts, vpW, vpH int) *layoutMetrics { + m := newLayoutMetrics(rp.box, rp.sm, vpW, vpH).scaled(e.devScale()) + m.root, m.sig = doc.Root, treeSignature(doc.Root) + sheets, imgSize := rp.sheets, rp.imgSize + m.relayout = func() (*layout.Box, css.StyleMap) { + sm := e.cascade(doc.Root, vpW, sheets, nil) + box, h := e.layoutDoc(doc.Root, sm, vpW, vpH, fonts, imgSize) + sm, box, _ = e.layoutWithContainers(doc.Root, sheets, vpW, vpH, fonts, imgSize, sm, box, h) + return box, sm + } + return m +} + +// Refresh implements js.Refresher: when the DOM differs from the one the +// current geometry describes, lay it out again first. +func (m *layoutMetrics) Refresh() { + if m.relayout == nil { + return + } + sig := treeSignature(m.root) + if sig == m.sig { + return + } + box, sm := m.relayout() + m.idx, m.sm, m.sig = layout.BuildIndex(box), sm, sig +} + +// treeSignature hashes everything layout depends on in the DOM — structure, +// tags, attributes (class and style included) and text — without +// serialising it, so the check before every geometry read stays cheap. Each +// node's child count is folded in, so moving a node to another parent +// changes it even when the markup reads the same. +func treeSignature(root *dom.Node) uint64 { + h := fnv.New64a() + var buf [8]byte + num := func(v uint64) { + for i := range buf { + buf[i] = byte(v >> (8 * i)) + } + _, _ = h.Write(buf[:]) + } + str := func(s string) { + num(uint64(len(s))) + _, _ = io.WriteString(h, s) + } + var walk func(n *dom.Node, depth int) + walk = func(n *dom.Node, depth int) { + if n == nil || depth > 512 { + return + } + num(uint64(n.Type)) + str(n.Tag) + str(n.Text) + num(uint64(len(n.Attr))) + if len(n.Attr) > 0 { + keys := make([]string, 0, len(n.Attr)) + for k := range n.Attr { + keys = append(keys, k) + } + sort.Strings(keys) // map order is random; the hash must not be + for _, k := range keys { + str(k) + str(n.Attr[k]) + } + } + num(uint64(len(n.Children))) + for _, c := range n.Children { + walk(c, depth+1) + } + if n.Shadow != nil { + for _, c := range n.Shadow.Children { + walk(c, depth+1) + } + } + } + walk(root, 0) + return h.Sum64() +} + // 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] @@ -296,7 +391,7 @@ func domSignature(root *dom.Node) uint64 { func (e *Engine) settle(ctx context.Context, doc *Document, vpW, vpH int, fonts *paint.Fonts, rp *renderPass, initialLayout time.Duration, onStage func(stage string, rp *renderPass)) *js.Session { sess := js.Begin(doc.Root, e.jsOptions(ctx, doc, vpW, vpH)) - sess.SetMetrics(newLayoutMetrics(rp.box, rp.sm, vpW, vpH).scaled(e.devScale())) + sess.SetMetrics(e.liveMetrics(doc, rp, fonts, vpW, vpH)) // Signature of the DOM the INITIAL layout was computed from (client-js already // applied), captured before the scripts run so a mutation is detectable. sig := domSignature(doc.Root) @@ -408,7 +503,7 @@ func (e *Engine) settle(ctx context.Context, doc *Document, vpW, vpH int, fonts onStage("settle", rp) } - sess.SetMetrics(newLayoutMetrics(rp.box, rp.sm, vpW, vpH).scaled(e.devScale())) + sess.SetMetrics(e.liveMetrics(doc, rp, fonts, vpW, vpH)) // Run any `) + dynamic := scriptTitle(t, ``+ + `

Short

`) + sw, err := strconv.ParseFloat(static, 64) + if err != nil || sw <= 0 { + t.Fatalf("static span width %q", static) + } + parts := strings.Split(dynamic, "|") + if parts[0] != static { + t.Errorf("span created by script measures %q, want %q (same as the static span)", parts[0], static) + } + if parts[1] == "0" { + t.Errorf("offsetWidth of the script-created span = 0") + } + + // A style change is a mutation too, and so is text. + got := scriptTitle(t, ``+ + `

a

`) + if got != "100,150,30,true" { + t.Errorf("widths after style/text mutations = %q, want 100,150,30,true", got) + } +} + +// TestTreeSignature: stable for an unchanged DOM (so a read without a +// mutation never re-lays out), sensitive to attributes, text and moves. +func TestTreeSignature(t *testing.T) { + root, err := dom.Parse(`

Short

x

`) + if err != nil { + t.Fatal(err) + } + s0 := treeSignature(root) + for i := 0; i < 5; i++ { + if treeSignature(root) != s0 { + t.Fatal("signature not stable for an unchanged tree (attribute map order?)") + } + } + h := dom.Find(root, "h1") + p := dom.Find(root, "p") + h.Attr["class"] = "b" + s1 := treeSignature(root) + if s1 == s0 { + t.Error("attribute change not detected") + } + p.Children[0].Text = "y" + s2 := treeSignature(root) + if s2 == s1 { + t.Error("text change not detected") + } + span := dom.NewElement("span") + txt := h.Children[0] + dom.RemoveChild(h, txt) + dom.AppendChild(span, txt) + dom.AppendChild(h, span) + if treeSignature(root) == s2 { + t.Error("wrapping the heading's text in a span not detected") + } + + m := &layoutMetrics{root: root, sig: treeSignature(root)} + calls := 0 + m.relayout = func() (*layout.Box, css.StyleMap) { calls++; return nil, nil } + m.Refresh() + if calls != 0 { + t.Error("re-laid out an unchanged DOM") + } + h.Attr["class"] = "c" + m.Refresh() + m.Refresh() + if calls != 1 { + t.Errorf("relayout calls = %d, want 1 (once per change)", calls) + } + (&layoutMetrics{}).Refresh() // a static snapshot: no-op +} diff --git a/interactive.go b/interactive.go index 5b938a9..11dc9bb 100644 --- a/interactive.go +++ b/interactive.go @@ -189,7 +189,7 @@ func (d *LiveDocument) resettle(ctx context.Context) { d.rp.sm, d.rp.box, d.rp.height = newSm, newBox, newHeight relaidOut = true - d.sess.SetMetrics(newLayoutMetrics(d.rp.box, d.rp.sm, d.vpW, d.vpH).scaled(d.e.devScale())) + d.sess.SetMetrics(d.e.liveMetrics(d.doc, d.rp, d.fonts, d.vpW, d.vpH)) // A newly-inserted `) + if err != nil { + t.Fatal(err) + } + d := dom.Find(root, "div") + m := &refreshFakeMetrics{fakeMetrics: fakeMetrics{rect: map[*dom.Node][4]float64{d: {0, 0, 64, 34}}}} + 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, "w=64,64") + if m.refreshes != 3 { + t.Errorf("Refresh called %d times, want once per geometry read (3)", m.refreshes) + } +} + // TestSessionRunPending proves a