UNPKG

chrome-devtools-frontend

Version:
773 lines (723 loc) • 31.5 kB
// 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. import '../../../ui/components/tooltips/tooltips.js'; import type * as Host from '../../../core/host/host.js'; import * as i18n from '../../../core/i18n/i18n.js'; import type * as Platform from '../../../core/platform/platform.js'; import * as SDK from '../../../core/sdk/sdk.js'; import * as Protocol from '../../../generated/protocol.js'; import * as AiAssistanceModel from '../../../models/ai_assistance/ai_assistance.js'; import * as PanelsCommon from '../../../panels/common/common.js'; import * as PanelUtils from '../../../panels/utils/utils.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as Input from '../../../ui/components/input/input.js'; import * as Snackbars from '../../../ui/components/snackbars/snackbars.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import chatInputStyles from './chatInput.css.js'; const {html, Directives: {createRef, ref}} = Lit; const {widget} = UI.Widget; const UIStrings = { /** * @description Label added to the text input to describe the context for screen readers. Not shown visibly on screen. */ inputTextAriaDescription: 'You can also use one of the suggested prompts above to start your conversation', /** * @description Label added to the button that reveals the selected context item in DevTools */ revealContextDescription: 'Reveal the selected context item in DevTools', /** * @description The footer disclaimer that links to more information about the AI feature. */ learnAbout: 'Learn about AI in DevTools', } as const; /* * Strings that don't need to be translated at this time. */ const UIStringsNotTranslate = { /** * @description Title for the send icon button. */ sendButtonTitle: 'Send', /** * @description Title for the start new chat */ startNewChat: 'Start new chat', /** * @description Title for the cancel icon button. */ cancelButtonTitle: 'Cancel', /** * @description Label for the "select an element" button. */ selectAnElement: 'Select an element', /** * @description Title for the take screenshot button. */ takeScreenshotButtonTitle: 'Take screenshot', /** * @description Title for the remove image input button. */ removeImageInputButtonTitle: 'Remove image input', /** * @description Title for the add image button. */ addImageButtonTitle: 'Add image', /** * @description Text displayed when the chat input is disabled due to reading past conversation. */ pastConversation: 'You\'re viewing a past conversation.', /** * @description Message displayed in toast in case of any failures while taking a screenshot of the page. */ screenshotFailureMessage: 'Failed to take a screenshot. Please try again.', /** * @description Message displayed in toast in case of any failures while uploading an image file as input. */ uploadImageFailureMessage: 'Failed to upload image. Please try again.', /** * @description Label added to the button that add selected context from the current panel in AI Assistance panel. */ addContext: 'Add item for context', /** * @description Label added to the button that remove the currently selected element in AI Assistance panel. */ removeContextElement: 'Remove element from context', /** * @description Label added to the button that remove the currently selected context in AI Assistance panel. */ removeContextRequest: 'Remove request from context', /** * @description Label added to the button that remove the currently selected context in AI Assistance panel. */ removeContextFile: 'Remove file from context', /** * @description Label added to the button that remove the currently selected context in AI Assistance panel. */ removeContextPerfInsight: 'Remove performance insight from context', /** * @description Label added to the button that remove the currently selected context in AI Assistance panel. */ removeContext: 'Remove from context', } as const; const str_ = i18n.i18n.registerUIStrings('panels/ai_assistance/components/ChatInput.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const lockedString = i18n.i18n.lockedString; const SCREENSHOT_QUALITY = 80; const JPEG_MIME_TYPE = 'image/jpeg'; const SHOW_LOADING_STATE_TIMEOUT = 100; const RELEVANT_DATA_LINK_CHAT_ID = 'relevant-data-link-chat'; const RELEVANT_DATA_LINK_FOOTER_ID = 'relevant-data-link-footer'; export type ImageInputData = { isLoading: true, }|{ isLoading: false, data: string, mimeType: string, inputType: AiAssistanceModel.AiAgent.MultimodalInputType, }; export interface ViewInput { isLoading: boolean; isTextInputEmpty: boolean; blockedByCrossOrigin: boolean; isTextInputDisabled: boolean; inputPlaceholder: Platform.UIString.LocalizedString; context: AiAssistanceModel.AiAgent.ConversationContext<unknown>|null; isContextSelected: boolean; inspectElementToggled: boolean; disclaimerText: string; conversationType: AiAssistanceModel.AiHistoryStorage.ConversationType; multimodalInputEnabled: boolean; imageInput?: ImageInputData; uploadImageInputEnabled: boolean; isReadOnly: boolean; textAreaRef: Lit.Directives.Ref<HTMLTextAreaElement>; onContextClick: () => void; onInspectElementClick: () => void; onSubmit: (ev: SubmitEvent) => void; onTextAreaKeyDown: (ev: KeyboardEvent) => void; onCancel: (ev: SubmitEvent) => void; onNewConversation: () => void; onTextInputChange: (input: string) => void; onTakeScreenshot: () => void; onRemoveImageInput: () => void; onImageUpload: (ev: Event) => void; onImagePaste: (event: ClipboardEvent) => void; onImageDragOver: (event: DragEvent) => void; onImageDrop: (event: DragEvent) => void; onContextRemoved: (() => void)|null; onContextAdd: (() => void)|null; } export type ViewOutput = undefined; function getContextRemoveLabel(context: AiAssistanceModel.AiAgent.ConversationContext<unknown>): Platform.UIString.LocalizedString { if (context instanceof AiAssistanceModel.FileAgent.FileContext) { return lockedString(UIStringsNotTranslate.removeContextFile); } if (context instanceof AiAssistanceModel.StylingAgent.NodeContext) { return lockedString(UIStringsNotTranslate.removeContextElement); } if (context instanceof AiAssistanceModel.NetworkAgent.RequestContext) { return lockedString(UIStringsNotTranslate.removeContextRequest); } if (context instanceof AiAssistanceModel.PerformanceAgent.PerformanceTraceContext) { return lockedString(UIStringsNotTranslate.removeContextPerfInsight); } return lockedString(UIStringsNotTranslate.removeContext); } export const DEFAULT_VIEW = (input: ViewInput, _output: ViewOutput, target: HTMLElement): void => { const chatInputContainerCls = Lit.Directives.classMap({ 'chat-input-container': true, 'single-line-layout': !input.context, disabled: input.isTextInputDisabled, }); const renderRelevantDataDisclaimer = (tooltipId: string): Lit.LitTemplate => { const classes = Lit.Directives.classMap({ 'chat-input-disclaimer': true, 'hide-divider': !input.isLoading && input.blockedByCrossOrigin, }); // clang-format off return html` <div class=${classes}> <button class="link" role="link" aria-details=${tooltipId} jslog=${VisualLogging.link('open-ai-settings').track({ click: true, })} @click=${(ev: Event) => { ev.preventDefault(); void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); }} >${lockedString('Relevant data')}</button>&nbsp;${lockedString('is sent to Google')} <devtools-tooltip id=${tooltipId} variant="rich" ><div class="info-tooltip-container"> ${input.disclaimerText} <button class="link tooltip-link" role="link" jslog=${VisualLogging.link('open-ai-settings').track({ click: true, })} @click=${() => { void UI.ViewManager.ViewManager.instance().showView('chrome-ai'); }}>${i18nString(UIStrings.learnAbout)} </button> </div></devtools-tooltip> </div> `; // clang-format on }; // clang-format off Lit.render(html` <style>${Input.textInputStyles}</style> <style>${chatInputStyles}</style> ${input.isReadOnly ? html` <div class="chat-readonly-container" jslog=${VisualLogging.section('read-only')} > <span>${lockedString(UIStringsNotTranslate.pastConversation)}</span> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.startNewChat)} class="chat-inline-button" @click=${input.onNewConversation} .data=${{ variant: Buttons.Button.Variant.TEXT, title: lockedString(UIStringsNotTranslate.startNewChat), jslogContext: 'start-new-chat', } as Buttons.Button.ButtonData} >${lockedString(UIStringsNotTranslate.startNewChat)}</devtools-button> </div>` : html` <form class="input-form" @submit=${input.onSubmit}> <div class=${chatInputContainerCls}> ${(input.multimodalInputEnabled && input.imageInput && !input.isTextInputDisabled) ? html` <div class="image-input-container"> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.removeImageInputButtonTitle)} @click=${input.onRemoveImageInput} .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.MICRO, iconName: 'cross', title: lockedString(UIStringsNotTranslate.removeImageInputButtonTitle), } as Buttons.Button.ButtonData} ></devtools-button> ${input.imageInput.isLoading ? html` <div class="loading"> <devtools-spinner></devtools-spinner> </div>` : html` <img src="data:${input.imageInput.mimeType};base64, ${input.imageInput.data}" alt="Image input" />` } </div>` : Lit.nothing} <textarea class="chat-input" .disabled=${input.isTextInputDisabled} wrap="hard" maxlength="10000" @keydown=${input.onTextAreaKeyDown} @paste=${input.onImagePaste} @dragover=${input.onImageDragOver} @drop=${input.onImageDrop} @input=${(event: KeyboardEvent) => { input.onTextInputChange((event.target as HTMLInputElement).value); }} placeholder=${input.inputPlaceholder} jslog=${VisualLogging.textField('query').track({ change: true, keydown: 'Enter', })} aria-description=${i18nString(UIStrings.inputTextAriaDescription)} ${ref(input.textAreaRef)} ></textarea> <div class="chat-input-actions"> <div class="chat-input-actions-left"> ${input.context ? html` <div class="select-element"> ${input.conversationType === AiAssistanceModel.AiHistoryStorage.ConversationType.STYLING ? html` <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON_TOGGLE, size: Buttons.Button.Size.SMALL, iconName: 'select-element', toggledIconName: 'select-element', toggleType: Buttons.Button.ToggleType.PRIMARY, toggled: input.inspectElementToggled, title: lockedString(UIStringsNotTranslate.selectAnElement), jslogContext: 'select-element', disabled: input.isTextInputDisabled, } as Buttons.Button.ButtonData} @click=${input.onInspectElementClick} ></devtools-button>` : Lit.nothing} <div class=${Lit.Directives.classMap({ 'resource-link': true, disabled: !input.isContextSelected, })} > ${ input.context instanceof AiAssistanceModel.StylingAgent.NodeContext ? html` <devtools-widget class="title" ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, { node: input.context.getItem(), options: { disabled: !input.isContextSelected, hiddenClassList: input.context.getItem().classNames().filter( className => className.startsWith(AiAssistanceModel.Injected.AI_ASSISTANCE_CSS_CLASS_NAME)), ariaDescription: i18nString(UIStrings.revealContextDescription), }, })} ></devtools-widget>` : html` ${input.context instanceof AiAssistanceModel.NetworkAgent.RequestContext ? PanelUtils.PanelUtils.getIconForNetworkRequest(input.context.getItem()) : input.context instanceof AiAssistanceModel.FileAgent.FileContext ? PanelUtils.PanelUtils.getIconForSourceFile(input.context.getItem()) : input.context instanceof AiAssistanceModel.AccessibilityAgent.AccessibilityContext ? html`<devtools-icon class="icon" name="performance" title="Lighthouse"></devtools-icon>` : input.context instanceof AiAssistanceModel.PerformanceAgent.PerformanceTraceContext ? html`<devtools-icon class="icon" name="performance" title="Performance"></devtools-icon>` : Lit.nothing} <span role="button" class="title" tabindex="0" @click=${input.onContextClick} @keydown=${(ev: KeyboardEvent) => { if (ev.key === 'Enter' || ev.key === ' ') { void input.onContextClick(); } }} aria-description=${i18nString(UIStrings.revealContextDescription)} >${input.context.getTitle()}</span>` } ${input.isContextSelected && input.onContextRemoved ? html` <devtools-button title=${getContextRemoveLabel(input.context)} aria-label=${getContextRemoveLabel(input.context)} class="remove-context" .iconName=${'cross'} .size=${Buttons.Button.Size.MICRO} .jslogContext=${'context-removed'} .variant=${Buttons.Button.Variant.ICON} @click=${input.onContextRemoved}></devtools-button>` : Lit.nothing} ${!input.isContextSelected && input.onContextAdd ? html` <devtools-button title=${lockedString(UIStringsNotTranslate.addContext)} aria-label=${lockedString(UIStringsNotTranslate.addContext)} class="add-context" .iconName=${'plus'} .size=${Buttons.Button.Size.MICRO} .jslogContext=${'context-added'} .variant=${Buttons.Button.Variant.ICON} @click=${input.onContextAdd}></devtools-button>` : Lit.nothing} </div> </div>` : Lit.nothing} </div> <div class="chat-input-actions-right"> <div class="chat-input-disclaimer-container"> ${renderRelevantDataDisclaimer(RELEVANT_DATA_LINK_CHAT_ID)} </div> ${(input.multimodalInputEnabled && !input.blockedByCrossOrigin) ? html` ${input.uploadImageInputEnabled ? html` <devtools-button class="chat-input-button" aria-label=${lockedString(UIStringsNotTranslate.addImageButtonTitle)} @click=${input.onImageUpload} .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.REGULAR, disabled: input.isTextInputDisabled || input.imageInput?.isLoading, iconName: 'add-photo', title: lockedString(UIStringsNotTranslate.addImageButtonTitle), jslogContext: 'upload-image', } as Buttons.Button.ButtonData} ></devtools-button>` : Lit.nothing} <devtools-button class="chat-input-button" aria-label=${lockedString(UIStringsNotTranslate.takeScreenshotButtonTitle)} @click=${input.onTakeScreenshot} .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.REGULAR, disabled: input.isTextInputDisabled || input.imageInput?.isLoading, iconName: 'photo-camera', title: lockedString(UIStringsNotTranslate.takeScreenshotButtonTitle), jslogContext: 'take-screenshot', } as Buttons.Button.ButtonData} ></devtools-button>` : Lit.nothing} ${input.isLoading ? html` <devtools-button class="chat-input-button" aria-label=${lockedString(UIStringsNotTranslate.cancelButtonTitle)} @click=${input.onCancel} .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.REGULAR, iconName: 'record-stop', title: lockedString(UIStringsNotTranslate.cancelButtonTitle), jslogContext: 'stop', } as Buttons.Button.ButtonData} ></devtools-button>` : input.blockedByCrossOrigin ? html` <devtools-button class="start-new-chat-button" aria-label=${lockedString(UIStringsNotTranslate.startNewChat)} @click=${input.onNewConversation} .data=${{ variant: Buttons.Button.Variant.OUTLINED, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.startNewChat), jslogContext: 'start-new-chat', } as Buttons.Button.ButtonData} >${lockedString(UIStringsNotTranslate.startNewChat)}</devtools-button>` : html` <devtools-button class="chat-input-button" aria-label=${lockedString(UIStringsNotTranslate.sendButtonTitle)} .data=${{ type: 'submit', variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.REGULAR, disabled: input.isTextInputDisabled || input.isTextInputEmpty || input.imageInput?.isLoading, iconName: 'send', title: lockedString(UIStringsNotTranslate.sendButtonTitle), jslogContext: 'send', } as Buttons.Button.ButtonData} ></devtools-button>` } </div> </div> </div> </form>` } <footer class=${Lit.Directives.classMap({ 'chat-input-footer': true, 'is-read-only': input.isReadOnly, })} jslog=${VisualLogging.section('footer')} > ${renderRelevantDataDisclaimer(RELEVANT_DATA_LINK_FOOTER_ID)} </footer> `, target,); // clang-format on }; /** * ChatInput is a presenter for the input area in the AI Assistance panel. */ export class ChatInput extends UI.Widget.Widget implements SDK.TargetManager.Observer { isLoading = false; blockedByCrossOrigin = false; isTextInputDisabled = false; inputPlaceholder = '' as Platform.UIString.LocalizedString; context: AiAssistanceModel.AiAgent.ConversationContext<unknown>|null = null; isContextSelected = false; inspectElementToggled = false; disclaimerText = ''; conversationType = AiAssistanceModel.AiHistoryStorage.ConversationType.STYLING; multimodalInputEnabled = false; uploadImageInputEnabled = false; isReadOnly = false; #textAreaRef = createRef<HTMLTextAreaElement>(); #imageInput?: ImageInputData; setInputValue(text: string): void { if (this.#textAreaRef.value) { this.#textAreaRef.value.value = text; } this.performUpdate(); } #isTextInputEmpty(): boolean { return !this.#textAreaRef.value?.value?.trim(); } onTextSubmit: (text: string, imageInput?: Host.AidaClient.Part, multimodalInputType?: AiAssistanceModel.AiAgent.MultimodalInputType) => void = () => {}; onContextClick = (): void => {}; onInspectElementClick = (): void => {}; onCancelClick = (): void => {}; onNewConversation = (): void => {}; onContextRemoved: (() => void)|null = null; onContextAdd: (() => void)|null = null; async #handleTakeScreenshot(): Promise<void> { const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); if (!mainTarget) { throw new Error('Could not find main target'); } const model = mainTarget.model(SDK.ScreenCaptureModel.ScreenCaptureModel); if (!model) { throw new Error('Could not find model'); } const showLoadingTimeout = setTimeout(() => { this.#imageInput = {isLoading: true}; this.performUpdate(); }, SHOW_LOADING_STATE_TIMEOUT); const bytes = await model.captureScreenshot( Protocol.Page.CaptureScreenshotRequestFormat.Jpeg, SCREENSHOT_QUALITY, SDK.ScreenCaptureModel.ScreenshotMode.FROM_VIEWPORT, ); clearTimeout(showLoadingTimeout); if (bytes) { this.#imageInput = { isLoading: false, data: bytes, mimeType: JPEG_MIME_TYPE, inputType: AiAssistanceModel.AiAgent.MultimodalInputType.SCREENSHOT }; this.performUpdate(); void this.updateComplete.then(() => { this.focusTextInput(); }); } else { this.#imageInput = undefined; this.performUpdate(); Snackbars.Snackbar.Snackbar.show({message: lockedString(UIStringsNotTranslate.screenshotFailureMessage)}); } } targetAdded(_target: SDK.Target.Target): void { } targetRemoved(_target: SDK.Target.Target): void { } #handleRemoveImageInput(): void { this.#imageInput = undefined; this.performUpdate(); void this.updateComplete.then(() => { this.focusTextInput(); }); } #handleImageDataTransferEvent(dataTransfer: DataTransfer|null, event: Event): void { if (this.conversationType !== AiAssistanceModel.AiHistoryStorage.ConversationType.STYLING) { return; } const files = dataTransfer?.files; if (!files || files.length === 0) { return; } const imageFile = Array.from(files).find(file => file.type.startsWith('image/')); if (!imageFile) { return; } event.preventDefault(); void this.#handleLoadImage(imageFile); } #handleImagePaste = (event: ClipboardEvent): void => { this.#handleImageDataTransferEvent(event.clipboardData, event); }; #handleImageDragOver = (event: DragEvent): void => { if (this.conversationType !== AiAssistanceModel.AiHistoryStorage.ConversationType.STYLING) { return; } event.preventDefault(); }; #handleImageDrop = (event: DragEvent): void => { this.#handleImageDataTransferEvent(event.dataTransfer, event); }; async #handleLoadImage(file: File): Promise<void> { const showLoadingTimeout = setTimeout(() => { this.#imageInput = {isLoading: true}; this.performUpdate(); }, SHOW_LOADING_STATE_TIMEOUT); try { const reader = new FileReader(); const dataUrl = await new Promise<string>((resolve, reject) => { reader.onload = () => { if (typeof reader.result === 'string') { resolve(reader.result); } else { reject(new Error('FileReader result was not a string.')); } }; reader.readAsDataURL(file); }); const commaIndex = dataUrl.indexOf(','); const bytes = dataUrl.substring(commaIndex + 1); this.#imageInput = { isLoading: false, data: bytes, mimeType: file.type, inputType: AiAssistanceModel.AiAgent.MultimodalInputType.UPLOADED_IMAGE }; } catch { this.#imageInput = undefined; Snackbars.Snackbar.Snackbar.show({message: lockedString(UIStringsNotTranslate.uploadImageFailureMessage)}); } clearTimeout(showLoadingTimeout); this.performUpdate(); void this.updateComplete.then(() => { this.focusTextInput(); }); } #view: typeof DEFAULT_VIEW; constructor(element?: HTMLElement, view?: typeof DEFAULT_VIEW) { super(element); this.#view = view ?? DEFAULT_VIEW; } override wasShown(): void { super.wasShown(); SDK.TargetManager.TargetManager.instance().addModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.#onPrimaryPageChanged, this); } override willHide(): void { super.willHide(); SDK.TargetManager.TargetManager.instance().removeModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.#onPrimaryPageChanged, this); } #onPrimaryPageChanged(): void { this.#imageInput = undefined; this.performUpdate(); } override performUpdate(): void { this.#view( { inputPlaceholder: this.inputPlaceholder, isLoading: this.isLoading, blockedByCrossOrigin: this.blockedByCrossOrigin, isTextInputDisabled: this.isTextInputDisabled, context: this.context, isContextSelected: this.isContextSelected, inspectElementToggled: this.inspectElementToggled, isTextInputEmpty: this.#isTextInputEmpty(), disclaimerText: this.disclaimerText, conversationType: this.conversationType, multimodalInputEnabled: this.multimodalInputEnabled, imageInput: this.#imageInput, uploadImageInputEnabled: this.uploadImageInputEnabled, isReadOnly: this.isReadOnly, textAreaRef: this.#textAreaRef, onContextClick: this.onContextClick, onInspectElementClick: this.onInspectElementClick, onImagePaste: this.#handleImagePaste, onNewConversation: this.onNewConversation, onTextInputChange: () => { this.requestUpdate(); }, onTakeScreenshot: this.#handleTakeScreenshot.bind(this), onRemoveImageInput: this.#handleRemoveImageInput.bind(this), onSubmit: this.onSubmit, onTextAreaKeyDown: this.onTextAreaKeyDown, onCancel: this.onCancel, onImageUpload: this.onImageUpload, onImageDragOver: this.#handleImageDragOver, onImageDrop: this.#handleImageDrop, onContextRemoved: this.onContextRemoved, onContextAdd: this.onContextAdd, }, undefined, this.contentElement); } focusTextInput(): void { this.#textAreaRef.value?.focus(); } onSubmit = (event: SubmitEvent): void => { event.preventDefault(); if (this.#imageInput?.isLoading) { return; } const imageInput = !this.#imageInput?.isLoading && this.#imageInput?.data ? {inlineData: {data: this.#imageInput.data, mimeType: this.#imageInput.mimeType}} : undefined; this.onTextSubmit(this.#textAreaRef.value?.value ?? '', imageInput, this.#imageInput?.inputType); this.#imageInput = undefined; this.setInputValue(''); }; onTextAreaKeyDown = (event: KeyboardEvent): void => { if (!event.target || !(event.target instanceof HTMLTextAreaElement)) { return; } // Go to a new line on Shift+Enter. On Enter, submit unless the // user is in IME composition. if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); if (!event.target?.value || this.#imageInput?.isLoading) { return; } const imageInput = !this.#imageInput?.isLoading && this.#imageInput?.data ? {inlineData: {data: this.#imageInput.data, mimeType: this.#imageInput.mimeType}} : undefined; this.onTextSubmit(event.target.value, imageInput, this.#imageInput?.inputType); this.#imageInput = undefined; this.setInputValue(''); } }; onCancel = (ev: SubmitEvent): void => { ev.preventDefault(); if (!this.isLoading) { return; } this.onCancelClick(); }; onImageUpload = (ev: Event): void => { ev.stopPropagation(); const fileSelector = UI.UIUtils.createFileSelectorElement(this.#handleLoadImage.bind(this), '.jpeg,.jpg,.png'); fileSelector.click(); }; }