UNPKG

@adobe/uxp-optimized

Version:

Library of react components optimized for Adobe's Unified Extensibility Platform

752 lines (750 loc) 32.4 kB
"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");