UNPKG

chrome-devtools-frontend

Version:
373 lines (321 loc) 15.2 kB
// Copyright 2024 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 i18n from '../../../../core/i18n/i18n.js'; import * as Platform from '../../../../core/platform/platform.js'; import * as ComponentHelpers from '../../../../ui/components/helpers/helpers.js'; import * as ThemeSupport from '../../../../ui/legacy/theme_support/theme_support.js'; import {html, render} from '../../../../ui/lit/lit.js'; import * as VisualLogging from '../../../../ui/visual_logging/visual_logging.js'; import stylesRaw from './entryLabelOverlay.css.js'; // TODO(crbug.com/391381439): Fully migrate off of constructed style sheets. const styles = new CSSStyleSheet(); styles.replaceSync(stylesRaw.cssContent); const UIStrings = { /** * @description Accessible label used to explain to a user that they are viewing an entry label. */ entryLabel: 'Entry label', /** *@description Accessible label used to prompt the user to input text into the field. */ inputTextPrompt: 'Enter an annotation label', }; const str_ = i18n.i18n.registerUIStrings('panels/timeline/overlays/components/EntryLabelOverlay.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class EmptyEntryLabelRemoveEvent extends Event { static readonly eventName = 'emptyentrylabelremoveevent'; constructor() { super(EmptyEntryLabelRemoveEvent.eventName); } } export class EntryLabelChangeEvent extends Event { static readonly eventName = 'entrylabelchangeevent'; constructor(public newLabel: string) { super(EntryLabelChangeEvent.eventName); } } export class EntryLabelOverlay extends HTMLElement { // The label is angled on the left from the centre of the entry it belongs to. // `LABEL_AND_CONNECTOR_SHIFT_LENGTH` specifies how many pixels to the left it is shifted. static readonly LABEL_AND_CONNECTOR_SHIFT_LENGTH = 8; // Length of the line that connects the label to the entry. static readonly LABEL_CONNECTOR_HEIGHT = 7; static readonly LABEL_HEIGHT = 17; static readonly LABEL_PADDING = 4; static readonly LABEL_AND_CONNECTOR_HEIGHT = EntryLabelOverlay.LABEL_HEIGHT + EntryLabelOverlay.LABEL_PADDING * 2 + EntryLabelOverlay.LABEL_CONNECTOR_HEIGHT; // Set the max label length to avoid labels that could signicantly increase the file size. static readonly MAX_LABEL_LENGTH = 100; readonly #shadow = this.attachShadow({mode: 'open'}); readonly #boundRender = this.#render.bind(this); // Once a label is bound for deletion, we remove it from the DOM via events // that are dispatched. But in the meantime the blur event of the input box // can fire, and that triggers a second removal. So we set this flag after // the first removal to avoid a duplicate event firing which is a no-op but // causes errors when we try to delete an already deleted annotation. #isPendingRemoval: boolean = false; // The label is set to editable when it is double clicked. If the user clicks away from the label box // element, the label is set to not editable until it double clicked.s #isLabelEditable: boolean = true; #entryLabelVisibleHeight: number|null = null; #labelPartsWrapper: HTMLElement|null = null; #entryHighlightWrapper: HTMLElement|null = null; #inputField: HTMLElement|null = null; #connectorLineContainer: SVGAElement|null = null; #label: string; #shouldDrawBelowEntry: boolean; /** * The entry label overlay consists of 3 parts - the label part with the label string inside, * the line connecting the label to the entry, and a black box around an entry to highlight the entry with a label. * ________ * |_label__| <-- label part with the label string inside * \ * \ <-- line connecting the label to the entry with a circle at the end * \ * _______◯_________ * |_____entry______| <--- box around an entry * * `drawLabel` method below draws the first part. * `drawConnector` method below draws the second part - the connector line with a circle and the svg container for them. * `drawEntryHighlightWrapper` draws the third part. * We only rerender the first part if the label changes and the third part if the size of the entry changes. * The connector and circle shapes never change so we only draw the second part when the component is created. * * Otherwise, the entry label overlay object only gets repositioned. */ constructor(label: string, shouldDrawBelowEntry: boolean = false) { super(); this.#render(); this.#shouldDrawBelowEntry = shouldDrawBelowEntry; this.#labelPartsWrapper = this.#shadow.querySelector<HTMLElement>('.label-parts-wrapper'); this.#inputField = this.#labelPartsWrapper?.querySelector<HTMLElement>('.input-field') ?? null; this.#connectorLineContainer = this.#labelPartsWrapper?.querySelector<SVGAElement>('.connectorContainer') ?? null; this.#entryHighlightWrapper = this.#labelPartsWrapper?.querySelector<HTMLElement>('.entry-highlight-wrapper') ?? null; this.#label = label; this.#drawLabel(label); // If the label is not empty, it was loaded from the trace file. // In that case, do not auto-focus it as if the user were creating it for the first time if (label !== '') { this.setLabelEditabilityAndRemoveEmptyLabel(false); } const ariaLabel = label === '' ? i18nString(UIStrings.inputTextPrompt) : label; this.#inputField?.setAttribute('aria-label', ariaLabel); this.#drawConnector(); } connectedCallback(): void { this.#shadow.adoptedStyleSheets = [styles]; } entryHighlightWrapper(): HTMLElement|null { return this.#entryHighlightWrapper; } #handleLabelInputKeyUp(): void { // If the label changed on key up, dispatch label changed event. const labelBoxTextContent = this.#inputField?.textContent?.trim() ?? ''; if (labelBoxTextContent !== this.#label) { this.#label = labelBoxTextContent; this.dispatchEvent(new EntryLabelChangeEvent(this.#label)); } this.#inputField?.setAttribute('aria-label', labelBoxTextContent); } #handleLabelInputKeyDown(event: KeyboardEvent): boolean { if (!this.#inputField) { return false; } const allowedKeysAfterReachingLenLimit = [ 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', ]; // We do not want to create multi-line labels. // Therefore, if the new key is `Enter` key, treat it // as the end of the label input and blur the input field. if (event.key === Platform.KeyboardUtilities.ENTER_KEY || event.key === Platform.KeyboardUtilities.ESCAPE_KEY) { // Note that we do not stop the event propagating here; this is on // purpose because we need it to bubble up into TimelineFlameChartView's // handler. That updates the state and deals with the keydown. this.#inputField.dispatchEvent(new FocusEvent('blur', {bubbles: true})); return false; } // If the max limit is not reached, return true if (this.#inputField.textContent !== null && this.#inputField.textContent.length <= EntryLabelOverlay.MAX_LABEL_LENGTH) { return true; } if (allowedKeysAfterReachingLenLimit.includes(event.key)) { return true; } if (event.key.length === 1 && event.ctrlKey /* Ctrl + A for selecting all */) { return true; } event.preventDefault(); return false; } #handleLabelInputPaste(event: ClipboardEvent): void { event.preventDefault(); const clipboardData = event.clipboardData; if (!clipboardData || !this.#inputField) { return; } const pastedText = clipboardData.getData('text'); const newText = this.#inputField.textContent + pastedText; const trimmedText = newText.slice(0, EntryLabelOverlay.MAX_LABEL_LENGTH + 1); this.#inputField.textContent = trimmedText; // Reset the selection to the end const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(this.#inputField); range.collapse(false); selection?.removeAllRanges(); selection?.addRange(range); } set entryLabelVisibleHeight(entryLabelVisibleHeight: number) { if (entryLabelVisibleHeight === this.#entryLabelVisibleHeight) { // Even the position is not changed, the theme color might change, so we need to redraw the connector here. this.#drawConnector(); return; } this.#entryLabelVisibleHeight = entryLabelVisibleHeight; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); // If the label is editable, focus cursor on it. // This method needs to be called after rendering the wrapper because it is the last label overlay element to render. // By doing this, the cursor focuses when the label is created. if (this.#isLabelEditable) { this.#focusInputBox(); } // The label and connector can move depending on the height of the entry this.#drawLabel(); this.#drawConnector(); } #drawConnector(): void { if (!this.#connectorLineContainer) { console.error('`connectorLineContainer` element is missing.'); return; } if (this.#shouldDrawBelowEntry && this.#entryLabelVisibleHeight) { const translation = this.#entryLabelVisibleHeight + EntryLabelOverlay.LABEL_CONNECTOR_HEIGHT; this.#connectorLineContainer.style.transform = `translateY(${translation}px) rotate(180deg)`; } const connector = this.#connectorLineContainer.querySelector('line'); const circle = this.#connectorLineContainer.querySelector('circle'); if (!connector || !circle) { console.error('Some entry label elements are missing.'); return; } // PART 2: draw the connector from label to the entry // Set the width of the canvas that draws the connector to be equal to the length of the shift multiplied by two. // That way, we can draw the connector from its corner to its middle. Since all elements are aligned in the middle, the connector // will end in the middle of the entry. this.#connectorLineContainer.setAttribute( 'width', (EntryLabelOverlay.LABEL_AND_CONNECTOR_SHIFT_LENGTH * 2).toString()); this.#connectorLineContainer.setAttribute('height', EntryLabelOverlay.LABEL_CONNECTOR_HEIGHT.toString()); // Start drawing the top right corner. connector.setAttribute('x1', '0'); connector.setAttribute('y1', '0'); // Finish drawing in middle of the connector container. connector.setAttribute('x2', EntryLabelOverlay.LABEL_AND_CONNECTOR_SHIFT_LENGTH.toString()); connector.setAttribute('y2', EntryLabelOverlay.LABEL_CONNECTOR_HEIGHT.toString()); const connectorColor = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-text-primary'); connector.setAttribute('stroke', connectorColor); connector.setAttribute('stroke-width', '2'); // Draw the circle at the bottom of the connector circle.setAttribute('cx', EntryLabelOverlay.LABEL_AND_CONNECTOR_SHIFT_LENGTH.toString()); circle.setAttribute('cy', EntryLabelOverlay.LABEL_CONNECTOR_HEIGHT.toString()); circle.setAttribute('r', '3'); circle.setAttribute('fill', connectorColor); } #drawLabel(initialLabel?: string): void { if (!this.#inputField) { console.error('`labelBox`element is missing.'); return; } if (typeof initialLabel === 'string') { this.#inputField.innerText = initialLabel; } let xTranslation: number|null = null; let yTranslation: number|null = null; // PART 1: draw the label box if (this.#shouldDrawBelowEntry) { // Label is drawn below and slightly to the right. xTranslation = EntryLabelOverlay.LABEL_AND_CONNECTOR_SHIFT_LENGTH; } else { // If the label is drawn above, the connector goes up and to the left, so // we pull the label back slightly to align it nicely. xTranslation = EntryLabelOverlay.LABEL_AND_CONNECTOR_SHIFT_LENGTH * -1; } if (this.#shouldDrawBelowEntry && this.#entryLabelVisibleHeight) { // Move the label down from above the entry to below it. The label is positioned by default quite far above the entry, hence why we add: // 1. the height of the entry + of the label (inc its padding) // 2. the height of the connector (*2), so we have room to draw it const verticalTransform = this.#entryLabelVisibleHeight + EntryLabelOverlay.LABEL_HEIGHT + EntryLabelOverlay.LABEL_PADDING * 2 + EntryLabelOverlay.LABEL_CONNECTOR_HEIGHT * 2; yTranslation = verticalTransform; } let transformString = ''; if (xTranslation) { transformString += `translateX(${xTranslation}px) `; } if (yTranslation) { transformString += `translateY(${yTranslation}px)`; } if (transformString.length) { this.#inputField.style.transform = transformString; } } #focusInputBox(): void { if (!this.#inputField) { console.error('`labelBox` element is missing.'); return; } this.#inputField.focus(); } setLabelEditabilityAndRemoveEmptyLabel(editable: boolean): void { this.#isLabelEditable = editable; this.#render(); // If the label is editable, focus cursor on it if (editable) { this.#focusInputBox(); } // On MacOS when clearing the input box it is left with a new line, so we // trim the string to remove any accidental trailing whitespace. const newLabelText = this.#inputField?.textContent?.trim() ?? ''; // If the label is empty when it is being navigated away from, dispatch an event to remove this entry overlay if (!editable && newLabelText.length === 0 && !this.#isPendingRemoval) { this.#isPendingRemoval = true; this.dispatchEvent(new EmptyEntryLabelRemoveEvent()); } } #render(): void { // clang-format off render( html` <span class="label-parts-wrapper" role="region" aria-label=${i18nString(UIStrings.entryLabel)}> <span class="input-field" role="textbox" @dblclick=${() => this.setLabelEditabilityAndRemoveEmptyLabel(true)} @blur=${() => this.setLabelEditabilityAndRemoveEmptyLabel(false)} @keydown=${this.#handleLabelInputKeyDown} @paste=${this.#handleLabelInputPaste} @keyup=${this.#handleLabelInputKeyUp} contenteditable=${this.#isLabelEditable ? 'plaintext-only' : false} jslog=${VisualLogging.textField('timeline.annotations.entry-label-input').track({keydown: true, click: true})} ></span> <svg class="connectorContainer"> <line/> <circle/> </svg> <div class="entry-highlight-wrapper"></div> </span>`, this.#shadow, {host: this}); // clang-format on } } customElements.define('devtools-entry-label-overlay', EntryLabelOverlay); declare global { interface HTMLElementTagNameMap { 'devtools-entry-label-overlay': EntryLabelOverlay; } }