chrome-devtools-frontend
Version:
Chrome DevTools UI
773 lines (723 loc) • 31.5 kB
text/typescript
// 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> ${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();
};
}