UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

431 lines (430 loc) 19.8 kB
import { ObservableValue } from '../Core/Observable'; export class Selection extends ObservableValue { constructor(options) { super([]); this.selectedRanges = []; this.lockCount = 0; this.unselectableRangesValue = []; this.selectedCount = 0; this.unselectableCount = 0; this.onItemsChanged = (change, action) => { const index = change.index; let removedUnselectableRange, unselectedRange; if (action === "change") { return; } // Unselect any items that were removed from the underlying item collection. if (change.removedItems && change.removedItems.length) { removedUnselectableRange = this.removeUnselectableInternal(index, change.removedItems.length); unselectedRange = this.unselectInternal(index, change.removedItems.length); } // Offset any selection by the items added. if (change.addedItems || change.removedItems) { const adjustCount = (change.addedItems ? change.addedItems.length : 0) - (change.removedItems ? change.removedItems.length : 0); const adjustedSelectionRanges = adjustRanges(index, adjustCount, this.selectedRanges); const adjustedUnselectableRanges = adjustRanges(index, adjustCount, this.unselectableRanges); if (adjustedSelectionRanges.length) { this.notify(adjustedSelectionRanges, "set"); } if (adjustedUnselectableRanges.length) { this.notify(adjustedUnselectableRanges, "setUnselectable"); } } if (removedUnselectableRange) { this.notify([removedUnselectableRange], "removeUnselectable"); } if (unselectedRange) { this.notify([unselectedRange], "unselect"); } }; if (typeof options === "boolean" || options === undefined) { this.multiSelect = !!options || false; } else { this.alwaysMerge = !!options.alwaysMerge; this.multiSelect = !!options.multiSelect; this.unselectableRanges = options.unselectableRanges || []; this.value = options.selectedRanges || []; } } get value() { return this.selectedRanges; } set value(ranges) { this.selectedCount = 0; this.selectedRanges = ranges.map(range => { this.selectedCount += range.endIndex - range.beginIndex + 1; return { beginIndex: range.beginIndex, endIndex: range.endIndex }; }); this.notify(ranges, "set"); } get unselectableRanges() { return this.unselectableRangesValue; } set unselectableRanges(ranges) { this.unselectableCount = 0; this.unselectableRangesValue = ranges.map(range => { this.unselectableCount += range.endIndex - range.beginIndex + 1; return { beginIndex: range.beginIndex, endIndex: range.endIndex }; }); this.notify(ranges, "setUnselectable"); } clear() { const selectedRanges = this.clearSelectedRanges(); if (selectedRanges) { // Go through and notify any observers of the change. this.notify(selectedRanges, "unselect"); } } clearUnselectable() { const unselectableRanges = [...this.unselectableRangesValue]; this.unselectableRanges = []; this.unselectableCount = 0; this.notify(unselectableRanges, "removeUnselectable"); } selectable(index) { return !indexWithinRanges(index, this.unselectableRanges); } selected(index) { return indexWithinRanges(index, this.selectedRanges); } addUnselectable(index, count) { let updatedRanges = false; const beginIndex = index; const endIndex = index + (count || 1) - 1; // If no count is specified we will add a single item. count = count || 1; for (; count > 0; count--) { if (!this.selectable(index)) { index++; continue; } let rangeIndex = 0; let updatedRange; // Determine if there is a range we can add this unselectable item to. for (; rangeIndex < this.unselectableRanges.length; rangeIndex++) { const unselectableRange = this.unselectableRanges[rangeIndex]; // Check if this unselectable item occurs before this unselectableRange. if (index < unselectableRange.beginIndex) { if (index === unselectableRange.beginIndex - 1) { updatedRange = unselectableRange; updatedRange.beginIndex--; } break; } // If this index is directly after this range we will extend it. else if (index === unselectableRange.endIndex + 1) { // If there is a gap of 1 number we will merge the two ranges. if (rangeIndex < this.unselectableRanges.length - 1 && index === this.unselectableRanges[rangeIndex + 1].beginIndex - 1) { updatedRange = unselectableRange; updatedRange.endIndex = this.unselectableRanges[rangeIndex + 1].endIndex; // Remove the second range since it is merged into the previous range. this.unselectableRanges.splice(rangeIndex + 1, 1); } else { updatedRange = unselectableRange; updatedRange.endIndex++; } break; } } // If there was no range to merge with, add a new one. if (!updatedRange) { updatedRange = { beginIndex: index, endIndex: index }; this.unselectableRanges.splice(rangeIndex, 0, updatedRange); } updatedRanges = true; this.unselectableCount++; index++; } // Notify observers of the added item. if (updatedRanges) { this.notify([{ beginIndex: beginIndex, endIndex: endIndex }], "addUnselectable"); } } removeUnselectable(index, count) { const removedRange = this.removeUnselectableInternal(index, count); if (removedRange) { this.notify([removedRange], "removeUnselectable"); } } select(index, count, merge = this.alwaysMerge, multiSelect = this.multiSelect) { if (!this.lockCount) { const beginIndex = index; const endIndex = beginIndex + (count || 1) - 1; let updatedRanges = false; let unselectedRanges; if (!multiSelect) { if (!this.selected(index) && this.selectable(index)) { unselectedRanges = this.clearSelectedRanges(); const updatedRange = { beginIndex: index, endIndex: index }; this.selectedRanges.push(updatedRange); this.selectedCount++; updatedRanges = true; } } else { if (!merge) { unselectedRanges = this.clearSelectedRanges(); } // If no count is specified we will use a single item selection. count = count || 1; // @TODO: Implement a more optimal multi-count selection for (; count > 0; count--) { if (this.selected(index) || !this.selectable(index)) { index++; continue; } let rangeIndex = 0; let updatedRange; // Determine if there is a range we can add this selection to. for (; rangeIndex < this.selectedRanges.length; rangeIndex++) { const selectionRange = this.selectedRanges[rangeIndex]; // Check if this selection occurs before this selectionRange. if (index < selectionRange.beginIndex) { if (index === selectionRange.beginIndex - 1) { updatedRange = selectionRange; updatedRange.beginIndex--; } break; } // If this index is directly after this range we will extend it. else if (index === selectionRange.endIndex + 1) { // If there is a gap of 1 number we will merge the two ranges. if (rangeIndex < this.selectedRanges.length - 1 && index === this.selectedRanges[rangeIndex + 1].beginIndex - 1) { updatedRange = selectionRange; updatedRange.endIndex = this.selectedRanges[rangeIndex + 1].endIndex; // Remove the second range since it is merged into the previous range. this.selectedRanges.splice(rangeIndex + 1, 1); } else { updatedRange = selectionRange; updatedRange.endIndex++; } break; } } // If there was no range to merge with, add a new one. if (!updatedRange) { updatedRange = { beginIndex: index, endIndex: index }; this.selectedRanges.splice(rangeIndex, 0, updatedRange); } this.selectedCount++; index++; updatedRanges = true; } } if (unselectedRanges) { this.notify(unselectedRanges, "unselect"); } // Notify observers of the added selection. if (updatedRanges) { this.notify([{ beginIndex: beginIndex, endIndex: endIndex }], "select"); } } } toggle(index, merge = this.alwaysMerge, multiSelect = this.multiSelect) { if (this.selected(index)) { this.unselect(index); } else { this.select(index, 1, merge, multiSelect); } } unselect(index, count) { const unselectedRange = this.unselectInternal(index, count); if (unselectedRange) { this.notify([unselectedRange], "unselect"); } } lock() { this.lockCount++; } unlock() { this.lockCount--; } removeUnselectableInternal(index, count) { const beginIndex = index; const endIndex = beginIndex + (count || 1) - 1; let updatedRanges = false; // If no count is specified we will use a single item selection. count = count || 1; // @TODO: Implement a more optimal multi-count selection for (; count > 0; count--) { if (this.selectable(index)) { index++; continue; } // Determine the range we are unselecting the item from. for (let rangeIndex = 0; rangeIndex < this.unselectableRanges.length; rangeIndex++) { const unselectableRange = this.unselectableRanges[rangeIndex]; // If this index if before this range move on to the next one. if (index < unselectableRange.beginIndex) { continue; } // Determine whether or not this index falls into this range. if (index >= unselectableRange.beginIndex && index <= unselectableRange.endIndex) { // If the index is on the start or end of the range, we will just shrink it. // Otherwise we will have to split it. if (index === unselectableRange.beginIndex) { unselectableRange.beginIndex++; } else if (index === unselectableRange.endIndex) { unselectableRange.endIndex--; } else { this.unselectableRanges.splice(rangeIndex + 1, 0, { beginIndex: index + 1, endIndex: unselectableRange.endIndex }); unselectableRange.endIndex = index - 1; } // Shrinking may have created an empty range, we need to remove it. if (unselectableRange.beginIndex > unselectableRange.endIndex) { this.unselectableRanges.splice(rangeIndex, 1); } this.unselectableCount--; updatedRanges = true; break; } } index++; } if (updatedRanges) { return { beginIndex, endIndex }; } } unselectInternal(index, count) { let updatedRanges = false; const beginIndex = index; const endIndex = beginIndex + (count || 1) - 1; if (!this.lockCount) { // If no count is specified we will use a single item selection. count = count || 1; // @TODO: Implement a more optimal multi-count selection for (; count > 0; count--) { if (!this.selected(index)) { index++; continue; } // Determine the range we are unselecting the item from. for (let rangeIndex = 0; rangeIndex < this.selectedRanges.length; rangeIndex++) { const selectionRange = this.selectedRanges[rangeIndex]; // If this index if before this range move on to the next one. if (index < selectionRange.beginIndex) { continue; } // Determine whether or not this index falls into this range. if (index >= selectionRange.beginIndex && index <= selectionRange.endIndex) { // If the index is on the start or end of the range, we will just shrink it. // Otherwise we will have to split it. if (index === selectionRange.beginIndex) { selectionRange.beginIndex++; } else if (index === selectionRange.endIndex) { selectionRange.endIndex--; } else { this.selectedRanges.splice(rangeIndex + 1, 0, { beginIndex: index + 1, endIndex: selectionRange.endIndex }); selectionRange.endIndex = index - 1; } // Shrinking may have created an empty range, we need to remove it. if (selectionRange.beginIndex > selectionRange.endIndex) { this.selectedRanges.splice(rangeIndex, 1); } this.selectedCount--; updatedRanges = true; break; } } index++; } } if (updatedRanges) { return { beginIndex, endIndex }; } } clearSelectedRanges() { if (!this.lockCount && this.selectedRanges.length > 0) { // Save the current selection ranges for notification. const selectedRanges = [...this.selectedRanges]; // Reset the selection to an empty selection. this.selectedRanges = []; this.selectedCount = 0; return selectedRanges; } } } export function indexWithinRanges(index, ranges) { if (ranges) { for (const range of ranges) { if (index >= range.beginIndex && index <= range.endIndex) { return true; } } } return false; } function adjustRanges(index, adjustCount, ranges) { const adjustedRanges = []; for (let rangeIndex = 0; rangeIndex < ranges.length; rangeIndex++) { const range = ranges[rangeIndex]; // If the added items are before the range shift it down. if (index <= range.beginIndex) { // If this adjustment will create a continuous range with the previous range // we merge the ranges. if (rangeIndex > 0 && range.beginIndex + adjustCount === ranges[rangeIndex - 1].endIndex + 1) { ranges[rangeIndex - 1].endIndex = range.endIndex + adjustCount; ranges.splice(rangeIndex--, 1); adjustedRanges.push(ranges[rangeIndex]); } else { range.beginIndex += adjustCount; range.endIndex += adjustCount; adjustedRanges.push(range); } } else if (index > range.beginIndex && index <= range.endIndex) { // Create the new split selection range. const splitRange = { beginIndex: index + adjustCount, endIndex: range.endIndex + adjustCount }; ranges.splice(++rangeIndex, 0, splitRange); adjustedRanges.push(splitRange); // If the added items are in the middle of range we need to split the range. range.endIndex = index - 1; adjustedRanges.push(range); } } return adjustedRanges; } /** * return an array describing the difference of two sets of selection ranges. Postive values in the array are indices in second * that are not in first. Negative values in the array are indices that are in first that are not in second. * @param firstRanges the first set of values to use in the comparison. * @param secondRanges the second set of values to use in the comparison. */ export function compareSelectionRanges(firstRanges, secondRanges) { const difference = []; for (let rangeIndex = 0; rangeIndex < firstRanges.length; rangeIndex++) { const range = firstRanges[rangeIndex]; for (let selectionIndex = range.beginIndex; selectionIndex <= range.endIndex; selectionIndex++) { if (!indexWithinRanges(selectionIndex, secondRanges)) { difference.push(selectionIndex * -1); } } } for (let rangeIndex = 0; rangeIndex < secondRanges.length; rangeIndex++) { const range = secondRanges[rangeIndex]; for (let selectionIndex = range.beginIndex; selectionIndex <= range.endIndex; selectionIndex++) { if (!indexWithinRanges(selectionIndex, firstRanges)) { difference.push(selectionIndex); } } } return difference; }