Commit f1ddcc97 authored by Fergal Daly's avatar Fergal Daly Committed by Commit Bot

Add a virtual-content element that uses display locking to make content

low cost when off-screen.

Exposes .mjs files as http-available files for web_tests. This required
adding .mjs to the mime config and various things in BUILD files.

Add .mjs as javascript to the mime configs for webtest server.

Delete old virtual-scroller code.

Bug: 979108
Change-Id: Idfde0bb23263b571fc3abf98845bb7d0327baf07
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1680170
Commit-Queue: Fergal Daly <fergal@chromium.org>
Reviewed-by: default avatarTakuto Ikuta <tikuta@chromium.org>
Reviewed-by: default avatarKent Tamura <tkent@chromium.org>
Cr-Commit-Position: refs/heads/master@{#676308}
parent ae1771a0
...@@ -1029,6 +1029,7 @@ if (!is_ios) { ...@@ -1029,6 +1029,7 @@ if (!is_ios) {
"//testing/buildbot/filters:blink_web_tests_filter", "//testing/buildbot/filters:blink_web_tests_filter",
"//third_party/blink/public:blink_devtools_frontend_resources_files", "//third_party/blink/public:blink_devtools_frontend_resources_files",
"//third_party/blink/public/mojom:mojom_platform_js_data_deps", "//third_party/blink/public/mojom:mojom_platform_js_data_deps",
"//third_party/blink/renderer/core/script:js_files_for_web_tests",
"//third_party/mesa_headers", "//third_party/mesa_headers",
"//tools/imagediff", "//tools/imagediff",
] ]
......
...@@ -75,3 +75,24 @@ blink_core_sources("script") { ...@@ -75,3 +75,24 @@ blink_core_sources("script") {
jumbo_excluded_sources = [ "modulator.cc" ] # https://crbug.com/716395 jumbo_excluded_sources = [ "modulator.cc" ] # https://crbug.com/716395
} }
copy("layered_apis_elements_virtual_scroller_js") {
testonly = true
sources = [
"resources/layered_api/elements/virtual-scroller/find-element.mjs",
"resources/layered_api/elements/virtual-scroller/sets.mjs",
"resources/layered_api/elements/virtual-scroller/visibility-manager.mjs",
]
outputs = [
"{{source_gen_dir}}/{{source_file_part}}",
]
}
group("js_files_for_web_tests") {
testonly = true
data_deps = [
":layered_apis_elements_virtual_scroller_js",
]
}
...@@ -18,8 +18,8 @@ enum class Module { ...@@ -18,8 +18,8 @@ enum class Module {
kElementsInternal, kElementsInternal,
kElementsSwitch, kElementsSwitch,
kElementsToast, kElementsToast,
kElementsVirtualScroller,
kKvStorage, kKvStorage,
kVirtualScroller,
}; };
} // namespace layered_api } // namespace layered_api
......
...@@ -45,6 +45,19 @@ const LayeredAPIResource kLayeredAPIResources[] = { ...@@ -45,6 +45,19 @@ const LayeredAPIResource kLayeredAPIResources[] = {
{"elements/toast/index.mjs", IDR_LAYERED_API_ELEMENTS_TOAST_INDEX_MJS, {"elements/toast/index.mjs", IDR_LAYERED_API_ELEMENTS_TOAST_INDEX_MJS,
Module::kElementsToast}, Module::kElementsToast},
{"elements/virtual-scroller/find-element.mjs",
IDR_LAYERED_API_ELEMENTS_VIRTUAL_SCROLLER_FIND_ELEMENT_MJS,
Module::kElementsVirtualScroller},
{"elements/virtual-scroller/index.mjs",
IDR_LAYERED_API_ELEMENTS_VIRTUAL_SCROLLER_INDEX_MJS,
Module::kElementsVirtualScroller},
{"elements/virtual-scroller/sets.mjs",
IDR_LAYERED_API_ELEMENTS_VIRTUAL_SCROLLER_SETS_MJS,
Module::kElementsVirtualScroller},
{"elements/virtual-scroller/visibility-manager.mjs",
IDR_LAYERED_API_ELEMENTS_VIRTUAL_SCROLLER_VISIBILITY_MANAGER_MJS,
Module::kElementsVirtualScroller},
{"kv-storage/async_iterator.mjs", {"kv-storage/async_iterator.mjs",
IDR_LAYERED_API_KV_STORAGE_ASYNC_ITERATOR_MJS, Module::kKvStorage}, IDR_LAYERED_API_KV_STORAGE_ASYNC_ITERATOR_MJS, Module::kKvStorage},
{"kv-storage/idb_utils.mjs", IDR_LAYERED_API_KV_STORAGE_IDB_UTILS_MJS, {"kv-storage/idb_utils.mjs", IDR_LAYERED_API_KV_STORAGE_IDB_UTILS_MJS,
...@@ -52,28 +65,6 @@ const LayeredAPIResource kLayeredAPIResources[] = { ...@@ -52,28 +65,6 @@ const LayeredAPIResource kLayeredAPIResources[] = {
{"kv-storage/index.mjs", IDR_LAYERED_API_KV_STORAGE_INDEX_MJS, {"kv-storage/index.mjs", IDR_LAYERED_API_KV_STORAGE_INDEX_MJS,
Module::kKvStorage}, Module::kKvStorage},
{"virtual-scroller/index.mjs", IDR_LAYERED_API_VIRTUAL_SCROLLER_INDEX_MJS,
Module::kVirtualScroller},
{"virtual-scroller/item-source.mjs",
IDR_LAYERED_API_VIRTUAL_SCROLLER_ITEM_SOURCE_MJS,
Module::kVirtualScroller},
{"virtual-scroller/virtual-repeater.mjs",
IDR_LAYERED_API_VIRTUAL_SCROLLER_VIRTUAL_REPEATER_MJS,
Module::kVirtualScroller},
{"virtual-scroller/virtual-scroller.mjs",
IDR_LAYERED_API_VIRTUAL_SCROLLER_VIRTUAL_SCROLLER_MJS,
Module::kVirtualScroller},
{"virtual-scroller/layouts/layout-1d-base.mjs",
IDR_LAYERED_API_VIRTUAL_SCROLLER_LAYOUTS_LAYOUT_1D_BASE_MJS,
Module::kVirtualScroller},
{"virtual-scroller/layouts/layout-1d-grid.mjs",
IDR_LAYERED_API_VIRTUAL_SCROLLER_LAYOUTS_LAYOUT_1D_GRID_MJS,
Module::kVirtualScroller},
{"virtual-scroller/layouts/layout-1d.mjs",
IDR_LAYERED_API_VIRTUAL_SCROLLER_LAYOUTS_LAYOUT_1D_MJS,
Module::kVirtualScroller},
}; };
} // namespace } // namespace
......
...@@ -59,8 +59,6 @@ bool ModulatorImplBase::BuiltInModuleEnabled(layered_api::Module module) const { ...@@ -59,8 +59,6 @@ bool ModulatorImplBase::BuiltInModuleEnabled(layered_api::Module module) const {
switch (module) { switch (module) {
case layered_api::Module::kBlank: case layered_api::Module::kBlank:
return true; return true;
case layered_api::Module::kVirtualScroller:
return false;
case layered_api::Module::kKvStorage: case layered_api::Module::kKvStorage:
return RuntimeEnabledFeatures::BuiltInModuleKvStorageEnabled( return RuntimeEnabledFeatures::BuiltInModuleKvStorageEnabled(
GetExecutionContext()); GetExecutionContext());
...@@ -71,6 +69,8 @@ bool ModulatorImplBase::BuiltInModuleEnabled(layered_api::Module module) const { ...@@ -71,6 +69,8 @@ bool ModulatorImplBase::BuiltInModuleEnabled(layered_api::Module module) const {
return RuntimeEnabledFeatures::BuiltInModuleSwitchElementEnabled(); return RuntimeEnabledFeatures::BuiltInModuleSwitchElementEnabled();
case layered_api::Module::kElementsToast: case layered_api::Module::kElementsToast:
return RuntimeEnabledFeatures::BuiltInModuleAllEnabled(); return RuntimeEnabledFeatures::BuiltInModuleAllEnabled();
case layered_api::Module::kElementsVirtualScroller:
return false;
} }
} }
...@@ -90,7 +90,7 @@ void ModulatorImplBase::BuiltInModuleUseCount( ...@@ -90,7 +90,7 @@ void ModulatorImplBase::BuiltInModuleUseCount(
case layered_api::Module::kElementsToast: case layered_api::Module::kElementsToast:
UseCounter::Count(GetExecutionContext(), WebFeature::kBuiltInModuleToast); UseCounter::Count(GetExecutionContext(), WebFeature::kBuiltInModuleToast);
break; break;
case layered_api::Module::kVirtualScroller: case layered_api::Module::kElementsVirtualScroller:
UseCounter::Count(GetExecutionContext(), UseCounter::Count(GetExecutionContext(),
WebFeature::kBuiltInModuleVirtualScroller); WebFeature::kBuiltInModuleVirtualScroller);
break; break;
......
...@@ -8,8 +8,7 @@ import sys ...@@ -8,8 +8,7 @@ import sys
def _CommonChecks(input_api, output_api): def _CommonChecks(input_api, output_api):
results = [] results = []
# We don't apply eslint to virtual-scroller/, which is not developed here. # TODO(tkent): {kv-storage,toast,virtual-scroller}/.eslintrc.js specify babel-eslint parser, which
# TODO(tkent): kv-storage/.eslintrc.js and toast/.eslintrc.js specify babel-eslint parser, which
# is not in third_party/node/node_modules/. # is not in third_party/node/node_modules/.
mjs_files = input_api.AffectedFiles( mjs_files = input_api.AffectedFiles(
file_filter=lambda f: (f.LocalPath().endswith('.mjs') and file_filter=lambda f: (f.LocalPath().endswith('.mjs') and
......
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
module.exports = {
root: true,
env: {
es6: true,
browser: true
},
'parser': 'babel-eslint',
parserOptions: {
sourceType: 'module',
ecmaVersion: 2019
},
rules: {
'for-direction': 'error',
'getter-return': 'error',
'no-async-promise-executor': 'error',
'no-await-in-loop': 'error',
'no-compare-neg-zero': 'error',
'no-cond-assign': ['error', 'except-parens'],
'no-console': 'error',
'no-constant-condition': ['error', {checkLoops: false}],
'no-control-regex': 'error',
'no-debugger': 'error',
'no-dupe-args': 'error',
'no-dupe-keys': 'error',
'no-duplicate-case': 'error',
'no-empty': 'error',
'no-empty-character-class': 'error',
'no-ex-assign': 'error',
'no-extra-boolean-cast': 'error',
'no-extra-parens': [
'error',
'all',
{
conditionalAssign: false,
nestedBinaryExpressions: false,
returnAssign: false
}
],
'no-extra-semi': 'error',
'no-func-assign': 'error',
'no-inner-declarations': 'off',
'no-invalid-regexp': 'error',
'no-irregular-whitespace': 'error',
'no-misleading-character-class': 'error',
'no-obj-calls': 'error',
'no-prototype-builtins': 'error',
'no-regex-spaces': 'error',
'no-sparse-arrays': 'error',
'no-template-curly-in-string': 'error',
'no-unexpected-multiline': 'error',
'no-unreachable': 'error',
'no-unsafe-finally': 'off',
'no-unsafe-negation': 'error',
'use-isnan': 'error',
'valid-typeof': 'error',
'accessor-pairs': 'error',
'array-callback-return': 'error',
'block-scoped-var': 'off',
'class-methods-use-this': 'off',
'complexity': 'off',
'consistent-return': 'error',
'curly': ['error', 'all'],
'default-case': 'off',
'dot-location': ['error', 'property'],
'dot-notation': 'error',
'eqeqeq': 'error',
'guard-for-in': 'off',
'no-alert': 'error',
'no-caller': 'error',
'no-case-declarations': 'error',
'no-div-regex': 'off',
'no-empty-function': 'off',
'no-empty-pattern': 'error',
'no-eq-null': 'error',
'no-eval': 'error',
'no-extend-native': 'error',
'no-extra-bind': 'error',
'no-extra-label': 'error',
'no-fallthrough': 'error',
'no-floating-decimal': 'error',
'no-global-assign': 'error',
'no-implicit-coercion': 'error',
'no-implicit-globals': 'error',
'no-implied-eval': 'error',
'no-iterator': 'error',
'no-labels': ['error', {allowLoop: true}],
'no-lone-blocks': 'error',
'no-loop-func': 'error',
'no-magic-numbers': ['error', {ignore: [0, 1, 2]}],
'no-multi-spaces': ['error', {ignoreEOLComments: true}],
'no-multi-str': 'error',
'no-new': 'error',
'no-new-func': 'error',
'no-new-wrappers': 'error',
'no-octal': 'error',
'no-octal-escape': 'error',
'no-param-reassign': 'off',
'no-process-env': 'error',
'no-proto': 'error',
'no-redeclare': 'error',
'no-restricted-properties': 'off',
'no-return-assign': ['error', 'except-parens'],
'no-return-await': 'error',
'no-script-url': 'off',
'no-self-assign': 'error',
'no-self-compare': 'error',
'no-sequences': 'error',
'no-throw-literal': 'error',
'no-unmodified-loop-condition': 'error',
'no-unused-expressions': 'error',
'no-unused-labels': 'error',
'no-useless-call': 'error',
'no-useless-concat': 'error',
'no-useless-escape': 'error',
'no-useless-return': 'error',
'no-void': 'error',
'no-warning-comments': 'off',
'no-with': 'error',
'prefer-promise-reject-errors': 'error',
'radix': ['error', 'as-needed'],
'require-await': 'off',
'vars-on-top': 'off',
'wrap-iife': ['error', 'outside'],
'yoda': ['error', 'never'],
'strict': ['error', 'global'],
'init-declarations': 'off',
'no-delete-var': 'error',
'no-label-var': 'error',
'no-restricted-globals': 'off',
'no-shadow': 'error',
'no-shadow-restricted-names': 'error',
'no-undef': 'error',
'no-undef-init': 'error',
'no-undefined': 'off',
'no-unused-vars': 'error',
'no-use-before-define': ['error', 'nofunc'],
'callback-return': 'off',
'global-require': 'error',
'handle-callback-err': 'error',
'no-buffer-constructor': 'error',
'no-mixed-requires': ['error', true],
'no-new-require': 'error',
'no-path-concat': 'error',
'no-process-exit': 'error',
'no-restricted-modules': 'off',
'no-sync': 'off',
'array-bracket-newline': ['error', {multiline: true}],
'array-bracket-spacing': ['error', 'never'],
'array-element-newline': 'off',
'block-spacing': ['error', 'always'],
'brace-style': [
'error',
'1tbs',
{allowSingleLine: false}
],
camelcase: ['error', {properties: 'always'}],
'capitalized-comments': 'off',
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': [
'error',
{
before: false,
after: true
}
],
'comma-style': ['error', 'last'],
'computed-property-spacing': ['error', 'never'],
'consistent-this': 'off',
'eol-last': 'error',
'func-call-spacing': ['error', 'never'],
'func-name-matching': 'error',
'func-names': 'off',
'func-style': ['error', 'declaration'],
'function-paren-newline': 'off',
'id-blacklist': 'off',
'id-length': 'off',
'id-match': 'off',
indent: 'off', // not really compatible with clang-format
'jsx-quotes': 'off',
'key-spacing': [
'error',
{
beforeColon: false,
afterColon: true,
mode: 'strict'
}
],
'keyword-spacing': [
'error',
{
before: true,
after: true
}
],
'line-comment-position': 'off',
'linebreak-style': ['error', 'unix'],
'lines-around-comment': 'off',
'max-depth': 'off',
'max-len': ['error', {
tabWidth: 2,
ignorePattern: "(^import |// eslint-disable-line )"}],
'max-lines': 'off',
'max-nested-callbacks': 'off',
'max-params': 'off',
'max-statements': 'off',
'max-statements-per-line': ['error', {max: 1}],
'multiline-ternary': ['error', 'always-multiline'],
'new-cap': 'error',
'new-parens': 'error',
'newline-per-chained-call': 'off',
'no-array-constructor': 'error',
'no-bitwise': 'off',
'no-continue': 'off',
'no-inline-comments': 'off',
'no-mixed-operators': [
'error',
{
groups: [
['&', '|', '^', '~', '<<', '>>', '>>>'],
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
['&&', '||'],
['in', 'instanceof']
]
}
],
'no-mixed-spaces-and-tabs': 'error',
'no-multi-assign': 'off',
'no-multiple-empty-lines': 'error',
'no-negated-condition': 'off',
'no-nested-ternary': 'error',
'no-new-object': 'error',
'no-plusplus': 'off',
'no-restricted-syntax': 'off',
'no-tabs': 'error',
'no-ternary': 'off',
'no-trailing-spaces': 'error',
'no-underscore-dangle': 'off',
'no-unneeded-ternary': 'error',
'no-whitespace-before-property': 'error',
'nonblock-statement-body-position': 'error',
'object-curly-newline': ['error', {consistent: true}],
'object-curly-spacing': ['error', 'never'],
'object-property-newline': 'off',
'one-var': ['error', 'never'],
'one-var-declaration-per-line': ['error', 'initializations'],
'operator-assignment': ['error', 'always'],
'operator-linebreak': ['error', 'after'],
'padded-blocks': ['error', 'never'],
'padding-line-between-statements': 'off',
'quote-props': ['error', 'as-needed'],
quotes: [
'error',
'single',
{
avoidEscape: true,
allowTemplateLiterals: true
}
],
semi: ['error', 'always'],
'semi-spacing': 'error',
'semi-style': 'error',
'sort-keys': 'off',
'sort-vars': 'off',
'space-before-blocks': ['error', 'always'],
'space-before-function-paren': [
'error',
{
anonymous: 'always',
named: 'never'
}
],
'space-in-parens': ['error', 'never'],
'space-infix-ops': 'error',
'space-unary-ops': [
'error',
{
words: true,
nonwords: false
}
],
'spaced-comment': ['error', 'always'],
'switch-colon-spacing': 'error',
'template-tag-spacing': 'error',
'unicode-bom': 'error',
'wrap-regex': 'off',
'arrow-body-style': 'off',
'arrow-parens': ['error', 'as-needed'],
'arrow-spacing': 'error',
'constructor-super': 'error',
'generator-star-spacing': ['error', 'neither'],
'no-class-assign': 'error',
'no-confusing-arrow': 'off',
'no-const-assign': 'error',
'no-dupe-class-members': 'error',
'no-duplicate-imports': 'error',
'no-new-symbol': 'error',
'no-restricted-imports': 'off',
'no-this-before-super': 'error',
'no-useless-computed-key': 'error',
'no-useless-constructor': 'error',
'no-useless-rename': 'error',
'no-var': 'error',
'object-shorthand': 'error',
'prefer-arrow-callback': 'error',
'prefer-const': ['error', {ignoreReadBeforeAssign: true}],
'prefer-destructuring': [
'error',
{
VariableDeclarator: {
array: false,
object: true
},
AssignmentExpression: {
array: false,
object: false
}
},
{
enforceForRenamedProperties: false
}
],
'prefer-numeric-literals': 'error',
'prefer-rest-params': 'error',
'prefer-spread': 'error',
'prefer-template': 'off',
'require-yield': 'error',
'rest-spread-spacing': 'error',
'sort-imports': 'off',
'symbol-description': 'error',
'template-curly-spacing': ['error', 'never'],
'yield-star-spacing': ['error', 'after']
}
};
/**
* Copyright 2019 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*
* @fileoverview Utilities for binary searching by layed-out pixed offset in a
* list of elements.
* @package
*/
/** Symbols for use with @see findElement */
export const BIAS_LOW = Symbol('BIAS_LOW');
export const BIAS_HIGH = Symbol('BIAS_HIGH');
function getBound(elements, edgeIndex) {
const element = elements[Math.floor(edgeIndex / 2)];
const rect = element.getBoundingClientRect();
return edgeIndex % 2 ? rect.bottom : rect.top;
}
/**
* Does the actual work of binary searching. This searches amongst the 2*N edges
* of the N elements. Returns the index of an edge found, 2i is the low edge of
* the ith element, 2i+1 is the high edge of the ith element. If |bias| is low
* then we find the index of the lowest edge >= offset. Otherwise we find index
* of the highest edge > offset.
*/
function findEdgeIndex(elements, offset, bias) {
let low = 0;
let high = elements.length * 2 - 1;
while (low < high) {
const i = Math.floor((low + high) / 2);
const bound = getBound(elements, i);
if (bias === BIAS_LOW) {
if (bound < offset) {
low = i + 1;
} else {
high = i;
}
} else {
if (offset < bound) {
high = i;
} else {
low = i + 1;
}
}
}
return low;
}
/**
* Binary searches inside the array |elements| to find an element containing or
* nearest to |offset| (based on @see Element#getBoundingClientRect()). Assumes
* that the elements are already sorted in increasing pixel order. |bias|
* controls what happens if |offset| is not contained within any element or if
* |offset| is contained with 2 elements (this only happens if there is no
* margin between the elements). If |bias| is BIAS_LOW, then this selects the
* lower element nearest |offset|, otherwise it selects the higher element.
*
* Returns null if |offset| is not within any element.
*
* @param {!Element[]} elements An array of Elements in display order,
* i.e. the pixel offsets of later element are higher than those of earlier
* elements.
* @param {!number} offset The target offset in pixels to search for.
* @param {!Symbol} bias Controls whether we prefer a higher or lower element
* when there is a choice between two elements.
*/
export function findElement(elements, offset, bias) {
if (elements.length === 0) {
return null;
}
// Check if the offset is outside the range entirely.
if (offset < getBound(elements, 0) ||
offset > getBound(elements, elements.length * 2 - 1)) {
return null;
}
let edgeIndex = findEdgeIndex(elements, offset, bias);
// Fix up edge cases.
if (bias === BIAS_LOW) {
// bound(0)..bound(edgeIndex) < offset <= bound(edgeIndex+1) ...
// If we bias low and we got a low edge and we weren't exactly on the edge
// then we want to select the element that's lower.
if (edgeIndex % 2 === 0) {
const bound = getBound(elements, edgeIndex);
if (offset < bound) {
edgeIndex--;
}
}
} else {
// bound(0)..bound(edgeIndex - 1) <= offset < bound(edgeIndex) ...
// If we bias high and we got a low edge, we need to check if we were
// exactly on the edge of the previous element.
if (edgeIndex % 2 === 0) {
const bound = getBound(elements, edgeIndex - 1);
if (offset === bound) {
edgeIndex--;
}
}
}
return elements[Math.floor(edgeIndex / 2)];
}
/**
* Copyright 2019 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*
* @fileoverview This file defines virtual-scroller element.
* EXPLAINER: https://github.com/fergald/virtual-scroller/blob/master/README.md
* TEST PATH: third_party/blink/web_tests/http/tests/virtual-scroller/*
* third_party/blink/web_tests/wpt_internal/virtual-scroller/*
* @package
*/
import {VisibilityManager} from './visibility-manager.mjs';
function styleSheetFactory() {
let styleSheet;
return () => {
if (!styleSheet) {
styleSheet = new CSSStyleSheet();
styleSheet.replaceSync(`
:host {
display: block;
}
::slotted(*) {
display: block !important;
contain: layout style;
}
`);
}
return styleSheet;
};
}
/**
* The class backing the virtual-scroller custom element.
*/
export class VirtualScrollerElement extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({mode: 'closed'});
shadowRoot.adoptedStyleSheets = [styleSheetFactory()()];
shadowRoot.appendChild(document.createElement('slot'));
const visibilityManager = new VisibilityManager(this.children);
new ResizeObserver(() => {
visibilityManager.scheduleSync();
}).observe(this);
new MutationObserver(records => {
visibilityManager.applyMutationObserverRecords(records);
}).observe(this, {childList: true});
}
}
customElements.define('virtual-scroller', VirtualScrollerElement);
/**
* Copyright 2019 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*
* @fileoverview Utility functions for set operations.
* @package
*/
/*
* Returns the set of elements in |a| that are not in |b|.
*
* @param {!Set} a A set of elements.
* @param {!Set} b A set of elements.
*/
export function difference(a, b) {
const result = new Set();
for (const element of a) {
if (!b.has(element)) {
result.add(element);
}
}
return result;
}
/**
* Copyright 2019 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*
* @fileoverview This file provides the class backing the virtual-scroller
* element.
* @package
*/
import * as sets from './sets.mjs';
import * as findElement from './find-element.mjs';
// This controls how much above and below the current screen we
// reveal, e.g. 1 = 1 screen of content.
const BUFFER = 0.2;
// When we know about the heights of elements we default this height.
const DEFAULT_HEIGHT_ESTIMATE_PX = 100;
// When we lock an element, we use this as the width. We use 1px because locked
// items will not resize when their container changes and so could result in a
// horizontal scroll-bar appearing if it they are wide enough.
const LOCKED_WIDTH_PX = 1;
/**
* Represents a range of elements from |low| to |high|, inclusive.
* If either |low| or |high| are null then we treat this as an empty range.
*/
class ElementBounds {
/** @const {Element} */
low;
/** @const {Element} */
high;
constructor(low, high) {
this.low = low;
this.high = high;
}
// Returns a Set containing all of the elements from low to high.
elementSet() {
const result = new Set();
if (this.low === null || this.high === null) {
return result;
}
let element = this.low;
while (element) {
result.add(element);
if (element === this.high) {
break;
}
element = element.nextElementSibling;
}
return result;
}
}
const EMPTY_ELEMENT_BOUNDS = new ElementBounds(null, null);
/**
* Manages measuring and estimating sizes of elements.
*
* This tracks an average measured element size as elements are added
* and removed.
*/
class SizeManager {
#sizes = new WeakMap();
#totalMeasuredSize = 0;
#measuredCount = 0;
/**
* Measures and stores |element|'s size. If |element| was measured
* previously, this updates everything to use the new current size.
*
* @param {!Element} element The element to measure.
*/
measure(element) {
let oldSize = this.#sizes.get(element);
if (oldSize === undefined) {
oldSize = 0;
this.#measuredCount++;
}
const newSize = element.getBoundingClientRect().height;
this.#totalMeasuredSize += newSize - oldSize;
this.#sizes.set(element, newSize);
}
/**
* Returns a size for |element|, either the last stored size or an
* estimate based on all other previously measured elements or a
* default.
*
* @param {!Element} element The element to produce a size for.
*/
getHopefulSize(element) {
const size = this.#sizes.get(element);
return size === undefined ? this.#getAverageSize() : size;
}
#getAverageSize = () => {
return this.#measuredCount > 0 ?
this.#totalMeasuredSize / this.#measuredCount :
DEFAULT_HEIGHT_ESTIMATE_PX;
}
/**
* Removes all data related to |element| from the manager.
*
* @param {!Element} element The element to remove.
*/
remove(element) {
const oldSize = this.#sizes.get(element);
if (oldSize === undefined) {
return;
}
this.#totalMeasuredSize -= oldSize;
this.#measuredCount--;
this.#sizes.delete(element);
}
}
/**
* Manages the visibility (locked/unlocked state) of a list of
* elements. This list of elements is assumed to be in vertical
* display order (e.g. from lowest to highest offset).
*
* It uses resize and intersection observers on all of the visible
* elements to ensure that changes that impact visibility cause us to
* recalulate things (e.g. scrolling, restyling).
*/
export class VisibilityManager {
#sizeManager = new SizeManager();
#elements;
#syncRAFToken;
#elementIntersectionObserver;
#elementResizeObserver;
#revealed = new Set();
constructor(elements) {
this.#elements = elements;
// We want to sync if any element's size changes or if it becomes
// more/less visible.
this.#elementIntersectionObserver = new IntersectionObserver(() => {
this.scheduleSync();
});
// TODO(fergal): Remove this? I'm not sure that we need the resize
// observer. Any resize that is important to us seems like it will
// also involve an intersection change.
this.#elementResizeObserver = new ResizeObserver(() => {
this.scheduleSync();
});
for (const element of this.#elements) {
this.#didAdd(element);
}
this.scheduleSync();
}
/**
* Attempts to unlock a range of elements suitable for the current
* viewport. This causes one forced layout.
*/
#sync = () => {
if (this.#elements.length === 0) {
return;
}
// The basic idea is ...
// The forced layout occurs at the start. We then use the laid out
// coordinates (which are based on a mix of real sizes for
// unlocked elements and the estimated sizes at the time of
// locking for locked elements) to calculate a set of elements
// which should be revealed. We use unlock/lock to move to this
// new set of revealed elements. We will check in the next frame
// whether we got it correct.
// This causes a forced layout and takes measurements of all
// currently revealed elements.
this.#measureRevealed();
// Compute the pixel bounds of what we would like to reveal. Then
// find the elements corresponding to these bounds.
// TODO(fergal): Use nearest scrolling ancestor?
const desiredLow = 0 - window.innerHeight * BUFFER;
const desiredHigh = window.innerHeight + window.innerHeight * BUFFER;
const newBounds = this.#findElementBounds(desiredLow, desiredHigh);
const newRevealed = newBounds.elementSet();
// TODO(fergal): We need to observe 1 element off the end of the
// list, to cope with e.g. the scrolling region suddenly growing.
// Lock and unlock the minimal set of elements to get us to the
// new state.
const toHide = sets.difference(this.#revealed, newRevealed);
toHide.forEach(e => this.#hide(e));
const toReveal = sets.difference(newRevealed, this.#revealed);
toReveal.forEach(e => this.#reveal(e));
// Now we have revealed what we hope will fill the screen. It
// could be incorrect. Rather than measuring now and correcting it
// which would involve an unknown number of forced layouts, we
// come back next frame and try to make it better. We know we can
// stop when we didn't hide or reveal any elements.
if (toHide.size > 0 || toReveal.size > 0) {
this.scheduleSync();
}
}
/**
* Searches within the managed elements and returns an ElementBounds
* object. This object may represent an empty range or a range whose low
* element contains or is lower than |low| (or the lowest element
* possible). Similarly for |high|.
*
* @param {!number} low The lower bound to locate.
* @param {!number} high The upper bound to locate.
*/
#findElementBounds = (low, high) => {
const lowElement = findElement.findElement(
this.#elements, low, findElement.BIAS_LOW);
const highElement = findElement.findElement(
this.#elements, high, findElement.BIAS_HIGH);
if (lowElement === null) {
if (highElement === null) {
return EMPTY_ELEMENT_BOUNDS;
} else {
return new ElementBounds(this.#elements[0], highElement);
}
} else if (highElement === null) {
return new ElementBounds(
lowElement, this.#elements[this.#elements.length - 1]);
}
return new ElementBounds(lowElement, highElement);
}
/**
* Updates the size manager with all of the currently revealed
* elements' sizes. This will cause a forced layout.
*/
#measureRevealed = () => {
for (const element of this.#revealed) {
this.#sizeManager.measure(element);
}
}
/**
* Reveals |element| so that it can be rendered. This includes
* unlocking and adding to various observers.
*
* @param {!Element} element The element to reveal.
*/
#reveal = element => {
this.#revealed.add(element);
this.#elementIntersectionObserver.observe(element);
this.#elementResizeObserver.observe(element);
this.#unlock(element);
}
#logLockingError = (operation, reason, element) => {
// TODO: Figure out the LAPIs error/warning logging story.
console.error('Rejected: ', operation, element, reason); // eslint-disable-line no-console
}
/**
* Unlocks |element|.
*
* @param {!Element} element The element to unlock.
*/
#unlock = element => {
element.displayLock.commit().catch(reason => {
// Only warn if the unlocked failed and we should be revealed.
if (this.#revealed.has(element)) {
this.#logLockingError('Commit', reason, element);
}
});
}
/**
* Hides |element| so that it cannot be rendered. This includes
* locking and removing from various observers.
*
* @param {!Element} element The element to hide.
*/
#hide = element => {
this.#revealed.delete(element);
this.#elementIntersectionObserver.unobserve(element);
this.#elementResizeObserver.unobserve(element);
element.displayLock.acquire({
timeout: Infinity,
activatable: true,
size: [LOCKED_WIDTH_PX, this.#sizeManager.getHopefulSize(element)],
}).catch(reason => {
// Only warn if the lock failed and we should be locked.
if (!this.#revealed.has(element)) {
this.#logLockingError('Acquire', reason, element);
}
});
}
/**
* Notify the manager that |element| has been added to the list of
* managed elements.
*
* @param {!Element} element The element that was added.
*/
#didAdd = element => {
// Added children should be invisible initially. We want to make them
// invisible at this MutationObserver timing, so that there is no
// frame where the browser is asked to render all of the children
// (which could be a lot).
this.#hide(element);
}
/**
* Notify the manager that |element| has been removed from the list
* of managed elements.
*
* @param {!Element} element The element that was removed.
*/
#didRemove = element => {
// Removed children should be made visible again. We should stop
// observing them and discard any size info we have for them as it
// may have become incorrect.
//
// TODO(fergal): Decide whether to also unlock if
// displayLock.locked is true. That would only be necessary if we
// got out of sync between this.#revealed and the locked state. So
// for now, assume are not buggy.
if (this.#revealed.has(element)) {
this.#unlock(element);
}
this.#revealed.delete(element);
this.#elementIntersectionObserver.unobserve(element);
this.#elementResizeObserver.unobserve(element);
this.#sizeManager.remove(element);
}
/**
* Ensures that @see #sync() will be called at the next animation frame.
*/
scheduleSync() {
if (this.#syncRAFToken !== undefined) {
return;
}
this.#syncRAFToken = window.requestAnimationFrame(() => {
this.#syncRAFToken = undefined;
this.#sync();
});
}
/**
* Applys |records| generated by a mutation event to the manager.
* This computes the elements that were newly added/removed and
* notifies the managers for each.
*
* @param {!Object} records The mutations records.
*/
applyMutationObserverRecords(records) {
// It's unclear if we can support children which are not
// elements. We cannot control their visibility using display
// locking but we can just leave them alone.
//
// Relevant mutations are any additions or removals, including
// non-elements and also elements that are removed and then
// re-added as this may impact element bounds.
let relevantMutation = false;
const toRemove = new Set();
for (const record of records) {
relevantMutation = relevantMutation || record.removedNodes.length > 0;
for (const node of record.removedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
toRemove.add(node);
}
}
}
const toAdd = new Set();
for (const record of records) {
relevantMutation = relevantMutation || record.addedNodes.length > 0;
for (const node of record.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (toRemove.has(node)) {
toRemove.delete(node);
} else {
toAdd.add(node);
}
}
}
}
for (const node of toRemove) {
this.#didRemove(node);
}
for (const node of toAdd) {
this.#didAdd(node);
}
if (relevantMutation) {
this.scheduleSync();
}
}
}
...@@ -13,14 +13,11 @@ ...@@ -13,14 +13,11 @@
<include name="IDR_LAYERED_API_ELEMENTS_SWITCH_STYLE_MJS" file="../renderer/core/script/resources/layered_api/elements/switch/style.mjs" type="BINDATA" skip_minify="true" compress="gzip"/> <include name="IDR_LAYERED_API_ELEMENTS_SWITCH_STYLE_MJS" file="../renderer/core/script/resources/layered_api/elements/switch/style.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_ELEMENTS_SWITCH_TRACK_MJS" file="../renderer/core/script/resources/layered_api/elements/switch/track.mjs" type="BINDATA" skip_minify="true" compress="gzip"/> <include name="IDR_LAYERED_API_ELEMENTS_SWITCH_TRACK_MJS" file="../renderer/core/script/resources/layered_api/elements/switch/track.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_ELEMENTS_TOAST_INDEX_MJS" file="../renderer/core/script/resources/layered_api/elements/toast/index.mjs" type="BINDATA" skip_minify="true" compress="gzip"/> <include name="IDR_LAYERED_API_ELEMENTS_TOAST_INDEX_MJS" file="../renderer/core/script/resources/layered_api/elements/toast/index.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_ELEMENTS_VIRTUAL_SCROLLER_FIND_ELEMENT_MJS" file="../renderer/core/script/resources/layered_api/elements/virtual-scroller/find-element.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_ELEMENTS_VIRTUAL_SCROLLER_INDEX_MJS" file="../renderer/core/script/resources/layered_api/elements/virtual-scroller/index.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_ELEMENTS_VIRTUAL_SCROLLER_SETS_MJS" file="../renderer/core/script/resources/layered_api/elements/virtual-scroller/sets.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_ELEMENTS_VIRTUAL_SCROLLER_VISIBILITY_MANAGER_MJS" file="../renderer/core/script/resources/layered_api/elements/virtual-scroller/visibility-manager.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_KV_STORAGE_ASYNC_ITERATOR_MJS" file="../renderer/core/script/resources/layered_api/kv-storage/async_iterator.mjs" type="BINDATA" skip_minify="true" compress="gzip"/> <include name="IDR_LAYERED_API_KV_STORAGE_ASYNC_ITERATOR_MJS" file="../renderer/core/script/resources/layered_api/kv-storage/async_iterator.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_KV_STORAGE_IDB_UTILS_MJS" file="../renderer/core/script/resources/layered_api/kv-storage/idb_utils.mjs" type="BINDATA" skip_minify="true" compress="gzip"/> <include name="IDR_LAYERED_API_KV_STORAGE_IDB_UTILS_MJS" file="../renderer/core/script/resources/layered_api/kv-storage/idb_utils.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_KV_STORAGE_INDEX_MJS" file="../renderer/core/script/resources/layered_api/kv-storage/index.mjs" type="BINDATA" skip_minify="true" compress="gzip"/> <include name="IDR_LAYERED_API_KV_STORAGE_INDEX_MJS" file="../renderer/core/script/resources/layered_api/kv-storage/index.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_VIRTUAL_SCROLLER_INDEX_MJS" file="../renderer/core/script/resources/layered_api/virtual-scroller/index.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_VIRTUAL_SCROLLER_ITEM_SOURCE_MJS" file="../renderer/core/script/resources/layered_api/virtual-scroller/item-source.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_VIRTUAL_SCROLLER_VIRTUAL_REPEATER_MJS" file="../renderer/core/script/resources/layered_api/virtual-scroller/virtual-repeater.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_VIRTUAL_SCROLLER_VIRTUAL_SCROLLER_MJS" file="../renderer/core/script/resources/layered_api/virtual-scroller/virtual-scroller.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_VIRTUAL_SCROLLER_LAYOUTS_LAYOUT_1D_BASE_MJS" file="../renderer/core/script/resources/layered_api/virtual-scroller/layouts/layout-1d-base.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_VIRTUAL_SCROLLER_LAYOUTS_LAYOUT_1D_GRID_MJS" file="../renderer/core/script/resources/layered_api/virtual-scroller/layouts/layout-1d-grid.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
<include name="IDR_LAYERED_API_VIRTUAL_SCROLLER_LAYOUTS_LAYOUT_1D_MJS" file="../renderer/core/script/resources/layered_api/virtual-scroller/layouts/layout-1d.mjs" type="BINDATA" skip_minify="true" compress="gzip"/>
</grit-part> </grit-part>
Name: virtual-scroller Layered API
URL: https://github.com/valdrinkoshi/virtual-scroller
Version: 58659cee10c5d9237821d5c475dac89720bd995d
Security Critical: no
Description:
Temporarily, the files under this directory are authored by Chromium Authors
on a github repository, and then imported to Chromium repository directly here,
until a long-term Layered API development plan is settled.
Local Modifications:
None (except for renaming virtual-scroller-element.mjs to index.mjs)
import {_item, _key, ItemSource} from './item-source.mjs';
import {default as Layout1dGrid} from './layouts/layout-1d-grid.mjs';
import {default as Layout1d} from './layouts/layout-1d.mjs';
import {VirtualScroller} from './virtual-scroller.mjs';
export {ItemSource};
/** Properties */
const _scroller = Symbol();
const _createElement = Symbol();
const _updateElement = Symbol();
const _recycleElement = Symbol();
const _nodePool = Symbol();
const _rawItemSource = Symbol();
const _itemSource = Symbol();
const _elementSource = Symbol();
const _firstConnected = Symbol();
/** Functions */
const _render = Symbol();
export class VirtualScrollerElement extends HTMLElement {
constructor() {
super();
this[_scroller] = null;
// Default create/update/recycleElement.
this[_nodePool] = [];
let childTemplate = null;
this[_createElement] = () => {
if (this[_nodePool] && this[_nodePool].length) {
return this[_nodePool].pop();
}
if (!childTemplate) {
const template = this.querySelector('template');
childTemplate = template && template.content.firstElementChild ?
template.content.firstElementChild :
document.createElement('div');
}
return childTemplate.cloneNode(true);
};
this[_updateElement] = (element, item) => element.textContent =
item.toString();
this[_recycleElement] = (element) => this[_nodePool].push(element);
this[_itemSource] = this[_rawItemSource] = null;
this[_elementSource] = {};
this[_firstConnected] = false;
}
connectedCallback() {
if (!this[_firstConnected]) {
this.attachShadow({mode: 'open'}).innerHTML = `
<style>
:host {
display: block;
position: relative;
contain: strict;
height: 150px;
overflow: auto;
}
:host([hidden]) {
display: none;
}
::slotted(*) {
box-sizing: border-box;
}
:host([layout=vertical]) ::slotted(*) {
width: 100%;
}
:host([layout=horizontal]) ::slotted(*) {
height: 100%;
}
</style>
<slot></slot>`;
// Set default values.
if (!this.layout) {
this.layout = 'vertical';
}
// Enables rendering.
this[_firstConnected] = true;
}
this[_render]();
}
static get observedAttributes() {
return ['layout'];
}
attributeChangedCallback(name, oldVal, newVal) {
this[_render]();
}
get layout() {
return this.getAttribute('layout');
}
set layout(layout) {
this.setAttribute('layout', layout);
}
get itemSource() {
return this[_itemSource];
}
set itemSource(itemSource) {
// No Change.
if (this[_rawItemSource] === itemSource) {
return;
}
this[_rawItemSource] = itemSource;
this[_itemSource] = Array.isArray(itemSource) ?
ItemSource.fromArray(itemSource) :
itemSource;
this[_render]();
}
get createElement() {
return this[_createElement];
}
set createElement(fn) {
// Resets default recycling.
if (this[_nodePool]) {
this.recycleElement = null;
}
this[_createElement] = fn;
// Invalidate wrapped function.
this[_elementSource].createElement = null;
this[_render]();
}
get updateElement() {
return this[_updateElement];
}
set updateElement(fn) {
this[_updateElement] = fn;
// Invalidate wrapped function.
this[_elementSource].updateElement = null;
this[_render]();
}
get recycleElement() {
return this[_recycleElement];
}
set recycleElement(fn) {
// Marks default recycling changed.
this[_nodePool] = null;
this[_recycleElement] = fn;
// Invalidate wrapped function.
this[_elementSource].recycleElement = null;
this[_render]();
}
itemsChanged() {
if (this[_scroller]) {
// Render because length might have changed.
this[_render]();
// Request reset because items might have changed.
this[_scroller].requestReset();
}
}
scrollToIndex(index, { position = 'start' } = {}) {
if (this[_scroller]) {
this[_scroller].layout.scrollToIndex(index, position);
}
}
[_render]() {
// Wait first connected as scroller needs to measure
// sizes of container and children.
if (!this[_firstConnected] || !this.createElement) {
return;
}
if (!this[_scroller]) {
this[_scroller] =
new VirtualScroller({container: this, scrollTarget: this});
}
const scroller = this[_scroller];
const layoutAttr = this.layout;
const Layout = layoutAttr.endsWith('-grid') ? Layout1dGrid : Layout1d;
const direction =
layoutAttr.startsWith('horizontal') ? 'horizontal' : 'vertical';
const layout = scroller.layout instanceof Layout &&
scroller.layout.direction === direction ?
scroller.layout :
new Layout({direction});
let {createElement, updateElement, recycleElement} = this[_elementSource];
if (!createElement) {
createElement = this[_elementSource].createElement = (index) =>
this.createElement(this.itemSource[_item](index), index);
}
if (this.updateElement && !updateElement) {
updateElement = this[_elementSource].updateElement = (element, index) =>
this.updateElement(element, this.itemSource[_item](index), index);
}
if (this.recycleElement && !recycleElement) {
recycleElement = this[_elementSource].recycleElement = (element, index) =>
this.recycleElement(element, this.itemSource[_item](index), index);
}
const elementKey = this.itemSource ? this.itemSource[_key] : null;
const totalItems = this.itemSource ? this.itemSource.length : 0;
Object.assign(scroller, {
layout,
createElement,
updateElement,
recycleElement,
elementKey,
totalItems
});
}
}
customElements.define('virtual-scroller', VirtualScrollerElement);
export const _getLength = Symbol();
export const _item = Symbol();
export const _key = Symbol();
export class ItemSource {
constructor({getLength, item, key}) {
if (typeof getLength !== 'function') {
throw new TypeError('getLength option must be a function');
}
if (typeof item !== 'function') {
throw new TypeError('item option must be a function');
}
if (typeof key !== 'function') {
throw new TypeError('key option must be a function');
}
this[_getLength] = getLength;
this[_item] = item;
this[_key] = key;
}
static fromArray(array, key) {
if (!Array.isArray(array)) {
throw new TypeError('First argument to fromArray() must be an array');
}
if (typeof key !== 'function' && key !== undefined) {
throw new TypeError(
'Second argument to fromArray() must be a function or undefined');
}
return new this({
getLength() {
return array.length;
},
item(index) {
return array[index];
},
key(index) {
return key ? key(array[index], index) : array[index];
}
});
}
get length() {
return this[_getLength]();
}
}
export default class Layout extends EventTarget {
constructor(config) {
super();
this._physicalMin = 0;
this._physicalMax = 0;
this._first = -1;
this._last = -1;
this._latestCoords = {left: 0, top: 0};
this._itemSize = {width: 100, height: 100};
this._spacing = 0;
this._sizeDim = 'height';
this._secondarySizeDim = 'width';
this._positionDim = 'top';
this._secondaryPositionDim = 'left';
this._direction = 'vertical';
this._scrollPosition = 0;
this._scrollError = 0;
this._viewportSize = {width: 0, height: 0};
this._totalItems = 0;
this._scrollSize = 1;
this._overhang = 150;
this._pendingReflow = false;
this._scrollToIndex = -1;
this._scrollToAnchor = 0;
Object.assign(this, config);
}
// public properties
get totalItems() {
return this._totalItems;
}
set totalItems(num) {
if (num !== this._totalItems) {
this._totalItems = num;
this._maxIdx = num - 1;
this._scheduleReflow();
}
}
get direction() {
return this._direction;
}
set direction(dir) {
// Force it to be either horizontal or vertical.
dir = (dir === 'horizontal') ? dir : 'vertical';
if (dir !== this._direction) {
this._direction = dir;
this._sizeDim = (dir === 'horizontal') ? 'width' : 'height';
this._secondarySizeDim = (dir === 'horizontal') ? 'height' : 'width';
this._positionDim = (dir === 'horizontal') ? 'left' : 'top';
this._secondaryPositionDim = (dir === 'horizontal') ? 'top' : 'left';
this._scheduleReflow();
}
}
get itemSize() {
return this._itemSize;
}
set itemSize(dims) {
const {_itemDim1, _itemDim2} = this;
Object.assign(this._itemSize, dims);
if (_itemDim1 !== this._itemDim1 || _itemDim2 !== this._itemDim2) {
if (_itemDim2 !== this._itemDim2) {
this._itemDim2Changed();
} else {
this._scheduleReflow();
}
}
}
get spacing() {
return this._spacing;
}
set spacing(px) {
if (px !== this._spacing) {
this._spacing = px;
this._scheduleReflow();
}
}
get viewportSize() {
return this._viewportSize;
}
set viewportSize(dims) {
const {_viewDim1, _viewDim2} = this;
Object.assign(this._viewportSize, dims);
if (_viewDim2 !== this._viewDim2) {
this._viewDim2Changed();
} else if (_viewDim1 !== this._viewDim1) {
this._checkThresholds();
}
}
get viewportScroll() {
return this._latestCoords;
}
set viewportScroll(coords) {
Object.assign(this._latestCoords, coords);
const oldPos = this._scrollPosition;
this._scrollPosition = this._latestCoords[this._positionDim];
if (oldPos !== this._scrollPosition) {
this._scrollPositionChanged(oldPos, this._scrollPosition);
}
this._checkThresholds();
}
// private properties
get _delta() {
return this._itemDim1 + this._spacing;
}
get _itemDim1() {
return this._itemSize[this._sizeDim];
}
get _itemDim2() {
return this._itemSize[this._secondarySizeDim];
}
get _viewDim1() {
return this._viewportSize[this._sizeDim];
}
get _viewDim2() {
return this._viewportSize[this._secondarySizeDim];
}
get _num() {
if (this._first === -1 || this._last === -1) {
return 0;
}
return this._last - this._first + 1;
}
// public methods
reflowIfNeeded() {
if (this._pendingReflow) {
this._pendingReflow = false;
this._reflow();
}
}
scrollToIndex(index, position = 'start') {
if (!Number.isFinite(index))
return;
index = Math.min(this.totalItems, Math.max(0, index));
this._scrollToIndex = index;
if (position === 'nearest') {
position = index > this._first + this._num / 2 ? 'end' : 'start';
}
switch (position) {
case 'start':
this._scrollToAnchor = 0;
break;
case 'center':
this._scrollToAnchor = 0.5;
break;
case 'end':
this._scrollToAnchor = 1;
break;
default:
throw new TypeError(
'position must be one of: start, center, end, nearest');
}
this._scheduleReflow();
this.reflowIfNeeded();
}
///
_scheduleReflow() {
this._pendingReflow = true;
}
_reflow() {
const {_first, _last, _scrollSize} = this;
this._updateScrollSize();
this._getActiveItems();
this._scrollIfNeeded();
if (this._scrollSize !== _scrollSize) {
this._emitScrollSize();
}
if (this._first === -1 && this._last === -1) {
this._emitRange();
} else if (
this._first !== _first || this._last !== _last ||
this._spacingChanged) {
this._emitRange();
this._emitChildPositions();
}
this._emitScrollError();
}
_updateScrollSize() {
// Ensure we have at least 1px - this allows getting at least 1 item to be
// rendered.
this._scrollSize = Math.max(1, this._totalItems * this._delta);
}
_checkThresholds() {
if (this._viewDim1 === 0 && this._num > 0) {
this._scheduleReflow();
} else {
const min = Math.max(0, this._scrollPosition - this._overhang);
const max = Math.min(
this._scrollSize,
this._scrollPosition + this._viewDim1 + this._overhang);
if (this._physicalMin > min || this._physicalMax < max) {
this._scheduleReflow();
}
}
}
_scrollIfNeeded() {
if (this._scrollToIndex === -1) {
return;
}
const index = this._scrollToIndex;
const anchor = this._scrollToAnchor;
const pos = this._getItemPosition(index)[this._positionDim];
const size = this._getItemSize(index)[this._sizeDim];
const curAnchorPos = this._scrollPosition + this._viewDim1 * anchor;
const newAnchorPos = pos + size * anchor;
// Ensure scroll position is an integer within scroll bounds.
const scrollPosition = Math.floor(Math.min(
this._scrollSize - this._viewDim1,
Math.max(0, this._scrollPosition - curAnchorPos + newAnchorPos)));
this._scrollError += this._scrollPosition - scrollPosition;
this._scrollPosition = scrollPosition;
}
_emitRange(inProps) {
const detail = Object.assign(
{
first: this._first,
last: this._last,
num: this._num,
stable: true,
},
inProps);
this.dispatchEvent(new CustomEvent('rangechange', {detail}));
}
_emitScrollSize() {
const detail = {
[this._sizeDim]: this._scrollSize,
};
this.dispatchEvent(new CustomEvent('scrollsizechange', {detail}));
}
_emitScrollError() {
if (this._scrollError) {
const detail = {
[this._positionDim]: this._scrollError,
[this._secondaryPositionDim]: 0,
};
this.dispatchEvent(new CustomEvent('scrollerrorchange', {detail}));
this._scrollError = 0;
}
}
_emitChildPositions() {
const detail = {};
for (let idx = this._first; idx <= this._last; idx++) {
detail[idx] = this._getItemPosition(idx);
}
this.dispatchEvent(new CustomEvent('itempositionchange', {detail}));
}
_itemDim2Changed() {
// Override
}
_viewDim2Changed() {
// Override
}
_scrollPositionChanged(oldPos, newPos) {
// When both values are bigger than the max scroll position, keep the
// current _scrollToIndexx, otherwise invalidate it.
const maxPos = this._scrollSize - this._viewDim1;
if (oldPos < maxPos || newPos < maxPos) {
this._scrollToIndex = -1;
}
}
_getActiveItems() {
// Override
}
_getItemPosition(idx) {
// Override.
}
_getItemSize(idx) {
// Override.
return {
[this._sizeDim]: this._itemDim1,
[this._secondarySizeDim]: this._itemDim2,
};
}
}
\ No newline at end of file
import Layout1dBase from './layout-1d-base.mjs';
export default class Layout extends Layout1dBase {
constructor(config) {
super(config);
this._rolumns = 1;
}
updateItemSizes(sizes) {
// Assume all items have the same size.
const size = Object.values(sizes)[0];
if (size) {
this.itemSize = size;
}
}
_viewDim2Changed() {
this._defineGrid();
}
_itemDim2Changed() {
this._defineGrid();
}
_getActiveItems() {
const min = Math.max(0, this._scrollPosition - this._overhang);
const max = Math.min(
this._scrollSize,
this._scrollPosition + this._viewDim1 + this._overhang);
const firstCow = Math.floor(min / this._delta);
const lastCow = Math.ceil(max / this._delta) - 1;
this._first = firstCow * this._rolumns;
this._last =
Math.min(((lastCow + 1) * this._rolumns) - 1, this._totalItems);
this._physicalMin = this._delta * firstCow;
this._physicalMax = this._delta * (lastCow + 1);
}
_getItemPosition(idx) {
return {
[this._positionDim]: Math.floor(idx / this._rolumns) * this._delta,
[this._secondaryPositionDim]: this._spacing +
((idx % this._rolumns) * (this._spacing + this._itemDim2))
}
}
_defineGrid() {
const {_spacing} = this;
this._rolumns = Math.max(1, Math.floor(this._viewDim2 / this._itemDim2));
if (this._rolumns > 1) {
this._spacing = (this._viewDim2 % (this._rolumns * this._itemDim2)) /
(this._rolumns + 1);
}
this._spacingChanged = !(_spacing === this._spacing);
this._scheduleReflow();
}
_updateScrollSize() {
this._scrollSize =
Math.max(1, Math.ceil(this._totalItems / this._rolumns) * this._delta);
}
}
\ No newline at end of file
import Layout1dBase from './layout-1d-base.mjs';
export default class Layout extends Layout1dBase {
constructor(config) {
super(config);
this._physicalItems = new Map();
this._newPhysicalItems = new Map();
this._metrics = new Map();
this._anchorIdx = null;
this._anchorPos = null;
this._stable = true;
this._needsRemeasure = false;
this._nMeasured = 0;
this._tMeasured = 0;
this._estimate = true;
}
updateItemSizes(sizes) {
Object.keys(sizes).forEach((key) => {
const metrics = sizes[key], mi = this._getMetrics(key),
prevSize = mi[this._sizeDim];
// TODO(valdrin) Handle margin collapsing.
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing
mi.width = metrics.width + (metrics.marginLeft || 0) +
(metrics.marginRight || 0);
mi.height = metrics.height + (metrics.marginTop || 0) +
(metrics.marginBottom || 0);
const size = mi[this._sizeDim];
const item = this._getPhysicalItem(Number(key));
if (item) {
let delta;
if (size !== undefined) {
item.size = size;
if (prevSize === undefined) {
delta = size;
this._nMeasured++;
} else {
delta = size - prevSize;
}
}
this._tMeasured = this._tMeasured + delta;
} else {
// console.debug(`Could not find physical item for key ${key}`);
}
});
if (!this._nMeasured) {
console.warn(`No items measured yet.`);
} else {
this._updateItemSize();
this._scheduleReflow();
}
}
_updateItemSize() {
// Keep integer values.
this._itemSize[this._sizeDim] =
Math.round(this._tMeasured / this._nMeasured);
}
//
_getMetrics(idx) {
return (this._metrics[idx] = this._metrics[idx] || {});
}
_getPhysicalItem(idx) {
return this._newPhysicalItems.get(idx) || this._physicalItems.get(idx);
}
_getSize(idx) {
const item = this._getPhysicalItem(idx);
return item && item.size;
}
_getPosition(idx) {
const item = this._physicalItems.get(idx);
return item ? item.pos : (idx * (this._delta)) + this._spacing;
}
_calculateAnchor(lower, upper) {
if (lower === 0) {
return 0;
}
if (upper > this._scrollSize - this._viewDim1) {
return this._maxIdx;
}
return Math.max(
0,
Math.min(
this._maxIdx, Math.floor(((lower + upper) / 2) / this._delta)));
}
_getAnchor(lower, upper) {
if (this._physicalItems.size === 0) {
return this._calculateAnchor(lower, upper);
}
if (this._first < 0) {
console.error('_getAnchor: negative _first');
return this._calculateAnchor(lower, upper);
}
if (this._last < 0) {
console.error('_getAnchor: negative _last');
return this._calculateAnchor(lower, upper);
}
const firstItem = this._getPhysicalItem(this._first),
lastItem = this._getPhysicalItem(this._last),
firstMin = firstItem.pos, firstMax = firstMin + firstItem.size,
lastMin = lastItem.pos, lastMax = lastMin + lastItem.size;
if (lastMax < lower) {
// Window is entirely past physical items, calculate new anchor
return this._calculateAnchor(lower, upper);
}
if (firstMin > upper) {
// Window is entirely before physical items, calculate new anchor
return this._calculateAnchor(lower, upper);
}
if (firstMin >= lower || firstMax >= lower) {
// First physical item overlaps window, choose it
return this._first;
}
if (lastMax <= upper || lastMin <= upper) {
// Last physical overlaps window, choose it
return this._last;
}
// Window contains a physical item, but not the first or last
let maxIdx = this._last, minIdx = this._first;
while (true) {
let candidateIdx = Math.round((maxIdx + minIdx) / 2),
candidate = this._physicalItems.get(candidateIdx),
cMin = candidate.pos, cMax = cMin + candidate.size;
if ((cMin >= lower && cMin <= upper) ||
(cMax >= lower && cMax <= upper)) {
return candidateIdx;
} else if (cMax < lower) {
minIdx = candidateIdx + 1;
} else if (cMin > upper) {
maxIdx = candidateIdx - 1;
}
}
}
_getActiveItems() {
if (this._viewDim1 === 0 || this._totalItems === 0) {
this._clearItems();
} else {
const upper = Math.min(
this._scrollSize,
this._scrollPosition + this._viewDim1 + this._overhang),
lower = Math.max(0, upper - this._viewDim1 - (2 * this._overhang));
this._getItems(lower, upper);
}
}
_clearItems() {
this._first = -1;
this._last = -1;
this._physicalMin = 0;
this._physicalMax = 0;
const items = this._newPhysicalItems;
this._newPhysicalItems = this._physicalItems;
this._newPhysicalItems.clear();
this._physicalItems = items;
this._stable = true;
}
_getItems(lower, upper) {
const items = this._newPhysicalItems;
// The anchorIdx is the anchor around which we reflow.
// It is designed to allow jumping to any point of the scroll size.
// We choose it once and stick with it until stable. first and last are
// deduced around it.
if (this._anchorIdx === null || this._anchorPos === null) {
this._anchorIdx = this._getAnchor(lower, upper);
this._anchorPos = this._getPosition(this._anchorIdx);
}
let anchorSize = this._getSize(this._anchorIdx);
if (anchorSize === undefined) {
anchorSize = this._itemDim1;
}
// Anchor might be outside bounds, so prefer correcting the error and keep
// that anchorIdx.
let anchorErr = 0;
if (this._anchorPos + anchorSize + this._spacing < lower) {
anchorErr = lower - (this._anchorPos + anchorSize + this._spacing);
}
if (this._anchorPos > upper) {
anchorErr = upper - this._anchorPos;
}
if (anchorErr) {
this._scrollPosition -= anchorErr;
lower -= anchorErr;
upper -= anchorErr;
this._scrollError += anchorErr;
}
items.set(this._anchorIdx, {pos: this._anchorPos, size: anchorSize});
this._first = (this._last = this._anchorIdx);
this._physicalMin = (this._physicalMax = this._anchorPos);
this._stable = true;
while (this._physicalMin > lower && this._first > 0) {
let size = this._getSize(--this._first);
if (size === undefined) {
this._stable = false;
size = this._itemDim1;
}
const pos = (this._physicalMin -= size + this._spacing);
items.set(this._first, {pos, size});
if (this._stable === false && this._estimate === false) {
break;
}
}
while (this._physicalMax < upper && this._last < this._totalItems) {
let size = this._getSize(this._last);
if (size === undefined) {
this._stable = false;
size = this._itemDim1;
}
items.set(this._last++, {pos: this._physicalMax, size});
if (this._stable === false && this._estimate === false) {
break;
} else {
this._physicalMax += size + this._spacing;
}
}
this._last--;
// This handles the cases where we were relying on estimated sizes.
const extentErr = this._calculateError();
if (extentErr) {
this._physicalMin -= extentErr;
this._physicalMax -= extentErr;
this._anchorPos -= extentErr;
this._scrollPosition -= extentErr;
items.forEach(item => item.pos -= extentErr);
this._scrollError += extentErr;
}
if (this._stable) {
this._newPhysicalItems = this._physicalItems;
this._newPhysicalItems.clear();
this._physicalItems = items;
}
}
_calculateError() {
if (this._first === 0) {
return this._physicalMin;
} else if (this._physicalMin <= 0) {
return this._physicalMin - (this._first * this._delta);
} else if (this._last === this._maxIdx) {
return this._physicalMax - this._scrollSize;
} else if (this._physicalMax >= this._scrollSize) {
return (
(this._physicalMax - this._scrollSize) +
((this._maxIdx - this._last) * this._delta));
}
return 0;
}
_updateScrollSize() {
// Reuse previously calculated physical max, as it might be
// higher than the estimated size.
super._updateScrollSize();
this._scrollSize = Math.max(this._physicalMax, this._scrollSize);
}
// TODO: Can this be made to inherit from base, with proper hooks?
_reflow() {
const {_first, _last, _scrollSize} = this;
this._updateScrollSize();
this._getActiveItems();
this._scrollIfNeeded();
if (this._scrollSize !== _scrollSize) {
this._emitScrollSize();
}
this._emitRange();
if (this._first === -1 && this._last === -1) {
this._resetReflowState();
} else if (
this._first !== _first || this._last !== _last ||
this._needsRemeasure) {
this._emitChildPositions();
this._emitScrollError();
} else {
this._emitChildPositions();
this._emitScrollError();
this._resetReflowState();
}
}
_resetReflowState() {
this._anchorIdx = null;
this._anchorPos = null;
this._stable = true;
}
_getItemPosition(idx) {
return {
[this._positionDim]: this._getPosition(idx),
[this._secondaryPositionDim]: 0
}
}
_getItemSize(idx) {
return {
[this._sizeDim]: this._getSize(idx) || this._itemDim1,
[this._secondarySizeDim]: this._itemDim2,
};
}
_viewDim2Changed() {
this._needsRemeasure = true;
this._scheduleReflow();
}
_emitRange() {
const remeasure = this._needsRemeasure;
const stable = this._stable;
this._needsRemeasure = false;
super._emitRange({remeasure, stable});
}
}
export const Repeats = Superclass => class extends Superclass {
constructor(config) {
super();
this._createElementFn = null;
this._updateElementFn = null;
this._recycleElementFn = null;
this._elementKeyFn = null;
this._measureCallback = null;
this._totalItems = 0;
// Consider renaming this. firstVisibleIndex?
this._first = 0;
// Consider renaming this. count? visibleElements?
this._num = Infinity;
this.__incremental = false;
// used only internally..
// legacy from 1st approach to preact integration
this._manageDom = true;
// used to check if it is more perf if you don't care of dom order?
this._maintainDomOrder = true;
this._last = 0;
this._prevFirst = 0;
this._prevLast = 0;
this._needsReset = false;
this._needsRemeasure = false;
this._pendingRender = null;
// Contains child nodes in the rendered order.
this._ordered = [];
// this._pool = [];
this._active = new Map();
this._prevActive = new Map();
// Both used for recycling purposes.
this._keyToChild = new Map();
this._childToKey = new WeakMap();
// Used to keep track of measures by index.
this._indexToMeasure = {};
// Used to debounce _measureChildren calls.
this._measuringId = -1;
if (config) {
Object.assign(this, config);
}
}
// API
get container() {
return this._container;
}
set container(container) {
if (container === this._container) {
return;
}
if (this._container) {
// Remove children from old container.
this._ordered.forEach((child) => this._removeChild(child));
}
this._container = container;
if (container) {
// Insert children in new container.
this._ordered.forEach((child) => this._insertBefore(child, null));
} else {
this._ordered.length = 0;
this._active.clear();
this._prevActive.clear();
}
this.requestReset();
}
get createElement() {
return this._createElementFn;
}
set createElement(fn) {
if (fn !== this._createElementFn) {
this._createElementFn = fn;
this._keyToChild.clear();
this.requestReset();
}
}
get updateElement() {
return this._updateElementFn;
}
set updateElement(fn) {
if (fn !== this._updateElementFn) {
this._updateElementFn = fn;
this.requestReset();
}
}
get recycleElement() {
return this._recycleElementFn;
}
set recycleElement(fn) {
if (fn !== this._recycleElementFn) {
this._recycleElementFn = fn;
this.requestReset();
}
}
get elementKey() {
return this._elementKeyFn;
}
set elementKey(fn) {
if (fn !== this._elementKeyFn) {
this._elementKeyFn = fn;
this._keyToChild.clear();
this.requestReset();
}
}
get first() {
return this._first;
}
set first(idx) {
if (typeof idx === 'number') {
const newFirst = Math.max(0, Math.min(idx, this._totalItems - this._num));
if (newFirst !== this._first) {
this._first = newFirst;
this._scheduleRender();
}
}
}
get num() {
return this._num;
}
set num(n) {
if (typeof n === 'number') {
if (n !== this._num) {
this._num = n;
this.first = this._first;
this._scheduleRender();
}
}
}
get totalItems() {
return this._totalItems;
}
set totalItems(num) {
// TODO(valdrin) should we check if it is a finite number?
// Technically, Infinity would break Layout, not VirtualRepeater.
if (typeof num === 'number' && num !== this._totalItems) {
this._totalItems = num;
this.first = this._first;
this.requestReset();
}
}
get _incremental() {
return this.__incremental;
}
set _incremental(inc) {
if (inc !== this.__incremental) {
this.__incremental = inc;
this._scheduleRender();
}
}
requestReset() {
this._needsReset = true;
this._scheduleRender();
}
requestRemeasure() {
this._needsRemeasure = true;
this._scheduleRender();
}
// Core functionality
/**
* @protected
*/
_shouldRender() {
return Boolean(this.container && this.createElement);
}
/**
* @private
*/
_scheduleRender() {
if (!this._pendingRender) {
this._pendingRender = requestAnimationFrame(() => {
this._pendingRender = null;
if (this._shouldRender()) {
this._render();
}
});
}
}
/**
* Returns those children that are about to be displayed and that
* require to be positioned. If reset or remeasure has been triggered,
* all children are returned.
* @return {{indices:Array<number>,children:Array<Element>}}
* @private
*/
get _toMeasure() {
return this._ordered.reduce((toMeasure, c, i) => {
const idx = this._first + i;
if (this._needsReset || this._needsRemeasure || idx < this._prevFirst ||
idx > this._prevLast) {
toMeasure.indices.push(idx);
toMeasure.children.push(c);
}
return toMeasure;
}, {indices: [], children: []});
}
/**
* Measures each child bounds and builds a map of index/bounds to be passed to
* the `_measureCallback`
* @private
*/
_measureChildren({indices, children}) {
let pm = children.map(
(c, i) => this._indexToMeasure[indices[i]] || this._measureChild(c));
const mm = /** @type {{ number: { width: number, height: number } }} */
(pm.reduce((out, cur, i) => {
out[indices[i]] = this._indexToMeasure[indices[i]] = cur;
return out;
}, {}));
this._measureCallback(mm);
}
/**
* @protected
*/
_render() {
const rangeChanged =
this._first !== this._prevFirst || this._num !== this._prevNum;
// Create/update/recycle DOM.
if (rangeChanged || this._needsReset) {
this._last =
this._first + Math.min(this._num, this._totalItems - this._first) - 1;
if (this._num || this._prevNum) {
if (this._needsReset) {
this._reset(this._first, this._last);
} else {
this._discardHead();
this._discardTail();
this._addHead();
this._addTail();
}
}
}
if (this._needsRemeasure || this._needsReset) {
this._indexToMeasure = {};
}
// Retrieve DOM to be measured.
// Do it right before cleanup and reset of properties.
const shouldMeasure = this._num > 0 && this._measureCallback &&
(rangeChanged || this._needsRemeasure || this._needsReset);
const toMeasure = shouldMeasure ? this._toMeasure : null;
// Cleanup.
if (!this._incremental) {
this._prevActive.forEach((idx, child) => this._unassignChild(child, idx));
this._prevActive.clear();
}
// Reset internal properties.
this._prevFirst = this._first;
this._prevLast = this._last;
this._prevNum = this._num;
this._needsReset = false;
this._needsRemeasure = false;
// Notify render completed.
this._didRender();
// Measure DOM.
if (toMeasure) {
this._measureChildren(toMeasure);
}
}
/**
* Invoked after DOM is updated, and before it gets measured.
* @protected
*/
_didRender() {
}
/**
* @private
*/
_discardHead() {
const o = this._ordered;
for (let idx = this._prevFirst; o.length && idx < this._first; idx++) {
this._unassignChild(o.shift(), idx);
}
}
/**
* @private
*/
_discardTail() {
const o = this._ordered;
for (let idx = this._prevLast; o.length && idx > this._last; idx--) {
this._unassignChild(o.pop(), idx);
}
}
/**
* @private
*/
_addHead() {
const start = this._first;
const end = Math.min(this._last, this._prevFirst - 1);
for (let idx = end; idx >= start; idx--) {
const child = this._assignChild(idx);
if (this._manageDom) {
if (this._maintainDomOrder || !this._childIsAttached(child)) {
this._insertBefore(child, this._firstChild);
}
}
if (this.updateElement) {
this.updateElement(child, idx);
}
this._ordered.unshift(child);
}
}
/**
* @private
*/
_addTail() {
const start = Math.max(this._first, this._prevLast + 1);
const end = this._last;
for (let idx = start; idx <= end; idx++) {
const child = this._assignChild(idx);
if (this._manageDom) {
if (this._maintainDomOrder || !this._childIsAttached(child)) {
this._insertBefore(child, null);
}
}
if (this.updateElement) {
this.updateElement(child, idx);
}
this._ordered.push(child);
}
}
/**
* @param {number} first
* @param {number} last
* @private
*/
_reset(first, last) {
const len = last - first + 1;
// Explain why swap prevActive with active - affects _assignChild.
const prevActive = this._active;
this._active = this._prevActive;
this._prevActive = prevActive;
let currentMarker = this._manageDom && this._firstChild;
this._ordered.length = 0;
for (let n = 0; n < len; n++) {
const idx = first + n;
const child = this._assignChild(idx);
this._ordered.push(child);
if (this._manageDom) {
if (currentMarker && this._maintainDomOrder) {
if (currentMarker === this._node(child)) {
currentMarker = this._nextSibling(child);
} else {
this._insertBefore(child, currentMarker);
}
} else if (!this._childIsAttached(child)) {
this._insertBefore(child, null);
}
}
if (this.updateElement) {
this.updateElement(child, idx);
}
}
}
/**
* @param {number} idx
* @private
*/
_assignChild(idx) {
const key = this.elementKey ? this.elementKey(idx) : idx;
let child;
if (child = this._keyToChild.get(key)) {
this._prevActive.delete(child);
} else {
child = this.createElement(idx);
this._keyToChild.set(key, child);
this._childToKey.set(child, key);
}
this._showChild(child);
this._active.set(child, idx);
return child;
}
/**
* @param {*} child
* @param {number} idx
* @private
*/
_unassignChild(child, idx) {
this._hideChild(child);
if (this._incremental) {
this._active.delete(child);
this._prevActive.set(child, idx);
} else {
const key = this._childToKey.get(child);
this._childToKey.delete(child);
this._keyToChild.delete(key);
this._active.delete(child);
if (this.recycleElement) {
this.recycleElement(child, idx);
} else if (this._node(child).parentNode) {
this._removeChild(child);
}
}
}
// TODO: Is this the right name?
/**
* @private
*/
get _firstChild() {
return this._ordered.length && this._childIsAttached(this._ordered[0]) ?
this._node(this._ordered[0]) :
null;
}
// Overridable abstractions for child manipulation
/**
* @protected
*/
_node(child) {
return child;
}
/**
* @protected
*/
_nextSibling(child) {
return child.nextSibling;
}
/**
* @protected
*/
_insertBefore(child, referenceNode) {
this._container.insertBefore(child, referenceNode);
}
/**
* @protected
*/
_childIsAttached(child) {
const node = this._node(child);
return node && node.parentNode === this._container;
}
/**
* @protected
*/
_hideChild(child) {
if (child.style) {
child.style.display = 'none';
}
}
/**
* @protected
*/
_showChild(child) {
if (child.style) {
child.style.display = null;
}
}
/**
*
* @param {!Element} child
* @return {{width: number, height: number, marginTop: number, marginBottom: number, marginLeft: number, marginRight: number}} childMeasures
* @protected
*/
_measureChild(child) {
// offsetWidth doesn't take transforms in consideration,
// so we use getBoundingClientRect which does.
const {width, height} = child.getBoundingClientRect();
// console.debug(`_measureChild #${this._container.id} > #${
// child.id}: height: ${height}px`);
return Object.assign({width, height}, getMargins(child));
}
/**
* Remove child.
* Override to control child removal.
*
* @param {*} child
* @protected
*/
_removeChild(child) {
child.parentNode.removeChild(child);
}
}
function getMargins(el) {
const style = window.getComputedStyle(el);
// console.log(el.id, style.position);
return {
marginLeft: getMarginValue(style.marginLeft),
marginRight: getMarginValue(style.marginRight),
marginTop: getMarginValue(style.marginTop),
marginBottom: getMarginValue(style.marginBottom),
};
}
function getMarginValue(value) {
value = value ? parseFloat(value) : NaN;
return value !== value ? 0 : value;
}
export const VirtualRepeater = Repeats(class {});
\ No newline at end of file
import {Repeats} from './virtual-repeater.mjs';
export class RangeChangeEvent extends Event {
constructor(type, init) {
super(type, init);
this._first = Math.floor(init.first || 0);
this._last = Math.floor(init.last || 0);
}
get first() {
return this._first;
}
get last() {
return this._last;
}
}
export const RepeatsAndScrolls = Superclass => class extends Repeats
(Superclass) {
constructor(config) {
super();
this._num = 0;
this._first = -1;
this._last = -1;
this._prevFirst = -1;
this._prevLast = -1;
this._needsUpdateView = false;
this._containerElement = null;
this._layout = null;
this._scrollTarget = null;
// Keep track of original inline style of the container,
// so it can be restored when container is changed.
this._containerInlineStyle = null;
// A sentinel element that sizes the container when
// it is a scrolling element.
this._sizer = null;
// Layout provides these values, we set them on _render().
this._scrollSize = null;
this._scrollErr = null;
this._childrenPos = null;
this._containerSize = null;
this._containerRO = new ResizeObserver(
(entries) => this._containerSizeChanged(entries[0].contentRect));
this._skipNextChildrenSizeChanged = false;
this._childrenRO =
new ResizeObserver((entries) => this._childrenSizeChanged(entries));
if (config) {
Object.assign(this, config);
}
}
get container() {
return this._container;
}
set container(container) {
super.container = container;
const oldEl = this._containerElement;
// Consider document fragments as shadowRoots.
const newEl =
(container && container.nodeType === Node.DOCUMENT_FRAGMENT_NODE) ?
container.host :
container;
if (oldEl === newEl) {
return;
}
this._containerRO.disconnect();
this._containerSize = null;
if (oldEl) {
if (this._containerInlineStyle) {
oldEl.setAttribute('style', this._containerInlineStyle);
} else {
oldEl.removeAttribute('style');
}
this._containerInlineStyle = null;
if (oldEl === this._scrollTarget) {
oldEl.removeEventListener('scroll', this, {passive: true});
this._sizer && this._sizer.remove();
}
} else {
// First time container was setup, add listeners only now.
addEventListener('scroll', this, {passive: true});
}
this._containerElement = newEl;
if (newEl) {
this._containerInlineStyle = newEl.getAttribute('style') || null;
if (newEl === this._scrollTarget) {
this._sizer = this._sizer || this._createContainerSizer();
this._container.prepend(this._sizer);
}
this._scheduleUpdateView();
this._containerRO.observe(newEl);
}
}
get layout() {
return this._layout;
}
set layout(layout) {
if (layout === this._layout) {
return;
}
if (this._layout) {
this._measureCallback = null;
this._layout.removeEventListener('scrollsizechange', this);
this._layout.removeEventListener('scrollerrorchange', this);
this._layout.removeEventListener('itempositionchange', this);
this._layout.removeEventListener('rangechange', this);
// Reset container size so layout can get correct viewport size.
if (this._containerElement) {
this._sizeContainer();
}
}
this._layout = layout;
if (this._layout) {
if (typeof this._layout.updateItemSizes === 'function') {
this._measureCallback = this._layout.updateItemSizes.bind(this._layout);
this.requestRemeasure();
}
this._layout.addEventListener('scrollsizechange', this);
this._layout.addEventListener('scrollerrorchange', this);
this._layout.addEventListener('itempositionchange', this);
this._layout.addEventListener('rangechange', this);
this._scheduleUpdateView();
}
}
/**
* The element that generates scroll events and defines the container
* viewport. The value `null` (default) corresponds to `window` as scroll
* target.
* @type {Element|null}
*/
get scrollTarget() {
return this._scrollTarget;
}
/**
* @param {Element|null} target
*/
set scrollTarget(target) {
// Consider window as null.
if (target === window) {
target = null;
}
if (this._scrollTarget === target) {
return;
}
if (this._scrollTarget) {
this._scrollTarget.removeEventListener('scroll', this, {passive: true});
if (this._sizer && this._scrollTarget === this._containerElement) {
this._sizer.remove();
}
}
this._scrollTarget = target;
if (target) {
target.addEventListener('scroll', this, {passive: true});
if (target === this._containerElement) {
this._sizer = this._sizer || this._createContainerSizer();
this._container.prepend(this._sizer);
}
}
}
/**
* @protected
*/
_render() {
// console.time(`render ${this._containerElement.localName}#${
// this._containerElement.id}`);
this._childrenRO.disconnect();
// Update layout properties before rendering to have correct
// first, num, scroll size, children positions.
this._layout.totalItems = this.totalItems;
if (this._needsUpdateView) {
this._needsUpdateView = false;
this._updateView();
}
this._layout.reflowIfNeeded();
// Keep rendering until there is no more scheduled renders.
while (true) {
if (this._pendingRender) {
cancelAnimationFrame(this._pendingRender);
this._pendingRender = null;
}
// Update scroll size and correct scroll error before rendering.
this._sizeContainer(this._scrollSize);
if (this._scrollErr) {
// This triggers a 'scroll' event (async) which triggers another
// _updateView().
this._correctScrollError(this._scrollErr);
this._scrollErr = null;
}
// Position children (_didRender()), and provide their measures to layout.
super._render();
this._layout.reflowIfNeeded();
// If layout reflow did not provoke another render, we're done.
if (!this._pendingRender) {
break;
}
}
// We want to skip the first ResizeObserver callback call as we already
// measured the children.
this._skipNextChildrenSizeChanged = true;
this._kids.forEach(child => this._childrenRO.observe(child));
// console.timeEnd(`render ${this._containerElement.localName}#${
// this._containerElement.id}`);
}
/**
* Position children before they get measured.
* Measuring will force relayout, so by positioning
* them first, we reduce computations.
* @protected
*/
_didRender() {
if (this._childrenPos) {
this._positionChildren(this._childrenPos);
this._childrenPos = null;
}
}
/**
* @param {!Event} event
* @private
*/
handleEvent(event) {
switch (event.type) {
case 'scroll':
if (!this._scrollTarget || event.target === this._scrollTarget) {
this._scheduleUpdateView();
}
break;
case 'scrollsizechange':
this._scrollSize = event.detail;
this._scheduleRender();
break;
case 'scrollerrorchange':
this._scrollErr = event.detail;
this._scheduleRender();
break;
case 'itempositionchange':
this._childrenPos = event.detail;
this._scheduleRender();
break;
case 'rangechange':
this._adjustRange(event.detail);
break;
default:
console.warn('event not handled', event);
}
}
/**
* @return {!Element}
* @private
*/
_createContainerSizer() {
const sizer = document.createElement('div');
// When the scrollHeight is large, the height
// of this element might be ignored.
// Setting content and font-size ensures the element
// has a size.
Object.assign(sizer.style, {
position: 'absolute',
margin: '-2px 0 0 0',
padding: 0,
visibility: 'hidden',
fontSize: '2px',
});
sizer.innerHTML = '&nbsp;';
return sizer;
}
// Rename _ordered to _kids?
/**
* @protected
*/
get _kids() {
return this._ordered;
}
/**
* @private
*/
_scheduleUpdateView() {
this._needsUpdateView = true;
this._scheduleRender();
}
/**
* @private
*/
_updateView() {
let width, height, top, left;
if (this._scrollTarget === this._containerElement) {
width = this._containerSize.width;
height = this._containerSize.height;
left = this._containerElement.scrollLeft;
top = this._containerElement.scrollTop;
} else {
const containerBounds = this._containerElement.getBoundingClientRect();
const scrollBounds = this._scrollTarget ?
this._scrollTarget.getBoundingClientRect() :
{top: 0, left: 0, width: innerWidth, height: innerHeight};
const scrollerWidth = scrollBounds.width;
const scrollerHeight = scrollBounds.height;
const xMin = Math.max(
0, Math.min(scrollerWidth, containerBounds.left - scrollBounds.left));
const yMin = Math.max(
0, Math.min(scrollerHeight, containerBounds.top - scrollBounds.top));
const xMax = this._layout.direction === 'vertical' ?
Math.max(
0,
Math.min(
scrollerWidth, containerBounds.right - scrollBounds.left)) :
scrollerWidth;
const yMax = this._layout.direction === 'vertical' ?
scrollerHeight :
Math.max(
0,
Math.min(
scrollerHeight, containerBounds.bottom - scrollBounds.top));
width = xMax - xMin;
height = yMax - yMin;
left = Math.max(0, -(containerBounds.x - scrollBounds.left));
top = Math.max(0, -(containerBounds.y - scrollBounds.top));
}
this._layout.viewportSize = {width, height};
this._layout.viewportScroll = {top, left};
}
/**
* @private
*/
_sizeContainer(size) {
if (this._scrollTarget === this._containerElement) {
const left = size && size.width ? size.width - 1 : 0;
const top = size && size.height ? size.height - 1 : 0;
this._sizer.style.transform = `translate(${left}px, ${top}px)`;
} else {
const style = this._containerElement.style;
style.minWidth = size && size.width ? size.width + 'px' : null;
style.minHeight = size && size.height ? size.height + 'px' : null;
}
}
/**
* @private
*/
_positionChildren(pos) {
const kids = this._kids;
Object.keys(pos).forEach(key => {
const idx = key - this._first;
const child = kids[idx];
if (child) {
const {top, left} = pos[key];
// console.debug(`_positionChild #${this._container.id} >
// #${child.id}: top ${top}`);
child.style.position = 'absolute';
child.style.transform = `translate(${left}px, ${top}px)`;
}
});
}
/**
* @private
*/
_adjustRange(range) {
this.num = range.num;
this.first = range.first;
this._incremental = !(range.stable);
if (range.remeasure) {
this.requestRemeasure();
} else if (range.stable) {
this._notifyStable();
}
}
/**
* @protected
*/
_shouldRender() {
if (!super._shouldRender() || !this._layout) {
return false;
}
// NOTE: we're about to render, but the ResizeObserver didn't execute yet.
// Since we want to keep rAF timing, we compute _containerSize now.
// Would be nice to have a way to flush ResizeObservers
if (this._containerSize === null) {
const {width, height} = this._containerElement.getBoundingClientRect();
this._containerSize = {width, height};
}
return this._containerSize.width > 0 || this._containerSize.height > 0;
}
/**
* @private
*/
_correctScrollError(err) {
if (this._scrollTarget) {
this._scrollTarget.scrollTop -= err.top;
this._scrollTarget.scrollLeft -= err.left;
} else {
window.scroll(window.scrollX - err.left, window.scrollY - err.top);
}
}
/**
* @protected
*/
_notifyStable() {
const {first, num} = this;
const last = first + num - 1;
this._container.dispatchEvent(
new RangeChangeEvent('rangechange', {first, last}));
}
/**
* @private
*/
_containerSizeChanged(size) {
const {width, height} = size;
this._containerSize = {width, height};
// console.debug('container changed size', this._containerSize);
this._scheduleUpdateView();
}
/**
* @private
*/
_childrenSizeChanged() {
if (this._skipNextChildrenSizeChanged) {
this._skipNextChildrenSizeChanged = false;
} else {
this.requestRemeasure();
}
}
};
export const VirtualScroller = RepeatsAndScrolls(class {});
...@@ -357,7 +357,7 @@ application/x-futuresplash spl ...@@ -357,7 +357,7 @@ application/x-futuresplash spl
application/x-gtar gtar application/x-gtar gtar
application/x-gzip application/x-gzip
application/x-hdf hdf application/x-hdf hdf
application/x-javascript js application/x-javascript js mjs
application/x-java-jnlp-file jnlp application/x-java-jnlp-file jnlp
application/x-koan skp skd skt skm application/x-koan skp skd skt skm
application/x-latex latex application/x-latex latex
......
...@@ -20,7 +20,7 @@ content_types = utils.invert_dict({ ...@@ -20,7 +20,7 @@ content_types = utils.invert_dict({
"text/css": ["css"], "text/css": ["css"],
"text/event-stream": ["event_stream"], "text/event-stream": ["event_stream"],
"text/html": ["htm", "html"], "text/html": ["htm", "html"],
"text/javascript": ["js"], "text/javascript": ["js", "mjs"],
"text/plain": ["txt", "md"], "text/plain": ["txt", "md"],
"text/vtt": ["vtt"], "text/vtt": ["vtt"],
"video/mp4": ["mp4", "m4v"], "video/mp4": ["mp4", "m4v"],
......
...@@ -91,6 +91,7 @@ class ApacheHTTP(server_base.ServerBase): ...@@ -91,6 +91,7 @@ class ApacheHTTP(server_base.ServerBase):
'-c', 'Alias /webaudio-resources "%s"' % webaudio_resources_dir, '-c', 'Alias /webaudio-resources "%s"' % webaudio_resources_dir,
'-c', 'Alias /inspector-sources "%s"' % inspector_sources_dir, '-c', 'Alias /inspector-sources "%s"' % inspector_sources_dir,
'-c', 'Alias /gen "%s"' % generated_sources_dir, '-c', 'Alias /gen "%s"' % generated_sources_dir,
'-c', 'Alias /wpt_internal "%s/wpt_internal"' % test_dir,
'-c', 'TypesConfig "%s"' % mime_types_path, '-c', 'TypesConfig "%s"' % mime_types_path,
'-c', 'CustomLog "%s" common' % self._access_log_path, '-c', 'CustomLog "%s" common' % self._access_log_path,
'-c', 'ErrorLog "%s"' % self._error_log_path, '-c', 'ErrorLog "%s"' % self._error_log_path,
......
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 1, margin: '0px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 1, margin: '10px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 2, margin: '0px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 2, margin: '10px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 3, margin: '0px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 3, margin: '10px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 62, margin: '0px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 62, margin: '10px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 63, margin: '0px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 63, margin: '10px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 64, margin: '0px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 64, margin: '10px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 65, margin: '0px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 65, margin: '10px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 66, margin: '0px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as helpers from './resources/helpers.mjs';
helpers.testFindElement({elementCount: 66, margin: '10px'});
</script>
<!DOCTYPE html>
<title>Test the find-element.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as findElement from '/gen/third_party/blink/renderer/core/script/resources/layered_api/elements/virtual-scroller/find-element.mjs';
import * as helpers from './resources/helpers.mjs';
import * as wptHelpers from '/wpt_internal/virtual-scroller/resources/helpers.mjs';
test(() => {
helpers.assertFindsElement([], 100, findElement.BIAS_LOW, null);
helpers.assertFindsElement([], 100, findElement.BIAS_HIGH, null);
}, 'Test findElement on empty array');
test(() => {
wptHelpers.withElement('div', containerDiv => {
// We set up a first and last div and between them there are 100
// 0px divs (no height, no margin). Depending on the bias we
// should always find either the first or last div, never a 0px
// div.
const firstDiv = wptHelpers.div('first');
firstDiv.style.margin = "0px";
containerDiv.appendChild(firstDiv);
for (let i = 0; i < 100; i++) {
const d = wptHelpers.div('d' + i);
d.style.margin = "0px";
d.style.height = "0px";
containerDiv.appendChild(d);
}
const lastDiv = wptHelpers.div('lastDiv');
lastDiv.style.margin = "0px";
containerDiv.appendChild(lastDiv);
const elements = containerDiv.children;
const offset = helpers.bottomOf(firstDiv); // Also helpers.topOf(lastDiv).
helpers.assertFindsElement(elements, offset, findElement.BIAS_LOW, firstDiv);
helpers.assertFindsElement(elements, offset, findElement.BIAS_HIGH, lastDiv);
});
}, 'Test findElement skips 0px elements');
</script>
<!DOCTYPE html>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script src="resources/helpers.js"></script>
<div id=contentDiv>
</div>
<script type="module">
'use strict';
import * as findElement from '/gen/third_party/blink/renderer/core/script/resources/layered_api/elements/virtual-scroller/find-element.mjs';
import * as helpers from './resources/helpers.mjs';
import * as wptHelpers from '/wpt_internal/virtual-scroller/resources/helpers.mjs';
/**
* Asserts that we find |element| when we search in |elements| with this |offset| and |bias|.
*/
function assertFindsElement(elements, offset, bias, element) {
const found = helpers.simpleFindElement(elements, offset, bias);
assert_equals(found, element, `Searching offset=${offset}, bias=${bias.toString()}`);
}
// Since even simpleFindElement isn't that simple, we have some tests for it too.
test(() => {
assertFindsElement([], 100, findElement.BIAS_LOW, null);
assertFindsElement([], 100, findElement.BIAS_HIGH, null);
}, 'Test simpleFindElement no elements');
test(() => {
wptHelpers.withElement('div', containerDiv => {
wptHelpers.appendDivs(containerDiv, 2, "10px");
const elements = containerDiv.children;
// Check all boundaries of the first element.
// Check 1px before the top.
assertFindsElement(
elements, helpers.topOf(elements[0]) - 1, findElement.BIAS_LOW, null);
assertFindsElement(
elements, helpers.topOf(elements[0]) - 1, findElement.BIAS_HIGH, null);
// Check at the top.
assertFindsElement(
elements, helpers.topOf(elements[0]), findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.topOf(elements[0]), findElement.BIAS_HIGH, elements[0]);
// Check 1px after the top.
assertFindsElement(
elements, helpers.topOf(elements[0]) + 1, findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.topOf(elements[0]) + 1, findElement.BIAS_HIGH, elements[0]);
// Check 1px before the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[0]) - 1, findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.bottomOf(elements[0]) - 1, findElement.BIAS_HIGH, elements[0]);
// Check at the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[0]), findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.bottomOf(elements[0]), findElement.BIAS_HIGH, elements[0]);
// Check 1px after the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[0]) + 1, findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.bottomOf(elements[0]) + 1, findElement.BIAS_HIGH, elements[1]);
// Check all boundaries of the second (last) element.
// Check 1px before the top.
assertFindsElement(
elements, helpers.topOf(elements[1]) - 1, findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.topOf(elements[1]) - 1, findElement.BIAS_HIGH, elements[1]);
// Check at the top.
assertFindsElement(
elements, helpers.topOf(elements[1]), findElement.BIAS_LOW, elements[1]);
assertFindsElement(
elements, helpers.topOf(elements[1]), findElement.BIAS_HIGH, elements[1]);
// Check 1px after the top.
assertFindsElement(
elements, helpers.topOf(elements[1]) + 1, findElement.BIAS_LOW, elements[1]);
assertFindsElement(
elements, helpers.topOf(elements[1]) + 1, findElement.BIAS_HIGH, elements[1]);
// Check 1px before the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[1]) - 1, findElement.BIAS_LOW, elements[1]);
assertFindsElement(
elements, helpers.bottomOf(elements[1]) - 1, findElement.BIAS_HIGH, elements[1]);
// Check at the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[1]), findElement.BIAS_LOW, elements[1]);
assertFindsElement(
elements, helpers.bottomOf(elements[1]), findElement.BIAS_HIGH, elements[1]);
// Check 1px after the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[1]) + 1, findElement.BIAS_LOW, null);
assertFindsElement(
elements, helpers.bottomOf(elements[1]) + 1, findElement.BIAS_HIGH, null);
});
}, 'Test simpleFindElement 10px margin');
test(() => {
wptHelpers.withElement('div', containerDiv => {
wptHelpers.appendDivs(containerDiv, 2, "10px");
const elements = containerDiv.children;
// Check all boundaries of the first element.
// Check 1px before the top.
assertFindsElement(
elements, helpers.topOf(elements[0]) - 1, findElement.BIAS_LOW, null);
assertFindsElement(
elements, helpers.topOf(elements[0]) - 1, findElement.BIAS_HIGH, null);
// Check at the top.
assertFindsElement(
elements, helpers.topOf(elements[0]), findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.topOf(elements[0]), findElement.BIAS_HIGH, elements[0]);
// Check 1px after the top.
assertFindsElement(
elements, helpers.topOf(elements[0]) + 1, findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.topOf(elements[0]) + 1, findElement.BIAS_HIGH, elements[0]);
// Check 1px before the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[0]) - 1, findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.bottomOf(elements[0]) - 1, findElement.BIAS_HIGH, elements[0]);
// Check at the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[0]), findElement.BIAS_LOW, elements[0]);
assertFindsElement(
elements, helpers.bottomOf(elements[0]), findElement.BIAS_HIGH, elements[0]);
// Bottom of first == top of second.
// Check all boundaries of the second (last) element.
// Check 1px after the top.
assertFindsElement(
elements, helpers.topOf(elements[1]) + 1, findElement.BIAS_LOW, elements[1]);
assertFindsElement(
elements, helpers.topOf(elements[1]) + 1, findElement.BIAS_HIGH, elements[1]);
// Check 1px before the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[1]) - 1, findElement.BIAS_LOW, elements[1]);
assertFindsElement(
elements, helpers.bottomOf(elements[1]) - 1, findElement.BIAS_HIGH, elements[1]);
// Check at the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[1]), findElement.BIAS_LOW, elements[1]);
assertFindsElement(
elements, helpers.bottomOf(elements[1]), findElement.BIAS_HIGH, elements[1]);
// Check 1px after the bottom.
assertFindsElement(
elements, helpers.bottomOf(elements[1]) + 1, findElement.BIAS_LOW, null);
assertFindsElement(
elements, helpers.bottomOf(elements[1]) + 1, findElement.BIAS_HIGH, null);
});
}, 'Test simpleFindElement 0px margin');
</script>
/**
* Copyright 2019 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*
* @fileoverview Helpers for testing virtual-scroller code.
* @package
*/
import * as wptHelpers from '/wpt_internal/virtual-scroller/resources/helpers.mjs';
import * as findElement from '/gen/third_party/blink/renderer/core/script/resources/layered_api/elements/virtual-scroller/find-element.mjs';
export function topOf(element) {
return element.getBoundingClientRect().top;
}
export function bottomOf(element) {
return element.getBoundingClientRect().bottom;
}
/**
* An inefficient but simple implementation of findElement that searches
* linearly.
*/
export function simpleFindElement(elements, offset, bias) {
if (elements.length === 0) {
return null;
}
const lastIndex = elements.length - 1;
if (bias === findElement.BIAS_LOW) {
if (offset < topOf(elements[0])) {
// Lower than first element.
return null;
}
// We bias low, so we accept the first element that either
// contains offset or is followed by an element that is fully
// higher than offset.
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
// We already know that offset >= top.
if (offset <= bottomOf(element) ||
(i < lastIndex && offset < topOf(elements[i + 1]))) {
return element;
}
}
} else {
if (offset > bottomOf(elements[lastIndex])) {
// Higher than last element.
return null;
}
// We bias high, so we iterate backwards and accept the first
// element that either contains offset or is followed by an
// element that is fully lower than offset.
for (let i = lastIndex; i >= 0; i--) {
const element = elements[i];
// We already know that offset <= bottom.
if (offset >= topOf(element) ||
(i > 0 && offset > bottomOf(elements[i - 1]))) {
return element;
}
}
}
// We went through all the elements without accepting any.
return null;
}
/**
* Asserts that we find |element| when we search in |elements| with this
* |offset| and |bias|.
*/
export function assertFindsElement(elements, offset, bias, element) {
const found = findElement.findElement(elements, offset, bias);
window.assert_equals(
found, element, `Searching offset=${offset}, bias=${bias.toString()}`);
}
/**
* Starts 10px before the first element and iterates until 10px after the last
* element. At each point compares the results of simpleFindElement with the
* real findElement.
*/
function testFindElementAtAllOffsets(elements, margin, bias) {
const startPx = topOf(elements[0]);
const endPx = bottomOf(elements[elements.length - 1]);
const BUFFER_PX = 10;
// index will go from -1 to length (both are out of bounds)
for (let offset = startPx - BUFFER_PX;
offset <= endPx + BUFFER_PX;
offset++) {
const element = simpleFindElement(elements, offset, bias);
assertFindsElement(elements, offset, bias, element);
}
}
/**
* Constructs a container with |elementCount| divs each with margin |margin| and
* exhaustively tests findElement against this.
*/
export function testFindElement({elementCount, margin}) {
window.test(() => {
wptHelpers.withElement('div', containerDiv => {
wptHelpers.appendDivs(containerDiv, elementCount, margin);
const elements = containerDiv.children;
testFindElementAtAllOffsets(elements, margin, findElement.BIAS_LOW);
testFindElementAtAllOffsets(elements, margin, findElement.BIAS_HIGH);
});
}, `Test findElement elementCount=${elementCount}, margin=${margin}`);
}
/**
* Asserts that the elements of actual and expected are the same, ignoring
* order. This uses Array#sort() so will only work for elements that can be
* sorted.
*/
export function assertElementsEqual(actual, expected, description) {
const actualArray = Array.from(actual);
actualArray.sort();
const expectedArray = Array.from(expected);
expectedArray.sort();
window.assert_array_equals(actualArray, expectedArray, description);
}
<!DOCTYPE html>
<title>Test the sets.mjs functions.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import * as sets from '/gen/third_party/blink/renderer/core/script/resources/layered_api/elements/virtual-scroller/sets.mjs';
import * as helpers from './resources/helpers.mjs';
test(() => {
const s1 = new Set([1, 2, 3]);
const s2 = new Set([1, 2, 3]);
helpers.assertElementsEqual(sets.difference(s1, s2), []);
}, 'diff self is empty');
test(() => {
const s1 = new Set([1, 2, 3, 4]);
const s2 = new Set([3, 4, 5, 6]);
helpers.assertElementsEqual(sets.difference(s1, s2), [1, 2]);
helpers.assertElementsEqual(sets.difference(s2, s1), [5, 6]);
}, 'two overlapping');
test(() => {
const s1 = new Set([3, 4]);
const s2 = new Set([3, 4, 5, 6]);
helpers.assertElementsEqual(sets.difference(s1, s2), []);
helpers.assertElementsEqual(sets.difference(s2, s1), [5, 6]);
}, 'one-sided overlapping');
test(() => {
const s1 = new Set([]);
const s2 = new Set([3, 4]);
helpers.assertElementsEqual(sets.difference(s1, s2), []);
helpers.assertElementsEqual(sets.difference(s2, s1), [3, 4]);
}, 'empty set');
</script>
<!DOCTYPE html>
<title>Test the virtual-scroller element.</title>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script type="module">
import {VirtualScrollerElement} from 'std:elements/virtual-scroller';
import * as helpers from './resources/helpers.mjs';
import * as wptHelpers from '/wpt_internal/virtual-scroller/resources/helpers.mjs';
test(() => {
wptHelpers.withElement('virtual-scroller', vc => {
assert_not_equals(vc, null);
assert_true(vc instanceof VirtualScrollerElement);});
}, 'Test no content');
test(() => {
wptHelpers.withElement('virtual-scroller', vc => {
helpers.assertElementsEqual(
Object.getOwnPropertyNames(vc.__proto__),
['constructor']);
});
}, 'Only exposes certain properties');
</script>
<!DOCTYPE html>
<html class="reftest-wait">
<style>body { overflow:hidden; }</style>
<script type="module">
import * as refTests from './resources/ref-tests.mjs';
refTests.withDiv(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<style>body { overflow:hidden; }</style>
<script type="module">
import * as refTests from './resources/ref-tests.mjs';
refTests.withDiv(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<meta charset="utf8">
<title>Creates more than a screenful of divs and scrolls down.</title>
<style>body { overflow:hidden; }</style>
<link rel="author" title="Fergal Daly" href="mailto:fergal@chromium.org">
<link rel="match" href="full-scroll-500px-ref.html">
<script type="module">
import 'std:elements/virtual-scroller';
import * as refTests from './resources/ref-tests.mjs';
refTests.withVirtualScroller(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<meta charset="utf8">
<title>Creates more than a screenful of divs.</title>
<style>body { overflow:hidden; }</style>
<link rel="author" title="Fergal Daly" href="mailto:fergal@chromium.org">
<link rel="match" href="full-scroll-500px-ref.html">
<script type="module">
import 'std:elements/virtual-scroller';
import * as refTests from './resources/ref-tests.mjs';
refTests.withVirtualScroller(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<style>body { overflow:hidden; }</style>
<script type="module">
import * as refTests from './resources/ref-tests.mjs';
refTests.withDiv(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<style>body { overflow:hidden; }</style>
<script type="module">
import * as refTests from './resources/ref-tests.mjs';
refTests.withDiv(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<meta charset="utf8">
<title>
Creates more than a screenful of divs and moves the first to the
end and scrolls to it.
</title>
<style>body { overflow:hidden; }</style>
<link rel="author" title="Fergal Daly" href="mailto:fergal@chromium.org">
<link rel="match" href="full-scroll-500px-ref.html">
<script type="module">
import 'std:elements/virtual-scroller';
import * as refTests from './resources/ref-tests.mjs';
refTests.withVirtualScroller(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<meta charset="utf8">
<title>Creates more than a screenful of divs and moves the first to the end.</title>
<style>body { overflow:hidden; }</style>
<link rel="author" title="Fergal Daly" href="mailto:fergal@chromium.org">
<link rel="match" href="full-scroll-500px-ref.html">
<script type="module">
import 'std:elements/virtual-scroller';
import * as refTests from './resources/ref-tests.mjs';
refTests.withVirtualScroller(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<style>body { overflow:hidden; }</style>
<script type="module">
import * as refTests from './resources/ref-tests.mjs';
refTests.withDiv(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<meta charset="utf8">
<title>Creates less than a screenful of divs.</title>
<style>body { overflow:hidden; }</style>
<link rel="author" title="Fergal Daly" href="mailto:fergal@chromium.org">
<link rel="match" href="full-scroll-500px-ref.html">
<script type="module">
import 'std:elements/virtual-scroller';
import * as refTests from './resources/ref-tests.mjs';
refTests.withVirtualScroller(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<style>body { overflow:hidden; }</style>
<script type="module">
import * as refTests from './resources/ref-tests.mjs';
refTests.withDiv(refTests.testFullScroll500px);
</script>
</html>
<!DOCTYPE html>
<html class="reftest-wait">
<meta charset="utf8">
<title>Creates more than a screenful of divs and then resizes the container.</title>
<style>body { overflow:hidden; }</style>
<link rel="author" title="Fergal Daly" href="mailto:fergal@chromium.org">
<link rel="match" href="full-scroll-500px-ref.html">
<script type="module">
import 'std:elements/virtual-scroller';
import * as refTests from './resources/ref-tests.mjs';
refTests.withVirtualScroller(refTests.testFullScroll500px);
</script>
</html>
/**
* Copyright 2019 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*
* @fileoverview Helpers for testing virtual-scroller code.
* @package
*/
/**
* Creates a DIV with id and textContent set to |id|.
*/
export function div(id) {
const d = document.createElement('div');
d.id = id;
d.textContent = id;
return d;
}
/**
* Creates a container DIV with |n| child DIVs with their margin=|margin|.
*/
export function appendDivs(container, n, margin) {
for (let i = 0; i < n; i++) {
const d = div('d' + i);
d.style.margin = margin;
container.append(d);
}
}
/*
* Creates an element, appends it to the BODY, passes it to |callback| and
* removes it in a finally.
*/
export function withElement(name, callback) {
const element = document.createElement(name);
try {
document.body.appendChild(element);
callback(element);
} finally {
element.remove();
}
}
/**
* Remove the reftest-wait class from the HTML element.
*/
export function stopWaiting() {
document.documentElement.classList.remove('reftest-wait');
}
/**
* Generate a string with |n| words.
*/
export function words(n) {
let w = '';
for (let i = 0; i < n; i++) {
w += 'word ';
}
return w;
}
/**
* Allow the current frame to end and then call |callback| asap in the next
* frame.
*/
export function nextFrame(callback) {
window.requestAnimationFrame(() => {
window.setTimeout(callback, 0);
});
}
/**
* Copyright 2019 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*
* @fileoverview Runs a ref-test based on the filename.
* @package
*/
import * as helpers from './helpers.mjs';
const LESS_THAN_SCREENFUL = 5;
const MORE_THAN_SCREENFUL = 100;
const WORDS_IN_PARAGRAPH = 50;
export function testFull(target) {
helpers.appendDivs(target, MORE_THAN_SCREENFUL, '10px');
helpers.stopWaiting();
}
export function testFullScroll500px(target) {
helpers.appendDivs(target, MORE_THAN_SCREENFUL, '10px');
helpers.nextFrame(() => {
window.scrollBy(0, 500); // eslint-disable-line no-magic-numbers
helpers.stopWaiting();
});
};
export function testMoveElement(target) {
helpers.appendDivs(target, MORE_THAN_SCREENFUL, '10px');
helpers.nextFrame(() => {
const element = target.firstElementChild;
target.appendChild(element);
helpers.stopWaiting();
});
}
export function testMoveElementScrollIntoView(target) {
helpers.appendDivs(target, MORE_THAN_SCREENFUL, '10px');
const element = target.firstElementChild;
target.appendChild(element);
helpers.nextFrame(() => {
element.scrollIntoView();
helpers.stopWaiting();
});
}
export function testPart(target) {
helpers.appendDivs(target, LESS_THAN_SCREENFUL, '10px');
helpers.stopWaiting();
}
export function testResize(target) {
target.style.overflow = 'hidden';
target.style.width = '500px';
helpers.appendDivs(target, MORE_THAN_SCREENFUL, '10px');
const text = helpers.words(WORDS_IN_PARAGRAPH);
for (const e of target.children) {
e.innerText = text;
}
helpers.nextFrame(() => {
target.style.width = '300px';
helpers.stopWaiting();
});
}
/**
* Runs |test| with a <virtual-scroller>, waiting until the custom element is
* defined.
*/
export function withVirtualScroller(test) {
customElements.whenDefined('virtual-scroller').then(() => {
runTest("virtual-scroller", test);
});
}
/**
* Runs |test| with a <div>.
*/
export function withDiv(test) {
runTest("div", test);
}
function runTest(elementName, test) {
const element = document.createElement(elementName);
document.body.appendChild(element);
test(element);
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment