[scroll-timeline] Implement element-based scroll offset

Implement basic element-based offset calculation taking
edge and threshold into account.

Test: external/wpt/scroll-animations/element-based-offset.html
Bug: 1023375

Change-Id: I38caf32e775e6827a9b7d8763bb7cf9c86fc29c3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2100887
Reviewed-by: Majid Valipour <majidvp@chromium.org>
Reviewed-by: Yi Gu <yigu@chromium.org>
Commit-Queue: Majid Valipour <majidvp@chromium.org>
Cr-Commit-Position: refs/heads/master@{#759266}
diff --git a/scroll-animations/element-based-offset.html b/scroll-animations/element-based-offset.html
new file mode 100644
index 0000000..1c7d998
--- /dev/null
+++ b/scroll-animations/element-based-offset.html
@@ -0,0 +1,212 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Test element-based scroll offset for scroll timeline.</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+
+<style>
+  .scroller {
+    overflow: auto;
+    height: 500px;
+    width: 500px;
+  }
+
+  .contents {
+    height: 2000px;
+    width: 2000px;
+    position: relative;
+  }
+
+  .vertical #start, .vertical #end {
+    background: blue;
+    border-top: 5px solid pink;
+    box-sizing: border-box;
+    width: 100%;
+    height: 50px;
+  }
+
+  .vertical #start {
+    position: absolute;
+    top: 50px;
+  }
+
+  .vertical #end {
+    position: absolute;
+    top: 1050px;
+  }
+
+  .horizontal #start, .horizontal #end {
+    background: blue;
+    border-left:5px solid pink;
+    box-sizing: border-box;
+    height: 100%;
+    width: 50px;
+  }
+
+  .horizontal #start {
+    position: absolute;
+    left: 50px;
+  }
+
+  .horizontal #end {
+    position: absolute;
+    left: 1050px;
+  }
+</style>
+<div id="log"></div>
+<script>
+  'use strict';
+
+  function createScrollerWithStartAndEnd(test, orientationClass) {
+    var scroller = createDiv(test);
+    scroller.innerHTML =
+     `<div class='contents'>
+        <div id='start'></div>
+        <div id='end'></div>
+      </div>`;
+    scroller.classList.add('scroller');
+    scroller.classList.add(orientationClass);
+
+    return scroller;
+  }
+
+  async function createScrollAnimationTest(description, config) {
+    promise_test(async t => {
+      const scroller = createScrollerWithStartAndEnd(t, config.orientation);
+      t.add_cleanup(() => scroller.remove());
+
+      const start = scroller.querySelector("#start");
+      const end = scroller.querySelector("#end")
+
+      const timeline = createScrollTimeline(t, {
+        scrollSource: scroller,
+        orientation: config.orientation,
+        timeRange: 1000,
+        fill: 'both',
+        startScrollOffset: {target: start, ...config.start},
+        endScrollOffset: {target: end, ...config.end }
+      });
+
+      const animation = createScrollLinkedAnimation(t, timeline);
+      const scrollRange = end.offsetTop - start.offsetTop;
+      const timeRange = animation.timeline.timeRange;
+
+      // Verify initial start and current times in Idle state.
+      assert_equals(animation.currentTime, null,
+        "The current time is null in Idle state.");
+      assert_equals(animation.startTime, null,
+        "The start time is null in Idle state.");
+
+      animation.play();
+      // Verify initial start and current times in Pending state.
+      assert_times_equal(animation.currentTime, 0,
+        "The current time is a hold time in Pending state.");
+      assert_equals(animation.startTime, null,
+        "The start time is null in Pending state.");
+
+      await animation.ready;
+      // Verify initial start and current times in Playing state.
+      assert_times_equal(animation.currentTime, 0,
+        "The current time is zero in Playing state.");
+      assert_times_equal(animation.startTime, 0,
+        "The start time is zero in Playing state.");
+
+      // Now do some scrolling and make sure that the Animation current time is
+      // correct.
+      if (config.orientation == 'vertical') {
+        scroller.scrollTo({top: config.scrollTo});
+        assert_equals(scroller.scrollTop, config.scrollTo);
+      } else {
+        scroller.scrollTo({left: config.scrollTo});
+        assert_equals(scroller.scrollLeft, config.scrollTo);
+      }
+
+      await waitForNextFrame();
+      assert_times_equal(animation.timeline.currentTime, config.expectedCurrentTime,
+        "The timeline current time corresponds to the scroll position of the scroller.");
+      assert_times_equal(animation.currentTime, config.expectedCurrentTime,
+        "The animation current time corresponds to the scroll position of the scroller.");
+      assert_times_equal(
+        animation.effect.getComputedTiming().localTime,
+        config.expectedCurrentTime,
+        'Effect local time corresponds to the scroll position of the scroller.');
+    }, description);
+  }
+
+  // start is @   50px
+  // end is   @   1050px
+  // both have    50px heights
+  // scroller has 500px heights
+  // For each test the expected start/end is in the comment to help with the
+  // verification.
+  const tests = {
+    // offsets: [100, 1100]
+    "at start": {
+      scrollTo: 100,
+      expectedCurrentTime: 0,
+    },
+    // offsets: [100, 1100]
+    "after start": {
+      scrollTo: 200,
+      expectedCurrentTime: 100,
+    },
+    // offsets: [100, 1100]
+    "at middle" : {
+      scrollTo: 600,
+      expectedCurrentTime: 500,
+    },
+    // offsets: [100, 1100]
+    "at end" : {
+      scrollTo: 1099,
+      expectedCurrentTime: 999,
+    },
+    // offsets: [100, 1100]
+    "after end" : {
+      scrollTo: 1150,
+      expectedCurrentTime: 1000,
+    },
+    // offsets: [75, 1075]
+    "with threshold 0.5" : {
+      // give threshold to both start and end to keep scrollRange
+      // 1000 which simplifies the calculation.
+      start: {threshold: 0.5},
+      end: {threshold: 0.5},
+      scrollTo: 600 - 25,
+      expectedCurrentTime: 500,
+    },
+    // offsets: [50, 1050]
+    "with threshold 1.0": {
+      start: {threshold: 1.0},
+      end: {threshold: 1.0},
+      scrollTo: 600 - 50,
+      expectedCurrentTime: 500,
+    },
+    // offset: [100, 550]
+    "with end edge" : {
+      end: {edge: "end"},
+      scrollTo: 325,
+      expectedCurrentTime: 500,
+    },
+    // offset: [100, 600]
+     "with end edge and threshold 1.0": {
+      end: {
+        threshold: 1.0,
+        edge: "end"
+      },
+      scrollTo: 350,
+      expectedCurrentTime: 500,
+    },
+  };
+
+  for (let orientation of ['vertical', 'horizontal']) {
+    for (let testName in tests) {
+      const description = `Animation start and current times are correct given
+          element-based offsets for orienation ${orientation} and ${testName}.`;
+      const config = tests[testName];
+      config.orientation = orientation;
+      createScrollAnimationTest(description, config);
+    }
+  }
+</script>
\ No newline at end of file
diff --git a/scroll-animations/testcommon.js b/scroll-animations/testcommon.js
index ab22ff6..2a89d8e 100644
--- a/scroll-animations/testcommon.js
+++ b/scroll-animations/testcommon.js
@@ -5,15 +5,16 @@
   return scroller;
 }
 
-function createScrollTimeline(test) {
-  return new ScrollTimeline({
+function createScrollTimeline(test, options) {
+  options = options || {
     scrollSource: createScroller(test),
     timeRange: 1000
-  });
+  }
+  return new ScrollTimeline(options);
 }
 
 function createScrollTimelineWithOffsets(test, startOffset, endOffset) {
-  return new ScrollTimeline({
+  return createScrollTimeline(test, {
     scrollSource: createScroller(test),
     orientation: "vertical",
     startScrollOffset: startOffset,
@@ -23,7 +24,7 @@
 }
 
 function createScrollLinkedAnimation(test, timeline) {
-  if(timeline === undefined)
+  if (timeline === undefined)
     timeline = createScrollTimeline(test);
   const DURATION = 1000; // ms
   const KEYFRAMES = { opacity: [1, 0] };