UNPKG

chrome-devtools-frontend

Version:
1,057 lines (941 loc) 38 kB
/* * Copyright (C) 2012 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 Common from '../../../../core/common/common.js'; import * as Host from '../../../../core/host/host.js'; import * as i18n from '../../../../core/i18n/i18n.js'; import * as Platform from '../../../../core/platform/platform.js'; import * as SDK from '../../../../core/sdk/sdk.js'; import * as Bindings from '../../../../models/bindings/bindings.js'; import * as Breakpoints from '../../../../models/breakpoints/breakpoints.js'; import * as TextUtils from '../../../../models/text_utils/text_utils.js'; import * as Workspace from '../../../../models/workspace/workspace.js'; import type * as Protocol from '../../../../generated/protocol.js'; import type * as IconButton from '../../../components/icon_button/icon_button.js'; import * as UI from '../../legacy.js'; const UIStrings = { /** *@description Text in Linkifier */ unknown: '(unknown)', /** *@description Text short for automatic */ auto: 'auto', /** *@description Text in Linkifier *@example {Sources panel} PH1 */ revealInS: 'Reveal in {PH1}', /** *@description Text for revealing an item in its destination */ reveal: 'Reveal', /** *@description A context menu item in the Linkifier *@example {Extension} PH1 */ openUsingS: 'Open using {PH1}', /** * @description The name of a setting which controls how links are handled in the UI. 'Handling' * refers to the ability of extensions to DevTools to be able to intercept link clicks so that they * can react to them. */ linkHandling: 'Link handling:', }; const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/utils/Linkifier.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const instances = new Set<Linkifier>(); let decorator: LinkDecorator|null = null; const anchorsByUISourceCode = new WeakMap<Workspace.UISourceCode.UISourceCode, Set<Element>>(); const infoByAnchor = new WeakMap<Node, _LinkInfo>(); const textByAnchor = new WeakMap<Node, string>(); const linkHandlers = new Map<string, LinkHandler>(); let linkHandlerSettingInstance: Common.Settings.Setting<string>; export class Linkifier implements SDK.TargetManager.Observer { private readonly maxLength: number; private readonly anchorsByTarget: Map<SDK.Target.Target, Element[]>; private readonly locationPoolByTarget: Map<SDK.Target.Target, Bindings.LiveLocation.LiveLocationPool>; private onLiveLocationUpdate: (() => void); private useLinkDecorator: boolean; constructor( maxLengthForDisplayedURLs?: number, useLinkDecorator?: boolean, onLiveLocationUpdate: (() => void) = (): void => {}) { this.maxLength = maxLengthForDisplayedURLs || UI.UIUtils.MaxLengthForDisplayedURLs; this.anchorsByTarget = new Map(); this.locationPoolByTarget = new Map(); this.onLiveLocationUpdate = onLiveLocationUpdate; this.useLinkDecorator = Boolean(useLinkDecorator); instances.add(this); SDK.TargetManager.TargetManager.instance().observeTargets(this); } static setLinkDecorator(linkDecorator: LinkDecorator): void { console.assert(!decorator, 'Cannot re-register link decorator.'); decorator = linkDecorator; linkDecorator.addEventListener(LinkDecorator.Events.LinkIconChanged, onLinkIconChanged); for (const linkifier of instances) { linkifier.updateAllAnchorDecorations(); } function onLinkIconChanged(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): void { const uiSourceCode = event.data; const links = anchorsByUISourceCode.get(uiSourceCode) || []; for (const link of links) { Linkifier.updateLinkDecorations(link); } } } private updateAllAnchorDecorations(): void { for (const anchors of this.anchorsByTarget.values()) { for (const anchor of anchors) { Linkifier.updateLinkDecorations(anchor); } } } private static bindUILocation(anchor: Element, uiLocation: Workspace.UISourceCode.UILocation): void { const linkInfo = Linkifier.linkInfo(anchor); if (!linkInfo) { return; } linkInfo.uiLocation = uiLocation; if (!uiLocation) { return; } const uiSourceCode = uiLocation.uiSourceCode; let sourceCodeAnchors = anchorsByUISourceCode.get(uiSourceCode); if (!sourceCodeAnchors) { sourceCodeAnchors = new Set(); anchorsByUISourceCode.set(uiSourceCode, sourceCodeAnchors); } sourceCodeAnchors.add(anchor); } private static unbindUILocation(anchor: Element): void { const info = Linkifier.linkInfo(anchor); if (!info || !info.uiLocation) { return; } const uiSourceCode = info.uiLocation.uiSourceCode; info.uiLocation = null; const sourceCodeAnchors = anchorsByUISourceCode.get(uiSourceCode); if (sourceCodeAnchors) { sourceCodeAnchors.delete(anchor); } } /** * When we link to a breakpoint condition, we need to stash the BreakpointLocation as the revealable * in the LinkInfo. */ private static bindBreakpoint(anchor: Element, uiLocation: Workspace.UISourceCode.UILocation): void { const info = Linkifier.linkInfo(anchor); if (!info) { return; } const breakpoint = Breakpoints.BreakpointManager.BreakpointManager.instance().findBreakpoint(uiLocation); if (breakpoint) { info.revealable = breakpoint; } } /** * When we link to a breakpoint condition, we store the BreakpointLocation in the revealable. * Clear it when the LiveLocation updates. */ private static unbindBreakpoint(anchor: Element): void { const info = Linkifier.linkInfo(anchor); if (info && info.revealable) { info.revealable = null; } } targetAdded(target: SDK.Target.Target): void { this.anchorsByTarget.set(target, []); this.locationPoolByTarget.set(target, new Bindings.LiveLocation.LiveLocationPool()); } targetRemoved(target: SDK.Target.Target): void { const locationPool = this.locationPoolByTarget.get(target); this.locationPoolByTarget.delete(target); if (!locationPool) { return; } locationPool.disposeAll(); const anchors = (this.anchorsByTarget.get(target) as HTMLElement[] | null); if (!anchors) { return; } this.anchorsByTarget.delete(target); for (const anchor of anchors) { const info = Linkifier.linkInfo(anchor); if (!info) { continue; } info.liveLocation = null; Linkifier.unbindUILocation(anchor); const fallback = info.fallback; if (fallback) { anchor.replaceWith(fallback); } } } maybeLinkifyScriptLocation( target: SDK.Target.Target|null, scriptId: Protocol.Runtime.ScriptId|null, sourceURL: Platform.DevToolsPath.UrlString, lineNumber: number|undefined, options?: LinkifyOptions): HTMLElement |null { let fallbackAnchor: HTMLElement|null = null; const linkifyURLOptions: LinkifyURLOptions = { lineNumber, maxLength: this.maxLength, columnNumber: options?.columnNumber, showColumnNumber: Boolean(options?.showColumnNumber), className: options?.className, tabStop: options?.tabStop, inlineFrameIndex: options?.inlineFrameIndex ?? 0, userMetric: options?.userMetric, }; const {columnNumber, className = ''} = linkifyURLOptions; if (sourceURL) { fallbackAnchor = Linkifier.linkifyURL(sourceURL, linkifyURLOptions); } if (!target || target.isDisposed()) { return fallbackAnchor; } const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel); if (!debuggerModel) { return fallbackAnchor; } // Prefer createRawLocationByScriptId() here, since it will always produce a correct // link, since the script ID is unique. Only fall back to createRawLocationByURL() // when all we have is an URL, which is not guaranteed to be unique. const rawLocation = scriptId ? debuggerModel.createRawLocationByScriptId( scriptId, lineNumber || 0, columnNumber, linkifyURLOptions.inlineFrameIndex) : debuggerModel.createRawLocationByURL( sourceURL, lineNumber || 0, columnNumber, linkifyURLOptions.inlineFrameIndex); if (!rawLocation) { return fallbackAnchor; } const createLinkOptions: _CreateLinkOptions = { tabStop: options?.tabStop, }; const {link, linkInfo} = Linkifier.createLink( fallbackAnchor && fallbackAnchor.textContent ? fallbackAnchor.textContent : '', className, createLinkOptions); linkInfo.enableDecorator = this.useLinkDecorator; linkInfo.fallback = fallbackAnchor; linkInfo.userMetric = options?.userMetric; const pool = this.locationPoolByTarget.get(rawLocation.debuggerModel.target()); if (!pool) { return fallbackAnchor; } const linkDisplayOptions: LinkDisplayOptions = { showColumnNumber: linkifyURLOptions.showColumnNumber, revealBreakpoint: options?.revealBreakpoint, }; const currentOnLiveLocationUpdate = this.onLiveLocationUpdate; void Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance() .createLiveLocation(rawLocation, this.updateAnchor.bind(this, link, linkDisplayOptions), pool) .then(liveLocation => { if (liveLocation) { linkInfo.liveLocation = liveLocation; currentOnLiveLocationUpdate(); } }); const anchors = (this.anchorsByTarget.get(rawLocation.debuggerModel.target()) as Element[]); anchors.push(link); return link; } linkifyScriptLocation( target: SDK.Target.Target|null, scriptId: Protocol.Runtime.ScriptId|null, sourceURL: Platform.DevToolsPath.UrlString, lineNumber: number|undefined, options?: LinkifyOptions): HTMLElement { const scriptLink = this.maybeLinkifyScriptLocation(target, scriptId, sourceURL, lineNumber, options); const linkifyURLOptions: LinkifyURLOptions = { lineNumber, maxLength: this.maxLength, className: options?.className, columnNumber: options?.columnNumber, showColumnNumber: Boolean(options?.showColumnNumber), inlineFrameIndex: options?.inlineFrameIndex ?? 0, tabStop: options?.tabStop, userMetric: options?.userMetric, }; return scriptLink || Linkifier.linkifyURL(sourceURL, linkifyURLOptions); } linkifyRawLocation( rawLocation: SDK.DebuggerModel.Location, fallbackUrl: Platform.DevToolsPath.UrlString, className?: string): Element { return this.linkifyScriptLocation( rawLocation.debuggerModel.target(), rawLocation.scriptId, fallbackUrl, rawLocation.lineNumber, { columnNumber: rawLocation.columnNumber, className, inlineFrameIndex: rawLocation.inlineFrameIndex, }); } maybeLinkifyConsoleCallFrame( target: SDK.Target.Target|null, callFrame: Protocol.Runtime.CallFrame, options?: LinkifyOptions): HTMLElement |null { const linkifyOptions: LinkifyOptions = { ...options, columnNumber: callFrame.columnNumber, inlineFrameIndex: options?.inlineFrameIndex ?? 0, }; return this.maybeLinkifyScriptLocation( target, callFrame.scriptId, callFrame.url as Platform.DevToolsPath.UrlString, callFrame.lineNumber, linkifyOptions); } linkifyStackTraceTopFrame(target: SDK.Target.Target|null, stackTrace: Protocol.Runtime.StackTrace): HTMLElement { console.assert(stackTrace.callFrames.length > 0); const {url, lineNumber, columnNumber} = stackTrace.callFrames[0]; const fallbackAnchor = Linkifier.linkifyURL(url as Platform.DevToolsPath.UrlString, { lineNumber, columnNumber, showColumnNumber: false, inlineFrameIndex: 0, maxLength: this.maxLength, preventClick: true, }); // HAR imported network logs have no associated NetworkManager. if (!target) { return fallbackAnchor; } // The contract is that disposed targets don't have a LiveLocationPool // associated, whereas all active targets have one such pool. This ensures // that the fallbackAnchor is only ever used when the target was disposed. const pool = this.locationPoolByTarget.get(target); if (!pool) { console.assert(target.isDisposed()); return fallbackAnchor; } console.assert(!target.isDisposed()); // All targets that can report stack traces also have a debugger model. const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel) as SDK.DebuggerModel.DebuggerModel; const {link, linkInfo} = Linkifier.createLink('', ''); linkInfo.enableDecorator = this.useLinkDecorator; linkInfo.fallback = fallbackAnchor; const linkDisplayOptions = {showColumnNumber: false}; const currentOnLiveLocationUpdate = this.onLiveLocationUpdate; void Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance() .createStackTraceTopFrameLiveLocation( debuggerModel.createRawLocationsByStackTrace(stackTrace), this.updateAnchor.bind(this, link, linkDisplayOptions), pool) .then(liveLocation => { linkInfo.liveLocation = liveLocation; currentOnLiveLocationUpdate(); }); const anchors = (this.anchorsByTarget.get(target) as Element[]); anchors.push(link); return link; } linkifyCSSLocation(rawLocation: SDK.CSSModel.CSSLocation, classes?: string): Element { const createLinkOptions: _CreateLinkOptions = { tabStop: true, }; const {link, linkInfo} = Linkifier.createLink('', classes || '', createLinkOptions); linkInfo.enableDecorator = this.useLinkDecorator; const pool = this.locationPoolByTarget.get(rawLocation.cssModel().target()); if (!pool) { return link; } const linkDisplayOptions = {showColumnNumber: false}; const currentOnLiveLocationUpdate = this.onLiveLocationUpdate; void Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance() .createLiveLocation(rawLocation, this.updateAnchor.bind(this, link, linkDisplayOptions), pool) .then(liveLocation => { linkInfo.liveLocation = liveLocation; currentOnLiveLocationUpdate(); }); const anchors = (this.anchorsByTarget.get(rawLocation.cssModel().target()) as Element[]); anchors.push(link); return link; } reset(): void { // Create a copy of {keys} so {targetRemoved} can safely modify the map. for (const target of [...this.anchorsByTarget.keys()]) { this.targetRemoved(target); this.targetAdded(target); } } dispose(): void { // Create a copy of {keys} so {targetRemoved} can safely modify the map. for (const target of [...this.anchorsByTarget.keys()]) { this.targetRemoved(target); } SDK.TargetManager.TargetManager.instance().unobserveTargets(this); instances.delete(this); } private async updateAnchor( anchor: HTMLElement, options: LinkDisplayOptions, liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> { Linkifier.unbindUILocation(anchor); if (options.revealBreakpoint) { Linkifier.unbindBreakpoint(anchor); } const uiLocation = await liveLocation.uiLocation(); if (!uiLocation) { if (liveLocation instanceof Bindings.CSSWorkspaceBinding.LiveLocation) { const header = (liveLocation as Bindings.CSSWorkspaceBinding.LiveLocation).header(); if (header && header.ownerNode) { anchor.addEventListener('click', event => { event.consume(true); void Common.Revealer.reveal(header.ownerNode || null); }, false); // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // This workaround is needed to make stylelint happy Linkifier.setTrimmedText( anchor, '<' + 'style>'); } } return; } Linkifier.bindUILocation(anchor, uiLocation); if (options.revealBreakpoint) { Linkifier.bindBreakpoint(anchor, uiLocation); } const text = uiLocation.linkText(true /* skipTrim */, options.showColumnNumber); Linkifier.setTrimmedText(anchor, text, this.maxLength); let titleText: string = uiLocation.uiSourceCode.url(); if (uiLocation.uiSourceCode.mimeType() === 'application/wasm') { // For WebAssembly locations, we follow the conventions described in // github.com/WebAssembly/design/blob/master/Web.md#developer-facing-display-conventions if (typeof uiLocation.columnNumber === 'number') { titleText += `:0x${uiLocation.columnNumber.toString(16)}`; } } else { titleText += ':' + (uiLocation.lineNumber + 1); if (options.showColumnNumber && typeof uiLocation.columnNumber === 'number') { titleText += ':' + (uiLocation.columnNumber + 1); } } UI.Tooltip.Tooltip.install(anchor, titleText); anchor.classList.toggle('ignore-list-link', await liveLocation.isIgnoreListed()); Linkifier.updateLinkDecorations(anchor); } setLiveLocationUpdateCallback(callback: () => void): void { this.onLiveLocationUpdate = callback; } private static updateLinkDecorations(anchor: Element): void { const info = Linkifier.linkInfo(anchor); if (!info || !info.enableDecorator) { return; } if (!decorator || !info.uiLocation) { return; } if (info.icon && info.icon.parentElement) { anchor.removeChild(info.icon); } const icon = decorator.linkIcon(info.uiLocation.uiSourceCode); if (icon) { icon.style.setProperty('margin-right', '2px'); anchor.insertBefore(icon, anchor.firstChild); } info.icon = icon; } static linkifyURL(url: Platform.DevToolsPath.UrlString, options?: LinkifyURLOptions): HTMLElement { options = options || { showColumnNumber: false, inlineFrameIndex: 0, }; const text = options.text; const className = options.className || ''; const lineNumber = options.lineNumber; const columnNumber = options.columnNumber; const showColumnNumber = options.showColumnNumber; const preventClick = options.preventClick; const maxLength = options.maxLength || UI.UIUtils.MaxLengthForDisplayedURLs; const bypassURLTrimming = options.bypassURLTrimming; if (!url || url.trim().toLowerCase().startsWith('javascript:')) { const element = document.createElement('span'); if (className) { element.className = className; } element.textContent = text || url || i18nString(UIStrings.unknown); return element; } let linkText = text || Bindings.ResourceUtils.displayNameForURL(url); if (typeof lineNumber === 'number' && !text) { linkText += ':' + (lineNumber + 1); if (showColumnNumber && typeof columnNumber === 'number') { linkText += ':' + (columnNumber + 1); } } const title = linkText !== url ? url : ''; const linkOptions = {maxLength, title, href: url, preventClick, tabStop: options.tabStop, bypassURLTrimming}; const {link, linkInfo} = Linkifier.createLink(linkText, className, linkOptions); if (lineNumber) { linkInfo.lineNumber = lineNumber; } if (columnNumber) { linkInfo.columnNumber = columnNumber; } linkInfo.userMetric = options?.userMetric; return link; } static linkifyRevealable( revealable: Object, text: string|HTMLElement, fallbackHref?: Platform.DevToolsPath.UrlString, title?: string, className?: string): HTMLElement { const createLinkOptions: _CreateLinkOptions = { maxLength: UI.UIUtils.MaxLengthForDisplayedURLs, href: (fallbackHref), title, }; const {link, linkInfo} = Linkifier.createLink(text, className || '', createLinkOptions); linkInfo.revealable = revealable; return link; } private static createLink(text: string|HTMLElement, className: string, options: _CreateLinkOptions = {}): {link: HTMLElement, linkInfo: _LinkInfo} { const {maxLength, title, href, preventClick, tabStop, bypassURLTrimming} = options; const link = document.createElement('span'); if (className) { link.className = className; } link.classList.add('devtools-link'); if (title) { UI.Tooltip.Tooltip.install(link, title); } if (href) { // @ts-ignore link.href = href; } if (text instanceof HTMLElement) { link.appendChild(text); } else { if (bypassURLTrimming) { link.classList.add('devtools-link-styled-trim'); Linkifier.appendTextWithoutHashes(link, text); } else { Linkifier.setTrimmedText(link, text, maxLength); } } const linkInfo = { icon: null, enableDecorator: false, uiLocation: null, liveLocation: null, url: href || null, lineNumber: null, columnNumber: null, inlineFrameIndex: 0, revealable: null, fallback: null, }; infoByAnchor.set(link, linkInfo); if (!preventClick) { link.addEventListener('click', event => { if (Linkifier.handleClick(event)) { event.consume(true); } }, false); link.addEventListener('keydown', event => { if (event.key === 'Enter' && Linkifier.handleClick(event)) { event.consume(true); } }, false); } else { link.classList.add('devtools-link-prevent-click'); } UI.ARIAUtils.markAsLink(link); link.tabIndex = tabStop ? 0 : -1; return {link, linkInfo}; } private static setTrimmedText(link: Element, text: string, maxLength?: number): void { link.removeChildren(); if (maxLength && text.length > maxLength) { const middleSplit = splitMiddle(text, maxLength); Linkifier.appendTextWithoutHashes(link, middleSplit[0]); Linkifier.appendHiddenText(link, middleSplit[1]); Linkifier.appendTextWithoutHashes(link, middleSplit[2]); } else { Linkifier.appendTextWithoutHashes(link, text); } function splitMiddle(string: string, maxLength: number): string[] { let leftIndex = Math.floor(maxLength / 2); let rightIndex = string.length - Math.ceil(maxLength / 2) + 1; const codePointAtRightIndex = string.codePointAt(rightIndex - 1); // Do not truncate between characters that use multiple code points (emojis). if (typeof codePointAtRightIndex !== 'undefined' && codePointAtRightIndex >= 0x10000) { rightIndex++; leftIndex++; } const codePointAtLeftIndex = string.codePointAt(leftIndex - 1); if (typeof codePointAtLeftIndex !== 'undefined' && leftIndex > 0 && codePointAtLeftIndex >= 0x10000) { leftIndex--; } return [string.substring(0, leftIndex), string.substring(leftIndex, rightIndex), string.substring(rightIndex)]; } } private static appendTextWithoutHashes(link: Element, string: string): void { const hashSplit = TextUtils.TextUtils.Utils.splitStringByRegexes(string, [/[a-f0-9]{20,}/g]); for (const match of hashSplit) { if (match.regexIndex === -1) { UI.UIUtils.createTextChild(link, match.value); } else { UI.UIUtils.createTextChild(link, match.value.substring(0, 7)); Linkifier.appendHiddenText(link, match.value.substring(7)); } } } private static appendHiddenText(link: Element, string: string): void { const ellipsisNode = UI.UIUtils.createTextChild(link.createChild('span', 'devtools-link-ellipsis'), '…'); textByAnchor.set(ellipsisNode, string); } static untruncatedNodeText(node: Node): string { return textByAnchor.get(node) || node.textContent || ''; } static linkInfo(link: Element|null): _LinkInfo|null { return link ? infoByAnchor.get(link) || null : null as _LinkInfo | null; } private static handleClick(event: Event): boolean { const link = (event.currentTarget as Element); if (UI.UIUtils.isBeingEdited((event.target as Node)) || link.hasSelection()) { return false; } const linkInfo = Linkifier.linkInfo(link); if (!linkInfo) { return false; } return Linkifier.invokeFirstAction(linkInfo); } static handleClickFromNewComponentLand(linkInfo: _LinkInfo): void { Linkifier.invokeFirstAction(linkInfo); } static invokeFirstAction(linkInfo: _LinkInfo): boolean { const actions = Linkifier.linkActions(linkInfo); if (actions.length) { void actions[0].handler.call(null); if (linkInfo.userMetric) { Host.userMetrics.actionTaken(linkInfo.userMetric); } return true; } return false; } static linkHandlerSetting(): Common.Settings.Setting<string> { if (!linkHandlerSettingInstance) { linkHandlerSettingInstance = Common.Settings.Settings.instance().createSetting('openLinkHandler', i18nString(UIStrings.auto)); } return linkHandlerSettingInstance; } static registerLinkHandler(title: string, handler: LinkHandler): void { linkHandlers.set(title, handler); LinkHandlerSettingUI.instance().update(); } static unregisterLinkHandler(title: string): void { linkHandlers.delete(title); LinkHandlerSettingUI.instance().update(); } static uiLocation(link: Element): Workspace.UISourceCode.UILocation|null { const info = Linkifier.linkInfo(link); return info ? info.uiLocation : null; } static linkActions(info: _LinkInfo): { section: string, title: string, handler: () => Promise<void>| void, }[] { const result: { section: string, title: string, handler: () => Promise<void>| void, }[] = []; if (!info) { return result; } let url = Platform.DevToolsPath.EmptyUrlString; let uiLocation: Workspace.UISourceCode.UILocation|(Workspace.UISourceCode.UILocation | null)|null = null; if (info.uiLocation) { uiLocation = info.uiLocation; url = uiLocation.uiSourceCode.contentURL(); } else if (info.url) { url = info.url; const uiSourceCode = Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(url) || Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL( Common.ParsedURL.ParsedURL.urlWithoutHash(url) as Platform.DevToolsPath.UrlString); uiLocation = uiSourceCode ? uiSourceCode.uiLocation(info.lineNumber || 0, info.columnNumber || 0) : null; } const resource = url ? Bindings.ResourceUtils.resourceForURL(url) : null; const contentProvider = uiLocation ? uiLocation.uiSourceCode : resource; const revealable = info.revealable || uiLocation || resource; if (revealable) { const destination = Common.Revealer.revealDestination(revealable); result.push({ section: 'reveal', title: destination ? i18nString(UIStrings.revealInS, {PH1: destination}) : i18nString(UIStrings.reveal), handler: (): Promise<void> => { if (revealable instanceof Breakpoints.BreakpointManager.BreakpointLocation) { Host.userMetrics.breakpointEditDialogRevealedFrom( Host.UserMetrics.BreakpointEditDialogRevealedFrom.Linkifier); } return Common.Revealer.reveal(revealable); }, }); } if (contentProvider) { const lineNumber = uiLocation ? uiLocation.lineNumber : info.lineNumber || 0; for (const title of linkHandlers.keys()) { const handler = linkHandlers.get(title); if (!handler) { continue; } const action = { section: 'reveal', title: i18nString(UIStrings.openUsingS, {PH1: title}), handler: handler.bind(null, contentProvider, lineNumber), }; if (title === Linkifier.linkHandlerSetting().get()) { result.unshift(action); } else { result.push(action); } } } if (resource || info.url) { result.push({ section: 'reveal', title: UI.UIUtils.openLinkExternallyLabel(), handler: (): void => Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(url), }); result.push({ section: 'clipboard', title: UI.UIUtils.copyLinkAddressLabel(), handler: (): void => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(url), }); } if (uiLocation && uiLocation.uiSourceCode) { const contentProvider = uiLocation.uiSourceCode; result.push({ section: 'clipboard', title: UI.UIUtils.copyFileNameLabel(), handler: (): void => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentProvider.displayName()), }); } return result; } } export interface LinkDecorator extends Common.EventTarget.EventTarget<LinkDecorator.EventTypes> { linkIcon(uiSourceCode: Workspace.UISourceCode.UISourceCode): IconButton.Icon.Icon|null; } export namespace LinkDecorator { // TODO(crbug.com/1167717): Make this a const enum again // eslint-disable-next-line rulesdir/const_enum export enum Events { LinkIconChanged = 'LinkIconChanged', } export type EventTypes = { [Events.LinkIconChanged]: Workspace.UISourceCode.UISourceCode, }; } let linkContextMenuProviderInstance: LinkContextMenuProvider; export class LinkContextMenuProvider implements UI.ContextMenu.Provider { static instance(opts: { forceNew: boolean|null, } = {forceNew: null}): LinkContextMenuProvider { const {forceNew} = opts; if (!linkContextMenuProviderInstance || forceNew) { linkContextMenuProviderInstance = new LinkContextMenuProvider(); } return linkContextMenuProviderInstance; } appendApplicableItems(event: Event, contextMenu: UI.ContextMenu.ContextMenu, target: Object): void { let targetNode: (Node|null) = (target as Node | null); while (targetNode && !infoByAnchor.get(targetNode)) { targetNode = targetNode.parentNodeOrShadowHost(); } const link = (targetNode as Element | null); const linkInfo = Linkifier.linkInfo(link); if (!linkInfo) { return; } const actions = Linkifier.linkActions(linkInfo); for (const action of actions) { contextMenu.section(action.section).appendItem(action.title, action.handler); } } } let linkHandlerSettingUIInstance: LinkHandlerSettingUI; export class LinkHandlerSettingUI implements UI.SettingsUI.SettingUI { private element: HTMLSelectElement; private constructor() { this.element = document.createElement('select'); this.element.classList.add('chrome-select'); this.element.addEventListener('change', this.onChange.bind(this), false); this.update(); } static instance(opts: { forceNew: boolean|null, } = {forceNew: null}): LinkHandlerSettingUI { const {forceNew} = opts; if (!linkHandlerSettingUIInstance || forceNew) { linkHandlerSettingUIInstance = new LinkHandlerSettingUI(); } return linkHandlerSettingUIInstance; } update(): void { this.element.removeChildren(); const names = [...linkHandlers.keys()]; names.unshift(i18nString(UIStrings.auto)); for (const name of names) { const option = document.createElement('option'); option.textContent = name; option.selected = name === Linkifier.linkHandlerSetting().get(); this.element.appendChild(option); } this.element.disabled = names.length <= 1; } private onChange(event: Event): void { if (!event.target) { return; } const value = (event.target as HTMLSelectElement).value; Linkifier.linkHandlerSetting().set(value); } settingElement(): Element|null { return UI.SettingsUI.createCustomSetting(i18nString(UIStrings.linkHandling), this.element); } } let listeningToNewEvents = false; function listenForNewComponentLinkifierEvents(): void { if (listeningToNewEvents) { return; } listeningToNewEvents = true; window.addEventListener('linkifieractivated', function(event: Event) { // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // eslint-disable-next-line @typescript-eslint/no-explicit-any const unknownEvent = (event as any); const eventWithData = (unknownEvent as { data: _LinkInfo, }); Linkifier.handleClickFromNewComponentLand(eventWithData.data); }); } listenForNewComponentLinkifierEvents(); let contentProviderContextMenuProviderInstance: ContentProviderContextMenuProvider; export class ContentProviderContextMenuProvider implements UI.ContextMenu.Provider { static instance(opts: { forceNew: boolean|null, } = {forceNew: null}): ContentProviderContextMenuProvider { const {forceNew} = opts; if (!contentProviderContextMenuProviderInstance || forceNew) { contentProviderContextMenuProviderInstance = new ContentProviderContextMenuProvider(); } return contentProviderContextMenuProviderInstance; } appendApplicableItems(event: Event, contextMenu: UI.ContextMenu.ContextMenu, target: Object): void { const contentProvider = (target as Workspace.UISourceCode.UISourceCode); const contentUrl = contentProvider.contentURL(); if (!contentUrl) { return; } if (!contentUrl.startsWith('file://')) { contextMenu.revealSection().appendItem( UI.UIUtils.openLinkExternallyLabel(), () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab( contentUrl.endsWith(':formatted') ? Common.ParsedURL.ParsedURL.slice(contentUrl, 0, contentUrl.lastIndexOf(':')) : contentUrl)); } for (const title of linkHandlers.keys()) { const handler = linkHandlers.get(title); if (!handler) { continue; } contextMenu.revealSection().appendItem( i18nString(UIStrings.openUsingS, {PH1: title}), handler.bind(null, contentProvider, 0)); } if (contentProvider instanceof SDK.NetworkRequest.NetworkRequest) { return; } contextMenu.clipboardSection().appendItem( UI.UIUtils.copyLinkAddressLabel(), () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentUrl)); contextMenu.clipboardSection().appendItem( UI.UIUtils.copyFileNameLabel(), () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentProvider.displayName())); } } // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // eslint-disable-next-line @typescript-eslint/naming-convention export interface _LinkInfo { icon: IconButton.Icon.Icon|null; enableDecorator: boolean; uiLocation: Workspace.UISourceCode.UILocation|null; liveLocation: Bindings.LiveLocation.LiveLocation|null; url: Platform.DevToolsPath.UrlString|null; lineNumber: number|null; columnNumber: number|null; inlineFrameIndex: number; revealable: Object|null; fallback: Element|null; userMetric?: Host.UserMetrics.Action; } export interface LinkifyURLOptions { text?: string; className?: string; lineNumber?: number; columnNumber?: number; showColumnNumber: boolean; inlineFrameIndex: number; preventClick?: boolean; maxLength?: number; tabStop?: boolean; bypassURLTrimming?: boolean; userMetric?: Host.UserMetrics.Action; } export interface LinkifyOptions { className?: string; columnNumber?: number; showColumnNumber?: boolean; inlineFrameIndex: number; tabStop?: boolean; userMetric?: Host.UserMetrics.Action; /** * {@link LinkDisplayOptions.revealBreakpoint} */ revealBreakpoint?: boolean; } // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // eslint-disable-next-line @typescript-eslint/naming-convention export interface _CreateLinkOptions { maxLength?: number; title?: string; href?: Platform.DevToolsPath.UrlString; preventClick?: boolean; tabStop?: boolean; bypassURLTrimming?: boolean; } interface LinkDisplayOptions { showColumnNumber: boolean; /** * If true, we'll check if there is a breakpoint at the UILocation we get * from the LiveLocation. If we find a breakpoint, we'll reveal the corresponding * {@link Breakpoints.BreakpointManager.BreakpointLocation}. Which opens the * breakpoint edit dialog. */ revealBreakpoint?: boolean; } export type LinkHandler = (arg0: TextUtils.ContentProvider.ContentProvider, arg1: number) => void;