mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-07 04:26:32 +08:00
Removes `deepActiveElement` from the prototype and defines a util for it. Bug: 1050549 Change-Id: I0376ba0b3a00d82c070f2d4ada12350771046790 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3586910 Reviewed-by: Simon Zünd <szuend@chromium.org> Commit-Queue: Jack Franklin <jacktfranklin@chromium.org> Auto-Submit: Jack Franklin <jacktfranklin@chromium.org>
755 lines
28 KiB
TypeScript
755 lines
28 KiB
TypeScript
/*
|
|
* Copyright (C) 2013 Google Inc. All rights reserved.
|
|
*
|
|
* Redistribution and use in source and binary forms, with or without
|
|
* modification, are permitted provided that the following conditions are
|
|
* met:
|
|
*
|
|
* * Redistributions of source code must retain the above copyright
|
|
* notice, this list of conditions and the following disclaimer.
|
|
* * Redistributions in binary form must reproduce the above
|
|
* copyright notice, this list of conditions and the following disclaimer
|
|
* in the documentation and/or other materials provided with the
|
|
* distribution.
|
|
* * Neither the name of Google Inc. nor the names of its
|
|
* contributors may be used to endorse or promote products derived from
|
|
* this software without specific prior written permission.
|
|
*
|
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
*/
|
|
|
|
import * as Platform from '../../core/platform/platform.js';
|
|
import * as Components from '../../ui/legacy/components/utils/utils.js';
|
|
import * as UI from '../../ui/legacy/legacy.js';
|
|
|
|
import type {ConsoleViewMessage} from './ConsoleViewMessage.js';
|
|
|
|
interface SelectionModel {
|
|
item: number;
|
|
node: Node;
|
|
offset: number;
|
|
}
|
|
|
|
export class ConsoleViewport {
|
|
element: HTMLElement;
|
|
private topGapElement: HTMLElement;
|
|
private topGapElementActive: boolean;
|
|
private contentElementInternal: HTMLElement;
|
|
private bottomGapElement: HTMLElement;
|
|
private bottomGapElementActive: boolean;
|
|
private provider: ConsoleViewportProvider;
|
|
private virtualSelectedIndex: number;
|
|
private firstActiveIndex: number;
|
|
private lastActiveIndex: number;
|
|
private renderedItems: ConsoleViewportElement[];
|
|
private anchorSelection: SelectionModel|null;
|
|
private headSelection: SelectionModel|null;
|
|
private itemCount: number;
|
|
private cumulativeHeights: Int32Array;
|
|
private muteCopyHandler: boolean;
|
|
private readonly observer: MutationObserver;
|
|
private readonly observerConfig: {
|
|
childList: boolean,
|
|
subtree: boolean,
|
|
};
|
|
private stickToBottomInternal: boolean;
|
|
private selectionIsBackward: boolean;
|
|
private lastSelectedElement?: HTMLElement|null;
|
|
private cachedProviderElements?: (ConsoleViewportElement|null)[];
|
|
|
|
constructor(provider: ConsoleViewportProvider) {
|
|
this.element = document.createElement('div');
|
|
this.element.style.overflow = 'auto';
|
|
this.topGapElement = this.element.createChild('div');
|
|
this.topGapElement.style.height = '0px';
|
|
this.topGapElement.style.color = 'transparent';
|
|
this.topGapElementActive = false;
|
|
this.contentElementInternal = this.element.createChild('div');
|
|
this.bottomGapElement = this.element.createChild('div');
|
|
this.bottomGapElement.style.height = '0px';
|
|
this.bottomGapElement.style.color = 'transparent';
|
|
this.bottomGapElementActive = false;
|
|
|
|
// Text content needed for range intersection checks in updateSelectionModel.
|
|
// Use Unicode ZERO WIDTH NO-BREAK SPACE, which avoids contributing any height to the element's layout overflow.
|
|
this.topGapElement.textContent = '\uFEFF';
|
|
this.bottomGapElement.textContent = '\uFEFF';
|
|
|
|
UI.ARIAUtils.markAsHidden(this.topGapElement);
|
|
UI.ARIAUtils.markAsHidden(this.bottomGapElement);
|
|
|
|
this.provider = provider;
|
|
this.element.addEventListener('scroll', this.onScroll.bind(this), false);
|
|
this.element.addEventListener('copy', (this.onCopy.bind(this) as EventListener), false);
|
|
this.element.addEventListener('dragstart', (this.onDragStart.bind(this) as (arg0: Event) => unknown), false);
|
|
this.contentElementInternal.addEventListener('focusin', (this.onFocusIn.bind(this) as EventListener), false);
|
|
this.contentElementInternal.addEventListener('focusout', (this.onFocusOut.bind(this) as EventListener), false);
|
|
this.contentElementInternal.addEventListener('keydown', (this.onKeyDown.bind(this) as EventListener), false);
|
|
this.virtualSelectedIndex = -1;
|
|
this.contentElementInternal.tabIndex = -1;
|
|
|
|
this.firstActiveIndex = -1;
|
|
this.lastActiveIndex = -1;
|
|
this.renderedItems = [];
|
|
this.anchorSelection = null;
|
|
this.headSelection = null;
|
|
this.itemCount = 0;
|
|
this.cumulativeHeights = new Int32Array(0);
|
|
this.muteCopyHandler = false;
|
|
|
|
// Listen for any changes to descendants and trigger a refresh. This ensures
|
|
// that items updated asynchronously will not break stick-to-bottom behavior
|
|
// if they change the scroll height.
|
|
this.observer = new MutationObserver(this.refresh.bind(this));
|
|
this.observerConfig = {childList: true, subtree: true};
|
|
this.stickToBottomInternal = false;
|
|
this.selectionIsBackward = false;
|
|
}
|
|
|
|
stickToBottom(): boolean {
|
|
return this.stickToBottomInternal;
|
|
}
|
|
|
|
setStickToBottom(value: boolean): void {
|
|
this.stickToBottomInternal = value;
|
|
if (this.stickToBottomInternal) {
|
|
this.observer.observe(this.contentElementInternal, this.observerConfig);
|
|
} else {
|
|
this.observer.disconnect();
|
|
}
|
|
}
|
|
|
|
hasVirtualSelection(): boolean {
|
|
return this.virtualSelectedIndex !== -1;
|
|
}
|
|
|
|
copyWithStyles(): void {
|
|
this.muteCopyHandler = true;
|
|
this.element.ownerDocument.execCommand('copy');
|
|
this.muteCopyHandler = false;
|
|
}
|
|
|
|
private onCopy(event: ClipboardEvent): void {
|
|
if (this.muteCopyHandler) {
|
|
return;
|
|
}
|
|
|
|
const text = this.selectedText();
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
if (this.selectionContainsTable()) {
|
|
this.copyWithStyles();
|
|
} else if (event.clipboardData) {
|
|
event.clipboardData.setData('text/plain', text);
|
|
}
|
|
}
|
|
|
|
private onFocusIn(event: FocusEvent): void {
|
|
const renderedIndex =
|
|
this.renderedItems.findIndex(item => item.element().isSelfOrAncestor((event.target as Node | null)));
|
|
if (renderedIndex !== -1) {
|
|
this.virtualSelectedIndex = this.firstActiveIndex + renderedIndex;
|
|
}
|
|
let focusLastChild = false;
|
|
// Make default selection when moving from external (e.g. prompt) to the container.
|
|
if (this.virtualSelectedIndex === -1 && this.isOutsideViewport((event.relatedTarget as Element | null)) &&
|
|
event.target === this.contentElementInternal && this.itemCount) {
|
|
focusLastChild = true;
|
|
this.virtualSelectedIndex = this.itemCount - 1;
|
|
|
|
// Update stick to bottom before scrolling into view.
|
|
this.refresh();
|
|
this.scrollItemIntoView(this.virtualSelectedIndex);
|
|
}
|
|
this.updateFocusedItem(focusLastChild);
|
|
}
|
|
|
|
private onFocusOut(event: FocusEvent): void {
|
|
if (this.isOutsideViewport((event.relatedTarget as Element | null))) {
|
|
this.virtualSelectedIndex = -1;
|
|
}
|
|
this.updateFocusedItem();
|
|
}
|
|
|
|
private isOutsideViewport(element: Element|null): boolean {
|
|
return element !== null && !element.isSelfOrDescendant(this.contentElementInternal);
|
|
}
|
|
|
|
private onDragStart(event: DragEvent): boolean {
|
|
const text = this.selectedText();
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.clearData();
|
|
event.dataTransfer.setData('text/plain', text);
|
|
event.dataTransfer.effectAllowed = 'copy';
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private onKeyDown(event: KeyboardEvent): void {
|
|
if (UI.UIUtils.isEditing() || !this.itemCount || event.shiftKey) {
|
|
return;
|
|
}
|
|
let isArrowUp = false;
|
|
switch (event.key) {
|
|
case 'ArrowUp':
|
|
if (this.virtualSelectedIndex > 0) {
|
|
isArrowUp = true;
|
|
this.virtualSelectedIndex--;
|
|
} else {
|
|
return;
|
|
}
|
|
break;
|
|
case 'ArrowDown':
|
|
if (this.virtualSelectedIndex < this.itemCount - 1) {
|
|
this.virtualSelectedIndex++;
|
|
} else {
|
|
return;
|
|
}
|
|
break;
|
|
case 'Home':
|
|
this.virtualSelectedIndex = 0;
|
|
break;
|
|
case 'End':
|
|
this.virtualSelectedIndex = this.itemCount - 1;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
event.consume(true);
|
|
this.scrollItemIntoView(this.virtualSelectedIndex);
|
|
this.updateFocusedItem(isArrowUp);
|
|
}
|
|
|
|
private updateFocusedItem(focusLastChild?: boolean): void {
|
|
const selectedElement = this.renderedElementAt(this.virtualSelectedIndex);
|
|
const changed = this.lastSelectedElement !== selectedElement;
|
|
const containerHasFocus =
|
|
this.contentElementInternal === Platform.DOMUtilities.deepActiveElement(this.element.ownerDocument);
|
|
if (this.lastSelectedElement && changed) {
|
|
this.lastSelectedElement.classList.remove('console-selected');
|
|
}
|
|
if (selectedElement && (focusLastChild || changed || containerHasFocus) && this.element.hasFocus()) {
|
|
selectedElement.classList.add('console-selected');
|
|
// Do not focus the message if something within holds focus (e.g. object).
|
|
if (focusLastChild) {
|
|
this.setStickToBottom(false);
|
|
this.renderedItems[this.virtualSelectedIndex - this.firstActiveIndex].focusLastChildOrSelf();
|
|
} else if (!selectedElement.hasFocus()) {
|
|
selectedElement.focus({preventScroll: true});
|
|
}
|
|
}
|
|
if (this.itemCount && !this.contentElementInternal.hasFocus()) {
|
|
this.contentElementInternal.tabIndex = 0;
|
|
} else {
|
|
this.contentElementInternal.tabIndex = -1;
|
|
}
|
|
this.lastSelectedElement = selectedElement;
|
|
}
|
|
|
|
contentElement(): Element {
|
|
return this.contentElementInternal;
|
|
}
|
|
|
|
invalidate(): void {
|
|
delete this.cachedProviderElements;
|
|
this.itemCount = this.provider.itemCount();
|
|
if (this.virtualSelectedIndex > this.itemCount - 1) {
|
|
this.virtualSelectedIndex = this.itemCount - 1;
|
|
}
|
|
this.rebuildCumulativeHeights();
|
|
this.refresh();
|
|
}
|
|
|
|
private providerElement(index: number): ConsoleViewportElement|null {
|
|
if (!this.cachedProviderElements) {
|
|
this.cachedProviderElements = new Array(this.itemCount);
|
|
}
|
|
let element = this.cachedProviderElements[index];
|
|
if (!element) {
|
|
element = this.provider.itemElement(index);
|
|
this.cachedProviderElements[index] = element;
|
|
}
|
|
return element;
|
|
}
|
|
|
|
private rebuildCumulativeHeights(): void {
|
|
const firstActiveIndex = this.firstActiveIndex;
|
|
const lastActiveIndex = this.lastActiveIndex;
|
|
let height = 0;
|
|
this.cumulativeHeights = new Int32Array(this.itemCount);
|
|
for (let i = 0; i < this.itemCount; ++i) {
|
|
if (firstActiveIndex <= i && i - firstActiveIndex < this.renderedItems.length && i <= lastActiveIndex) {
|
|
height += this.renderedItems[i - firstActiveIndex].element().offsetHeight;
|
|
} else {
|
|
height += this.provider.fastHeight(i);
|
|
}
|
|
this.cumulativeHeights[i] = height;
|
|
}
|
|
}
|
|
|
|
private rebuildCumulativeHeightsIfNeeded(): void {
|
|
let totalCachedHeight = 0;
|
|
let totalMeasuredHeight = 0;
|
|
// Check whether current items in DOM have changed heights. Tolerate 1-pixel
|
|
// error due to double-to-integer rounding errors.
|
|
for (let i = 0; i < this.renderedItems.length; ++i) {
|
|
const cachedItemHeight = this.cachedItemHeight(this.firstActiveIndex + i);
|
|
const measuredHeight = this.renderedItems[i].element().offsetHeight;
|
|
if (Math.abs(cachedItemHeight - measuredHeight) > 1) {
|
|
this.rebuildCumulativeHeights();
|
|
return;
|
|
}
|
|
totalMeasuredHeight += measuredHeight;
|
|
totalCachedHeight += cachedItemHeight;
|
|
if (Math.abs(totalCachedHeight - totalMeasuredHeight) > 1) {
|
|
this.rebuildCumulativeHeights();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private cachedItemHeight(index: number): number {
|
|
return index === 0 ? this.cumulativeHeights[0] : this.cumulativeHeights[index] - this.cumulativeHeights[index - 1];
|
|
}
|
|
|
|
private isSelectionBackwards(selection: Selection|null): boolean {
|
|
if (!selection || !selection.rangeCount || !selection.anchorNode || !selection.focusNode) {
|
|
return false;
|
|
}
|
|
const range = document.createRange();
|
|
range.setStart(selection.anchorNode, selection.anchorOffset);
|
|
range.setEnd(selection.focusNode, selection.focusOffset);
|
|
return range.collapsed;
|
|
}
|
|
|
|
private createSelectionModel(itemIndex: number, node: Node, offset: number): {
|
|
item: number,
|
|
node: Node,
|
|
offset: number,
|
|
} {
|
|
return {item: itemIndex, node: node, offset: offset};
|
|
}
|
|
|
|
private updateSelectionModel(selection: Selection|null): boolean {
|
|
const range = selection && selection.rangeCount ? selection.getRangeAt(0) : null;
|
|
if (!range || (!selection || selection.isCollapsed) || !this.element.hasSelection()) {
|
|
this.headSelection = null;
|
|
this.anchorSelection = null;
|
|
return false;
|
|
}
|
|
|
|
let firstSelectedIndex: number = Number.MAX_VALUE;
|
|
let lastSelectedIndex = -1;
|
|
|
|
let hasVisibleSelection = false;
|
|
for (let i = 0; i < this.renderedItems.length; ++i) {
|
|
if (range.intersectsNode(this.renderedItems[i].element())) {
|
|
const index = i + this.firstActiveIndex;
|
|
firstSelectedIndex = Math.min(firstSelectedIndex, index);
|
|
lastSelectedIndex = Math.max(lastSelectedIndex, index);
|
|
hasVisibleSelection = true;
|
|
}
|
|
}
|
|
const topOverlap = range.intersectsNode(this.topGapElement) && this.topGapElementActive;
|
|
const bottomOverlap = range.intersectsNode(this.bottomGapElement) && this.bottomGapElementActive;
|
|
if (!topOverlap && !bottomOverlap && !hasVisibleSelection) {
|
|
this.headSelection = null;
|
|
this.anchorSelection = null;
|
|
return false;
|
|
}
|
|
|
|
if (!this.anchorSelection || !this.headSelection) {
|
|
this.anchorSelection = this.createSelectionModel(0, this.element, 0);
|
|
this.headSelection = this.createSelectionModel(this.itemCount - 1, this.element, this.element.children.length);
|
|
this.selectionIsBackward = false;
|
|
}
|
|
|
|
const isBackward = this.isSelectionBackwards(selection);
|
|
const startSelection = this.selectionIsBackward ? this.headSelection : this.anchorSelection;
|
|
const endSelection = this.selectionIsBackward ? this.anchorSelection : this.headSelection;
|
|
let firstSelected: SelectionModel|null = null;
|
|
let lastSelected: SelectionModel|null = null;
|
|
if (hasVisibleSelection) {
|
|
firstSelected = this.createSelectionModel(firstSelectedIndex, (range.startContainer as Node), range.startOffset);
|
|
lastSelected = this.createSelectionModel(lastSelectedIndex, (range.endContainer as Node), range.endOffset);
|
|
}
|
|
if (topOverlap && bottomOverlap && hasVisibleSelection) {
|
|
firstSelected = (firstSelected && firstSelected.item < startSelection.item) ? firstSelected : startSelection;
|
|
lastSelected = (lastSelected && lastSelected.item > endSelection.item) ? lastSelected : endSelection;
|
|
} else if (!hasVisibleSelection) {
|
|
firstSelected = startSelection;
|
|
lastSelected = endSelection;
|
|
} else if (topOverlap) {
|
|
firstSelected = isBackward ? this.headSelection : this.anchorSelection;
|
|
} else if (bottomOverlap) {
|
|
lastSelected = isBackward ? this.anchorSelection : this.headSelection;
|
|
}
|
|
|
|
if (isBackward) {
|
|
this.anchorSelection = lastSelected;
|
|
this.headSelection = firstSelected;
|
|
} else {
|
|
this.anchorSelection = firstSelected;
|
|
this.headSelection = lastSelected;
|
|
}
|
|
this.selectionIsBackward = isBackward;
|
|
return true;
|
|
}
|
|
|
|
private restoreSelection(selection: Selection|null): void {
|
|
if (!selection || !this.anchorSelection || !this.headSelection) {
|
|
return;
|
|
}
|
|
|
|
const clampSelection = (selection: SelectionModel, isSelectionBackwards: boolean): {
|
|
element: Node,
|
|
offset: number,
|
|
} => {
|
|
if (this.firstActiveIndex <= selection.item && selection.item <= this.lastActiveIndex) {
|
|
return {element: selection.node, offset: selection.offset};
|
|
}
|
|
|
|
const element = selection.item < this.firstActiveIndex ? this.topGapElement : this.bottomGapElement;
|
|
return {element, offset: isSelectionBackwards ? 1 : 0};
|
|
};
|
|
|
|
const {element: anchorElement, offset: anchorOffset} =
|
|
clampSelection(this.anchorSelection, Boolean(this.selectionIsBackward));
|
|
const {element: headElement, offset: headOffset} = clampSelection(this.headSelection, !this.selectionIsBackward);
|
|
selection.setBaseAndExtent(anchorElement, anchorOffset, headElement, headOffset);
|
|
}
|
|
|
|
private selectionContainsTable(): boolean {
|
|
if (!this.anchorSelection || !this.headSelection) {
|
|
return false;
|
|
}
|
|
|
|
const start = this.selectionIsBackward ? this.headSelection.item : this.anchorSelection.item;
|
|
const end = this.selectionIsBackward ? this.anchorSelection.item : this.headSelection.item;
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
const element = (this.providerElement(i) as ConsoleViewMessage);
|
|
if (element && element.consoleMessage().type === 'table') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
refresh(): void {
|
|
this.observer.disconnect();
|
|
this.innerRefresh();
|
|
if (this.stickToBottomInternal) {
|
|
this.observer.observe(this.contentElementInternal, this.observerConfig);
|
|
}
|
|
}
|
|
|
|
private innerRefresh(): void {
|
|
if (!this.visibleHeight()) {
|
|
return;
|
|
} // Do nothing for invisible controls.
|
|
|
|
if (!this.itemCount) {
|
|
for (let i = 0; i < this.renderedItems.length; ++i) {
|
|
this.renderedItems[i].willHide();
|
|
}
|
|
this.renderedItems = [];
|
|
this.contentElementInternal.removeChildren();
|
|
this.topGapElement.style.height = '0px';
|
|
this.bottomGapElement.style.height = '0px';
|
|
this.firstActiveIndex = -1;
|
|
this.lastActiveIndex = -1;
|
|
this.updateFocusedItem();
|
|
return;
|
|
}
|
|
|
|
const selection = this.element.getComponentSelection();
|
|
const shouldRestoreSelection = this.updateSelectionModel(selection);
|
|
|
|
const visibleFrom = this.element.scrollTop;
|
|
const visibleHeight = this.visibleHeight();
|
|
const activeHeight = visibleHeight * 2;
|
|
this.rebuildCumulativeHeightsIfNeeded();
|
|
|
|
// When the viewport is scrolled to the bottom, using the cumulative heights estimate is not
|
|
// precise enough to determine next visible indices. This stickToBottom check avoids extra
|
|
// calls to refresh in those cases.
|
|
if (this.stickToBottomInternal) {
|
|
this.firstActiveIndex = Math.max(this.itemCount - Math.ceil(activeHeight / this.provider.minimumRowHeight()), 0);
|
|
this.lastActiveIndex = this.itemCount - 1;
|
|
} else {
|
|
this.firstActiveIndex = Math.max(
|
|
Platform.ArrayUtilities.lowerBound(
|
|
this.cumulativeHeights, visibleFrom + 1 - (activeHeight - visibleHeight) / 2,
|
|
Platform.ArrayUtilities.DEFAULT_COMPARATOR),
|
|
0);
|
|
// Proactively render more rows in case some of them will be collapsed without triggering refresh. @see crbug.com/390169
|
|
this.lastActiveIndex = this.firstActiveIndex + Math.ceil(activeHeight / this.provider.minimumRowHeight()) - 1;
|
|
this.lastActiveIndex = Math.min(this.lastActiveIndex, this.itemCount - 1);
|
|
}
|
|
|
|
const topGapHeight = this.cumulativeHeights[this.firstActiveIndex - 1] || 0;
|
|
const bottomGapHeight =
|
|
this.cumulativeHeights[this.cumulativeHeights.length - 1] - this.cumulativeHeights[this.lastActiveIndex];
|
|
|
|
function prepare(this: ConsoleViewport): void {
|
|
this.topGapElement.style.height = topGapHeight + 'px';
|
|
this.bottomGapElement.style.height = bottomGapHeight + 'px';
|
|
this.topGapElementActive = Boolean(topGapHeight);
|
|
this.bottomGapElementActive = Boolean(bottomGapHeight);
|
|
this.contentElementInternal.style.setProperty('height', '10000000px');
|
|
}
|
|
|
|
this.partialViewportUpdate(prepare.bind(this));
|
|
this.contentElementInternal.style.removeProperty('height');
|
|
// Should be the last call in the method as it might force layout.
|
|
if (shouldRestoreSelection) {
|
|
this.restoreSelection(selection);
|
|
}
|
|
if (this.stickToBottomInternal) {
|
|
this.element.scrollTop = 10000000;
|
|
}
|
|
}
|
|
|
|
private partialViewportUpdate(prepare: () => void): void {
|
|
const itemsToRender = new Set<ConsoleViewportElement>();
|
|
for (let i = this.firstActiveIndex; i <= this.lastActiveIndex; ++i) {
|
|
const providerElement = this.providerElement(i);
|
|
console.assert(Boolean(providerElement), 'Expected provider element to be defined');
|
|
if (providerElement) {
|
|
itemsToRender.add(providerElement);
|
|
}
|
|
}
|
|
const willBeHidden = this.renderedItems.filter(item => !itemsToRender.has(item));
|
|
for (let i = 0; i < willBeHidden.length; ++i) {
|
|
willBeHidden[i].willHide();
|
|
}
|
|
prepare();
|
|
let hadFocus = false;
|
|
for (let i = 0; i < willBeHidden.length; ++i) {
|
|
hadFocus = hadFocus || willBeHidden[i].element().hasFocus();
|
|
willBeHidden[i].element().remove();
|
|
}
|
|
|
|
const wasShown = [];
|
|
let anchor: (ChildNode|null) = this.contentElementInternal.firstChild;
|
|
for (const viewportElement of itemsToRender) {
|
|
const element = viewportElement.element();
|
|
if (element !== anchor) {
|
|
const shouldCallWasShown = !element.parentElement;
|
|
if (shouldCallWasShown) {
|
|
wasShown.push(viewportElement);
|
|
}
|
|
this.contentElementInternal.insertBefore(element, anchor);
|
|
} else {
|
|
anchor = anchor.nextSibling;
|
|
}
|
|
}
|
|
for (let i = 0; i < wasShown.length; ++i) {
|
|
wasShown[i].wasShown();
|
|
}
|
|
this.renderedItems = Array.from(itemsToRender);
|
|
|
|
if (hadFocus) {
|
|
this.contentElementInternal.focus();
|
|
}
|
|
this.updateFocusedItem();
|
|
}
|
|
|
|
private selectedText(): string|null {
|
|
this.updateSelectionModel(this.element.getComponentSelection());
|
|
if (!this.headSelection || !this.anchorSelection) {
|
|
return null;
|
|
}
|
|
|
|
let startSelection: SelectionModel|null = null;
|
|
let endSelection: SelectionModel|null = null;
|
|
if (this.selectionIsBackward) {
|
|
startSelection = this.headSelection;
|
|
endSelection = this.anchorSelection;
|
|
} else {
|
|
startSelection = this.anchorSelection;
|
|
endSelection = this.headSelection;
|
|
}
|
|
|
|
const textLines = [];
|
|
for (let i = startSelection.item; i <= endSelection.item; ++i) {
|
|
const providerElement = this.providerElement(i);
|
|
console.assert(Boolean(providerElement));
|
|
if (!providerElement) {
|
|
continue;
|
|
}
|
|
const element = providerElement.element();
|
|
const lineContent = element.childTextNodes().map(Components.Linkifier.Linkifier.untruncatedNodeText).join('');
|
|
textLines.push(lineContent);
|
|
}
|
|
|
|
const endProviderElement = this.providerElement(endSelection.item);
|
|
const endSelectionElement = endProviderElement && endProviderElement.element();
|
|
if (endSelectionElement && endSelection.node && endSelection.node.isSelfOrDescendant(endSelectionElement)) {
|
|
const itemTextOffset = this.textOffsetInNode(endSelectionElement, endSelection.node, endSelection.offset);
|
|
if (textLines.length > 0) {
|
|
textLines[textLines.length - 1] = textLines[textLines.length - 1].substring(0, itemTextOffset);
|
|
}
|
|
}
|
|
|
|
const startProviderElement = this.providerElement(startSelection.item);
|
|
const startSelectionElement = startProviderElement && startProviderElement.element();
|
|
if (startSelectionElement && startSelection.node && startSelection.node.isSelfOrDescendant(startSelectionElement)) {
|
|
const itemTextOffset = this.textOffsetInNode(startSelectionElement, startSelection.node, startSelection.offset);
|
|
textLines[0] = textLines[0].substring(itemTextOffset);
|
|
}
|
|
|
|
return textLines.join('\n');
|
|
}
|
|
|
|
private textOffsetInNode(itemElement: Element, selectionNode: Node, offset: number): number {
|
|
// If the selectionNode is not a TextNode, we may need to convert a child offset into a character offset.
|
|
const textContentLength = selectionNode.textContent ? selectionNode.textContent.length : 0;
|
|
if (selectionNode.nodeType !== Node.TEXT_NODE) {
|
|
if (offset < selectionNode.childNodes.length) {
|
|
selectionNode = (selectionNode.childNodes.item(offset) as Node);
|
|
offset = 0;
|
|
} else {
|
|
offset = textContentLength;
|
|
}
|
|
}
|
|
|
|
let chars = 0;
|
|
let node: Node|null = itemElement;
|
|
while ((node = node.traverseNextNode(itemElement)) && node !== selectionNode) {
|
|
if (node.nodeType !== Node.TEXT_NODE ||
|
|
(node.parentElement &&
|
|
(node.parentElement.nodeName === 'STYLE' || node.parentElement.nodeName === 'SCRIPT'))) {
|
|
continue;
|
|
}
|
|
chars += Components.Linkifier.Linkifier.untruncatedNodeText(node).length;
|
|
}
|
|
// If the selected node text was truncated, treat any non-zero offset as the full length.
|
|
const untruncatedContainerLength = Components.Linkifier.Linkifier.untruncatedNodeText(selectionNode).length;
|
|
if (offset > 0 && untruncatedContainerLength !== textContentLength) {
|
|
offset = untruncatedContainerLength;
|
|
}
|
|
return chars + offset;
|
|
}
|
|
|
|
private onScroll(_event: Event): void {
|
|
this.refresh();
|
|
}
|
|
|
|
firstVisibleIndex(): number {
|
|
if (!this.cumulativeHeights.length) {
|
|
return -1;
|
|
}
|
|
this.rebuildCumulativeHeightsIfNeeded();
|
|
return Platform.ArrayUtilities.lowerBound(
|
|
this.cumulativeHeights, this.element.scrollTop + 1, Platform.ArrayUtilities.DEFAULT_COMPARATOR);
|
|
}
|
|
|
|
lastVisibleIndex(): number {
|
|
if (!this.cumulativeHeights.length) {
|
|
return -1;
|
|
}
|
|
this.rebuildCumulativeHeightsIfNeeded();
|
|
const scrollBottom = this.element.scrollTop + this.element.clientHeight;
|
|
const right = this.itemCount - 1;
|
|
return Platform.ArrayUtilities.lowerBound(
|
|
this.cumulativeHeights, scrollBottom, Platform.ArrayUtilities.DEFAULT_COMPARATOR, undefined, right);
|
|
}
|
|
|
|
renderedElementAt(index: number): HTMLElement|null {
|
|
if (index === -1 || index < this.firstActiveIndex || index > this.lastActiveIndex) {
|
|
return null;
|
|
}
|
|
return this.renderedItems[index - this.firstActiveIndex].element();
|
|
}
|
|
|
|
scrollItemIntoView(index: number, makeLast?: boolean): void {
|
|
const firstVisibleIndex = this.firstVisibleIndex();
|
|
const lastVisibleIndex = this.lastVisibleIndex();
|
|
if (index > firstVisibleIndex && index < lastVisibleIndex) {
|
|
return;
|
|
}
|
|
// If the prompt is visible, then the last item must be fully on screen.
|
|
if (index === lastVisibleIndex && this.cumulativeHeights[index] <= this.element.scrollTop + this.visibleHeight()) {
|
|
return;
|
|
}
|
|
if (makeLast) {
|
|
this.forceScrollItemToBeLast(index);
|
|
} else if (index <= firstVisibleIndex) {
|
|
this.forceScrollItemToBeFirst(index);
|
|
} else if (index >= lastVisibleIndex) {
|
|
this.forceScrollItemToBeLast(index);
|
|
}
|
|
}
|
|
|
|
forceScrollItemToBeFirst(index: number): void {
|
|
console.assert(index >= 0 && index < this.itemCount, 'Cannot scroll item at invalid index');
|
|
this.setStickToBottom(false);
|
|
this.rebuildCumulativeHeightsIfNeeded();
|
|
this.element.scrollTop = index > 0 ? this.cumulativeHeights[index - 1] : 0;
|
|
if (UI.UIUtils.isScrolledToBottom(this.element)) {
|
|
this.setStickToBottom(true);
|
|
}
|
|
this.refresh();
|
|
// After refresh, the item is in DOM, but may not be visible (items above were larger than expected).
|
|
const renderedElement = this.renderedElementAt(index);
|
|
if (renderedElement) {
|
|
renderedElement.scrollIntoView(true /* alignTop */);
|
|
}
|
|
}
|
|
|
|
forceScrollItemToBeLast(index: number): void {
|
|
console.assert(index >= 0 && index < this.itemCount, 'Cannot scroll item at invalid index');
|
|
this.setStickToBottom(false);
|
|
this.rebuildCumulativeHeightsIfNeeded();
|
|
this.element.scrollTop = this.cumulativeHeights[index] - this.visibleHeight();
|
|
if (UI.UIUtils.isScrolledToBottom(this.element)) {
|
|
this.setStickToBottom(true);
|
|
}
|
|
this.refresh();
|
|
// After refresh, the item is in DOM, but may not be visible (items above were larger than expected).
|
|
const renderedElement = this.renderedElementAt(index);
|
|
if (renderedElement) {
|
|
renderedElement.scrollIntoView(false /* alignTop */);
|
|
}
|
|
}
|
|
|
|
private visibleHeight(): number {
|
|
// Use offsetHeight instead of clientHeight to avoid being affected by horizontal scroll.
|
|
return this.element.offsetHeight;
|
|
}
|
|
}
|
|
|
|
export interface ConsoleViewportProvider {
|
|
fastHeight(index: number): number;
|
|
itemCount(): number;
|
|
minimumRowHeight(): number;
|
|
itemElement(index: number): ConsoleViewportElement|null;
|
|
}
|
|
|
|
export interface ConsoleViewportElement {
|
|
willHide(): void;
|
|
wasShown(): void;
|
|
element(): HTMLElement;
|
|
focusLastChildOrSelf(): void;
|
|
}
|