| // Copyright 2025 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-imperative-dom-api */ |
| /* eslint-disable @devtools/no-lit-render-outside-of-view */ |
| |
| import * as Annotations from '../../models/annotations/annotations.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js'; |
| import {html, nothing, render} from '../../ui/lit/lit.js'; |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; |
| |
| import annotationStyles from './annotation.css.js'; |
| |
| // This class handles drawing of Annotations for the GreenDev project, but |
| // is not for general use (at the moment). |
| // |
| // **Important**: all of this functionality is behind the GreenDev flag. We |
| // have **no intention** of pushing this feature live in this state. This |
| // is code landing to user test in Canary that will not ship without an |
| // additional project to make this code fully production worthy. That is |
| // why this CL has no tests, for example. |
| |
| // 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. |
| const LABEL_AND_CONNECTOR_SHIFT_LENGTH = 8; |
| // Length of the line that connects the label to the entry. |
| const LABEL_CONNECTOR_HEIGHT = 7; |
| |
| interface ViewInput { |
| inputText: string; |
| isExpanded: boolean; |
| anchored: boolean; |
| expandable: boolean; |
| showCloseButton: boolean; |
| clickHandler: () => void; |
| closeHandler: () => void; |
| } |
| |
| type View = (input: ViewInput, output: undefined, target: HTMLElement) => void; |
| |
| export const DEFAULT_VIEW: View = (input, _, target) => { |
| const {inputText: label, isExpanded, anchored, expandable, showCloseButton, clickHandler, closeHandler} = input; |
| |
| // TODO(finnur): Use `x`, and `y` passed via `input` to set the coordinates for the |
| // *Widget* (not the `overlay` div), then remove the `this.element.style` calls and |
| // remove the lint override no-imperative-dom-api from the top. |
| const connectorColor = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-text-primary'); |
| |
| const overlayStyles = [ |
| anchored ? 'left: 17px; top: 11px;' : '', |
| !expandable ? 'pointer-events: none;' : '', |
| ].join(' '); |
| |
| // clang-format off |
| render(html` |
| <style>${annotationStyles}</style> |
| ${anchored ? html` |
| <svg class="connectorContainer" |
| width=${LABEL_AND_CONNECTOR_SHIFT_LENGTH * 2} |
| height=${LABEL_CONNECTOR_HEIGHT}> |
| <line |
| x1=${LABEL_AND_CONNECTOR_SHIFT_LENGTH} |
| y1=0 |
| x2=${LABEL_AND_CONNECTOR_SHIFT_LENGTH * 2} |
| y2=${LABEL_CONNECTOR_HEIGHT} |
| stroke=${connectorColor} |
| stroke-width=2 |
| /> |
| <circle |
| cx=${LABEL_AND_CONNECTOR_SHIFT_LENGTH} |
| cy=0 |
| r=3 |
| fill=${connectorColor} |
| /> |
| </svg> |
| ` : nothing} |
| <div class='overlay' style=${overlayStyles} @click=${expandable ? clickHandler : null}> |
| ${isExpanded ? label : '!'} |
| </div> |
| ${showCloseButton ? |
| html`<svg @click=${closeHandler} class="close-button" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| <circle cx="8" cy="8" r="7.5" fill="#EEE" stroke="#888"/> |
| <path d="M5 5L11 11M5 11L11 5" stroke="#888" stroke-width="2"/> |
| </svg>` : nothing} |
| `, target); |
| // clang-format on |
| }; |
| |
| export class Annotation extends UI.Widget.Widget { |
| readonly #view: View; |
| readonly #id: number; |
| #inputText: string; |
| #x = 0; |
| #y = 0; |
| #isExpanded = false; |
| #hasShown = false; |
| #anchored = false; |
| #expandable = false; |
| #showCloseButton = false; |
| |
| constructor( |
| id: number, label: string, showExpanded: boolean, anchored: boolean, expandable: boolean, |
| showCloseButton: boolean, view = DEFAULT_VIEW) { |
| super({jslog: `${VisualLogging.panel('annotation').track({resize: true})}`, useShadowDom: true}); |
| this.#id = id; |
| this.#view = view; |
| this.#isExpanded = showExpanded; |
| this.#inputText = label; |
| this.#anchored = anchored; |
| this.#expandable = expandable; |
| this.#showCloseButton = showCloseButton; |
| } |
| |
| #toggle(): void { |
| this.#isExpanded = !this.#isExpanded; |
| this.requestUpdate(); |
| } |
| |
| #closeHandler(): void { |
| this.hide(); |
| Annotations.AnnotationRepository.instance().deleteAnnotation(this.#id); |
| } |
| |
| override wasShown(): void { |
| this.element.style.position = 'absolute'; |
| this.element.style.left = `${this.#x}px`; |
| this.element.style.top = `${this.#y}px`; |
| super.wasShown(); |
| this.#hasShown = true; |
| this.requestUpdate(); |
| } |
| |
| override performUpdate(): void { |
| if (!this.isShowing()) { |
| return; |
| } |
| const input = { |
| inputText: this.#inputText, |
| isExpanded: this.#isExpanded, |
| anchored: this.#anchored, |
| expandable: this.#expandable, |
| showCloseButton: this.#showCloseButton, |
| x: this.#x, |
| y: this.#y, |
| clickHandler: this.#toggle.bind(this), |
| closeHandler: this.#closeHandler.bind(this), |
| }; |
| this.#view(input, undefined, this.contentElement); |
| |
| if (this.#showCloseButton) { |
| const overlay = this.contentElement.querySelector('.overlay') as HTMLElement | null; |
| const closeButton = this.contentElement.querySelector('.close-button') as HTMLElement | null; |
| if (overlay && closeButton) { |
| const overlayLeft = parseFloat(overlay.style.left || '0'); |
| const overlayWidth = overlay.getBoundingClientRect().width; |
| // Position the button to the right of the overlay, adjusting for button width. |
| closeButton.style.left = `${overlayLeft + overlayWidth - 16}px`; |
| } |
| } |
| } |
| |
| hide(): void { |
| this.detach(); |
| } |
| |
| getCoordinates(): {x: number, y: number} { |
| return {x: this.#x, y: this.#y}; |
| } |
| |
| setCoordinates(x: number, y: number): void { |
| this.#x = x; |
| this.#y = y; |
| if (this.isShowing()) { |
| this.element.style.left = `${this.#x}px`; |
| this.element.style.top = `${this.#y}px`; |
| } |
| this.requestUpdate(); |
| } |
| |
| hasShown(): boolean { |
| return this.#hasShown; |
| } |
| } |