src/components/chat-item/prompt-input/prompt-text-input.ts (465 lines of code) (raw):
import { Config } from '../../../helper/config';
import { DomBuilder, ExtendedHTMLElement } from '../../../helper/dom';
import { MynahUIGlobalEvents } from '../../../helper/events';
import { MynahUITabsStore } from '../../../helper/tabs-store';
import { MynahEventNames, QuickActionCommand } from '../../../static';
import { MAX_USER_INPUT } from '../chat-prompt-input';
import { Overlay, OverlayHorizontalDirection, OverlayVerticalDirection } from '../../overlay';
import { Card } from '../../card/card';
import { CardBody } from '../../card/card-body';
import testIds from '../../../helper/test-ids';
import { generateUID } from '../../../main';
import { Icon } from '../../icon';
const PREVIEW_DELAY = 500;
export interface PromptTextInputProps {
tabId: string;
initMaxLength: number;
children?: ExtendedHTMLElement[];
onKeydown: (e: KeyboardEvent) => void;
onInput?: (e: KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
}
export class PromptTextInput {
render: ExtendedHTMLElement;
promptTextInputMaxLength: number;
private lastCursorIndex: number = 0;
private readonly props: PromptTextInputProps;
private readonly promptTextInput: ExtendedHTMLElement;
private promptInputOverlay: Overlay | null = null;
private keydownSupport: boolean = true;
private readonly selectedContext: Record<string, QuickActionCommand> = {};
private contextTooltip: Overlay | null;
private contextTooltipTimeout: ReturnType<typeof setTimeout>;
constructor (props: PromptTextInputProps) {
this.props = props;
this.promptTextInputMaxLength = props.initMaxLength;
const initialDisabledState = MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).getValue('promptInputDisabledState') as boolean;
this.promptTextInput = DomBuilder.getInstance().build({
type: 'div',
testId: testIds.prompt.input,
classNames: [ 'mynah-chat-prompt-input', 'empty' ],
innerHTML: '',
attributes: {
contenteditable: 'plaintext-only',
...(initialDisabledState ? { disabled: 'disabled' } : {}),
tabindex: '0',
rows: '1',
maxlength: MAX_USER_INPUT().toString(),
type: 'text',
placeholder: MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).getValue('promptInputPlaceholder'),
...(Config.getInstance().config.autoFocus ? { autofocus: 'autofocus' } : {})
},
events: {
keypress: (e: KeyboardEvent) => {
if (!this.keydownSupport) {
this.props.onKeydown(e);
}
},
keydown: (e: KeyboardEvent) => {
if (e.key !== '') {
this.keydownSupport = true;
this.props.onKeydown(e);
} else {
this.keydownSupport = false;
}
this.hideContextTooltip();
},
keyup: (e: KeyboardEvent) => {
this.lastCursorIndex = this.updateCursorPos();
},
input: (e: KeyboardEvent) => {
if (this.props.onInput !== undefined) {
this.props.onInput(e);
}
this.removeContextPlaceholderOverlay();
this.checkIsEmpty();
},
focus: () => {
if (typeof this.props.onFocus !== 'undefined') {
this.props.onFocus();
}
this.lastCursorIndex = this.updateCursorPos();
},
blur: () => {
if (typeof this.props.onBlur !== 'undefined') {
this.props.onBlur();
}
},
paste: (e: ClipboardEvent): void => {
// Prevent the default paste behavior
e.preventDefault();
// Get plain text from clipboard
const text = e.clipboardData?.getData('text/plain');
if (text != null) {
// Insert text at cursor position
const selection = window.getSelection();
if ((selection?.rangeCount) != null) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
// Move cursor to end of inserted text
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
// Check if input is empty and trigger input event
this.checkIsEmpty();
if (this.props.onInput != null) {
this.props.onInput(new KeyboardEvent('input'));
}
}
},
},
});
this.render = DomBuilder.getInstance().build({
type: 'div',
testId: testIds.prompt.inputWrapper,
classNames: [ 'mynah-chat-prompt-input-inner-wrapper', 'no-text' ],
children: [
...(this.props.children ?? []),
this.promptTextInput,
]
});
MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).subscribe('promptInputDisabledState', (isDisabled: boolean) => {
if (isDisabled) {
this.promptTextInput.setAttribute('disabled', 'disabled');
this.promptTextInput.setAttribute('contenteditable', 'false');
this.promptTextInput.blur();
} else {
// Enable the input field and focus on it
this.promptTextInput.removeAttribute('disabled');
this.promptTextInput.setAttribute('contenteditable', 'plaintext-only');
if (Config.getInstance().config.autoFocus && document.hasFocus()) {
this.promptTextInput.focus();
}
}
});
MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).subscribe('promptInputPlaceholder', (placeholderText: string) => {
if (placeholderText !== undefined) {
this.promptTextInput.update({
attributes: {
placeholder: placeholderText
}
});
}
});
MynahUIGlobalEvents.getInstance().addListener(MynahEventNames.TAB_FOCUS, (data) => {
if (data.tabId === this.props.tabId) {
this.promptTextInput.focus();
}
});
this.clear();
}
private readonly updateCursorPos = (): number => {
const selection = window.getSelection();
if ((selection == null) || (selection.rangeCount === 0)) return 0;
const range = selection.getRangeAt(0);
const container = this.promptTextInput;
// If the selection is not within our container, return 0
if (!container.contains(range.commonAncestorContainer)) return 0;
// Get the range from start of container to cursor position
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(container);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
};
private readonly checkIsEmpty = (): void => {
if (this.promptTextInput.textContent === '' && this.promptTextInput.querySelectorAll('span.context').length === 0) {
this.promptTextInput.addClass('empty');
this.render.addClass('no-text');
} else {
this.promptTextInput.removeClass('empty');
this.render.removeClass('no-text');
}
};
private readonly removeContextPlaceholderOverlay = (): void => {
this.promptInputOverlay?.close();
this.promptInputOverlay?.render.remove();
this.promptInputOverlay = null;
};
private readonly insertElementToGivenPosition = (
element: HTMLElement | Text,
position: number,
endPosition?: number,
maintainCursor: boolean = false
): void => {
const selection = window.getSelection();
if (selection == null) {
this.promptTextInput.insertChild('beforeend', element as HTMLElement);
return;
}
// Store original cursor position if we need to maintain it
const originalRange = maintainCursor ? selection.getRangeAt(0).cloneRange() : null;
const range = document.createRange();
let currentPos = 0;
// Find the correct text node and offset
for (const node of this.promptTextInput.childNodes) {
const length = node.textContent?.length ?? 0;
if (currentPos + length >= position) {
if (node.nodeType === Node.TEXT_NODE || node.nodeName === 'BR') {
const offset = Math.min(position - currentPos, length);
range.setStart(node, offset);
if (endPosition != null) {
let endNode = node;
let endOffset = Math.min(endPosition - currentPos, length);
if (endPosition > currentPos + length) {
let endPos = currentPos + length;
for (let i = Array.from(this.promptTextInput.childNodes).indexOf(node) + 1;
i < this.promptTextInput.childNodes.length;
i++) {
const nextNode = this.promptTextInput.childNodes[i];
const nextLength = nextNode.textContent?.length ?? 0;
if (endPos + nextLength >= endPosition) {
endNode = nextNode;
endOffset = endPosition - endPos;
break;
}
endPos += nextLength;
}
}
range.setEnd(endNode, endOffset);
range.deleteContents();
}
range.insertNode(element);
if (endPosition != null) {
const spaceNode = document.createTextNode('\u00A0');
range.setStartAfter(element);
range.insertNode(spaceNode);
range.setStartAfter(spaceNode);
element = spaceNode;
} else {
range.setStartAfter(element);
}
break;
}
}
currentPos += length;
}
if (!maintainCursor) {
// Only modify cursor position if maintainCursor is false
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else if (originalRange != null) {
// Restore original cursor position
selection.removeAllRanges();
selection.addRange(originalRange);
}
};
private readonly moveCursorToEnd = (): void => {
const range = document.createRange();
range.selectNodeContents(this.promptTextInput);
range.collapse(false);
const selection = window.getSelection();
if (selection != null) {
selection.removeAllRanges();
selection.addRange(range);
}
};
private readonly showContextTooltip = (e: MouseEvent, contextItem: QuickActionCommand): void => {
clearTimeout(this.contextTooltipTimeout);
this.contextTooltipTimeout = setTimeout(() => {
const elm: HTMLElement = e.target as HTMLElement;
this.contextTooltip = new Overlay({
testId: testIds.prompt.contextTooltip,
background: true,
closeOnOutsideClick: false,
referenceElement: elm,
dimOutside: false,
removeOtherOverlays: true,
verticalDirection: OverlayVerticalDirection.TO_TOP,
horizontalDirection: OverlayHorizontalDirection.START_TO_RIGHT,
children: [
DomBuilder.getInstance().build({
type: 'div',
testId: testIds.prompt.contextTooltip,
classNames: [ 'mynah-chat-prompt-context-tooltip' ],
children: [
...(contextItem.icon !== undefined
? [
new Icon({
icon: contextItem.icon
}).render
]
: []),
{
type: 'div',
classNames: [ 'mynah-chat-prompt-context-tooltip-container' ],
children: [
{
type: 'div',
classNames: [ 'mynah-chat-prompt-context-tooltip-name' ],
children: [ contextItem.command ]
},
...(contextItem.description !== undefined
? [ {
type: 'div',
classNames: [ 'mynah-chat-prompt-context-tooltip-description' ],
children: [ contextItem.description ]
} ]
: [])
]
}
]
})
],
});
}, PREVIEW_DELAY);
};
private readonly hideContextTooltip = (): void => {
if (this.contextTooltipTimeout !== null) {
clearTimeout(this.contextTooltipTimeout);
}
if (this.contextTooltip != null) {
this.contextTooltip.close();
this.contextTooltip = null;
}
};
public readonly insertContextItem = (contextItem: QuickActionCommand, position: number): void => {
const temporaryId = generateUID();
this.selectedContext[temporaryId] = contextItem;
const contextSpanElement = DomBuilder.getInstance().build({
type: 'span',
children: [
...(contextItem.icon != null ? [ new Icon({ icon: contextItem.icon }).render ] : [ ]),
{ type: 'span', classNames: [ 'at-char' ], innerHTML: '@' },
`${contextItem.command.replace(/^@?(.*)$/, '$1')}`
],
classNames: [ 'context' ],
attributes: {
'context-tmp-id': temporaryId,
contenteditable: 'false'
},
events: {
mouseenter: (e) => {
this.showContextTooltip(e, contextItem);
},
mouseleave: this.hideContextTooltip,
}
});
this.insertElementToGivenPosition(contextSpanElement, position, this.getCursorPos());
if (contextItem.placeholder != null) {
this.promptInputOverlay = new Overlay({
background: true,
closeOnOutsideClick: true,
referenceElement: contextSpanElement ?? this.render,
dimOutside: false,
removeOtherOverlays: true,
verticalDirection: OverlayVerticalDirection.TO_TOP,
horizontalDirection: OverlayHorizontalDirection.START_TO_RIGHT,
children: [
new Card({
border: false,
children: [
new CardBody({
body: contextItem.placeholder
}).render
]
}).render
],
});
}
this.checkIsEmpty();
};
public readonly getCursorPos = (): number => this.lastCursorIndex;
public readonly clear = (): void => {
this.promptTextInput.innerHTML = '';
const defaultPlaceholder = MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).getValue('promptInputPlaceholder');
this.updateTextInputPlaceholder(defaultPlaceholder);
this.removeContextPlaceholderOverlay();
this.checkIsEmpty();
};
public readonly focus = (): void => {
if (Config.getInstance().config.autoFocus) {
this.promptTextInput.focus();
}
this.moveCursorToEnd();
};
public readonly blur = (): void => {
this.promptTextInput.blur();
this.checkIsEmpty();
};
public readonly getTextInputValue = (withInputLineBreaks?: boolean): string => {
if (withInputLineBreaks === true) {
return (this.promptTextInput.innerText ?? '').trim();
}
return (this.promptTextInput.textContent ?? '').trim();
};
public readonly updateTextInputValue = (value: string): void => {
this.promptTextInput.innerText = value;
this.checkIsEmpty();
};
public readonly insertEndSpace = (): void => {
this.promptTextInput.insertAdjacentText('beforeend', ' ');
};
public readonly updateTextInputMaxLength = (maxLength: number): void => {
this.promptTextInputMaxLength = maxLength;
this.promptTextInput.update({
attributes: {
maxlength: maxLength.toString(),
}
});
};
public readonly updateTextInputPlaceholder = (text: string): void => {
this.promptTextInput.update({
attributes: {
placeholder: text,
}
});
};
public readonly deleteTextRange = (position: number, endPosition: number): void => {
const selection = window.getSelection();
if (selection == null) return;
const range = document.createRange();
let currentPos = 0;
let startNode = null;
let startOffset = 0;
let endNode = null;
let endOffset = 0;
// Find start and end positions
for (const node of this.promptTextInput.childNodes) {
const length = node.textContent?.length ?? 0;
// Find start position
if ((startNode == null) && currentPos + length >= position) {
startNode = node;
startOffset = position - currentPos;
}
// Find end position
if (currentPos + length >= endPosition) {
endNode = node;
endOffset = endPosition - currentPos;
break;
}
currentPos += length;
}
// If we found both positions, delete the range
if ((startNode != null) && (endNode != null)) {
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
range.deleteContents();
}
this.checkIsEmpty();
};
/**
* Returns the cursorLine, totalLines and if the cursor is at the beginning or end of the whole text
* @returns {cursorLine: number, totalLines: number, isAtTheBeginning: boolean, isAtTheEnd: boolean}
*/
public readonly getCursorPosition = (): { cursorLine: number; totalLines: number; isAtTheBeginning: boolean; isAtTheEnd: boolean } => {
const lineHeight = parseFloat(window.getComputedStyle(this.promptTextInput, null).getPropertyValue('line-height'));
let isAtTheBeginning = false;
let isAtTheEnd = false;
let cursorLine = -1;
const cursorElm = DomBuilder.getInstance().build({
type: 'span',
classNames: [ 'cursor' ]
}) as HTMLSpanElement;
this.insertElementToGivenPosition(cursorElm, this.getCursorPos(), undefined, true);
cursorLine = Math.floor((cursorElm.offsetTop + (cursorElm.offsetHeight)) / lineHeight) ?? 0;
if (cursorLine <= 1 && (cursorElm?.offsetLeft ?? 0) === 0) {
isAtTheBeginning = true;
}
const eolElm = DomBuilder.getInstance().build({
type: 'span',
classNames: [ 'eol' ]
}) as HTMLSpanElement;
this.promptTextInput.insertChild('beforeend', eolElm);
const totalLines = Math.floor((eolElm.offsetTop + (eolElm.offsetHeight)) / lineHeight) ?? 0;
if (cursorElm.offsetLeft === eolElm.offsetLeft && cursorElm.offsetTop === eolElm.offsetTop) {
isAtTheEnd = true;
}
cursorElm.remove();
eolElm.remove();
return {
cursorLine,
totalLines,
isAtTheBeginning,
isAtTheEnd,
};
};
public readonly getUsedContext = (): QuickActionCommand[] => {
return Array.from(this.promptTextInput.querySelectorAll('span.context')).map((context) => {
return this.selectedContext[context.getAttribute('context-tmp-id') ?? ''] ?? {};
});
};
}