| // Copyright 2021 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import './cluster_menu.js'; |
| import './horizontal_carousel.js'; |
| import './search_query.js'; |
| import './url_visit.js'; |
| import '//resources/cr_elements/cr_auto_img/cr_auto_img.js'; |
| |
| import {HistoryResultType} from '//resources/cr_components/history/constants.js'; |
| import {I18nMixinLit} from '//resources/cr_elements/i18n_mixin_lit.js'; |
| import {assert} from '//resources/js/assert.js'; |
| import {loadTimeData} from '//resources/js/load_time_data.js'; |
| import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js'; |
| import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js'; |
| |
| import {BrowserProxyImpl} from './browser_proxy.js'; |
| import {getCss} from './cluster.css.js'; |
| import {getHtml} from './cluster.html.js'; |
| import type {Cluster, SearchQuery, URLVisit} from './history_cluster_types.mojom-webui.js'; |
| import type {PageCallbackRouter} from './history_clusters.mojom-webui.js'; |
| import {ClusterAction, VisitAction} from './history_clusters.mojom-webui.js'; |
| import {MetricsProxyImpl} from './metrics_proxy.js'; |
| import {insertHighlightedTextWithMatchesIntoElement} from './utils.js'; |
| |
| /** |
| * @fileoverview This file provides a custom element displaying a cluster. |
| */ |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'history-cluster': ClusterElement; |
| } |
| } |
| |
| const ClusterElementBase = I18nMixinLit(CrLitElement); |
| |
| export interface ClusterElement { |
| $: { |
| label: HTMLElement, |
| container: HTMLElement, |
| }; |
| } |
| |
| export class ClusterElement extends ClusterElementBase { |
| static get is() { |
| return 'history-cluster'; |
| } |
| |
| static override get styles() { |
| return getCss(); |
| } |
| |
| override render() { |
| return getHtml.bind(this)(); |
| } |
| |
| static override get properties() { |
| return { |
| /** |
| * The cluster displayed by this element. |
| */ |
| cluster: {type: Object}, |
| |
| /** |
| * The index of the cluster. |
| */ |
| index: {type: Number}, |
| |
| /** |
| * Whether the cluster is in the side panel. |
| */ |
| inSidePanel: { |
| type: Boolean, |
| reflect: true, |
| }, |
| |
| /** |
| * The current query for which related clusters are requested and shown. |
| */ |
| query: {type: String}, |
| |
| /** |
| * The visible related searches. |
| */ |
| relatedSearches_: {type: Array}, |
| |
| /** |
| * The label for the cluster. This property is actually unused. The side |
| * effect of the compute function is used to insert the HTML elements for |
| * highlighting into this.$.label element. |
| */ |
| label_: { |
| type: String, |
| state: true, |
| }, |
| |
| /** |
| * The cluster's image URL in a form easily passed to cr-auto-img. |
| * Also notifies the outer iron-list of a resize. |
| */ |
| imageUrl_: {type: String}, |
| }; |
| } |
| |
| //============================================================================ |
| // Properties |
| //============================================================================ |
| |
| accessor cluster: Cluster|undefined; |
| accessor index: number = -1; // Initialized to an invalid value. |
| accessor inSidePanel: boolean = loadTimeData.getBoolean('inSidePanel'); |
| accessor query: string = ''; |
| protected accessor imageUrl_: string = ''; |
| protected accessor relatedSearches_: SearchQuery[] = []; |
| |
| private callbackRouter_: PageCallbackRouter; |
| private onVisitsHiddenListenerId_: number|null = null; |
| private onVisitsRemovedListenerId_: number|null = null; |
| private accessor label_: string = 'no_label'; |
| |
| //============================================================================ |
| // Overridden methods |
| //============================================================================ |
| |
| constructor() { |
| super(); |
| this.callbackRouter_ = BrowserProxyImpl.getInstance().callbackRouter; |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| this.onVisitsHiddenListenerId_ = |
| this.callbackRouter_.onVisitsHidden.addListener( |
| this.onVisitsRemovedOrHidden_.bind(this)); |
| this.onVisitsRemovedListenerId_ = |
| this.callbackRouter_.onVisitsRemoved.addListener( |
| this.onVisitsRemovedOrHidden_.bind(this)); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| |
| assert(this.onVisitsHiddenListenerId_); |
| this.callbackRouter_.removeListener(this.onVisitsHiddenListenerId_); |
| this.onVisitsHiddenListenerId_ = null; |
| |
| assert(this.onVisitsRemovedListenerId_); |
| this.callbackRouter_.removeListener(this.onVisitsRemovedListenerId_); |
| this.onVisitsRemovedListenerId_ = null; |
| } |
| |
| override willUpdate(changedProperties: PropertyValues<this>) { |
| super.willUpdate(changedProperties); |
| |
| if (changedProperties.has('cluster')) { |
| assert(this.cluster); |
| this.label_ = this.cluster.label ? this.cluster.label : 'no_label'; |
| this.imageUrl_ = this.cluster.imageUrl ? this.cluster.imageUrl.url : ''; |
| this.relatedSearches_ = this.cluster.relatedSearches.filter( |
| (query: SearchQuery, index: number) => { |
| return query && !(this.inSidePanel && index > 2); |
| }); |
| } |
| } |
| |
| override updated(changedProperties: PropertyValues<this>) { |
| super.updated(changedProperties); |
| |
| const changedPrivateProperties = |
| changedProperties as Map<PropertyKey, unknown>; |
| if (changedPrivateProperties.has('label_') && this.label_ !== 'no_label' && |
| this.cluster) { |
| insertHighlightedTextWithMatchesIntoElement( |
| this.$.label, this.cluster.label, this.cluster.labelMatchPositions); |
| } |
| if (changedPrivateProperties.has('imageUrl_')) { |
| // iron-list can't handle our size changing because of loading an image |
| // without an explicit event. But we also can't send this until we have |
| // updated the image property, so send it on the next idle. |
| requestIdleCallback(() => { |
| this.fire('iron-resize'); |
| }); |
| } else if (changedProperties.has('cluster')) { |
| // Iron-list re-assigns the `cluster` property to reuse existing elements |
| // as the user scrolls. Since this property can change the height of this |
| // element, we need to notify iron-list that this element's height may |
| // need to be re-calculated. |
| this.fire('iron-resize'); |
| } |
| } |
| |
| //============================================================================ |
| // Event handlers |
| //============================================================================ |
| |
| protected onRelatedSearchClicked_() { |
| MetricsProxyImpl.getInstance().recordClusterAction( |
| ClusterAction.kRelatedSearchClicked, this.index); |
| } |
| |
| /* Clears selection on non alt mouse clicks. Need to wait for browser to |
| * update the DOM fully. */ |
| protected clearSelection_(event: MouseEvent) { |
| this.onBrowserIdle_().then(() => { |
| if (window.getSelection() && !event.altKey) { |
| window.getSelection()?.empty(); |
| } |
| }); |
| } |
| |
| protected onVisitClicked_(event: CustomEvent<URLVisit>) { |
| MetricsProxyImpl.getInstance().recordClusterAction( |
| ClusterAction.kVisitClicked, this.index); |
| |
| const visit = event.detail; |
| const visitIndex = this.getVisitIndex_(visit); |
| MetricsProxyImpl.getInstance().recordVisitAction( |
| VisitAction.kClicked, visitIndex, MetricsProxyImpl.getVisitType(visit)); |
| |
| this.fire('record-history-link-click', { |
| resultType: HistoryResultType.GROUPED, |
| index: visitIndex, |
| }); |
| } |
| |
| protected onOpenAllVisits_() { |
| assert(this.cluster); |
| BrowserProxyImpl.getInstance().handler.openVisitUrlsInTabGroup( |
| this.cluster.visits, this.cluster.tabGroupName ?? null); |
| |
| MetricsProxyImpl.getInstance().recordClusterAction( |
| ClusterAction.kOpenedInTabGroup, this.index); |
| } |
| |
| protected onHideAllVisits_() { |
| this.fire('hide-visits', this.cluster ? this.cluster.visits : []); |
| } |
| |
| protected onRemoveAllVisits_() { |
| // Pass event up with new detail of all this cluster's visits. |
| this.fire('remove-visits', this.cluster ? this.cluster.visits : []); |
| } |
| |
| protected onHideVisit_(event: CustomEvent<URLVisit>) { |
| // The actual hiding is handled in clusters.ts. This is just a good place to |
| // record the metric. |
| const visit = event.detail; |
| MetricsProxyImpl.getInstance().recordVisitAction( |
| VisitAction.kHidden, this.getVisitIndex_(visit), |
| MetricsProxyImpl.getVisitType(visit)); |
| } |
| |
| protected onRemoveVisit_(event: CustomEvent<URLVisit>) { |
| // The actual removal is handled in clusters.ts. This is just a good place |
| // to record the metric. |
| const visit = event.detail; |
| MetricsProxyImpl.getInstance().recordVisitAction( |
| VisitAction.kDeleted, this.getVisitIndex_(visit), |
| MetricsProxyImpl.getVisitType(visit)); |
| |
| this.fire('remove-visits', [visit]); |
| } |
| |
| //============================================================================ |
| // Helper methods |
| //============================================================================ |
| |
| /** |
| * Returns a promise that resolves when the browser is idle. |
| */ |
| private onBrowserIdle_(): Promise<void> { |
| return new Promise(resolve => { |
| requestIdleCallback(() => { |
| resolve(); |
| }); |
| }); |
| } |
| |
| /** |
| * Called with the original remove or hide params when the last accepted |
| * request to browser to remove or hide visits succeeds. Since the same visit |
| * may appear in multiple Clusters, all Clusters receive this callback in |
| * order to get a chance to remove their matching visits. |
| */ |
| private onVisitsRemovedOrHidden_(removedVisits: URLVisit[]) { |
| assert(this.cluster); |
| const visitHasBeenRemoved = (visit: URLVisit) => { |
| return removedVisits.findIndex((removedVisit) => { |
| if (visit.normalizedUrl.url !== removedVisit.normalizedUrl.url) { |
| return false; |
| } |
| |
| // Remove the visit element if any of the removed visit's raw timestamps |
| // matches the canonical raw timestamp. |
| const rawVisitTime = visit.rawVisitData.visitTime.internalValue; |
| return (removedVisit.rawVisitData.visitTime.internalValue === |
| rawVisitTime) || |
| removedVisit.duplicates.map(data => data.visitTime.internalValue) |
| .includes(rawVisitTime); |
| }) !== -1; |
| }; |
| |
| const allVisits = this.cluster.visits; |
| const remainingVisits = allVisits.filter(v => !visitHasBeenRemoved(v)); |
| if (allVisits.length === remainingVisits.length) { |
| return; |
| } |
| |
| if (!remainingVisits.length) { |
| // If all the visits are removed, fire an event to also remove this |
| // cluster from the list of clusters. |
| this.fire('remove-cluster', this.index); |
| |
| MetricsProxyImpl.getInstance().recordClusterAction( |
| ClusterAction.kDeleted, this.index); |
| } else { |
| this.cluster.visits = remainingVisits; |
| this.requestUpdate(); |
| } |
| |
| this.updateComplete.then(() => { |
| this.fire('iron-resize'); |
| }); |
| } |
| |
| /** |
| * Returns the index of `visit` among the visits in the cluster. Returns -1 |
| * if the visit is not found in the cluster at all. |
| */ |
| private getVisitIndex_(visit: URLVisit): number { |
| return this.cluster ? this.cluster.visits.indexOf(visit) : -1; |
| } |
| |
| protected hideRelatedSearches_(): boolean { |
| return !this.cluster || !this.cluster.relatedSearches.length; |
| } |
| |
| protected debugInfo_(): string { |
| return this.cluster && this.cluster.debugInfo ? this.cluster.debugInfo : ''; |
| } |
| |
| protected timestamp_(): string { |
| return this.cluster && this.cluster.visits.length > 0 ? |
| this.cluster.visits[0]!.relativeDate : |
| ''; |
| } |
| |
| protected visits_(): URLVisit[] { |
| return this.cluster ? this.cluster.visits : []; |
| } |
| } |
| |
| customElements.define(ClusterElement.is, ClusterElement); |