diff --git a/all_devtools_modules.gni b/all_devtools_modules.gni index 25757ceb43..d5c4aba8f5 100644 --- a/all_devtools_modules.gni +++ b/all_devtools_modules.gni @@ -757,6 +757,7 @@ all_typescript_module_sources = [ "ui/components/DataGridController.js", "ui/components/DataGridRenderers.js", "ui/components/DataGridUtils.js", + "ui/components/Linkifier.js", "ui/components/components.js", "ui/utils/append-style.js", "ui/utils/create-shadow-root-with-core-styles.js", diff --git a/devtools_grd_files.gni b/devtools_grd_files.gni index 19acada63d..ca8c6507f0 100644 --- a/devtools_grd_files.gni +++ b/devtools_grd_files.gni @@ -1140,6 +1140,7 @@ grd_files_debug_sources = [ "front_end/ui/components/DataGridController.js", "front_end/ui/components/DataGridRenderers.js", "front_end/ui/components/DataGridUtils.js", + "front_end/ui/components/Linkifier.js", "front_end/ui/utils/append-style.js", "front_end/ui/utils/create-shadow-root-with-core-styles.js", "front_end/ui/utils/focus-changed.js", diff --git a/front_end/component_docs/BUILD.gn b/front_end/component_docs/BUILD.gn index a36a1edde0..e224b2198b 100644 --- a/front_end/component_docs/BUILD.gn +++ b/front_end/component_docs/BUILD.gn @@ -17,6 +17,7 @@ group("component_docs") { "./issue_survey_link", "./layout_pane", "./linear_memory_inspector", + "./linkifier", "./perf_piechart", "./theme_colors", "./webvitals", diff --git a/front_end/component_docs/linkifier/BUILD.gn b/front_end/component_docs/linkifier/BUILD.gn new file mode 100644 index 0000000000..cd17369a0a --- /dev/null +++ b/front_end/component_docs/linkifier/BUILD.gn @@ -0,0 +1,14 @@ +# Copyright 2020 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import("../../../scripts/build/ninja/copy.gni") + +copy_to_gen("linkifier") { + sources = [ "url.html" ] + + deps = [ + "../../component_helpers:bundle", + "../../ui/components:bundle", + ] +} diff --git a/front_end/component_docs/linkifier/url.html b/front_end/component_docs/linkifier/url.html new file mode 100644 index 0000000000..ae55ab9c7f --- /dev/null +++ b/front_end/component_docs/linkifier/url.html @@ -0,0 +1,50 @@ + + + + + + + Linkifier example with URL + + + + +
+
+ + + + diff --git a/front_end/components/Linkifier.js b/front_end/components/Linkifier.js index 87c64a4150..8dbb98e5be 100644 --- a/front_end/components/Linkifier.js +++ b/front_end/components/Linkifier.js @@ -744,6 +744,14 @@ export class Linkifier { return Linkifier.invokeFirstAction(linkInfo); } + /** + * + * @param {!_LinkInfo} linkInfo + */ + static _handleClickFromNewComponentLand(linkInfo) { + Linkifier.invokeFirstAction(linkInfo); + } + /** * @param {!_LinkInfo} linkInfo * @return {boolean} @@ -974,6 +982,29 @@ export class LinkHandlerSettingUI { } } +let listeningToNewEvents = false; +function listenForNewComponentLinkifierEvents() { + if (listeningToNewEvents) { + return; + } + + listeningToNewEvents = true; + + window.addEventListener( + 'linkifier-click', + /** + * + * @param {!Event} event + */ + function(event) { + const unknownEvent = /** @type {?} */ (event); + const eventWithData = /** @type {!{data: !_LinkInfo}} */ (unknownEvent); + Linkifier._handleClickFromNewComponentLand(eventWithData.data); + }); +} + +listenForNewComponentLinkifierEvents(); + /** * @implements {UI.ContextMenu.Provider} * @unrestricted diff --git a/front_end/ui/components/BUILD.gn b/front_end/ui/components/BUILD.gn index 23c65eb561..8734eb8fd0 100644 --- a/front_end/ui/components/BUILD.gn +++ b/front_end/ui/components/BUILD.gn @@ -11,8 +11,10 @@ devtools_module("components") { "DataGridController.ts", "DataGridRenderers.ts", "DataGridUtils.ts", + "Linkifier.ts", ] deps = [ + "../../bindings:bundle", "../../text_utils:bundle", "../../third_party/lit-html:bundle", ] diff --git a/front_end/ui/components/Linkifier.ts b/front_end/ui/components/Linkifier.ts new file mode 100644 index 0000000000..8d537b3b48 --- /dev/null +++ b/front_end/ui/components/Linkifier.ts @@ -0,0 +1,88 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Bindings from '../../bindings/bindings.js'; +import * as LitHtml from '../../third_party/lit-html/lit-html.js'; + +export interface LinkifierData { + url: string; + lineNumber?: number; + columnNumber?: number; +} + +export class LinkifierClick extends Event { + data: LinkifierData; + + constructor(data: LinkifierData) { + super('linkifier-click', { + bubbles: true, + composed: true, + }); + this.data = data; + } +} + +export class Linkifier extends HTMLElement { + private readonly shadow = this.attachShadow({mode: 'open'}); + private url: string = ''; + private lineNumber?: number; + private columnNumber?: number; + + set data(data: LinkifierData) { + this.url = data.url; + this.lineNumber = data.lineNumber; + this.columnNumber = data.columnNumber; + + this.render(); + } + + private onLinkActivation(event: Event) { + event.preventDefault(); + this.dispatchEvent(new LinkifierClick({ + url: this.url, + lineNumber: this.lineNumber, + columnNumber: this.columnNumber, + })); + } + + private linkText(): string { + if (this.url) { + const displayName = Bindings.ResourceUtils.displayNameForURL(this.url); + let text = `${displayName}`; + if (typeof this.lineNumber !== 'undefined') { + text += `:${this.lineNumber + 1}`; + } + return text; + } + + throw new Error('New linkifier component error: don\'t know how to generate link text for given arguments'); + } + + private render() { + if (!this.url) { + throw new Error('Cannot construct a Linkifier without providing a valid string URL.'); + } + // Disabled until https://crbug.com/1079231 is fixed. + // clang-format off + return LitHtml.render(LitHtml.html` + + ${this.linkText()} + `, this.shadow, { eventContext: this}); + // clang-format on + } +} + +customElements.define('devtools-linkifier', Linkifier); + +declare global { + interface HTMLElementTagNameMap { + 'devtools-linkifier': Linkifier; + } +} diff --git a/front_end/ui/components/components.ts b/front_end/ui/components/components.ts index 9e7a697489..c896af171b 100644 --- a/front_end/ui/components/components.ts +++ b/front_end/ui/components/components.ts @@ -6,10 +6,12 @@ import * as DataGrid from './DataGrid.js'; import * as DataGridController from './DataGridController.js'; import * as DataGridRenderers from './DataGridRenderers.js'; import * as DataGridUtils from './DataGridUtils.js'; +import * as Linkifier from './Linkifier.js'; export { DataGrid, DataGridController, DataGridRenderers, DataGridUtils, + Linkifier, }; diff --git a/test/unittests/front_end/helpers/DOMHelpers.ts b/test/unittests/front_end/helpers/DOMHelpers.ts index 9587e2001f..6176c610f5 100644 --- a/test/unittests/front_end/helpers/DOMHelpers.ts +++ b/test/unittests/front_end/helpers/DOMHelpers.ts @@ -137,14 +137,11 @@ export function waitForScrollLeft(element: T, desiredScrollLe } /** - * Dispatches a mouse click event. Errors if the event was not dispatched successfully. + * Dispatches a mouse click event. */ export function dispatchClickEvent(element: T, options: MouseEventInit = {}) { const clickEvent = new MouseEvent('click', options); - const success = element.dispatchEvent(clickEvent); - if (!success) { - assert.fail('Failed to trigger click event successfully.'); - } + element.dispatchEvent(clickEvent); } /** diff --git a/test/unittests/front_end/ui/components/BUILD.gn b/test/unittests/front_end/ui/components/BUILD.gn index a893dfb35c..59263c5fb0 100644 --- a/test/unittests/front_end/ui/components/BUILD.gn +++ b/test/unittests/front_end/ui/components/BUILD.gn @@ -6,6 +6,7 @@ ts_library("components") { "DataGridController_test.ts", "DataGridHelpers.ts", "DataGrid_test.ts", + "Linkifier_test.ts", ] deps = [ diff --git a/test/unittests/front_end/ui/components/Linkifier_test.ts b/test/unittests/front_end/ui/components/Linkifier_test.ts new file mode 100644 index 0000000000..ab51e9dfe2 --- /dev/null +++ b/test/unittests/front_end/ui/components/Linkifier_test.ts @@ -0,0 +1,68 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as UIComponents from '../../../../../front_end/ui/components/components.js'; + +import {assertElement, assertShadowRoot, dispatchClickEvent, getEventPromise, renderElementIntoDOM} from '../../helpers/DOMHelpers.js'; +const {assert} = chai; + +describe('Linkifier', () => { + it('renders a link when given a URL', () => { + const component = new UIComponents.Linkifier.Linkifier(); + component.data = { + url: 'https://example.com', + }; + renderElementIntoDOM(component); + assertShadowRoot(component.shadowRoot); + const link = component.shadowRoot.querySelector('a'); + assertElement(link, HTMLAnchorElement); + assert.strictEqual(link.innerText, 'example.com'); + }); + + it('throws when given an invalid URL', () => { + const component = new UIComponents.Linkifier.Linkifier(); + assert.throws(() => { + component.data = { + url: '', + }; + }, 'Cannot construct a Linkifier without providing a valid string URL.'); + }); + + it('appends the line number to the URL if given, and adds one to deal with 0 indexing', () => { + const component = new UIComponents.Linkifier.Linkifier(); + component.data = { + url: 'https://example.com', + lineNumber: 1, + }; + renderElementIntoDOM(component); + assertShadowRoot(component.shadowRoot); + const link = component.shadowRoot.querySelector('a'); + assertElement(link, HTMLAnchorElement); + assert.strictEqual(link.innerText, 'example.com:2'); + }); + + it('emits an event when clicked', async () => { + const component = new UIComponents.Linkifier.Linkifier(); + component.data = { + url: 'https://example.com', + lineNumber: 1, + columnNumber: 50, + }; + renderElementIntoDOM(component); + assertShadowRoot(component.shadowRoot); + const link = component.shadowRoot.querySelector('a'); + assertElement(link, HTMLAnchorElement); + + const clickEventPromise = getEventPromise(component, 'linkifier-click'); + dispatchClickEvent(link, { + cancelable: true, + }); + const clickEvent = await clickEventPromise; + assert.deepEqual(clickEvent.data, { + url: 'https://example.com', + lineNumber: 1, + columnNumber: 50, + }); + }); +});