@adobe/uxp-optimized
Version:
Library of react components optimized for Adobe's Unified Extensibility Platform
752 lines (750 loc) • 32.4 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/*
Copyright 2019 Adobe
All Rights Reserved.
NOTICE: Adobe permits you to use, modify, and distribute this file in
accordance with the terms of the Adobe license agreement accompanying
it. If you have received this file from a source other than Adobe,
then your use, modification, or distribution of it requires the prior
written permission of Adobe.
*/
require("../common/ResizeObserver");
const ResizeObserver_1 = __importDefault(require("../common/ResizeObserver"));
const Margin_1 = __importStar(require("../common/Margin"));
const getStableArray_1 = __importDefault(require("./getStableArray"));
const react_1 = __importDefault(require("react"));
const lerp_1 = __importDefault(require("../common/lerp"));
const initialVisibleItemCount = 30;
class ClassProperties {
constructor(type) {
// we mark this on all class properties after a container resize
// so we can try to reload them if we still have
// components of their class present
this.valid = false;
this.column = null;
// these fields are used to estimate the size of future elements of this class
this.totalItemWidth = 0;
this.totalItemHeight = 0;
this.totalItemCount = 0;
this.type = type;
}
invalidate() {
this.valid = false;
this.totalItemCount = 0;
this.totalItemWidth = 0;
this.totalItemHeight = 0;
}
// this function stores and tracks an average item size of this type.
// this helps us more accurately estimate scroll location when doing flow layout.
setItemSize(width, height) {
this.totalItemWidth += width;
this.totalItemHeight += height;
this.totalItemCount++;
// recalculate width/height
this.width = Math.round(this.totalItemWidth / this.totalItemCount);
this.height = Math.round(this.totalItemHeight / this.totalItemCount);
}
setFromCss(css) {
if (this.cssInline == null) {
// we only record the first css display.
// chrome is changing inline-block to block occasionally
// this does mean clients cannot change display
// with responsive media queries.
this.cssInline = (css.display || "").indexOf("inline") >= 0;
}
this.cssMargin = Margin_1.default.fromCssMargin(css);
let columnCountCss = css.getPropertyValue("--column-count-self").trim();
let column = null;
if (columnCountCss) {
let count = columnCountCss === "auto" ? null : parseInt(columnCountCss);
let optimumWidth = null;
if (count == null) {
optimumWidth = (0, Margin_1.parseCssSize)(css.getPropertyValue("--optimum-width"));
if (optimumWidth === 0) {
let minWidth = (0, Margin_1.parseCssSize)(css.minWidth);
let maxWidth = (0, Margin_1.parseCssSize)(css.maxWidth);
if (minWidth === 0 || maxWidth === 0) {
throw new Error("Expected either --optimum-width or min-width and max-width");
}
optimumWidth = Math.ceil((minWidth + maxWidth) / 2);
}
}
column = { count, optimumWidth };
}
this.column = column;
}
}
class ItemProperties {
constructor(type) {
this.x = 0;
this.y = 0;
this.width = 0;
this.height = 0;
this.rendered = false;
this.type = type;
}
}
function isHTMLElement(node) {
return node != null && node.nodeType === 1;
}
// returns a memoized pixel css position.
const px = (() => {
const cache = [];
return (value) => {
let cached = cache[value];
if (cached === undefined) {
cached = cache[value] = `${value}px`;
}
return cached;
};
})();
/**
* This represents state which we cache permanently on the container.
* Changes to this DO NOT force a react re-render.
* By contrast changes to our react state does force a re-render.
*/
class VirtualManager {
constructor(props) {
this.itemProperties = {};
this.classProperties = new Map();
this.containerPadding = new Margin_1.default(0);
this.scrollAnchor = null;
this.isScrolling = false;
// TODO: This needs to be fixed for horizontal mode. Somehow we missed this.
this.lastScrollTop = 0;
this.scrollDirection = 0; // 1 = down, -1 = up, 0 = none
this.scrollDuration = 0.5;
this.scrollWaitAfterFinish = 0.2;
this.container = props.container;
this.containerWidth = this.container.clientWidth;
this.container[VirtualManager.symbol] = this;
this.horizontal = props.horizontal;
this.itemLookup = new Map();
this.itemKey = props.itemKey;
this.itemType = props.itemType;
this.itemRect = props.itemRect;
this.onresize = this.onresize.bind(this);
this.resizeObserver = new ResizeObserver_1.default(this.onresize);
this.updateAndLayout = this.updateAndLayout.bind(this);
this.container.addEventListener("scroll", (e) => {
this.updateAndLayout();
});
this.container.addEventListener("wheel", (e) => {
this.cancelScrollAnimation();
});
this.placeholder = document.createElement("div");
this.container.appendChild(this.placeholder);
this.update(props);
}
update(props) {
this.items = props.items;
this.renderKeys = props.renderKeys;
this.setRenderKeys = props.setRenderKeys;
this.onLayout = props.onLayout;
if (!this.isScrolling) {
// we skip these time consuming steps if we are scrolling.
// since they will be done at the end of the call chain anyways.
let checkForDuplicates = new Set();
for (let item of this.items) {
let key = this.itemKey(item);
if (checkForDuplicates.has(key)) {
console.error(`Duplicate items:`, this.itemLookup.get(key), item);
throw new Error(`Item keys must be unique. Found duplicate: ${key}`);
}
checkForDuplicates.add(key);
this.itemLookup.set(key, item);
}
this.updateAndLayout(true);
}
return this.renderKeys;
}
static getReactElements(items, renderKeys, itemKey, itemType, factory, cache) {
const itemsByKey = new Map();
for (let item of items) {
itemsByKey.set(itemKey(item), item);
}
return renderKeys.map((key, elementIndex) => {
const elementKey = String(elementIndex);
const item = itemsByKey.get(key);
if (item == null) {
// if the item is not found it may have been temporarily hidden
// we will try to render it from cached element.
const cached = cache[key];
if (cached) {
return cached;
}
/// console.warn("item missing: ", { key, elementIndex, items })
return null;
}
return (cache[key] = react_1.default.cloneElement(factory(item), {
key: elementKey,
"data-key": key,
"data-type": itemType(item),
}));
});
}
getItemRect(item, key) {
if (item == null && key != null) {
item = this.itemLookup.get(key) || null;
}
if (item != null) {
let rect = this.itemRect != null ? this.itemRect(item) : null;
if (rect != null) {
if (rect.x == null ||
rect.y == null ||
rect.width == null ||
rect.height == null) {
throw new Error(`Item rect missing required properties (x,y,width,height): ${JSON.stringify(rect)}`);
}
return rect;
}
if (key == null) {
key = this.itemKey(item);
}
if (key != null) {
let props = this.itemProperties[key];
if (props != null) {
return props;
}
}
}
return null;
}
updateAndLayout(forceLayout = false) {
this.isScrolling = true;
let needsLayout = this.updateIndexes();
this.ensureElementsObservedAndSized();
if (needsLayout || forceLayout) {
this.layoutChildren();
// fire layout event
if (this.onLayout) {
this.onLayout();
}
}
this.isScrolling = false;
}
layoutChildren() {
let containerCss = window.getComputedStyle(this.container);
let columnGap = (0, Margin_1.parseCssSize)(containerCss.getPropertyValue("--column-gap"));
let rowGap = (0, Margin_1.parseCssSize)(containerCss.getPropertyValue("--row-gap"));
// create quick element lookup by key.
let elementLookup = this.getElementLookupByKey();
let x = 0, y = 0, width = this.containerWidth - this.containerPadding.horizontal, height = 0;
function newLine() {
x = 0;
y = height + rowGap;
}
// now iterate items
for (let item of this.items) {
let key = this.itemKey(item);
let element = elementLookup.get(key);
if (this.itemRect != null) {
let rect = this.getItemRect(item, key);
if (element) {
const { style } = element;
style.position = `absolute`;
style.left = px(rect.x);
style.top = px(rect.y);
style.width = px(rect.width);
style.height = px(rect.height);
}
height = rect.y + rect.height;
continue;
}
let type = this.itemType(item);
let properties = this.getItemProperties(key, type);
let classProps = this.classProperties.get(type);
if (!classProps) {
// console.warn("missing class properties: " + type);
break;
}
let margin = classProps.cssMargin;
let inline = classProps.cssInline;
let elementWidth = properties.width + margin.horizontal;
let elementHeight = properties.height + margin.vertical;
let left, top;
// new column layout
let { column } = classProps;
if (column) {
let count = column.count ||
Math.max(1, Math.round(width / (column.optimumWidth + columnGap)));
let index = Math.round(x / (elementWidth + columnGap));
elementWidth = Math.floor((width - columnGap * (count - 1)) / count);
// now we will tweak the x position to make sure we right align
// and distribute extra pixels fairly evenly across all columns
let l2r = index * (elementWidth + columnGap);
let r2l = width -
(count - index) * (elementWidth + columnGap) +
columnGap;
let alpha = count == 1 ? 0 : index / (count - 1);
x = Math.round((0, lerp_1.default)(l2r, r2l, alpha));
}
if (x > 0 &&
!this.horizontal &&
(!inline || x + elementWidth > width)) {
newLine();
}
left = x + this.containerPadding.left;
top = y + this.containerPadding.top;
if (this.horizontal) {
width = Math.max(width, x + elementWidth);
}
height = Math.max(height, y + elementHeight);
if (inline) {
x += elementWidth + columnGap;
}
else {
y += elementHeight + rowGap;
}
// store on properties
properties.x = left;
properties.y = top;
if (element) {
// track that this element has actually been rendered
properties.rendered = true;
// now absolutely position if there is an element
const { style } = element;
if (style.position !== "absolute") {
style.position = "absolute";
}
style.left = px(left);
style.top = px(top);
if (column != null) {
style.width = px(elementWidth);
}
if (!inline) {
style.width = px(width - margin.horizontal);
}
}
}
// we do this immediately after a layout of children.
// there is logic in the scrollAnimationCallback that can correct
// for an updated measurement of children which could cause visual jump
// calling this immediately fixes it immediately so there will be no visual jump
// what can cause the jump is as follows:
// We estimate a scroll location at 10,000px.
// We scroll to that location.
// Upon actual sizing of some *estimated* elements, it turns out we need to be at 10,050 px.
// So we want to immediately correct the position rather than either
// being 50 pixels off or waiting a full animation frame to correct it.
this.scrollAnimationCallback();
// increase the placeholders height to match our layed out height.
if (this.horizontal) {
this.placeholder.style.width = px(width);
}
else {
this.placeholder.style.height = px(height);
}
}
getElementLookupByKey() {
let elementLookup = new Map();
let index = 0;
for (let child = this.container.firstChild; child != null; child = child.nextSibling) {
if (isHTMLElement(child)) {
let key = child.dataset.key;
if (key) {
elementLookup.set(key, child);
}
else if (child.previousSibling != null) {
// the first child is the height placeholder element
// every other element should have a data-key property
// if not that means they used a function or class component
// that is not passing ...otherProps to it's rendered element.
let item = this.items[index];
throw new Error(`Virtualizer item error: Function and Class components must pass through ...otherProps to rendered element. ie: <div {...otherProps}></div>. Check element created for ${JSON.stringify(item)}`);
}
}
index++;
}
return elementLookup;
}
ensureElementsObservedAndSized() {
this.resizeObserver.observe(this.container);
for (let child = this.container.firstChild; child != null; child = child.nextSibling) {
if (child.nodeType === 1) {
this.resizeObserver.observe(child);
// also make sure we've sized it if available.
// Browser ResizeObserver does not send resize events for inline elements.
this.onElementSized(child);
}
}
}
getVisibleRect() {
let x = this.container.scrollLeft;
let y = this.container.scrollTop;
let width = this.container.clientWidth;
let height = this.container.clientHeight;
return { x, y, width, height };
}
get pageSize() {
return this.container[this.horizontal ? "clientWidth" : "clientHeight"];
}
get prerenderOtherDirection() {
return 100;
}
get prerenderScrollDirection() {
return 500;
}
get isManualLayout() {
return this.itemRect != null;
}
getRenderItemIndices(scrollDirection) {
let { horizontal } = this;
let beginning = Math.max(0, this.container[horizontal ? "scrollLeft" : "scrollTop"]);
let { pageSize, prerenderScrollDirection, prerenderOtherDirection } = this;
if (scrollDirection === 0) {
// if we aren't scrolling then each direction is other.
prerenderScrollDirection = prerenderOtherDirection;
}
let end = beginning + pageSize;
let totalPagesSize = pageSize + prerenderScrollDirection + prerenderOtherDirection;
// now expand top
beginning = Math.max(0, beginning -
(scrollDirection >= 0
? prerenderOtherDirection
: prerenderScrollDirection));
// then expand bottom by whatever is remaining. (if this is larger than content area, that is fine)
end = beginning + totalPagesSize;
const renderKeys = new Set();
const existingKeys = new Set();
// keys which we are adding so that we can pre-size them.
// only used without manual layout.
// This pre-sizing code ensures that we always contain at least one of each
// item type in the document so that we can capture sizing and css class properties.
const presize = !this.isManualLayout;
const presizeTypes = presize ? new Map() : null;
let size = horizontal ? "width" : "height";
let position = horizontal ? "x" : "y";
for (let index = 0; index < this.items.length; index++) {
const item = this.items[index];
const key = this.itemKey(item);
existingKeys.add(key);
const rect = this.getItemRect(item, key);
if (rect &&
rect[position] + rect[size] > beginning &&
rect[position] <= end) {
renderKeys.add(key);
}
else if (presizeTypes != null) {
let type = this.itemType(item);
if (!presizeTypes.has(type)) {
presizeTypes.set(type, key);
}
}
}
// we have to render *some* items since we may only get their accurate reported positions after they are rendered.
if (renderKeys.size === 0) {
for (let i = 0; i < initialVisibleItemCount && i < this.items.length; i++) {
renderKeys.add(this.itemKey(this.items[i]));
}
}
// we also want to always keep rendering a focused item so add this too
const focusedKey = this.getFocusedItemKey();
if (focusedKey) {
renderKeys.add(focusedKey);
}
// finally, if we are pre-sizing elements we need to add some of each type.
if (presizeTypes && presizeTypes.size > 0) {
for (let key of presizeTypes.values()) {
renderKeys.add(key);
}
}
return [renderKeys, existingKeys];
}
/**
* Clip scrollTop - UXP should do this automatically, but it doesn't due to bug UXP-10612
* Can remove this once UXP bug is fixed
* @returns number between 0 the container area - total area
*/
getClippedScrollTarget(scrollTarget) {
const scrollPx = this.container[this.horizontal ? "scrollWidth" : "scrollHeight"];
const clientPx = this.container[this.horizontal ? "clientWidth" : "clientHeight"];
return Math.max(0, Math.min(scrollTarget, scrollPx - clientPx));
}
getFocusedItemKey() {
for (let element = document.activeElement; element != null; element = element.parentNode) {
if (isHTMLElement(element)) {
const key = element.dataset.key;
if (key) {
return key;
}
}
}
return null;
}
updateIndexes() {
if (this.pageSize === 0) {
return;
}
const scrollTop = this.getClippedScrollTarget(this.container.scrollTop);
if (this.container.scrollTop !== scrollTop) {
this.container.scrollTop = scrollTop;
}
const scrollDelta = scrollTop - this.lastScrollTop;
this.lastScrollTop = scrollTop;
const previousItems = this.previousItems || this.items;
this.previousItems = this.items;
const itemsProbablyChanged = this.items.length !== previousItems.length;
if (itemsProbablyChanged) {
this.scrollDirection = 0;
}
else if (scrollDelta !== 0) {
this.scrollDirection = Math.sign(scrollDelta);
}
// Get a set of the items we want to render, and a set of all the items
// (do this in one method, so we only need to iterate through all the items once)
const [renderKeys, existingKeys] = this.getRenderItemIndices(this.scrollDirection);
const getType = (key) => {
if (key != null) {
const item = this.itemLookup.get(key);
if (item) {
return this.itemType(item);
}
}
return null;
};
// We use keys since even with additions/removals, keys are stable
let oldKeys = this.renderKeys;
let newKeys = (0, getStableArray_1.default)(oldKeys, renderKeys, getType, getType);
const elementLookup = this.getElementLookupByKey();
newKeys.forEach((key) => {
let element = elementLookup.get(key);
if (element) {
const deleted = !existingKeys.has(key);
// we HAVE to use display: none
// if we use visibility: hidden then the hidden elements
// still count for scroll size
element.style.display = deleted ? "none" : "";
}
});
// console.log({ existingKeys, renderKeys, newKeys })
// we do a deep compare as we don't want to cause react re-rendering unless our rendered item renderKeys have changed.
if (oldKeys.length !== newKeys.length ||
JSON.stringify(oldKeys) !== JSON.stringify(newKeys)) {
// const debug = true;
// if (debug) {
// let counts: any = {};
// for (let key of newKeys) {
// let type = getType(key)!;
// counts[type] = (counts[type] || 0) + 1;
// }
// console.log(JSON.stringify(counts, null, 2));
// }
this.setRenderKeys(newKeys);
// this.renderKeys won't change till react re-renders and then calls VirtualManager.update().
return true;
}
return false;
}
onElementSized(element) {
if (element === this.container) {
this.containerPadding = Margin_1.default.fromCssPadding(getComputedStyle(element));
}
let { key, type } = element.dataset;
if (key && type) {
// cache element size
let properties = this.itemProperties[key];
if (properties == null) {
properties = this.getItemProperties(key, type);
}
// we REALLY only want to test these when absolutely necessary
// since reading them forces immediate layout.
const width = element.offsetWidth;
const height = element.offsetHeight;
if (width > 0 && height > 0) {
let changed = width !== properties.width || height !== properties.height;
properties.width = width;
properties.height = height;
// cache ComputedCssProperties by className
if (changed) {
let classProps = this.classProperties.get(type);
if (classProps == null || !classProps.valid) {
let css = getComputedStyle(element);
if (classProps == null) {
classProps = new ClassProperties(type);
this.classProperties.set(type, classProps);
}
classProps.setFromCss(css);
}
classProps.setItemSize(width, height);
}
}
}
}
onresize(entries) {
// if entries contains the container then we mark computed css obsolete
// that way we can recompute while traversing elements.
for (let entry of entries) {
if (entry.target === this.container) {
this.containerWidth = this.container.clientWidth;
for (let classProps of this.classProperties.values()) {
classProps.invalidate();
}
// also reset placeholder size
this.placeholder.style.width = "1px";
this.placeholder.style.height = "1px";
// TODO: Find currently visible items and use ScrollAnchor to restore them to same position after relayout.
// also, clear all cached item sizes.
for (let key in this.itemProperties) {
let itemProps = this.itemProperties[key];
itemProps.width = 0;
itemProps.height = 0;
}
}
else {
this.onElementSized(entry.target);
}
}
this.updateAndLayout(true);
}
getItemTargetScrollPosition(anchor) {
var _a, _b;
let item = this.itemLookup.get(anchor.itemKey);
if (item) {
let itemPin = (_a = anchor.itemPin) !== null && _a !== void 0 ? _a : 0;
let windowPin = (_b = anchor.windowPin) !== null && _b !== void 0 ? _b : 0;
const bounds = this.getItemRect(item);
if (bounds) {
const clientSize = this.container[this.horizontal ? "clientWidth" : "clientHeight"];
const targetScrollPosition = bounds[this.horizontal ? "x" : "y"] +
itemPin * bounds[this.horizontal ? "width" : "height"] -
windowPin * clientSize;
return targetScrollPosition;
}
}
return null;
}
scrollToItem(key, options) {
var _a, _b, _c;
// correct for legacy .behavior option
if (options != null &&
options.behavior != null &&
options.duration == null) {
options.duration =
options.behavior === "smooth" ? 0.5 : 0.0;
}
const itemPin = (_a = options === null || options === void 0 ? void 0 : options.position) !== null && _a !== void 0 ? _a : 0;
const windowPin = (_b = options === null || options === void 0 ? void 0 : options.position) !== null && _b !== void 0 ? _b : 0;
const duration = (_c = options === null || options === void 0 ? void 0 : options.duration) !== null && _c !== void 0 ? _c : 0.5;
const scrollAnchor = { itemKey: key, itemPin, windowPin, duration };
const top = this.getItemTargetScrollPosition(scrollAnchor);
if (top != null) {
this.startScrollAnimation(scrollAnchor);
this.updateAndLayout(false);
}
}
startScrollAnimation(scrollAnchor) {
this.scrollAnchor = scrollAnchor;
this.scrollStartTime = Date.now();
this.scrollStartPosition =
this.container[this.horizontal ? "scrollLeft" : "scrollTop"];
this.scrollDuration = scrollAnchor.duration;
this.scrollAnimationCallback();
}
cancelScrollAnimation() {
if (this.scrollAnimating != null) {
if (typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(this.scrollAnimating);
}
delete this.scrollAnimating;
}
this.scrollAnchor = null;
}
scrollAnimationCallback() {
let continueAnimating = false;
delete this.scrollAnimating;
if (this.scrollAnchor != null) {
let targetPosition = this.getItemTargetScrollPosition(this.scrollAnchor);
if (targetPosition != null) {
let startDelta = targetPosition - this.scrollStartPosition;
// we are going to try to smooth scroll... BUT if we are scrolling a very long way
// we are first going to jump much closer to the target location.
// let's say, not more than 2000 pixels away, maximum.
const maxDelta = 2000;
if (Math.abs(startDelta) > maxDelta) {
startDelta = Math.sign(startDelta) * maxDelta;
}
let time = (Date.now() - this.scrollStartTime) / 1000;
let elapsed = Math.min(time, this.scrollDuration);
let alpha = this.scrollDuration > 0
? elapsed / this.scrollDuration
: 1.0;
let animatedTopNow = targetPosition - (1 - alpha) * startDelta;
this.container[this.horizontal ? "scrollLeft" : "scrollTop"] =
this.getClippedScrollTarget(animatedTopNow);
// keep animating for twice the scroll duration.
// this provides extra time after scroll for resizing of
// nearby element positions to get fixed to the anchor point.
continueAnimating =
time < this.scrollDuration + this.scrollWaitAfterFinish;
// console.log({ animatedTopNow, targetTop, alpha, elapsed, time, startDelta, startTop: this.scrollStartPosition, continueAnimating });
}
}
if (continueAnimating) {
if (this.scrollAnimating == null &&
typeof requestAnimationFrame === "function") {
this.scrollAnimating = requestAnimationFrame(this.scrollAnimationCallback.bind(this));
}
}
else {
this.cancelScrollAnimation();
}
}
getItemProperties(key, type) {
let props = this.itemProperties[key];
if (props == null) {
props = this.itemProperties[key] = new ItemProperties(type);
}
let classProps = this.classProperties.get(type);
if (classProps) {
if (props.width === 0) {
props.width = classProps.width;
}
if (props.height === 0) {
props.height = classProps.height;
}
}
return props;
}
static instance(container, props) {
let manager = container[VirtualManager.symbol];
if (manager == null && props != null) {
manager = new VirtualManager(props);
}
return manager;
}
static update(props) {
let manager = VirtualManager.instance(props.container, props);
manager.update(props);
return manager.renderKeys;
}
}
exports.default = VirtualManager;
VirtualManager.symbol = Symbol("VirtualManager.symbol");