diff --git a/.gitignore b/.gitignore index 1a1e3091..ec25e7c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ out dist **api-docs +build node_modules *.bk *.zip diff --git a/.npmignore b/.npmignore index b3aa8402..9a646bb2 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,5 @@ out -node_modules +**/node_modules *.bk **/.DS_Store .idea @@ -12,6 +12,8 @@ node_modules package-lock.json tsconfig.json webpack.config.js -src -docs -example \ No newline at end of file +**/src +api-docs +**/__test__ +example +example-react \ No newline at end of file diff --git a/example/src/samples/sample-data.ts b/example/src/samples/sample-data.ts index c9342d07..621c233e 100644 --- a/example/src/samples/sample-data.ts +++ b/example/src/samples/sample-data.ts @@ -314,7 +314,7 @@ _To send the form, mandatory items should be filled._`, }, { id: 'email', - type: 'textinput', + type: 'email', mandatory: true, title: `Email`, placeholder: 'email', diff --git a/example/src/styles/variables.scss b/example/src/styles/variables.scss index 611eb228..ef6ea27c 100644 --- a/example/src/styles/variables.scss +++ b/example/src/styles/variables.scss @@ -1,4 +1,5 @@ :root { + font-size: 15px!important; --mynah-font-family: system-ui; --skeleton-default: var(--mynah-color-text-weak); --skeleton-selected: var(--mynah-color-button); diff --git a/package-lock.json b/package-lock.json index db3774fe..344537b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/mynah-ui", - "version": "4.12.0", + "version": "4.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/mynah-ui", - "version": "4.12.0", + "version": "4.13.0", "hasInstallScript": true, "license": "Apache License 2.0", "dependencies": { diff --git a/package.json b/package.json index 072f1d64..23032290 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@aws/mynah-ui", "displayName": "AWS Mynah UI", - "version": "4.12.0", + "version": "4.13.0", "description": "AWS Toolkit VSCode and Intellij IDE Extension Mynah UI", "publisher": "Amazon Web Services", "license": "Apache License 2.0", @@ -90,4 +90,4 @@ "arrowParens": "avoid", "endOfLine": "lf" } -} +} \ No newline at end of file diff --git a/src/components/__test__/syntax-highlighter.spec.ts b/src/components/__test__/syntax-highlighter.spec.ts index 0d6737e7..25e1f27f 100644 --- a/src/components/__test__/syntax-highlighter.spec.ts +++ b/src/components/__test__/syntax-highlighter.spec.ts @@ -24,10 +24,11 @@ describe('syntax-highlighter', () => { onInsertToCursorPosition: () => {}, block: true, }); - - expect(testSyntaxHighlighter.render.querySelectorAll('button')?.length).toBe(2); - expect(testSyntaxHighlighter.render.querySelectorAll('button')?.[0]?.title).toBe('Insert at cursor'); - expect(testSyntaxHighlighter.render.querySelectorAll('button')?.[1]?.title).toBe('Copy'); + setTimeout(() => { + expect(testSyntaxHighlighter.render.querySelectorAll('button')?.length).toBe(2); + expect(testSyntaxHighlighter.render.querySelectorAll('button')?.[0]?.title).toBe('Insert at cursor'); + expect(testSyntaxHighlighter.render.querySelectorAll('button')?.[1]?.title).toBe('Copy'); + }, 100); }); it('should NOT show related button if its event is not connected even when showCopyButtons true', () => { @@ -39,8 +40,9 @@ describe('syntax-highlighter', () => { onCopiedToClipboard: () => {}, block: true, }); - - expect(testSyntaxHighlighter.render.querySelectorAll('button')?.length).toBe(1); - expect(testSyntaxHighlighter.render.querySelectorAll('button')?.[0]?.title).toBe('Copy'); + setTimeout(() => { + expect(testSyntaxHighlighter.render.querySelectorAll('button')?.length).toBe(1); + expect(testSyntaxHighlighter.render.querySelectorAll('button')?.[0]?.title).toBe('Copy'); + }, 100); }); }); diff --git a/src/components/button.ts b/src/components/button.ts index 6b7acc03..b73cefaf 100644 --- a/src/components/button.ts +++ b/src/components/button.ts @@ -8,12 +8,14 @@ import { DomBuilder, ExtendedHTMLElement } from '../helper/dom'; import { Overlay, OverlayHorizontalDirection, OverlayVerticalDirection } from './overlay'; import { Card } from './card/card'; import { CardBody } from './card/card-body'; +import { Config } from '../helper/config'; +import '../styles/components/_button.scss'; const PREVIEW_DELAY = 350; export interface ButtonProps { classNames?: string[]; attributes?: Record; - icon?: HTMLElement | ExtendedHTMLElement | string; + icon?: HTMLElement | ExtendedHTMLElement; label?: HTMLElement | ExtendedHTMLElement | string; tooltip?: string; tooltipVerticalDirection?: OverlayVerticalDirection; @@ -23,11 +25,21 @@ export interface ButtonProps { additionalEvents?: Record any>; onClick: (e: Event) => void; } -export class Button { +export abstract class ButtonAbstract { + render: ExtendedHTMLElement; + updateLabel = (label: HTMLElement | ExtendedHTMLElement | string): void => { + }; + + setEnabled = (enabled: boolean): void => { + }; +} + +class ButtonInternal extends ButtonAbstract { render: ExtendedHTMLElement; private buttonTooltip: Overlay | null; private buttonTooltipTimeout: ReturnType; constructor (props: ButtonProps) { + super(); this.render = DomBuilder.getInstance().build({ type: 'button', classNames: [ @@ -106,3 +118,18 @@ export class Button { } }; } + +export class Button extends ButtonAbstract { + render: ExtendedHTMLElement; + + constructor (props: ButtonProps) { + super(); + return (new (Config.getInstance().config.componentClasses.Button ?? ButtonInternal)(props)); + } + + updateLabel = (label: HTMLElement | ExtendedHTMLElement | string): void => { + }; + + setEnabled = (enabled: boolean): void => { + }; +} diff --git a/src/components/card/card-body.ts b/src/components/card/card-body.ts index fe30f482..43603b63 100644 --- a/src/components/card/card-body.ts +++ b/src/components/card/card-body.ts @@ -9,26 +9,15 @@ import { OnInsertToCursorPositionFunction, ReferenceTrackerInformation, } from '../../static'; -import { RendererExtensionFunction, marked } from 'marked'; +import { marked } from 'marked'; import unescapeHTML from 'unescape-html'; import { Overlay, OverlayHorizontalDirection, OverlayVerticalDirection } from '../overlay'; import { SyntaxHighlighter } from '../syntax-highlighter'; import { generateUID } from '../../helper/guid'; +import '../../styles/components/card/_card.scss'; const PREVIEW_DELAY = 500; -// Marked doesn't exports it, needs manual addition -interface MarkedExtensions { - extensions?: null | { - renderers: { - [name: string]: RendererExtensionFunction; - }; - childTokens: { - [name: string]: string[]; - }; - }; -} - export const highlightersWithTooltip = { start: { markupStart: ' void; onCopiedToClipboard?: OnCopiedToClipboardFunction; onInsertToCursorPosition?: OnInsertToCursorPositionFunction; @@ -81,7 +71,7 @@ export class CardBody { ]; this.render = DomBuilder.getInstance().build({ type: 'div', - classNames: [ 'mynah-card-body' ], + classNames: [ 'mynah-card-body', ...(this.props.classNames ?? []) ], children: this.props.childLocation === 'above-body' ? childList.reverse() : childList, }); @@ -97,80 +87,93 @@ export class CardBody { } private readonly processNode = (node: HTMLElement): HTMLElement => { - const elementFromNode: HTMLElement = node; - if (elementFromNode.tagName?.toLowerCase() === 'a') { - const url = elementFromNode.getAttribute('href') ?? ''; - return DomBuilder.getInstance().build( - { - type: 'a', - events: { - click: (e: MouseEvent) => { - if (this.props.onLinkClick !== undefined) { - this.props.onLinkClick(url, e); - } - }, - auxclick: (e: MouseEvent) => { - if (this.props.onLinkClick !== undefined) { - this.props.onLinkClick(url, e); - } + let elementFromNode: HTMLElement = node; + if (this.props.useParts === true && elementFromNode.nodeType === Node.TEXT_NODE) { + elementFromNode = DomBuilder.getInstance().build({ + type: 'span', + classNames: [ 'mynah-ui-animation-text-content' ], + children: elementFromNode.textContent?.split(' ').map(textPart => DomBuilder.getInstance().build({ + type: 'span', + classNames: [ PARTS_CLASS_NAME ], + children: [ textPart, ' ' ] + })) + }); + } else { + if (elementFromNode.tagName?.toLowerCase() === 'a') { + const url = elementFromNode.getAttribute('href') ?? ''; + return DomBuilder.getInstance().build( + { + type: 'a', + classNames: this.props.useParts === true ? [ PARTS_CLASS_NAME ] : [], + events: { + click: (e: MouseEvent) => { + if (this.props.onLinkClick !== undefined) { + this.props.onLinkClick(url, e); + } + }, + auxclick: (e: MouseEvent) => { + if (this.props.onLinkClick !== undefined) { + this.props.onLinkClick(url, e); + } + }, }, - }, - attributes: { href: elementFromNode.getAttribute('href') ?? '', target: '_blank' }, - innerHTML: elementFromNode.innerHTML, - }); - } - if ((elementFromNode.tagName?.toLowerCase() === 'pre' && elementFromNode.querySelector('code') !== null) || - elementFromNode.tagName?.toLowerCase() === 'code' - ) { - const isBlockCode = elementFromNode.tagName?.toLowerCase() === 'pre' || elementFromNode.innerHTML.match(/\r|\n/) !== null; - const codeElement = (elementFromNode.tagName?.toLowerCase() === 'pre' ? elementFromNode.querySelector('code') : elementFromNode); - const snippetLanguage = Array.from(codeElement?.classList ?? []).find(className => className.match('language-'))?.replace('language-', ''); - const codeString = codeElement?.innerHTML ?? ''; + attributes: { href: elementFromNode.getAttribute('href') ?? '', target: '_blank' }, + innerHTML: elementFromNode.innerHTML, + }); + } + if ((elementFromNode.tagName?.toLowerCase() === 'pre' && elementFromNode.querySelector('code') !== null) || + elementFromNode.tagName?.toLowerCase() === 'code' + ) { + const isBlockCode = elementFromNode.tagName?.toLowerCase() === 'pre' || elementFromNode.innerHTML.match(/\r|\n/) !== null; + const codeElement = (elementFromNode.tagName?.toLowerCase() === 'pre' ? elementFromNode.querySelector('code') : elementFromNode); + const snippetLanguage = Array.from(codeElement?.classList ?? []).find(className => className.match('language-'))?.replace('language-', ''); + const codeString = codeElement?.innerHTML ?? ''; - const highlighter = new SyntaxHighlighter({ - codeStringWithMarkup: unescapeHTML(codeString), - language: snippetLanguage?.trim() !== '' ? snippetLanguage : '', - keepHighlights: true, - showCopyOptions: isBlockCode, - block: isBlockCode, - index: isBlockCode ? this.nextCodeBlockIndex : undefined, - onCopiedToClipboard: this.props.onCopiedToClipboard != null - ? (type, text, codeBlockIndex) => { - if (this.props.onCopiedToClipboard != null) { - this.props.onCopiedToClipboard( - type, - text, - this.getReferenceTrackerInformationFromElement(highlighter), - this.codeBlockStartIndex + (codeBlockIndex ?? 0), - this.nextCodeBlockIndex); + const highlighter = new SyntaxHighlighter({ + codeStringWithMarkup: unescapeHTML(codeString), + language: snippetLanguage?.trim() !== '' ? snippetLanguage : '', + keepHighlights: true, + showCopyOptions: isBlockCode, + block: isBlockCode, + index: isBlockCode ? this.nextCodeBlockIndex : undefined, + onCopiedToClipboard: this.props.onCopiedToClipboard != null + ? (type, text, codeBlockIndex) => { + if (this.props.onCopiedToClipboard != null) { + this.props.onCopiedToClipboard( + type, + text, + this.getReferenceTrackerInformationFromElement(highlighter), + this.codeBlockStartIndex + (codeBlockIndex ?? 0), + this.nextCodeBlockIndex); + } } - } - : undefined, - onInsertToCursorPosition: this.props.onInsertToCursorPosition != null - ? (type, text, codeBlockIndex) => { - if (this.props.onInsertToCursorPosition != null) { - this.props.onInsertToCursorPosition( - type, - text, - this.getReferenceTrackerInformationFromElement(highlighter), - this.codeBlockStartIndex + (codeBlockIndex ?? 0), - this.nextCodeBlockIndex); + : undefined, + onInsertToCursorPosition: this.props.onInsertToCursorPosition != null + ? (type, text, codeBlockIndex) => { + if (this.props.onInsertToCursorPosition != null) { + this.props.onInsertToCursorPosition( + type, + text, + this.getReferenceTrackerInformationFromElement(highlighter), + this.codeBlockStartIndex + (codeBlockIndex ?? 0), + this.nextCodeBlockIndex); + } } - } - : undefined - }).render; - if (this.props.useParts === true) { - highlighter.classList.add(PARTS_CLASS_NAME); - } - if (isBlockCode) { - ++this.nextCodeBlockIndex; + : undefined + }).render; + if (this.props.useParts === true) { + highlighter.classList.add(PARTS_CLASS_NAME); + } + if (isBlockCode) { + ++this.nextCodeBlockIndex; + } + return highlighter; } - return highlighter; - } - elementFromNode.childNodes?.forEach((child) => { - elementFromNode.replaceChild(this.processNode(child as HTMLElement), child); - }); + elementFromNode.childNodes?.forEach((child) => { + elementFromNode.replaceChild(this.processNode(child as HTMLElement), child); + }); + } return elementFromNode; }; @@ -254,50 +257,6 @@ export class CardBody { } }; - /** - * Returns extension additions - * @returns marked options extensions - */ - private readonly getMarkedExtensions = (): MarkedExtensions => { - return { - extensions: { - renderers: { - text: (token) => { - if (this.props.useParts === true) { - // We should skip words inside code blocks - // In general code blocks getting rendered before the text items - // However for listitems, the case is different - // We still need to check if the word is a code field start, - // is it inside a code field or is it a code field end - let codeOpen = false; - return token.text.split(' ').map((textPart: string) => { - if (textPart.match(/`/) != null) { - // revert the code field open state back - // or restart the open state of code field - codeOpen = !codeOpen; - // open or close state - // return the text as is - return textPart; - } - - // if inside code field - // return text as is - if (codeOpen) { - return textPart; - } - - // otherwise add typewriter animation wrapper - return `${textPart}`; - }).join(' '); - } - return token.text; - } - }, - childTokens: {} - } - }; - }; - private readonly getContentBodyChildren = (props: CardBodyProps): Array => { if (props.body != null && props.body.trim() !== '') { let incomingBody = props.body; @@ -325,7 +284,6 @@ export class CardBody { type: 'div', innerHTML: `${marked.parse(incomingBody, { breaks: true, - ...this.getMarkedExtensions() }) as string}`, }).childNodes ).map(node => { diff --git a/src/components/card/card.ts b/src/components/card/card.ts index 0bbdc5e2..2f323876 100644 --- a/src/components/card/card.ts +++ b/src/components/card/card.ts @@ -4,6 +4,7 @@ */ import { DomBuilder, DomBuilderObject, ExtendedHTMLElement } from '../../helper/dom'; import { EngagementType } from '../../static'; +import '../../styles/components/card/_card.scss'; /** * We'll not consider it as an engagement if the total spend time is lower than below constant and won't trigger the event @@ -31,12 +32,12 @@ export interface CardProps extends Partial { }) => void; } export class Card { + render: ExtendedHTMLElement; private readonly props: CardProps; private engagementStartTime: number = -1; private totalMouseDistanceTraveled: { x: number; y: number } = { x: 0, y: 0 }; private previousMousePosition!: { x: number; y: number }; private mouseDownInfo!: { x: number; y: number; time: number }; - render: ExtendedHTMLElement; constructor (props: CardProps) { this.props = props; this.render = DomBuilder.getInstance().build({ diff --git a/src/components/chat-item/chat-item-buttons.ts b/src/components/chat-item/chat-item-buttons.ts index 25d3be59..0efbb9be 100644 --- a/src/components/chat-item/chat-item-buttons.ts +++ b/src/components/chat-item/chat-item-buttons.ts @@ -6,14 +6,15 @@ import { DomBuilder, ExtendedHTMLElement } from '../../helper/dom'; import { ChatItemButton } from '../../static'; import { Button } from '../button'; +import { Icon } from '../icon'; import { ChatItemFollowUpOption } from './chat-item-followup-option'; import { ChatItemFormItemsWrapper } from './chat-item-form-items'; export interface ChatItemButtonsWrapperProps { tabId: string; + classNames?: string[]; buttons: ChatItemButton[]; formItems: ChatItemFormItemsWrapper | null; - useButtonComponent?: boolean; onActionClick: (action: ChatItemButton, e?: Event) => void; } export class ChatItemButtonsWrapper { @@ -28,43 +29,22 @@ export class ChatItemButtonsWrapper { this.props = props; this.render = DomBuilder.getInstance().build({ type: 'div', - classNames: [ 'mynah-chat-item-buttons-container', - props.useButtonComponent === true ? 'mynah-chat-item-buttons-container-use-real-buttons' : '' ], + classNames: [ 'mynah-chat-item-buttons-container', ...(this.props.classNames ?? []) ], children: this.props.buttons.map(chatActionAction => { - let actionItem; - if (props.useButtonComponent !== true) { - actionItem = new ChatItemFollowUpOption({ - followUpOption: { - pillText: chatActionAction.text, - disabled: chatActionAction.disabled, - description: chatActionAction.description, - status: chatActionAction.status, - icon: chatActionAction.icon, - }, - onClick: () => { - if (props.formItems !== null) { - props.formItems.disableAll(); - } - this.disableAll(); - this.props.onActionClick(chatActionAction); + const actionItem = new Button({ + label: chatActionAction.text, + icon: chatActionAction.icon != null ? new Icon({ icon: chatActionAction.icon }).render : undefined, + primary: chatActionAction.status !== undefined, + onClick: (e) => { + if (props.formItems !== null) { + props.formItems.disableAll(); } - }); - } else { - actionItem = new Button({ - label: chatActionAction.text, - icon: chatActionAction.icon, - primary: chatActionAction.status !== undefined, - onClick: (e) => { - if (props.formItems !== null) { - props.formItems.disableAll(); - } - this.disableAll(); - this.props.onActionClick(chatActionAction, e); - } - }); - if (chatActionAction.disabled === true) { - actionItem.setEnabled(false); + this.disableAll(); + this.props.onActionClick(chatActionAction, e); } + }); + if (chatActionAction.disabled === true) { + actionItem.setEnabled(false); } this.actions[chatActionAction.id] = { data: chatActionAction, diff --git a/src/components/chat-item/chat-item-card-content.ts b/src/components/chat-item/chat-item-card-content.ts new file mode 100644 index 00000000..69fcb045 --- /dev/null +++ b/src/components/chat-item/chat-item-card-content.ts @@ -0,0 +1,112 @@ +/*! + * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DomBuilderObject, ExtendedHTMLElement, getTypewriterPartsCss } from '../../helper/dom'; +import { CardRenderDetails, ChatItem, OnCopiedToClipboardFunction, OnInsertToCursorPositionFunction, ReferenceTrackerInformation } from '../../static'; +import { CardBody } from '../card/card-body'; +import { generateUID } from '../../helper/guid'; + +const TYPEWRITER_STACK_TIME = 500; +export interface ChatItemCardContentProps { + body?: string; + renderAsStream?: boolean; + classNames?: string[]; + codeReference?: ReferenceTrackerInformation[]; + onAnimationStateChange?: (isAnimating: boolean) => void; + contentEvents?: { + onLinkClick?: (url: string, e: MouseEvent) => void; + onCopiedToClipboard?: OnCopiedToClipboardFunction; + onInsertToCursorPosition?: OnInsertToCursorPositionFunction; + }; + children?: Array; +} +export class ChatItemCardContent { + private props: ChatItemCardContentProps; + render: ExtendedHTMLElement; + contentBody: CardBody | null = null; + private readonly updateStack: Array> = []; + private typewriterItemIndex: number = 0; + private readonly typewriterId: string = `typewriter-card-${generateUID()}`; + private lastAnimationDuration: number = 0; + private updateTimer: ReturnType | undefined; + constructor (props: ChatItemCardContentProps) { + this.props = props; + this.contentBody = this.getCardContent(); + this.render = this.contentBody.render; + + if ((this.props.renderAsStream ?? false) && (this.props.body ?? '').trim() !== '') { + this.updateCardStack({}); + } + } + + private readonly getCardContent = (): CardBody => { + return new CardBody({ + body: this.props.body ?? '', + useParts: this.props.renderAsStream, + classNames: [ this.typewriterId, ...(this.props.classNames ?? []) ], + highlightRangeWithTooltip: this.props.codeReference, + children: this.props.children, + ...this.props.contentEvents, + }); + }; + + private readonly updateCard = (): void => { + if (this.updateTimer === undefined && this.updateStack.length > 0) { + const updateWith: Partial | undefined = this.updateStack.shift(); + if (updateWith !== undefined) { + this.props = { + ...this.props, + ...updateWith, + }; + + const newCardContent = this.getCardContent(); + const upcomingWords = Array.from(newCardContent.render.querySelectorAll('.typewriter-part') ?? []); + for (let i = 0; i < upcomingWords.length; i++) { + upcomingWords[i].setAttribute('index', i.toString()); + } + // How many new words will be added + const newWordsCount = upcomingWords.length - this.typewriterItemIndex; + + // For each stack, without exceeding 500ms in total + // we're setting each words delay time according to the count of them. + // Word appearance time cannot exceed 50ms + // Stack's total appearance time cannot exceed 500ms + const timeForEach = Math.min(50, Math.floor(TYPEWRITER_STACK_TIME / newWordsCount)); + + // Generate animator style and inject into render + // CSS animations ~100 times faster then js timeouts/intervals + newCardContent.render.insertAdjacentElement('beforeend', + getTypewriterPartsCss(this.typewriterId, this.typewriterItemIndex, upcomingWords.length, timeForEach)); + + this.props.onAnimationStateChange?.(true); + this.contentBody = newCardContent; + this.render.replaceWith(this.contentBody.render); + this.render = this.contentBody.render; + + this.lastAnimationDuration = timeForEach * newWordsCount; + this.typewriterItemIndex = upcomingWords.length; + + // If there is another set + // call the same function to check after current stack totally shown + this.updateTimer = setTimeout(() => { + this.updateTimer = undefined; + this.props.onAnimationStateChange?.(false); + this.updateCard(); + }, this.lastAnimationDuration); + } + } + }; + + public readonly updateCardStack = (updateWith: Partial): void => { + this.updateStack.push(updateWith); + this.updateCard(); + }; + + public readonly getRenderDetails = (): CardRenderDetails => { + return { + totalNumberOfCodeBlocks: (this.contentBody?.nextCodeBlockIndex ?? 0) + }; + }; +} diff --git a/src/components/chat-item/chat-item-card.ts b/src/components/chat-item/chat-item-card.ts index 75dcf35c..2c57e87a 100644 --- a/src/components/chat-item/chat-item-card.ts +++ b/src/components/chat-item/chat-item-card.ts @@ -7,7 +7,6 @@ import { DomBuilder, DomBuilderObject, ExtendedHTMLElement } from '../../helper/ import { MynahUIGlobalEvents } from '../../helper/events'; import { MynahUITabsStore } from '../../helper/tabs-store'; import { CardRenderDetails, ChatItem, ChatItemType, MynahEventNames } from '../../static'; -import { Card } from '../card/card'; import { CardBody, CardBodyProps } from '../card/card-body'; import { Icon, MynahIcons } from '../icon'; import { ChatItemFollowUpContainer } from './chat-item-followup'; @@ -15,14 +14,14 @@ import { ChatItemSourceLinksContainer } from './chat-item-source-links'; import { ChatItemRelevanceVote } from './chat-item-relevance-vote'; import { ChatItemTreeViewWrapper } from './chat-item-tree-view-wrapper'; import { Config } from '../../helper/config'; -import { generateUID } from '../../helper/guid'; import { ChatItemFormItemsWrapper } from './chat-item-form-items'; import { ChatItemButtonsWrapper } from './chat-item-buttons'; import { cleanHtml } from '../../helper/sanitize'; import { CONTAINER_GAP } from './chat-wrapper'; import { chatItemHasContent } from '../../helper/chat-item'; +import { Card } from '../card/card'; +import { ChatItemCardContent, ChatItemCardContentProps } from './chat-item-card-content'; -const TYPEWRITER_STACK_TIME = 500; export interface ChatItemCardProps { tabId: string; chatItem: ChatItem; @@ -30,17 +29,18 @@ export interface ChatItemCardProps { export class ChatItemCard { readonly props: ChatItemCardProps; render: ExtendedHTMLElement; - contentBody: CardBody | null = null; - chatAvatar: ExtendedHTMLElement; - updateStack: Array> = []; - chatFormItems: ChatItemFormItemsWrapper | null = null; - customRendererWrapper: CardBody | null = null; - chatButtons: ChatItemButtonsWrapper | null = null; - fileTreeWrapper: ChatItemTreeViewWrapper | null = null; - typewriterItemIndex: number = 0; - previousTypewriterItemIndex: number = 0; - typewriterId: string; - private updateTimer: ReturnType | undefined; + private readonly card: Card | null = null; + private readonly updateStack: Array> = []; + private readonly initialSpinner: ExtendedHTMLElement[] | null = null; + private cardIcon: Icon | null = null; + private contentBody: ChatItemCardContent | null = null; + private chatAvatar: ExtendedHTMLElement; + private chatFormItems: ChatItemFormItemsWrapper | null = null; + private customRendererWrapper: CardBody | null = null; + private chatButtons: ChatItemButtonsWrapper | null = null; + private fileTreeWrapper: ChatItemTreeViewWrapper | null = null; + private followUps: ChatItemFollowUpContainer | null = null; + private votes: ChatItemRelevanceVote | null = null; constructor (props: ChatItemCardProps) { this.props = props; this.chatAvatar = this.getChatAvatar(); @@ -54,6 +54,19 @@ export class ChatItemCard { this.chatAvatar.remove(); } }); + if (this.props.chatItem.type === ChatItemType.ANSWER_STREAM) { + this.initialSpinner = [ DomBuilder.getInstance().build({ + type: 'div', + persistent: true, + classNames: [ 'mynah-chat-items-spinner' ], + children: [ { type: 'span' }, { type: 'div', children: [ Config.getInstance().config.texts.spinnerText ] } ], + }) ]; + } + this.card = new Card({ + classNames: [ 'hide-if-empty' ], + children: this.initialSpinner ?? [], + }); + this.updateCardContent(); this.render = this.generateCard(); if (this.props.chatItem.type === ChatItemType.ANSWER_STREAM && @@ -70,21 +83,9 @@ export class ChatItemCard { messageId: this.props.chatItem.messageId ?? 'unknown', }, children: [ - ...(this.props.chatItem.type === ChatItemType.ANSWER_STREAM && (this.props.chatItem.body ?? '').trim() === '' - ? [ - // Create an empty card with its child set to the loading spinner - new Card({ - children: [ - DomBuilder.getInstance().build({ - type: 'div', - persistent: true, - classNames: [ 'mynah-chat-items-spinner' ], - children: [ { type: 'span' }, { type: 'div', children: [ Config.getInstance().config.texts.spinnerText ] } ], - }), - ], - }).render, - ] - : [ ...this.getCardContent() ]), + ...(MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).getValue('showChatAvatars') === true ? [ this.chatAvatar ] : []), + ...(this.card != null ? [ this.card?.render ] : []), + ...(this.props.chatItem.followUp?.text !== undefined ? [ new ChatItemFollowUpContainer({ tabId: this.props.tabId, chatItem: this.props.chatItem }).render ] : []) ], }); @@ -115,9 +116,9 @@ export class ChatItemCard { ]; }; - private readonly getCardContent = (): Array => { + private readonly updateCardContent = (): void => { if (MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId) === undefined) { - return []; + return; } const bodyEvents: Partial = { @@ -137,7 +138,7 @@ export class ChatItemCard { text, referenceTrackerInformation, codeBlockIndex, - totalCodeBlocks: (this.contentBody?.nextCodeBlockIndex ?? 0) + (this.customRendererWrapper?.nextCodeBlockIndex ?? 0), + totalCodeBlocks: (this.contentBody?.getRenderDetails().totalNumberOfCodeBlocks ?? 0) + (this.customRendererWrapper?.nextCodeBlockIndex ?? 0), }); } } @@ -151,25 +152,46 @@ export class ChatItemCard { text, referenceTrackerInformation, codeBlockIndex, - totalCodeBlocks: (this.contentBody?.nextCodeBlockIndex ?? 0) + (this.customRendererWrapper?.nextCodeBlockIndex ?? 0), + totalCodeBlocks: (this.contentBody?.getRenderDetails().totalNumberOfCodeBlocks ?? 0) + (this.customRendererWrapper?.nextCodeBlockIndex ?? 0), }); } } : {}) }; + if (chatItemHasContent(this.props.chatItem)) { + this.initialSpinner?.[0]?.remove(); + } + /** - * Generate contentBody if available + * Generate card icon if available */ - if (this.contentBody !== null) { - this.contentBody.render.remove(); - this.contentBody = null; + if (this.props.chatItem.icon !== undefined) { + if (this.cardIcon !== null) { + this.cardIcon.render.remove(); + this.cardIcon = null; + } else { + this.cardIcon = new Icon({ icon: this.props.chatItem.icon, classNames: [ 'mynah-chat-item-card-icon', 'mynah-card-inner-order-10' ] }); + this.card?.render.insertChild('beforeend', this.cardIcon.render); + } } - if (this.props.chatItem.body !== undefined) { - this.contentBody = new CardBody({ + + /** + * Generate contentBody if available + */ + if (this.props.chatItem.body !== undefined && this.props.chatItem.body !== '') { + const updatedCardContentBodyProps: ChatItemCardContentProps = { body: this.props.chatItem.body ?? '', - useParts: this.props.chatItem.type === ChatItemType.ANSWER_STREAM, - highlightRangeWithTooltip: this.props.chatItem.codeReference, + classNames: [ 'mynah-card-inner-order-20' ], + renderAsStream: this.props.chatItem.type === ChatItemType.ANSWER_STREAM, + codeReference: this.props.chatItem.codeReference, + onAnimationStateChange: (isAnimating) => { + if (isAnimating) { + this.render.addClass('typewriter-animating'); + } else { + this.render.removeClass('typewriter-animating'); + } + }, children: this.props.chatItem.relatedContent !== undefined ? [ @@ -181,8 +203,14 @@ export class ChatItemCard { }).render, ] : [], - ...bodyEvents, - }); + contentEvents: bodyEvents, + }; + if (this.contentBody !== null) { + this.contentBody.updateCardStack(updatedCardContentBodyProps); + } else { + this.contentBody = new ChatItemCardContent(updatedCardContentBodyProps); + this.card?.render.insertChild('beforeend', this.contentBody.render); + } } /** @@ -204,22 +232,56 @@ export class ChatItemCard { this.customRendererWrapper = new CardBody({ body: customRendererContent.innerHTML, children: customRendererContent.children, + classNames: [ 'mynah-card-inner-order-30' ], processChildren: true, useParts: true, - codeBlockStartIndex: (this.contentBody?.nextCodeBlockIndex ?? 0), + codeBlockStartIndex: (this.contentBody?.getRenderDetails().totalNumberOfCodeBlocks ?? 0), ...bodyEvents, }); + + this.card?.render.insertChild('beforeend', this.customRendererWrapper.render); } /** - * Generate form items if available - */ + * Generate form items if available + */ if (this.chatFormItems !== null) { this.chatFormItems.render.remove(); this.chatFormItems = null; } if (this.props.chatItem.formItems !== undefined) { - this.chatFormItems = new ChatItemFormItemsWrapper({ tabId: this.props.tabId, chatItem: this.props.chatItem }); + this.chatFormItems = new ChatItemFormItemsWrapper({ + classNames: [ 'mynah-card-inner-order-40' ], + tabId: this.props.tabId, + chatItem: this.props.chatItem + }); + this.card?.render.insertChild('beforeend', this.chatFormItems.render); + } + + /** + * Generate file tree if available + */ + if (this.fileTreeWrapper !== null) { + this.fileTreeWrapper.render.remove(); + this.fileTreeWrapper = null; + } + if (this.props.chatItem.fileList !== undefined) { + const { filePaths = [], deletedFiles = [], actions, details } = this.props.chatItem.fileList; + const referenceSuggestionLabel = this.props.chatItem.body ?? ''; + this.fileTreeWrapper = new ChatItemTreeViewWrapper({ + tabId: this.props.tabId, + classNames: [ 'mynah-card-inner-order-50' ], + messageId: this.props.chatItem.messageId ?? '', + cardTitle: this.props.chatItem.fileList.fileTreeTitle, + rootTitle: this.props.chatItem.fileList.rootFolderTitle, + files: filePaths, + deletedFiles, + actions, + details, + references: this.props.chatItem.codeReference ?? [], + referenceSuggestionLabel, + }); + this.card?.render.insertChild('beforeend', this.fileTreeWrapper.render); } /** @@ -232,6 +294,7 @@ export class ChatItemCard { if (this.props.chatItem.buttons !== undefined) { this.chatButtons = new ChatItemButtonsWrapper({ tabId: this.props.tabId, + classNames: [ 'mynah-card-inner-order-60' ], formItems: this.chatFormItems, buttons: this.props.chatItem.buttons, onActionClick: action => { @@ -259,62 +322,36 @@ export class ChatItemCard { } }, }); + this.card?.render.insertChild('beforeend', this.chatButtons.render); } /** - * Generate file tree if available + * Generate votes if available */ - if (this.fileTreeWrapper !== null) { - this.fileTreeWrapper.render.remove(); - this.fileTreeWrapper = null; + if (this.votes !== null) { + this.votes.render.remove(); + this.votes = null; } - if (this.props.chatItem.fileList !== undefined) { - const { filePaths = [], deletedFiles = [], actions, details } = this.props.chatItem.fileList; - const referenceSuggestionLabel = this.props.chatItem.body ?? ''; - this.fileTreeWrapper = new ChatItemTreeViewWrapper({ + if (this.props.chatItem.canBeVoted === true && this.props.chatItem.messageId !== undefined) { + this.votes = new ChatItemRelevanceVote({ tabId: this.props.tabId, - messageId: this.props.chatItem.messageId ?? '', - cardTitle: this.props.chatItem.fileList.fileTreeTitle, - rootTitle: this.props.chatItem.fileList.rootFolderTitle, - files: filePaths, - deletedFiles, - actions, - details, - references: this.props.chatItem.codeReference ?? [], - referenceSuggestionLabel, + classNames: [ 'mynah-card-inner-order-70' ], + messageId: this.props.chatItem.messageId }); + this.card?.render.insertChild('beforeend', this.votes.render); } - return [ - ...(MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).getValue('showChatAvatars') === true ? [ this.chatAvatar ] : []), - - ...(chatItemHasContent(this.props.chatItem) - ? [ - new Card({ - onCardEngaged: engagement => { - MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.CHAT_ITEM_ENGAGEMENT, { - engagement, - messageId: this.props.chatItem.messageId, - }); - }, - children: [ - ...(this.props.chatItem.icon !== undefined - ? [ new Icon({ icon: this.props.chatItem.icon, classNames: [ 'mynah-chat-item-card-icon' ] }).render ] - : []), - ...(this.contentBody !== null ? [ this.contentBody.render ] : []), - ...(this.customRendererWrapper !== null ? [ this.customRendererWrapper.render ] : []), - ...(this.chatFormItems !== null ? [ this.chatFormItems.render ] : []), - ...(this.fileTreeWrapper !== null ? [ this.fileTreeWrapper.render ] : []), - ...(this.chatButtons !== null ? [ this.chatButtons.render ] : []), - ...(this.props.chatItem.canBeVoted === true && this.props.chatItem.messageId !== undefined - ? [ new ChatItemRelevanceVote({ tabId: this.props.tabId, messageId: this.props.chatItem.messageId }).render ] - : []), - ], - }).render, - ] - : ''), - this.props.chatItem.followUp?.text !== undefined ? new ChatItemFollowUpContainer({ tabId: this.props.tabId, chatItem: this.props.chatItem }).render : '', - ]; + /** + * Generate/update followups if available + */ + if (this.followUps !== null) { + this.followUps.render.remove(); + this.followUps = null; + } + if (this.props.chatItem.followUp?.text !== undefined) { + this.followUps = new ChatItemFollowUpContainer({ tabId: this.props.tabId, chatItem: this.props.chatItem }); + this.render?.insertChild('afterend', this.followUps.render); + } }; private readonly getChatAvatar = (): ExtendedHTMLElement => @@ -324,51 +361,6 @@ export class ChatItemCard { children: [ new Icon({ icon: this.props.chatItem.type === ChatItemType.PROMPT ? MynahIcons.USER : MynahIcons.MYNAH }).render ], }); - private readonly getInsertedTypewriterPartsCss = (): ExtendedHTMLElement => - DomBuilder.getInstance().build({ - type: 'style', - attributes: { - type: 'text/css', - }, - persistent: true, - innerHTML: ` - ${new Array(Math.max(0, (this.typewriterItemIndex ?? 0) - (this.previousTypewriterItemIndex ?? 0))) - .fill(null) - .map((n, i) => { - return ` - .${this.typewriterId} .typewriter-part[index="${i + this.previousTypewriterItemIndex}"] { - animation: none !important; - opacity: 1 !important; - visibility: visible !important; - } - - `; - }) - .join('')} - `, - }); - - private readonly getInsertingTypewriterPartsCss = (newWordsCount: number, timeForEach: number): ExtendedHTMLElement => - DomBuilder.getInstance().build({ - type: 'style', - attributes: { - type: 'text/css', - }, - innerHTML: ` - ${new Array(Math.max(0, newWordsCount ?? 0)) - .fill(null) - .map((n, i) => { - return ` - .${this.typewriterId} .typewriter-part[index="${i + this.typewriterItemIndex}"] { - animation: typewriter 100ms ease-out forwards; - animation-delay: ${i * timeForEach}ms !important; - } - `; - }) - .join('')} - `, - }); - private readonly checkCardSnap = (): void => { // If the chat item has snapToTop value as true, we'll snap the card to the container top if (this.render.offsetParent != null && this.props.chatItem.snapToTop === true) { @@ -377,8 +369,7 @@ export class ChatItemCard { }; public readonly updateCard = (): void => { - this.checkCardSnap(); - if (this.updateTimer === undefined && this.updateStack.length > 0) { + if (this.updateStack.length > 0) { const updateWith: Partial | undefined = this.updateStack.shift(); if (updateWith !== undefined) { this.props.chatItem = { @@ -404,14 +395,6 @@ export class ChatItemCard { ); } - const newCardContent = this.getCardContent(); - const upcomingWords = Array.from(this.contentBody?.render.querySelectorAll('.typewriter-part') ?? []); - for (let i = 0; i < upcomingWords.length; i++) { - upcomingWords[i].setAttribute('index', i.toString()); - } - if (this.typewriterId === undefined) { - this.typewriterId = `typewriter-card-${generateUID()}`; - } this.render?.update({ ...(this.props.chatItem.messageId != null ? { @@ -420,37 +403,15 @@ export class ChatItemCard { } } : {}), - classNames: [ ...this.getCardClasses(), 'reveal', this.typewriterId, 'typewriter-animating' ], - children: [ ...newCardContent, this.getInsertedTypewriterPartsCss() ], + classNames: [ ...this.getCardClasses(), 'reveal' ], }); - - // How many new words will be added - const newWordsCount = upcomingWords.length - this.typewriterItemIndex; - - // For each stack, without exceeding 500ms in total - // we're setting each words delay time according to the count of them. - // Word appearance time cannot exceed 50ms - // Stack's total appearance time cannot exceed 500ms - const timeForEach = Math.min(50, Math.floor(TYPEWRITER_STACK_TIME / newWordsCount)); - - // Generate animator style and inject into render - // CSS animations ~100 times faster then js timeouts/intervals - this.render.insertChild('beforeend', this.getInsertingTypewriterPartsCss(newWordsCount, timeForEach)); - - // All the animator selectors injected - // update the words count for a potential upcoming set - this.previousTypewriterItemIndex = this.typewriterItemIndex; - this.typewriterItemIndex = upcomingWords.length; - - // If there is another set - // call the same function to check after current stack totally shown - this.updateTimer = setTimeout(() => { - this.render.removeClass('typewriter-animating'); - this.render.insertChild('beforeend', this.getInsertedTypewriterPartsCss()); - this.updateTimer = undefined; - this.updateCard(); - }, timeForEach * newWordsCount); + this.updateCardContent(); + this.updateCard(); } + } else { + setTimeout(() => { + this.checkCardSnap(); + }, 200); } }; @@ -461,7 +422,7 @@ export class ChatItemCard { public readonly getRenderDetails = (): CardRenderDetails => { return { - totalNumberOfCodeBlocks: (this.contentBody?.nextCodeBlockIndex ?? 0) + (this.customRendererWrapper?.nextCodeBlockIndex ?? 0) + totalNumberOfCodeBlocks: (this.contentBody?.getRenderDetails().totalNumberOfCodeBlocks ?? 0) + (this.customRendererWrapper?.nextCodeBlockIndex ?? 0) }; }; } diff --git a/src/components/chat-item/chat-item-form-items.ts b/src/components/chat-item/chat-item-form-items.ts index 1d61cbff..98b773fb 100644 --- a/src/components/chat-item/chat-item-form-items.ts +++ b/src/components/chat-item/chat-item-form-items.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Config } from '../../helper/config'; import { DomBuilder, ExtendedHTMLElement } from '../../helper/dom'; import { ChatItem, ChatItemFormItem } from '../../static'; import { RadioGroup } from '../form-items/radio-group'; @@ -12,7 +13,11 @@ import { TextArea } from '../form-items/text-area'; import { TextInput } from '../form-items/text-input'; import { Icon, MynahIcons } from '../icon'; -export interface ChatItemFormItemsWrapperProps {tabId: string; chatItem: Partial} +export interface ChatItemFormItemsWrapperProps { + tabId: string; + chatItem: Partial; + classNames?: string[]; +} export class ChatItemFormItemsWrapper { private readonly props: ChatItemFormItemsWrapperProps; private readonly options: Record = {}; @@ -25,7 +30,7 @@ export class ChatItemFormItemsWrapper { this.props = props; this.render = DomBuilder.getInstance().build({ type: 'div', - classNames: [ 'mynah-chat-item-form-items-container' ], + classNames: [ 'mynah-chat-item-form-items-container', ...(this.props.classNames ?? []) ], children: this.props.chatItem.formItems?.map(chatItemOption => { let chatOption; let label: ExtendedHTMLElement | string = `${chatItemOption.mandatory === true ? '* ' : ''}${chatItemOption.title ?? ''}`; @@ -51,6 +56,7 @@ export class ChatItemFormItemsWrapper { value, options: chatItemOption.options, optional: chatItemOption.mandatory !== true, + placeholder: Config.getInstance().config.texts.pleaseSelect, ...(this.getValidationHandler(chatItemOption)) }); break; @@ -83,7 +89,16 @@ export class ChatItemFormItemsWrapper { chatOption = new TextInput({ label, value, - numeric: true, + type: 'number', + placeholder: chatItemOption.placeholder, + ...(this.getValidationHandler(chatItemOption)) + }); + break; + case 'email': + chatOption = new TextInput({ + label, + value, + type: 'email', placeholder: chatItemOption.placeholder, ...(this.getValidationHandler(chatItemOption)) }); diff --git a/src/components/chat-item/chat-item-relevance-vote.ts b/src/components/chat-item/chat-item-relevance-vote.ts index 340a08e4..60d16fbc 100644 --- a/src/components/chat-item/chat-item-relevance-vote.ts +++ b/src/components/chat-item/chat-item-relevance-vote.ts @@ -13,6 +13,7 @@ import { Config } from '../../helper/config'; const THANKS_REMOVAL_DURATION = 3500; export interface ChatItemRelevanceVoteProps { tabId: string; + classNames?: string[]; messageId: string; } export class ChatItemRelevanceVote { @@ -25,7 +26,7 @@ export class ChatItemRelevanceVote { this.votingId = `${this.props.tabId}-${this.props.messageId}`; this.render = DomBuilder.getInstance().build({ type: 'div', - classNames: [ 'mynah-card-votes-wrapper' ], + classNames: [ 'mynah-card-votes-wrapper', ...(this.props.classNames ?? []) ], children: [ { type: 'div', diff --git a/src/components/chat-item/chat-item-tree-view-wrapper.ts b/src/components/chat-item/chat-item-tree-view-wrapper.ts index d8354639..65e3c9a4 100644 --- a/src/components/chat-item/chat-item-tree-view-wrapper.ts +++ b/src/components/chat-item/chat-item-tree-view-wrapper.ts @@ -17,6 +17,7 @@ export interface ChatItemTreeViewWrapperProps { messageId: string; files: string[]; cardTitle?: string; + classNames?: string[]; rootTitle?: string; deletedFiles: string[]; actions?: Record; @@ -54,7 +55,7 @@ export class ChatItemTreeViewWrapper { this.render = DomBuilder.getInstance().build({ type: 'div', - classNames: [ 'mynah-chat-item-tree-view-wrapper' ], + classNames: [ 'mynah-chat-item-tree-view-wrapper', ...(props.classNames ?? []) ], children: [ { type: 'div', diff --git a/src/components/chat-item/chat-wrapper.ts b/src/components/chat-item/chat-wrapper.ts index 3e123f25..e61c8cd4 100644 --- a/src/components/chat-item/chat-wrapper.ts +++ b/src/components/chat-item/chat-wrapper.ts @@ -15,6 +15,7 @@ import { ChatItemCard } from './chat-item-card'; import { ChatPromptInput } from './chat-prompt-input'; import { ChatPromptInputInfo } from './chat-prompt-input-info'; import { ChatPromptInputStickyCard } from './chat-prompt-input-sticky-card'; +import '../../styles/components/chat/_chat-wrapper.scss'; export const CONTAINER_GAP = 12; export interface ChatWrapperProps { @@ -92,9 +93,8 @@ export class ChatWrapper { ...(this.props?.onStopChatResponse !== undefined ? [ new Button({ classNames: [ 'mynah-chat-stop-chat-response-button' ], - primary: false, label: Config.getInstance().config.texts.stopGenerating, - icon: new Icon({ icon: MynahIcons.BLOCK }).render, + icon: new Icon({ icon: MynahIcons.CANCEL }).render, onClick: () => { if ((this.props?.onStopChatResponse) !== undefined) { this.props?.onStopChatResponse(this.props.tabId); diff --git a/src/components/chat-item/prompt-input/send-button.ts b/src/components/chat-item/prompt-input/send-button.ts index 51ad6e49..43bf5035 100644 --- a/src/components/chat-item/prompt-input/send-button.ts +++ b/src/components/chat-item/prompt-input/send-button.ts @@ -3,32 +3,33 @@ import { MynahUITabsStore } from '../../../helper/tabs-store'; import { Button } from '../../button'; import { Icon, MynahIcons } from '../../icon'; -export interface ISendButtonProps { +export interface SendButtonProps { tabId: string; onClick: () => void; } export class SendButton { - private readonly _props: ISendButtonProps; - private readonly _render: ExtendedHTMLElement; - constructor (props: ISendButtonProps) { - this._props = props; + render: ExtendedHTMLElement; + private readonly props: SendButtonProps; + constructor (props: SendButtonProps) { + this.props = props; - const initialDisabledState = MynahUITabsStore.getInstance().getTabDataStore(this._props.tabId).getValue('promptInputDisabledState') as boolean; + const initialDisabledState = MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).getValue('promptInputDisabledState') as boolean; - this._render = new Button({ + this.render = new Button({ classNames: [ 'mynah-icon-button', 'mynah-chat-prompt-button' ], attributes: { ...(initialDisabledState ? { disabled: 'disabled' } : {}), tabindex: '5' }, icon: new Icon({ icon: MynahIcons.ENVELOPE_SEND }).render, + primary: false, onClick: () => { - this._props.onClick(); + this.props.onClick(); }, }).render; - MynahUITabsStore.getInstance().getTabDataStore(this._props.tabId).subscribe('promptInputDisabledState', (isDisabled: boolean) => { + MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId).subscribe('promptInputDisabledState', (isDisabled: boolean) => { if (isDisabled) { this.render.setAttribute('disabled', 'disabled'); } else { @@ -36,8 +37,4 @@ export class SendButton { } }); } - - get render (): ExtendedHTMLElement { - return this._render; - } } diff --git a/src/components/collapsible-content.ts b/src/components/collapsible-content.ts index 65681442..4301c0fe 100644 --- a/src/components/collapsible-content.ts +++ b/src/components/collapsible-content.ts @@ -7,6 +7,7 @@ import { DomBuilder, DomBuilderObject, ExtendedHTMLElement } from '../helper/dom'; import { generateUID } from '../helper/guid'; import { Icon, MynahIcons } from './icon'; +import '../styles/components/_collapsible-content.scss'; interface CollapsibleContentProps { title: string | ExtendedHTMLElement | HTMLElement | DomBuilderObject; diff --git a/src/components/feedback-form/custom-form.ts b/src/components/feedback-form/custom-form.ts index c67045a3..0e2fd259 100644 --- a/src/components/feedback-form/custom-form.ts +++ b/src/components/feedback-form/custom-form.ts @@ -84,7 +84,6 @@ export class CustomFormWrapper { this.chatButtons = new ChatItemButtonsWrapper({ tabId: this.props.tabId, formItems: this.chatFormItems, - useButtonComponent: true, buttons: this.props.chatItem.buttons, onActionClick: (action, e) => { if (e !== undefined) { diff --git a/src/components/feedback-form/feedback-form.ts b/src/components/feedback-form/feedback-form.ts index ccb93c7f..4d63acb1 100644 --- a/src/components/feedback-form/feedback-form.ts +++ b/src/components/feedback-form/feedback-form.ts @@ -12,6 +12,7 @@ import { Icon, MynahIcons } from '../icon'; import { Config } from '../../helper/config'; import { Select } from '../form-items/select'; import { CustomFormWrapper } from './custom-form'; +import '../../styles/components/_feedback-form.scss'; export interface FeedbackFormProps { initPayload?: FeedbackPayload; @@ -100,6 +101,7 @@ export class FeedbackForm { this.feedbackSubmitButton = new Button({ label: Config.getInstance().config.texts.submit, + primary: true, onClick: () => { this.onFeedbackSet(this.feedbackPayload); this.close(); diff --git a/src/components/form-items/radio-group.ts b/src/components/form-items/radio-group.ts index 89a6e0ec..571aaed8 100644 --- a/src/components/form-items/radio-group.ts +++ b/src/components/form-items/radio-group.ts @@ -3,10 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Config } from '../../helper/config'; import { DomBuilder, DomBuilderObject, ExtendedHTMLElement } from '../../helper/dom'; import { cancelEvent } from '../../helper/events'; import { generateUID } from '../../helper/guid'; import { Icon, MynahIcons } from '../icon'; +import '../../styles/components/_form-input.scss'; interface SelectOption { value: string; @@ -22,11 +24,19 @@ export interface RadioGroupProps { options?: SelectOption[]; onChange?: (value: string) => void; } -export class RadioGroup { + +export abstract class RadioGroupAbstract { + render: ExtendedHTMLElement; + setValue = (value: string): void => {}; + getValue = (): string => ''; + setEnabled = (enabled: boolean): void => {}; +} +export class RadioGroupInternal extends RadioGroupAbstract { private readonly radioGroupElement: ExtendedHTMLElement; private readonly groupName: string = generateUID(); render: ExtendedHTMLElement; constructor (props: RadioGroupProps) { + super(); this.radioGroupElement = DomBuilder.getInstance().build({ type: 'div', classNames: [ 'mynah-form-input', 'no-border', ...(props.classNames ?? []) ], @@ -115,3 +125,16 @@ export class RadioGroup { } }; } + +export class RadioGroup extends RadioGroupAbstract { + render: ExtendedHTMLElement; + + constructor (props: RadioGroupProps) { + super(); + return new (Config.getInstance().config.componentClasses.RadioGroup ?? RadioGroupInternal)(props); + } + + setValue = (value: string): void => {}; + getValue = (): string => ''; + setEnabled = (enabled: boolean): void => {}; +} diff --git a/src/components/form-items/select.ts b/src/components/form-items/select.ts index d0391b42..c8c7540c 100644 --- a/src/components/form-items/select.ts +++ b/src/components/form-items/select.ts @@ -6,6 +6,7 @@ import { Config } from '../../helper/config'; import { DomBuilder, DomBuilderObject, ExtendedHTMLElement } from '../../helper/dom'; import { Icon, MynahIcons } from '../icon'; +import '../../styles/components/_form-input.scss'; interface SelectOption { value: string; @@ -20,9 +21,18 @@ export interface SelectProps { value?: string; optional?: boolean; options?: SelectOption[]; + placeholder?: string; onChange?: (value: string) => void; } -export class Select { + +export abstract class SelectAbstract { + render: ExtendedHTMLElement; + setValue = (value: string): void => {}; + getValue = (): string => ''; + setEnabled = (enabled: boolean): void => {}; +} + +export class SelectInternal { private readonly selectElement: ExtendedHTMLElement; render: ExtendedHTMLElement; constructor (props: SelectProps) { @@ -39,7 +49,7 @@ export class Select { children: [ ...(props.optional === true ? [ { - label: Config.getInstance().config.texts.pleaseSelect, + label: props.placeholder ?? '...', value: '' } ] : []), ...props.options ?? [] ].map(option => ({ @@ -89,3 +99,16 @@ export class Select { } }; } + +export class Select extends SelectAbstract { + render: ExtendedHTMLElement; + + constructor (props: SelectProps) { + super(); + return new (Config.getInstance().config.componentClasses.Select ?? SelectInternal)(props); + } + + setValue = (value: string): void => {}; + getValue = (): string => ''; + setEnabled = (enabled: boolean): void => {}; +} diff --git a/src/components/form-items/stars.ts b/src/components/form-items/stars.ts index 2706de70..74c900e7 100644 --- a/src/components/form-items/stars.ts +++ b/src/components/form-items/stars.ts @@ -5,6 +5,7 @@ import { DomBuilder, ExtendedHTMLElement } from '../../helper/dom'; import { Icon, MynahIcons } from '../icon'; +import '../../styles/components/_form-input.scss'; export type StarValues = 1 | 2 | 3 | 4 | 5; export interface StarsProps { diff --git a/src/components/form-items/text-area.ts b/src/components/form-items/text-area.ts index 6f15c3fe..c4a073b8 100644 --- a/src/components/form-items/text-area.ts +++ b/src/components/form-items/text-area.ts @@ -3,7 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Config } from '../../helper/config'; import { DomBuilder, ExtendedHTMLElement } from '../../helper/dom'; +import '../../styles/components/_form-input.scss'; export interface TextAreaProps { classNames?: string[]; @@ -13,10 +15,18 @@ export interface TextAreaProps { value?: string; onChange?: (value: string) => void; } -export class TextArea { + +export abstract class TextAreaAbstract { + render: ExtendedHTMLElement; + setValue = (value: string): void => {}; + getValue = (): string => ''; + setEnabled = (enabled: boolean): void => {}; +} +export class TextAreaInternal extends TextAreaAbstract { private readonly inputElement: ExtendedHTMLElement; render: ExtendedHTMLElement; constructor (props: TextAreaProps) { + super(); this.inputElement = DomBuilder.getInstance().build({ type: 'textarea', classNames: [ 'mynah-form-input', ...(props.classNames ?? []) ], @@ -71,3 +81,16 @@ export class TextArea { } }; } + +export class TextArea extends TextAreaAbstract { + render: ExtendedHTMLElement; + + constructor (props: TextAreaProps) { + super(); + return new (Config.getInstance().config.componentClasses.TextArea ?? TextAreaInternal)(props); + } + + setValue = (value: string): void => {}; + getValue = (): string => ''; + setEnabled = (enabled: boolean): void => {}; +} diff --git a/src/components/form-items/text-input.ts b/src/components/form-items/text-input.ts index 6889ab70..9c825bb9 100644 --- a/src/components/form-items/text-input.ts +++ b/src/components/form-items/text-input.ts @@ -3,26 +3,37 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Config } from '../../helper/config'; import { DomBuilder, ExtendedHTMLElement } from '../../helper/dom'; +import '../../styles/components/_form-input.scss'; export interface TextInputProps { classNames?: string[]; attributes?: Record; label?: HTMLElement | ExtendedHTMLElement | string; placeholder?: string; - numeric?: boolean; + type?: 'text' | 'number' | 'email'; value?: string; onChange?: (value: string) => void; } -export class TextInput { + +export abstract class TextInputAbstract { + render: ExtendedHTMLElement; + setValue = (value: string): void => {}; + getValue = (): string => ''; + setEnabled = (enabled: boolean): void => {}; +} + +export class TextInputInternal extends TextInputAbstract { private readonly inputElement: ExtendedHTMLElement; render: ExtendedHTMLElement; constructor (props: TextInputProps) { + super(); this.inputElement = DomBuilder.getInstance().build({ type: 'input', classNames: [ 'mynah-form-input', ...(props.classNames ?? []) ], attributes: { - type: props.numeric === true ? 'number' : 'text', + type: props.type ?? 'text', ...(props.placeholder !== undefined ? { placeholder: props.placeholder @@ -75,3 +86,16 @@ export class TextInput { } }; } + +export class TextInput extends TextInputAbstract { + render: ExtendedHTMLElement; + + constructor (props: TextInputProps) { + super(); + return new (Config.getInstance().config.componentClasses.TextInput ?? TextInputInternal)(props); + } + + setValue = (value: string): void => {}; + getValue = (): string => ''; + setEnabled = (enabled: boolean): void => {}; +} diff --git a/src/components/icon.ts b/src/components/icon.ts index 4aafcf75..51ec1413 100644 --- a/src/components/icon.ts +++ b/src/components/icon.ts @@ -5,6 +5,7 @@ import { DomBuilder, ExtendedHTMLElement } from '../helper/dom'; import { MynahUIIconImporter } from './icon/icon-importer'; +import '../styles/components/_icon.scss'; export enum MynahIcons { MYNAH = 'mynah', diff --git a/src/components/navigation-tabs.ts b/src/components/navigation-tabs.ts index e5fa3880..0a21434c 100644 --- a/src/components/navigation-tabs.ts +++ b/src/components/navigation-tabs.ts @@ -15,6 +15,7 @@ import { Icon, MynahIcons } from './icon'; import { TabBarButtonsWrapper } from './navigation-tab-bar-buttons'; import { Overlay, OverlayHorizontalDirection, OverlayVerticalDirection } from './overlay'; import { Toggle, ToggleOption } from './toggle'; +import '../styles/components/_nav-tabs.scss'; export interface TabsProps { onChange?: (selectedTabId: string) => void; diff --git a/src/components/no-tabs.ts b/src/components/no-tabs.ts index 9c7bcd0a..39db0c5b 100644 --- a/src/components/no-tabs.ts +++ b/src/components/no-tabs.ts @@ -10,6 +10,7 @@ import { cancelEvent } from '../helper/events'; import { MynahUITabsStore } from '../helper/tabs-store'; import { Button } from './button'; import { Icon, MynahIcons } from './icon'; +import '../styles/components/_no-tabs.scss'; export class NoTabs { render: ExtendedHTMLElement; diff --git a/src/components/notification.ts b/src/components/notification.ts index d766fc39..0b17d3c2 100644 --- a/src/components/notification.ts +++ b/src/components/notification.ts @@ -8,6 +8,7 @@ import { cancelEvent } from '../helper/events'; import { NotificationType } from '../static'; import { Icon, MynahIcons } from './icon'; import { Overlay, OverlayHorizontalDirection, OverlayVerticalDirection, OVERLAY_MARGIN } from './overlay'; +import '../styles/components/_notification.scss'; type NotificationContentType = string | ExtendedHTMLElement | HTMLElement | DomBuilderObject; diff --git a/src/components/overlay.ts b/src/components/overlay.ts index 5c77680c..7cc01844 100644 --- a/src/components/overlay.ts +++ b/src/components/overlay.ts @@ -7,6 +7,7 @@ import { DomBuilder, DomBuilderObject, ExtendedHTMLElement } from '../helper/dom'; import { generateUID } from '../helper/guid'; import { MynahPortalNames } from '../static'; +import '../styles/components/_overlay.scss'; export const OVERLAY_MARGIN = 8; /** diff --git a/src/components/syntax-highlighter.ts b/src/components/syntax-highlighter.ts index 2c2ae64f..0ab61139 100644 --- a/src/components/syntax-highlighter.ts +++ b/src/components/syntax-highlighter.ts @@ -44,6 +44,7 @@ import { Config } from '../helper/config'; import { highlightersWithTooltip } from './card/card-body'; import escapeHTML from 'escape-html'; import unescapeHTML from 'unescape-html'; +import '../styles/components/_syntax-highlighter.scss'; const IMPORTED_LANGS = [ 'markup', @@ -132,11 +133,48 @@ export interface SyntaxHighlighterProps { export class SyntaxHighlighter { private readonly props?: SyntaxHighlighterProps; + private readonly codeBlockButtons: ExtendedHTMLElement[] = []; render: ExtendedHTMLElement; constructor (props: SyntaxHighlighterProps) { this.props = props; + if (props.showCopyOptions === true && this.props?.onInsertToCursorPosition != null) { + this.codeBlockButtons.push(new Button({ + icon: new Icon({ icon: MynahIcons.CURSOR_INSERT }).render, + label: Config.getInstance().config.texts.insertAtCursorLabel, + attributes: { title: Config.getInstance().config.texts.insertAtCursorLabel }, + primary: false, + onClick: e => { + cancelEvent(e); + const selectedCode = this.getSelectedCode(); + if (this.props?.onInsertToCursorPosition !== undefined) { + this.props.onInsertToCursorPosition( + selectedCode.type, + selectedCode.code, + this.props?.index + ); + } + }, + additionalEvents: { mousedown: cancelEvent }, + }).render); + } + + if (props.showCopyOptions === true && this.props?.onCopiedToClipboard != null) { + this.codeBlockButtons.push(new Button({ + icon: new Icon({ icon: MynahIcons.COPY }).render, + label: Config.getInstance().config.texts.copy, + attributes: { title: Config.getInstance().config.texts.copy }, + primary: false, + onClick: e => { + cancelEvent(e); + const selectedCode = this.getSelectedCode(); + this.copyToClipboard(selectedCode.code, selectedCode.type); + }, + additionalEvents: { mousedown: cancelEvent }, + }).render); + } + let codeMarkup = unescapeHTML(props.codeStringWithMarkup); // Replacing the incoming markups with keyword matching static texts if (props.keepHighlights === true) { @@ -193,59 +231,6 @@ export class SyntaxHighlighter { ...(props.block !== true ? [ 'mynah-inline-code' ] : []), ], children: [ - ...(props.showCopyOptions === true - ? [ - { - type: 'div', - classNames: [ 'mynah-syntax-highlighter-copy-buttons' ], - children: [ - ...(props.language !== undefined - ? [ { - type: 'span', - classNames: [ 'mynah-syntax-highlighter-language' ], - children: [ props.language ] - } ] - : []), - ...(this.props?.onInsertToCursorPosition != null - ? [ - new Button({ - icon: new Icon({ icon: MynahIcons.CURSOR_INSERT }).render, - label: Config.getInstance().config.texts.insertAtCursorLabel, - attributes: { title: Config.getInstance().config.texts.insertAtCursorLabel }, - primary: false, - onClick: e => { - cancelEvent(e); - const selectedCode = this.getSelectedCode(); - if (this.props?.onInsertToCursorPosition !== undefined) { - this.props.onInsertToCursorPosition( - selectedCode.type, - selectedCode.code, - this.props?.index - ); - } - }, - additionalEvents: { mousedown: cancelEvent }, - }).render - ] - : []), - ...(this.props?.onCopiedToClipboard != null - ? [ new Button({ - icon: new Icon({ icon: MynahIcons.COPY }).render, - label: Config.getInstance().config.texts.copy, - attributes: { title: Config.getInstance().config.texts.copy }, - primary: false, - onClick: e => { - cancelEvent(e); - const selectedCode = this.getSelectedCode(); - this.copyToClipboard(selectedCode.code, selectedCode.type); - }, - additionalEvents: { mousedown: cancelEvent }, - }).render ] - : []), - ], - }, - ] - : []), preElement, ...(props.showLineNumbers === true ? [ @@ -259,8 +244,28 @@ export class SyntaxHighlighter { } ] : []) - ], + ] }); + + if (this.codeBlockButtons.length > 0) { + setTimeout(() => { + this.render.insertAdjacentElement('afterbegin', DomBuilder.getInstance().build({ + type: 'div', + classNames: [ 'mynah-syntax-highlighter-copy-buttons' ], + children: [ + ...(props.language !== undefined + ? [ { + type: 'span', + classNames: [ 'mynah-syntax-highlighter-language' ], + children: [ props.language ] + } ] + : []), + ...this.codeBlockButtons + ], + })); + }, 1); + // This delay is required for broken code block renderings in JetBrainds JCEF browser. + } } private readonly getSelectedCodeContextMenu = (): { diff --git a/src/components/toggle.ts b/src/components/toggle.ts index 7a159e36..17078193 100644 --- a/src/components/toggle.ts +++ b/src/components/toggle.ts @@ -9,6 +9,7 @@ import { cancelEvent } from '../helper/events'; import { Button } from './button'; import { Icon, MynahIcons } from './icon'; import { Overlay, OverlayHorizontalDirection, OverlayVerticalDirection } from './overlay'; +import '../styles/components/_toggle.scss'; export interface ToggleOption { label?: ExtendedHTMLElement | string | HTMLElement; diff --git a/src/helper/config.ts b/src/helper/config.ts index 64d35493..98f9e65a 100644 --- a/src/helper/config.ts +++ b/src/helper/config.ts @@ -3,13 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ConfigModel, ConfigOptions, ConfigTexts } from '../static'; +import { ComponentOverrides, ConfigModel, ConfigOptions, ConfigTexts } from '../static'; interface ConfigFullModel extends ConfigOptions { texts: ConfigTexts; + componentClasses: ComponentOverrides; }; const configDefaults: ConfigFullModel = { + componentClasses: { + }, maxTabs: 1000, maxUserInput: 4096, showPromptField: true, @@ -85,6 +88,10 @@ export class Config { texts: { ...configDefaults.texts, ...config?.texts + }, + componentClasses: { + ...configDefaults.componentClasses, + ...config?.componentOverrides } }; } diff --git a/src/helper/dom.ts b/src/helper/dom.ts index 6d3d18d1..5381495f 100644 --- a/src/helper/dom.ts +++ b/src/helper/dom.ts @@ -82,12 +82,18 @@ export class DomBuilder { public static getInstance (rootSelector?: string): DomBuilder { if (!DomBuilder.instance) { - DomBuilder.instance = new DomBuilder(rootSelector !== undefined ? rootSelector : 'body'); + DomBuilder.instance = new DomBuilder(rootSelector != null ? rootSelector : 'body'); + } + if (rootSelector != null) { + DomBuilder.instance.setRoot(rootSelector); } - return DomBuilder.instance; } + setRoot = (rootSelector?: string): void => { + this.root = this.extendDomFunctionality((DS(rootSelector ?? 'body')[0] ?? document.body) as HTMLElement); + }; + addClass = function (this: ExtendedHTMLElement, className: string): ExtendedHTMLElement { if (className !== '') { this.classList.add(className); @@ -294,3 +300,62 @@ export const htmlDecode = (input: string): string => { e.innerHTML = input; return e.childNodes.length === 0 ? '' : e.childNodes[0].nodeValue ?? input; }; + +export const getTypewriterPartsCss = ( + typewriterId: string, + lastVisibleItemIndex: number, + totalNumberOfItems: number, + timeForEach: number): ExtendedHTMLElement => + DomBuilder.getInstance().build({ + type: 'style', + attributes: { + type: 'text/css' + }, + persistent: true, + innerHTML: ` + root:{ + --mynah-typewriter-bottom-pull: max(-100%, calc(-5 * var(--mynah-line-height, 1.5rem))); + } + @keyframes typewriter-${typewriterId} { + 0% { + opacity: 0; + margin-bottom: var(--mynah-typewriter-bottom-pull, -1.5rem); + visibility: visible; + } + 99% { + opacity: 1; + margin-bottom: 0px; + visibility: visible; + } + 100% { + opacity: 1; + margin-bottom: initial; + visibility: visible; + } + } + ${new Array(Math.max(0, totalNumberOfItems)) + .fill(null) + .map((n, i) => { + if (i < lastVisibleItemIndex) { + return ` + .${typewriterId} .typewriter-part[index="${i}"] { + visibility: visible !important; + opacity: 1 !important; + margin-bottom: inherit; + animation: none; + } + `; + } + return ` + .${typewriterId} .typewriter-part[index="${i}"] { + visibility: hidden; + opacity: 0; + margin-bottom: var(--mynah-typewriter-bottom-pull, -1.5rem); + animation: typewriter-${typewriterId} ${150 + timeForEach}ms ease-out forwards; + animation-delay: ${(i - lastVisibleItemIndex) * timeForEach}ms; + } + `; + }) + .join('')} + `, + }); diff --git a/src/helper/events.ts b/src/helper/events.ts index 9eafd442..fe4912b3 100644 --- a/src/helper/events.ts +++ b/src/helper/events.ts @@ -7,9 +7,9 @@ import { MynahEventNames } from '../static'; import { generateUID } from './guid'; export const cancelEvent = (event: Event): boolean => { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + event.preventDefault?.(); + event.stopPropagation?.(); + event.stopImmediatePropagation?.(); return false; }; diff --git a/src/main.ts b/src/main.ts index a63ff70a..c5b041eb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,6 +38,7 @@ import './styles/styles.scss'; import { generateUID } from './helper/guid'; import { NoTabs } from './components/no-tabs'; +export { generateUID } from './helper/guid'; export { ChatItemBodyRenderer, } from './helper/dom'; @@ -45,25 +46,42 @@ export { AllowedAttributesInCustomRenderer, AllowedTagsInCustomRenderer } from './helper/sanitize'; -export { - FeedbackPayload, - RelevancyVoteType, - EngagementType, - Engagement, - MynahUIDataModel, - NotificationType, - ChatItem, - ChatItemAction, - ChatItemType, - ChatPrompt, - SourceLink, -} from './static'; +export * from './static'; export { ToggleOption } from './components/toggle'; export { MynahIcons } from './components/icon'; +export { + DomBuilder, + DomBuilderObject, + ExtendedHTMLElement, +} from './helper/dom'; +export { + ButtonProps, + ButtonAbstract +} from './components/button'; +export { + RadioGroupProps, + RadioGroupAbstract +} from './components/form-items/radio-group'; +export { + SelectProps, + SelectAbstract +} from './components/form-items/select'; +export { + TextInputProps, + TextInputAbstract +} from './components/form-items/text-input'; +export { + TextAreaProps, + TextAreaAbstract +} from './components/form-items/text-area'; +export { + ChatItemCardContent, + ChatItemCardContentProps +} from './components/chat-item/chat-item-card-content'; export interface MynahUIProps { rootSelector?: string; diff --git a/src/static.ts b/src/static.ts index 91c1cecd..66bd6dfa 100644 --- a/src/static.ts +++ b/src/static.ts @@ -5,6 +5,18 @@ import { MynahIcons } from './components/icon'; import { ChatItemBodyRenderer } from './helper/dom'; +import { + SelectAbstract, + SelectProps, + RadioGroupAbstract, + RadioGroupProps, + ButtonAbstract, + ButtonProps, + TextInputProps, + TextInputAbstract, + TextAreaProps, + TextAreaAbstract, +} from './main'; export interface QuickActionCommand { command: string; @@ -206,7 +218,7 @@ export interface ChatItem { export interface ChatItemFormItem { id: string; - type: 'select' | 'textarea' | 'textinput' | 'numericinput' | 'stars' | 'radiogroup'; + type: 'select' | 'textarea' | 'textinput' | 'numericinput' | 'stars' | 'radiogroup' | 'email'; mandatory?: boolean; title?: string; placeholder?: string; @@ -386,6 +398,18 @@ export interface ConfigTexts { openNewTab: string; }; +type PickMatching = { + [K in keyof T as T[K] extends V ? K : never]: T[K]; +}; +type ExtractMethods = PickMatching; + +export interface ComponentOverrides { + Button?: new(props: ButtonProps) => ExtractMethods; + RadioGroup?: new(props: RadioGroupProps) => ExtractMethods; + Select?: new(props: SelectProps) => ExtractMethods; + TextInput?: new(props: TextInputProps) => ExtractMethods; + TextArea?: new(props: TextAreaProps) => ExtractMethods; +}; export interface ConfigOptions { feedbackOptions: Array<{ label: string; @@ -402,6 +426,7 @@ export interface ConfigOptions { export interface ConfigModel extends ConfigOptions { texts: Partial; + componentOverrides: Partial; } export interface CardRenderDetails { diff --git a/src/styles/_animations.scss b/src/styles/_animations.scss index cd5bbd0e..3d37504b 100644 --- a/src/styles/_animations.scss +++ b/src/styles/_animations.scss @@ -21,16 +21,3 @@ transform: rotate(360deg); } } - -@keyframes typewriter { - 0% { - visibility: visible; - opacity: 0; - max-height: 1px; - } - 100% { - opacity: 1; - max-height: 10000vh; - visibility: visible; - } -} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 2785070c..cf2ea8dc 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -1,3 +1,4 @@ +@import './mixins'; /** * Flatten a map or list into a string * @param {any} $value - The value to flatten diff --git a/src/styles/components/_button.scss b/src/styles/components/_button.scss index 9636c172..9e63935d 100644 --- a/src/styles/components/_button.scss +++ b/src/styles/components/_button.scss @@ -15,7 +15,9 @@ button.mynah-button { overflow: hidden; position: relative; transform: translate3d(0, 0, 0) scale(1.00001); - padding: 0; + padding-left: var(--mynah-sizing-3); + padding-right: var(--mynah-sizing-3); + gap: var(--mynah-sizing-2); filter: brightness(0.9); opacity: 0.85; line-height: var(--mynah-line-height); @@ -26,7 +28,7 @@ button.mynah-button { background-color: var(--mynah-card-bg); color: var(--mynah-color-text-default); } - &[disabled="disabled"] { + &[disabled='disabled'] { opacity: 0.25 !important; pointer-events: none; } @@ -36,6 +38,9 @@ button.mynah-button { box-shadow: none; opacity: 0.75; height: var(--mynah-sizing-6); + padding-left: var(--mynah-sizing-1); + padding-right: var(--mynah-sizing-1); + border-radius: 0; &:focus-visible, &:hover { opacity: 1; @@ -67,10 +72,5 @@ button.mynah-button { box-sizing: border-box; overflow: hidden; text-overflow: ellipsis; - padding-left: var(--mynah-sizing-3); - padding-right: var(--mynah-sizing-3); - } - > i + span { - padding-left: var(--mynah-sizing-base); } } diff --git a/src/styles/components/_form-input.scss b/src/styles/components/_form-input.scss index 7f76ec48..0036869a 100644 --- a/src/styles/components/_form-input.scss +++ b/src/styles/components/_form-input.scss @@ -193,7 +193,4 @@ padding-bottom: var(--mynah-sizing-1); justify-content: flex-end; align-items: center; - &.mynah-chat-item-buttons-container-use-real-buttons { - justify-content: flex-start; - } } diff --git a/src/styles/components/_main-container.scss b/src/styles/components/_main-container.scss index 95b8b60d..02395836 100644 --- a/src/styles/components/_main-container.scss +++ b/src/styles/components/_main-container.scss @@ -1,5 +1,3 @@ -$smoothbezier: cubic-bezier(0.25, 0, 0, 1); -$smoothduration: 400ms; #mynah-wrapper { display: flex; flex-flow: column nowrap; diff --git a/src/styles/components/_nav-tabs.scss b/src/styles/components/_nav-tabs.scss index 59261d8d..d072cfbf 100644 --- a/src/styles/components/_nav-tabs.scss +++ b/src/styles/components/_nav-tabs.scss @@ -63,44 +63,3 @@ } } } - -.mynah-no-tabs-wrapper { - display: flex; - flex-flow: column nowrap; - justify-content: center; - align-items: center; - gap: var(--mynah-sizing-6); - flex: 1; - &.hidden { - display: none; - } - &:not(.hidden) + .mynah-ui-tab-contents-wrapper { - display: none; - } - > .mynah-no-tabs-icon-wrapper { - > .mynah-ui-icon { - font-size: calc(2 * var(--mynah-sizing-18)); - color: var(--mynah-color-text-weak); - opacity: 0.15; - } - } - > .mynah-no-tabs-info { - > * { - margin: 1rem; - } - color: var(--mynah-color-text-weak); - font-size: var(--mynah-font-size-large); - opacity: 0.75; - text-align: center; - } - > .mynah-no-tabs-buttons-wrapper { - > .mynah-button { - padding: var(--mynah-sizing-3); - padding-right: 0; - max-height: initial; - max-width: initial; - height: auto; - width: auto; - } - } -} diff --git a/src/styles/components/_no-tabs.scss b/src/styles/components/_no-tabs.scss new file mode 100644 index 00000000..3107e943 --- /dev/null +++ b/src/styles/components/_no-tabs.scss @@ -0,0 +1,39 @@ +.mynah-no-tabs-wrapper { + display: flex; + flex-flow: column nowrap; + justify-content: center; + align-items: center; + gap: var(--mynah-sizing-6); + flex: 1; + &.hidden { + display: none; + } + &:not(.hidden) + .mynah-ui-tab-contents-wrapper { + display: none; + } + > .mynah-no-tabs-icon-wrapper { + > .mynah-ui-icon { + font-size: calc(2 * var(--mynah-sizing-18)); + color: var(--mynah-color-text-weak); + opacity: 0.15; + } + } + > .mynah-no-tabs-info { + > * { + margin: 1rem; + } + color: var(--mynah-color-text-weak); + font-size: var(--mynah-font-size-large); + opacity: 0.75; + text-align: center; + } + > .mynah-no-tabs-buttons-wrapper { + > .mynah-button { + padding: var(--mynah-sizing-2) var(--mynah-sizing-3); + max-height: initial; + max-width: initial; + height: auto; + width: auto; + } + } +} \ No newline at end of file diff --git a/src/styles/components/_syntax-highlighter.scss b/src/styles/components/_syntax-highlighter.scss index c3ec709a..bdf0f21b 100644 --- a/src/styles/components/_syntax-highlighter.scss +++ b/src/styles/components/_syntax-highlighter.scss @@ -1,3 +1,5 @@ +@import '../variables'; +@import '../mixins'; pre.diff-highlight > code .token.deleted:not(.prefix), pre > code.diff-highlight .token.deleted:not(.prefix) { background-color: rgba(255, 0, 0, 0.1); @@ -16,7 +18,6 @@ pre > code.diff-highlight .token.inserted:not(.prefix) { flex-flow: column nowrap; box-sizing: border-box; overflow: hidden; - margin: var(--mynah-sizing-1) 0; background-color: var(--mynah-card-bg); max-width: 100%; & + *:not(:empty) { diff --git a/src/styles/components/card/_card.scss b/src/styles/components/card/_card.scss index 36261f8a..e6299711 100644 --- a/src/styles/components/card/_card.scss +++ b/src/styles/components/card/_card.scss @@ -1,47 +1,59 @@ +@import '../../variables'; + .mynah-card { - text-decoration: none; - outline: none; - position: relative; - transition: var(--mynah-short-transition-rev); - box-sizing: border-box; - display: flex; - flex-flow: column nowrap; - gap: var(--mynah-sizing-3); - transform: translate3d(0, 0, 0); - flex: auto 0 0; - width: 100%; - overflow: hidden; - border-radius: var(--mynah-card-radius); - box-shadow: var(--mynah-shadow-card); - - .mynah-ui-clickable-item { - cursor: pointer; - } - - &.padding { - @each $size, $padding in $mynah-padding-sizes { - &-#{$size} { - padding: var(--mynah-sizing-#{$padding}); - @if $size == 'none' { - border-radius: 0; + text-decoration: none; + outline: none; + position: relative; + transition: var(--mynah-short-transition-rev); + box-sizing: border-box; + display: flex; + flex-flow: column nowrap; + gap: var(--mynah-sizing-3); + transform: translate3d(0, 0, 0); + flex: auto 0 0; + width: 100%; + overflow: hidden; + border-radius: var(--mynah-card-radius); + box-shadow: var(--mynah-shadow-card); + + @for $i from 0 through 10 { + > .mynah-card-inner-order-#{$i * 10} { + order: $i * 10; } - } } - } - &.background { - background-color: var(--mynah-card-bg); - } - &.border { - border: var(--mynah-border-width) solid var(--mynah-color-border-default); - } - - > * { - z-index: 10; - position: relative; - } - @import '../source-link-header'; - @import '../votes-wrapper'; + &.hide-if-empty:empty { + display: none; + } + + .mynah-ui-clickable-item { + cursor: pointer; + } + + &.padding { + @each $size, $padding in $mynah-padding-sizes { + &-#{$size} { + padding: var(--mynah-sizing-#{$padding}); + @if $size == 'none' { + border-radius: 0; + } + } + } + } + &.background { + background-color: var(--mynah-card-bg); + } + &.border { + border: var(--mynah-border-width) solid var(--mynah-color-border-default); + } + + > * { + z-index: 10; + position: relative; + } + + @import '../source-link-header'; + @import '../votes-wrapper'; } @import 'card-body'; diff --git a/src/styles/components/chat/_chat-command-selector.scss b/src/styles/components/chat/_chat-command-selector.scss index 95bcb77c..51098d7c 100644 --- a/src/styles/components/chat/_chat-command-selector.scss +++ b/src/styles/components/chat/_chat-command-selector.scss @@ -1,3 +1,4 @@ +@import '../../mixins'; .mynah-chat-command-selector { display: flex; box-sizing: border-box; diff --git a/src/styles/components/chat/_chat-item-card.scss b/src/styles/components/chat/_chat-item-card.scss index a09d27e6..958a06f3 100644 --- a/src/styles/components/chat/_chat-item-card.scss +++ b/src/styles/components/chat/_chat-item-card.scss @@ -1,3 +1,4 @@ +@import '../../variables'; .mynah-chat-item-card { display: inline-flex; flex-flow: column nowrap; @@ -44,9 +45,6 @@ &.typewriter-animating { @import "chat-items-bottom-animator"; } - .typewriter-part { - visibility: hidden; - } } .mynah-chat-item-card { diff --git a/src/styles/components/chat/_chat-item-tree-view.scss b/src/styles/components/chat/_chat-item-tree-view.scss index 28aeefa0..b442fa32 100644 --- a/src/styles/components/chat/_chat-item-tree-view.scss +++ b/src/styles/components/chat/_chat-item-tree-view.scss @@ -196,7 +196,6 @@ flex-flow: row-reverse nowrap; justify-content: flex-end; align-items: center; - gap: var(--mynah-sizing-2); font-size: 90%; z-index: 10; padding-right: var(--mynah-sizing-1); diff --git a/src/styles/components/chat/_chat-overflowing-intermediate-block.scss b/src/styles/components/chat/_chat-overflowing-intermediate-block.scss index e97cb83a..03eccbe6 100644 --- a/src/styles/components/chat/_chat-overflowing-intermediate-block.scss +++ b/src/styles/components/chat/_chat-overflowing-intermediate-block.scss @@ -1,43 +1,37 @@ &.loading { - > .mynah-chat-overflowing-intermediate-block { + > .mynah-chat-overflowing-intermediate-block { + display: flex; + flex-flow: column nowrap; + max-height: 0; + overflow: visible; + justify-content: flex-end; + &:not(.hidden) > .mynah-chat-stop-chat-response-button { + display: inline-flex; + } + } +} +> .mynah-chat-overflowing-intermediate-block { display: flex; flex-flow: column nowrap; max-height: 0; overflow: visible; justify-content: flex-end; - &:not(.hidden) > .mynah-chat-stop-chat-response-button { - display: inline-flex; - } - } -} -> .mynah-chat-overflowing-intermediate-block { - display: flex; - flex-flow: column nowrap; - max-height: 0; - overflow: visible; - justify-content: flex-end; - align-items: center; - border-bottom: 1px solid var(--mynah-color-border-default); - &.hidden > * { - display: none !important; - } - > .mynah-chat-stop-chat-response-button { - margin-bottom: var(--mynah-sizing-2); - display: none; - min-height: var(--mynah-sizing-8); - padding-left: var(--mynah-sizing-2); - border: var(--mynah-button-border-width) solid currentColor; - background-color: var(--mynah-card-bg); - &:not(:hover) { - opacity: 0.9; - filter: brightness(0.95); - } - &:active { - box-shadow: none; - filter: none; + align-items: center; + border-bottom: 1px solid var(--mynah-color-border-default); + &.hidden > * { + display: none !important; } - * { - font-size: var(--mynah-font-size-xsmall); + > .mynah-chat-stop-chat-response-button { + margin-bottom: var(--mynah-sizing-2); + display: none; + min-height: var(--mynah-sizing-8); + + &:active { + box-shadow: none; + filter: none; + } + * { + font-size: var(--mynah-font-size-xsmall); + } } - } } diff --git a/src/styles/components/chat/_chat-prompt-attachment.scss b/src/styles/components/chat/_chat-prompt-attachment.scss index a5cdb630..cc3f760c 100644 --- a/src/styles/components/chat/_chat-prompt-attachment.scss +++ b/src/styles/components/chat/_chat-prompt-attachment.scss @@ -1,3 +1,4 @@ +@import '../../mixins'; .outer-container { display: flex; &:not(:empty) { diff --git a/src/styles/components/chat/_chat-wrapper.scss b/src/styles/components/chat/_chat-wrapper.scss index 73d1bc11..804f41e4 100644 --- a/src/styles/components/chat/_chat-wrapper.scss +++ b/src/styles/components/chat/_chat-wrapper.scss @@ -1,3 +1,4 @@ +@import '../../mixins'; .mynah-chat-prompt-overlay-buttons-container { display: inline-flex; flex-flow: column nowrap; @@ -27,7 +28,7 @@ padding-right: var(--mynah-sizing-4); } &:after { - transition: all $smoothduration $smoothbezier; + transition: var(--mynah-very-short-transition); content: ""; position: absolute; top: 0; diff --git a/src/styles/styles.scss b/src/styles/styles.scss index c98c02fe..861b8934 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -3,18 +3,4 @@ @import 'animations'; @import 'dark'; @import 'favicons'; - -// Components @import './components/main-container'; -@import './components/button'; -@import './components/form-input'; -@import './components/toggle'; -@import './components/icon'; -@import './components/nav-tabs'; -@import './components/card/card'; -@import './components/feedback-form'; -@import './components/overlay'; -@import './components/notification'; -@import './components/syntax-highlighter'; -@import './components/chat/chat-wrapper'; -@import './components/collapsible-content'; diff --git a/tsconfig.json b/tsconfig.json index f336c6d7..4c29b3e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "jsx": "react", "esModuleInterop": true, "resolveJsonModule": true, "module": "CommonJS", diff --git a/webpack.config.js b/webpack.config.js index 91604849..403bcae1 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -42,7 +42,7 @@ const config = { }, { test: /\.ts$/, - exclude: [/node_modules/, /.\/example/], + exclude: [/node_modules/, /.\/example/, /.\/example-react/], use: [ { loader: 'ts-loader',