import {Directive, Input, ViewContainerRef, TemplateRef, EmbeddedViewRef} from 'angular2/core'; import {CSS} from '../../util/dom'; /** * NO DOM */ export function processRecords(stopAtHeight: number, records: any[], cells: VirtualCell[], headerFn: Function, footerFn: Function, data: VirtualData) { let record: any; let startRecordIndex: number; let previousCell: VirtualCell; let tmpData: any; let lastRecordIndex = (records.length - 1); if (cells.length) { // we already have cells previousCell = cells[ cells.length - 1]; if (previousCell.top + previousCell.height > stopAtHeight) { return; } startRecordIndex = (previousCell.record + 1); } else { // no cells have been created yet previousCell = { row: 0, width: 0, height: 0, top: 0, left: 0, tmpl: -1 }; startRecordIndex = 0; } let processedTotal = 0; for (var recordIndex = startRecordIndex; recordIndex <= lastRecordIndex; recordIndex++) { record = records[recordIndex]; if (headerFn) { tmpData = headerFn(record, recordIndex, records); if (tmpData !== null) { // add header data previousCell = addCell(previousCell, recordIndex, TEMPLATE_HEADER, tmpData, data.hdrWidth, data.hdrHeight, data.viewWidth); cells.push(previousCell); } } // add item data previousCell = addCell(previousCell, recordIndex, TEMPLATE_ITEM, null, data.itmWidth, data.itmHeight, data.viewWidth); cells.push(previousCell); if (footerFn) { tmpData = footerFn(record, recordIndex, records); if (tmpData !== null) { // add footer data previousCell = addCell(previousCell, recordIndex, TEMPLATE_FOOTER, tmpData, data.ftrWidth, data.ftrHeight, data.viewWidth); cells.push(previousCell); } } if (previousCell.record === lastRecordIndex) { previousCell.isLast = true; } // should always process at least 3 records processedTotal++; if (previousCell.top + previousCell.height + data.itmHeight > stopAtHeight && processedTotal > 3) { return; } } } function addCell(previousCell: VirtualCell, recordIndex: number, tmpl: number, tmplData: any, cellWidth: number, cellHeight: number, viewportWidth: number) { let newCell: VirtualCell; if (previousCell.left + previousCell.width + cellWidth > viewportWidth) { // add a new cell in a new row newCell = { record: recordIndex, tmpl: tmpl, row: (previousCell.row + 1), width: cellWidth, height: cellHeight, top: (previousCell.top + previousCell.height), left: 0, reads: 0, }; } else { // add a new cell in the same row newCell = { record: recordIndex, tmpl: tmpl, row: previousCell.row, width: cellWidth, height: cellHeight, top: previousCell.top, left: (previousCell.left + previousCell.width), reads: 0, }; } if (tmplData) { newCell.data = tmplData; } return newCell; } /** * NO DOM */ export function populateNodeData(startCellIndex: number, endCellIndex: number, viewportWidth: number, scrollingDown: boolean, cells: VirtualCell[], records: any[], nodes: VirtualNode[], viewContainer: ViewContainerRef, itmTmp: TemplateRef, hdrTmp: TemplateRef, ftrTmp: TemplateRef, initialLoad: boolean): boolean { let madeChanges = false; let node: VirtualNode; let availableNode: VirtualNode; let cell: VirtualCell; let previousCell: VirtualCell; let isAlreadyRendered: boolean; let lastRecordIndex = (records.length - 1); let viewInsertIndex: number = null; let totalNodes = nodes.length; startCellIndex = Math.max(startCellIndex, 0); endCellIndex = Math.min(endCellIndex, cells.length - 1); for (var cellIndex = startCellIndex; cellIndex <= endCellIndex; cellIndex++) { cell = cells[cellIndex]; availableNode = null; isAlreadyRendered = false; // find the first one that's available if (!initialLoad) { for (var i = 0; i < totalNodes; i++) { node = nodes[i]; if (cell.tmpl !== node.tmpl || i === 0 && cellIndex !== 0) { // the cell must use the correct template // first node can only be used by the first cell (css :first-child reasons) // this node is never available to be reused continue; } else if (node.isLastRecord) { // very last record, but could be a header/item/footer if (cell.record === lastRecordIndex) { availableNode = nodes[i]; availableNode.hidden = false; break; } // this node is for the last record, but not actually the last continue; } if (node.cell === cellIndex) { isAlreadyRendered = true; break; } if (node.cell < startCellIndex || node.cell > endCellIndex) { if (!availableNode) { // havent gotten an available node yet availableNode = nodes[i]; } else if (scrollingDown) { // scrolling down if (node.cell < availableNode.cell) { availableNode = nodes[i]; } } else { // scrolling up if (node.cell > availableNode.cell) { availableNode = nodes[i]; } } } } if (isAlreadyRendered) { continue; } } if (!availableNode) { // did not find an available node to put the cell data into // insert a new node before the last record nodes if (viewInsertIndex === null) { viewInsertIndex = -1; for (var j = totalNodes - 1; j >= 0; j--) { node = nodes[j]; if (node && !node.isLastRecord) { viewInsertIndex = viewContainer.indexOf(node.view); break; } } } availableNode = { tmpl: cell.tmpl, view: viewContainer.createEmbeddedView( cell.tmpl === TEMPLATE_HEADER ? hdrTmp : cell.tmpl === TEMPLATE_FOOTER ? ftrTmp : itmTmp, viewInsertIndex ) }; totalNodes = nodes.push(availableNode); // console.debug(`VirtrualScroll, new node, tmpl ${cell.tmpl}, height ${cell.height}`); } // console.debug(`node was cell ${availableNode.cell} but is now ${cellIndex}, was top: ${cell.top}`); // assign who's the new cell index for this node availableNode.cell = cellIndex; // apply the cell's data to this node availableNode.view.setLocal('\$implicit', cell.data || records[cell.record]); availableNode.view.setLocal('index', cellIndex); availableNode.view.setLocal('even', (cellIndex % 2 === 0)); availableNode.view.setLocal('odd', (cellIndex % 2 === 1)); availableNode.hasChanges = true; availableNode.lastTransform = null; madeChanges = true; } if (initialLoad) { // add nodes that go at the very end, and only represent the last record addLastNodes(nodes, viewContainer, TEMPLATE_HEADER, hdrTmp); addLastNodes(nodes, viewContainer, TEMPLATE_ITEM, itmTmp); addLastNodes(nodes, viewContainer, TEMPLATE_FOOTER, ftrTmp); } return madeChanges; } function addLastNodes(nodes: VirtualNode[], viewContainer: ViewContainerRef, templateType: number, templateRef: TemplateRef) { if (templateRef) { let node: VirtualNode = { tmpl: templateType, view: viewContainer.createEmbeddedView(templateRef), isLastRecord: true, hidden: true, }; node.view.setLocal('\$implicit', {}); nodes.push(node); } } /** * DOM READ THEN DOM WRITE */ export function initReadNodes(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData) { if (nodes.length && cells.length) { // first node // ******** DOM READ **************** cells[0].top = getElement(nodes[0]).offsetTop; cells[0].row = 0; // ******** DOM READ **************** updateDimensions(nodes, cells, data, true); // ******** DOM READS ABOVE / DOM WRITES BELOW **************** for (var i = 0; i < nodes.length; i++) { if (nodes[i].hidden) { // ******** DOM WRITE **************** getElement(nodes[i]).classList.add('virtual-hidden'); } } } } /** * DOM READ */ export function updateDimensions(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData, initialUpdate: boolean) { let node: VirtualNode; let element: HTMLElement; let totalCells = cells.length; let cell: VirtualCell; let previousCell: VirtualCell; for (var i = 0; i < nodes.length; i++) { node = nodes[i]; cell = cells[node.cell]; // read element dimensions if they haven't been checked enough times if (cell && cell.reads < REQUIRED_DOM_READS && !node.hidden) { element = getElement(node); // ******** DOM READ **************** readElements(cell, element); if (initialUpdate) { // update estimated dimensions with more accurate dimensions if (cell.tmpl === TEMPLATE_HEADER) { data.hdrHeight = cell.height; if (cell.left === 0) { data.hdrWidth = cell.width; } } else if (cell.tmpl === TEMPLATE_FOOTER) { data.ftrHeight = cell.height; if (cell.left === 0) { data.ftrWidth = cell.width; } } else { data.itmHeight = cell.height; if (cell.left === 0) { data.itmWidth = cell.width; } } } cell.reads++; } } // figure out which cells are currently viewable within the viewport let viewableBottom = (data.scrollTop + data.viewHeight); data.topViewCell = totalCells; data.bottomViewCell = 0; // completely realign position to ensure they're all accurately placed for (var i = 1; i < totalCells; i++) { cell = cells[i]; previousCell = cells[i - 1]; if (previousCell.left + previousCell.width + cell.width > data.viewWidth) { // new row cell.row++; cell.top = (previousCell.top + previousCell.height); cell.left = 0; } else { // same row cell.row = previousCell.row; cell.top = previousCell.top; cell.left = (previousCell.left + previousCell.width); } // figure out which cells are viewable within the viewport if (cell.top + cell.height > data.scrollTop && i < data.topViewCell) { data.topViewCell = i; } else if (cell.top < viewableBottom && i > data.bottomViewCell) { data.bottomViewCell = i; } } } /** * DOM READ */ function readElements(cell: VirtualCell, element: HTMLElement) { // ******** DOM READ **************** let styles = window.getComputedStyle(element); // ******** DOM READ **************** cell.left = (element.offsetLeft - parseFloat(styles.marginLeft)); // ******** DOM READ **************** cell.width = (element.offsetWidth + parseFloat(styles.marginLeft) + parseFloat(styles.marginRight)); // ******** DOM READ **************** cell.height = (element.offsetHeight + parseFloat(styles.marginTop) + parseFloat(styles.marginBottom)); } /** * DOM WRITE */ export function writeToNodes(nodes: VirtualNode[], cells: VirtualCell[], totalRecords: number) { let node: VirtualNode; let element: HTMLElement; let cell: VirtualCell; let totalCells = Math.max(totalRecords, cells.length).toString(); let transform: string; for (var i = 0, ilen = nodes.length; i < ilen; i++) { node = nodes[i]; if (node.hidden) { continue; } cell = cells[node.cell]; transform = `translate3d(${cell.left}px,${cell.top}px,0px)`; if (node.lastTransform === transform) { continue; } element = getElement(node); if (element) { // ******** DOM WRITE **************** element.style[CSS.transform] = node.lastTransform = transform; // ******** DOM WRITE **************** element.classList.add('virtual-position'); if (node.isLastRecord) { // its the last record, now with data and safe to show // ******** DOM WRITE **************** element.classList.remove('virtual-hidden'); } // https://www.w3.org/TR/wai-aria/states_and_properties#aria-posinset // ******** DOM WRITE **************** element.setAttribute('aria-posinset', (node.cell + 1).toString()); // https://www.w3.org/TR/wai-aria/states_and_properties#aria-setsize // ******** DOM WRITE **************** element.setAttribute('aria-setsize', totalCells); } } } /** * NO DOM */ export function adjustRendered(cells: VirtualCell[], data: VirtualData) { // figure out which cells should be rendered let cell: VirtualCell; let lastRow = -1; let cellsRenderHeight = 0; let maxRenderHeight = (data.renderHeight - data.itmHeight); let totalCells = cells.length; let viewableRenderedPadding = (data.itmHeight < 90 ? VIEWABLE_RENDERED_PADDING : 0); if (data.scrollDiff > 0) { // scrolling down data.topCell = Math.max(data.topViewCell - viewableRenderedPadding, 0); data.bottomCell = Math.min(data.topCell + 2, totalCells - 1); for (var i = data.topCell; i < totalCells; i++) { cell = cells[i]; if (cell.row !== lastRow) { cellsRenderHeight += cell.height; lastRow = cell.row; } if (i > data.bottomCell) { data.bottomCell = i; } if (cellsRenderHeight >= maxRenderHeight) { break; } } } else { // scroll up data.bottomCell = Math.min(data.bottomViewCell + viewableRenderedPadding, totalCells - 1); data.topCell = Math.max(data.bottomCell - 2, 0); for (var i = data.bottomCell; i >= 0; i--) { cell = cells[i]; if (cell.row !== lastRow) { cellsRenderHeight += cell.height; lastRow = cell.row; } if (i < data.topCell) { data.topCell = i; } if (cellsRenderHeight >= maxRenderHeight) { break; } } } // console.log(`adjustRendered topCell: ${data.topCell}, bottomCell: ${data.bottomCell}, cellsRenderHeight: ${cellsRenderHeight}, data.renderHeight: ${data.renderHeight}`); } /** * NO DOM */ export function getVirtualHeight(totalRecords: number, lastCell: VirtualCell): number { if (lastCell.record >= totalRecords - 1) { return (lastCell.top + lastCell.height); } let unknownRecords = (totalRecords - lastCell.record - 1); let knownHeight = (lastCell.top + lastCell.height); return Math.ceil(knownHeight + ((knownHeight / (totalRecords - unknownRecords)) * unknownRecords)); } /** * NO DOM */ export function estimateHeight(totalRecords: number, lastCell: VirtualCell, existingHeight: number, difference: number): number { let newHeight = getVirtualHeight(totalRecords, lastCell); let percentToBottom = (lastCell.record / (totalRecords - 1)); let diff = Math.abs(existingHeight - newHeight); if ((diff > (newHeight * difference)) || (percentToBottom > .995)) { return newHeight; } return existingHeight; } /** * DOM READ */ export function calcDimensions(data: VirtualData, viewportElement: HTMLElement, approxItemWidth: string, approxItemHeight: string, appoxHeaderWidth: string, approxHeaderHeight: string, approxFooterWidth: string, approxFooterHeight: string, bufferRatio: number) { // get the parent container's viewport height // ******** DOM READ **************** data.viewWidth = viewportElement.offsetWidth; // ******** DOM READ **************** data.viewHeight = viewportElement.offsetHeight; // the height we'd like to render, which is larger than viewable data.renderHeight = (data.viewHeight * bufferRatio); if (data.viewWidth > 0 && data.viewHeight > 0) { data.itmWidth = calcWidth(data.viewWidth, approxItemWidth); data.itmHeight = calcHeight(data.viewHeight, approxItemHeight); data.hdrWidth = calcWidth(data.viewWidth, appoxHeaderWidth); data.hdrHeight = calcHeight(data.viewHeight, approxHeaderHeight); data.ftrWidth = calcWidth(data.viewWidth, approxFooterWidth); data.ftrHeight = calcHeight(data.viewHeight, approxFooterHeight); data.valid = true; } } /** * NO DOM */ function calcWidth(viewportWidth: number, approxWidth: string): number { if (approxWidth.indexOf('%') > 0) { return (viewportWidth * (parseFloat(approxWidth) / 100)); } else if (approxWidth.indexOf('px') > 0) { return parseFloat(approxWidth); } throw 'virtual scroll width can only use "%" or "px" units'; } /** * NO DOM */ function calcHeight(viewportHeight: number, approxHeight: string): number { if (approxHeight.indexOf('px') > 0) { return parseFloat(approxHeight); } throw 'virtual scroll height must use "px" units'; } /** * NO DOM */ function getElement(node: VirtualNode) { let rootNodes = node.view.rootNodes; for (var i = 0; i < rootNodes.length; i++) { if (rootNodes[i].nodeType === 1) { return rootNodes[i]; } } } // could be either record data or divider data export interface VirtualCell { record?: number; tmpl?: number; data?: any; row?: number; left?: number; width?: number; top?: number; height?: number; reads?: number; isLast?: boolean; } // one of the rendered nodes export interface VirtualNode { cell?: number; tmpl: number; view: EmbeddedViewRef; isLastRecord?: boolean; hidden?: boolean; hasChanges?: boolean; lastTransform?: string; } export interface VirtualData { scrollTop?: number; scrollDiff?: number; viewWidth?: number; viewHeight?: number; renderHeight?: number; topCell?: number; bottomCell?: number; topViewCell?: number; bottomViewCell?: number; valid?: boolean; itmWidth?: number; itmHeight?: number; hdrWidth?: number; hdrHeight?: number; ftrWidth?: number; ftrHeight?: number; } const TEMPLATE_ITEM = 0; const TEMPLATE_HEADER = 1; const TEMPLATE_FOOTER = 2; const VIEWABLE_RENDERED_PADDING = 3; const REQUIRED_DOM_READS = 2;