diff --git a/pom.xml b/pom.xml index 87b4183..e88be35 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.vaadin.addons.flowingcode chat-assistant-addon - 4.0.1-SNAPSHOT + 5.0.0-SNAPSHOT Chat Assistant Add-on Chat Assistant Add-on for Vaadin Flow @@ -124,11 +124,6 @@ markdown-editor-addon ${markdown-editor.version} - - com.flowingcode.vaadin - json-migration-helper - 0.9.2 - org.projectlombok lombok diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 9c025b0..98f3a60 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -17,241 +17,506 @@ * limitations under the License. * #L% */ - package com.flowingcode.vaadin.addons.chatassistant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; -import com.flowingcode.vaadin.jsonmigration.JsonMigration; -import com.vaadin.flow.component.ClickNotifier; +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.ClientCallable; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.HasElement; import com.vaadin.flow.component.Tag; import com.vaadin.flow.component.avatar.Avatar; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.dependency.JsModule; -import com.vaadin.flow.component.dependency.NpmPackage; +import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.SvgIcon; import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.messages.MessageInput; -import com.vaadin.flow.component.messages.MessageInput.SubmitEvent; import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.popover.Popover; -import com.vaadin.flow.component.react.ReactAdapterComponent; +import com.vaadin.flow.component.popover.PopoverPosition; import com.vaadin.flow.component.virtuallist.VirtualList; import com.vaadin.flow.data.provider.DataProvider; import com.vaadin.flow.data.renderer.ComponentRenderer; import com.vaadin.flow.data.renderer.Renderer; +import com.vaadin.flow.dom.Style; import com.vaadin.flow.function.SerializableSupplier; import com.vaadin.flow.shared.Registration; + +import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import lombok.experimental.ExtensionMethod; -/** - * Component that allows to create a floating chat button that will open a chat window that can be - * used to provide a chat assistant feature. - * - * @author mmlopez - */ -@SuppressWarnings("serial") -@NpmPackage(value = "react-draggable", version = "4.4.6") -@NpmPackage(value = "@mui/material", version = "7.1.2") -@NpmPackage(value = "@mui/icons-material", version = "6.1.0") -@NpmPackage(value = "@emotion/react", version = "11.14.0") -@NpmPackage(value = "@emotion/styled", version = "11.14.0") -@JsModule("./react/animated-fab.tsx") -@JsModule("./fcChatAssistantConnector.js") +@JsModule("./fc-chat-assistant-movement.js") +@JsModule("./fc-chat-assistant-resize.js") +@CssImport("./styles/fc-chat-assistant-style.css") @Tag("animated-fab") -@CssImport("./styles/chat-assistant-styles.css") -@ExtensionMethod(value = JsonMigration.class, suppressBaseMethods = true) -public class ChatAssistant extends ReactAdapterComponent implements ClickNotifier> { +public class ChatAssistant extends Div { + + protected SvgIcon fabIcon; + + protected final Button fab = new Button(); + protected final Div unreadBadge = new Div(); + protected final Div fabWrapper = new Div(fab, unreadBadge); + protected final Popover chatWindow = new Popover(); + protected final Div overlay = new Div(); + protected final VerticalLayout container = new VerticalLayout(); + + protected final Div resizerTop = new Div(); + protected final Div resizerBottom = new Div(); + protected final Div resizerTopRight = new Div(); + protected final Div resizerBottomRight = new Div(); + protected final Div resizerRight = new Div(); + protected final Div resizerLeft = new Div(); + protected final Div resizerBottomLeft = new Div(); + protected final Div resizerTopLeft = new Div(); - private static final String CHAT_HEADER_CLASS_NAME = "chat-header"; + protected static final int DEFAULT_FAB_SIZE = 60; + protected static final int DEFAULT_FAB_ICON_SIZE = 45; + protected static final int DEFAULT_FAB_MARGIN = 25; + protected static final int DEFAULT_RESIZER_SIZE = 25; + protected static final int DEFAULT_MAX_RESIZER_SIZE = 200; + protected static final int DEFAULT_DRAG_SENSITIVITY = 10; + + private static final int DEFAULT_CONTENT_MIN_WIDTH = 150; + private static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; + private static final String DEFAULT_POPOVER_TAG = "fc-chat-assistant-popover"; + private static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab"; + private static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize"; + private static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge"; private Component headerComponent; - private VerticalLayout container; private Component footerContainer; - private VirtualList content = new VirtualList<>(); - private Popover chatWindow; + private VirtualList content; private List messages; private MessageInput messageInput; private Span whoIsTyping; - private boolean minimized = true; private Registration defaultSubmitListenerRegistration; - private SerializableSupplier avatarProvider = () -> new Avatar("Chat Assistant"); - private Avatar avatar; + private int unreadMessages = 0; + + public ChatAssistant(List messages, boolean markdownEnabled) { + this.setUI(); + + this.content = new VirtualList(); + this.messages = messages; + this.initializeHeader(); + this.initializeFooter(); + this.initializeContent(markdownEnabled); + this.initializeChatWindow(); + } - /** - * Default constructor. Creates a ChatAssistant with no messages. - */ public ChatAssistant() { - this(new ArrayList<>(), false); + this(new ArrayList(), false); } - /** - * Creates a ChatAssistant with no messages. - * - * @param markdownEnabled flag to enable or disable markdown support - */ public ChatAssistant(boolean markdownEnabled) { - this(new ArrayList<>(), markdownEnabled); + this(new ArrayList(), markdownEnabled); + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + addComponentRefreshedListener( + "fc-chat-assistant-drag-listener", + "window.fcChatAssistantMovement($0, $1, $2, $3, $4, $5);", + this.getElement(), fabWrapper.getElement(), overlay, fab.getElement(), DEFAULT_FAB_MARGIN, + DEFAULT_DRAG_SENSITIVITY + + ); + chatWindow.addOpenedChangeListener(ev -> { + if (ev.isOpened()) { + addComponentRefreshedListener( + "fc-chat-assistant-resize-top-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top');", + resizerTop.getElement(), overlay, + DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE + + ); + addComponentRefreshedListener( + "fc-chat-assistant-resize-bottom-right-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom-right');", + resizerBottomRight.getElement(), overlay, + DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE + ); + addComponentRefreshedListener( + "fc-chat-assistant-resize-top-right-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top-right');", + resizerTopRight.getElement(), overlay, + DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE + ); + addComponentRefreshedListener( + "fc-chat-assistant-resize-right-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'right');", + resizerRight.getElement(), overlay, + DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE + ); + addComponentRefreshedListener( + "fc-chat-assistant-resize-bottom-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom');", + resizerBottom.getElement(), overlay, + DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE + ); + addComponentRefreshedListener( + "fc-chat-assistant-resize-left-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'left');", + resizerLeft.getElement(), overlay, + DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE + ); + addComponentRefreshedListener( + "fc-chat-assistant-resize-top-left-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top-left');", + resizerTopLeft.getElement(), overlay, + DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE + ); + addComponentRefreshedListener( + "fc-chat-assistant-resize-bottom-left-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom-left');", + resizerBottomLeft.getElement(), overlay, + DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE + ); + } + }); + } + + private void setUI() { + getStyle() + .setZIndex(1000); + + overlay.getStyle() + .setMinHeight(DEFAULT_CONTENT_MIN_HEIGHT + "px") + .setMinWidth(DEFAULT_CONTENT_MIN_WIDTH + "px"); + + fabIcon = new SvgIcon("/icons/chatbot.svg"); + fabIcon.setSize(DEFAULT_FAB_ICON_SIZE + "px"); + + fab.getStyle() + .setBorderRadius("50%") + .setMinHeight(DEFAULT_FAB_SIZE + "px") + .setMinWidth(DEFAULT_FAB_SIZE + "px") + .setHeight(DEFAULT_FAB_SIZE + "px") + .setWidth(DEFAULT_FAB_SIZE + "px") + .setMaxHeight(DEFAULT_FAB_SIZE + "px") + .setMaxWidth(DEFAULT_FAB_SIZE + "px"); + fab.setIcon(fabIcon); + fab.addClassName(DEFAULT_FAB_CLASS); + fab.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + fabWrapper.getStyle() + .setHeight(DEFAULT_FAB_SIZE + "px") + .setWidth(DEFAULT_FAB_SIZE + "px") + .setDisplay(Style.Display.INLINE_FLEX) + .setAlignItems(Style.AlignItems.CENTER) + .setJustifyContent(Style.JustifyContent.CENTER) + .setPosition(Style.Position.FIXED); + + unreadBadge.setText(String.valueOf(unreadMessages)); + unreadBadge.addClassName(DEFAULT_UNREAD_BADGE_CLASS); + unreadBadge.getStyle() + .setTextAlign(Style.TextAlign.CENTER) + .setPosition(Style.Position.ABSOLUTE) + .setJustifyContent(Style.JustifyContent.CENTER) + .setAlignItems(Style.AlignItems.CENTER) + .setDisplay(Style.Display.FLEX) + .setPadding("var(--lumo-space-xs)") + .setFontWeight(Style.FontWeight.BOLD) + .setFontSize("var(--lumo-font-size-xs)") + .setBorderRadius("50%") + .setBackgroundColor("var(--lumo-warning-color)") + .setScale("0") + .setMinHeight("var(--lumo-font-size-xs)") + .setMinWidth("var(--lumo-font-size-xs)") + .setHeight("var(--lumo-font-size-xs)") + .setWidth("var(--lumo-font-size-xs)") + .setMaxHeight("var(--lumo-font-size-xs)") + .setMaxWidth("var(--lumo-font-size-xs)") + .setTop("0") + .setRight("0") + .setColor("var(--lumo-warning-text-color)"); + + chatWindow.add(overlay); + chatWindow.setPosition(PopoverPosition.TOP); + chatWindow.addClassName(DEFAULT_POPOVER_TAG); + chatWindow.setOpenOnClick(false); + chatWindow.setTarget(fab); + + applyGenericResizerStyle(resizerTop, "top"); + resizerTop.getStyle() + .setTop("0") + .setLeft("0") + .setHeight(DEFAULT_RESIZER_SIZE + "px") + .setWidth("100%"); + + applyGenericResizerStyle(resizerBottom, "bottom"); + resizerBottom.getStyle() + .setBottom("0") + .setLeft("0") + .setHeight(DEFAULT_RESIZER_SIZE + "px") + .setWidth("100%"); + + applyGenericResizerStyle(resizerTopRight, "top-right"); + resizerTopRight.getStyle() + .setRight("0") + .setTop("0") + .setHeight(DEFAULT_RESIZER_SIZE + "px") + .setWidth(DEFAULT_RESIZER_SIZE + "px"); + + applyGenericResizerStyle(resizerBottomRight, "bottom-right"); + resizerBottomRight.getStyle() + .setBottom("0") + .setRight("0") + .setHeight(DEFAULT_RESIZER_SIZE + "px") + .setWidth(DEFAULT_RESIZER_SIZE + "px"); + + applyGenericResizerStyle(resizerRight, "right"); + resizerRight.getStyle() + .setTop("0") + .setRight("0") + .setHeight("100%") + .setWidth(DEFAULT_RESIZER_SIZE + "px"); + + applyGenericResizerStyle(resizerLeft, "left"); + resizerLeft.getStyle() + .setTop("0") + .setLeft("0") + .setHeight("100%") + .setWidth(DEFAULT_RESIZER_SIZE + "px"); + + applyGenericResizerStyle(resizerBottomLeft, "bottom-left"); + resizerBottomLeft.getStyle() + .setBottom("0") + .setLeft("0") + .setHeight(DEFAULT_RESIZER_SIZE + "px") + .setWidth(DEFAULT_RESIZER_SIZE + "px"); + + applyGenericResizerStyle(resizerTopLeft, "top-left"); + resizerTopLeft.getStyle() + .setTop("0") + .setLeft("0") + .setHeight(DEFAULT_RESIZER_SIZE + "px") + .setWidth(DEFAULT_RESIZER_SIZE + "px"); + + overlay.add( + resizerTop, resizerBottom, + resizerRight, resizerTopRight, resizerBottomRight, + resizerLeft, resizerTopLeft, resizerBottomLeft, + container + ); + add(chatWindow, fabWrapper); + } + + /** Receives click events from the client side to toggle the chat window's opened state. */ + @ClientCallable + protected void onClick() { + if(chatWindow.isOpened()) { + chatWindow.close(); + } + else { + chatWindow.open(); + } } - + + /** Applies common styles to the resizer elements based on the specified direction. */ + protected void applyGenericResizerStyle(Div resizer, String direction) { + resizer.getStyle() + .setPosition(Style.Position.ABSOLUTE) + .setDisplay(Style.Display.INLINE_BLOCK) + .setZIndex(1001); + resizer.addClassName(DEFAULT_RESIZE_CLASS + "-" + direction); + } + + /** - * Creates a ChatAssistant with the given list of messages. - * - * @param messages the list of messages - * @param markdownEnabled flag to enable or disable markdown support + * Adds a component refresh listener that prevents stacking up duplicate listeners on the client side. + * Uses a unique flag to track if the listener has already been added for this component instance, + * ensuring the callback only executes once per component refresh cycle. + * + * @param uniqueFlag a unique identifier for the component instance + * @param executable the JavaScript action to execute when the component is refreshed, + * @param parameters parameters for the executable */ - public ChatAssistant(List messages, boolean markdownEnabled) { - this.messages = messages; - initializeHeader(); - initializeFooter(); - initializeContent(markdownEnabled); - initializeChatWindow(); - initializeAvatar(); + protected void addComponentRefreshedListener(String uniqueFlag, String executable, Serializable... parameters) { + this.getElement().executeJs( + String.format( + """ + if(!this['%1$s']) { %2$s } + if(!this['%1$s']) { + this['%1$s'] = '%1$s'; + }; + """, uniqueFlag, executable), + parameters + ); + } + + /** Sets the icon for the floating action button. + *
The icon's size is automatically adjusted to fit within the FAB. */ + public void setFabIcon(Component icon) { + icon.getStyle() + .setWidth(DEFAULT_FAB_ICON_SIZE + "px") + .setHeight(DEFAULT_FAB_ICON_SIZE + "px"); + fab.setIcon(icon); + } + + /** Sets the opened state of the chat window. If true, opens the window; if false, closes it. */ + public void setOpened(boolean opened) { + if(opened) { + chatWindow.open(); + } + else { + chatWindow.close(); + } } + /** Opens the chat window. */ + public void open() { + chatWindow.open(); + } + + /** Closes the chat window. */ + public void close() { + chatWindow.close(); + } + + /** Sets the chat window minimum width. Applies when resizing. **/ + public void setWindowMinWidth(String minWidth) { + this.overlay.setMinWidth(minWidth); + } + + /** Sets the chat window minimum height. Applies when resizing. **/ + public void setWindowMinHeight(String minHeight) { + this.overlay.setMinHeight(minHeight); + } + + /** Sets the chat window maximum width. Applies when resizing. **/ + public void setWindowMaxWidth(String maxWidth) { + this.overlay.setMaxWidth(maxWidth); + } + + /** Sets the chat window maximum height. Applies when resizing. **/ + public void setWindowMaxHeight(String maxHeight) { + this.overlay.setMaxHeight(maxHeight); + } + + /** Sets the chat window default height. Applies when resizing. **/ + public void setWindowHeight(String height) { + this.overlay.setHeight(height); + } + + /** Sets the chat window default width. Applies when resizing. **/ + public void setWindowWidth(String width) { + this.overlay.setWidth(width); + } + + @SuppressWarnings("unchecked") private void initializeHeader() { Icon minimize = VaadinIcon.CLOSE.create(); - minimize.addClickListener(ev -> setMinimized(!minimized)); + minimize.addClickListener((ev) -> onClick()); Span title = new Span("Chat Assistant"); title.setWidthFull(); - HorizontalLayout header = new HorizontalLayout(title, minimize); + HorizontalLayout header = new HorizontalLayout(new Component[]{title, minimize}); header.setWidthFull(); - headerComponent = header; + this.headerComponent = header; } @SuppressWarnings("unchecked") private void initializeFooter() { - messageInput = new MessageInput(); - messageInput.setWidthFull(); - messageInput.setMaxHeight("80px"); - messageInput.getStyle().set("padding", "0"); - defaultSubmitListenerRegistration = messageInput.addSubmitListener(se -> { - sendMessage((T) Message.builder().messageTime(LocalDateTime.now()) - .name("User").content(se.getValue()).build()); - }); - whoIsTyping = new Span(); - whoIsTyping.setClassName("chat-assistant-who-is-typing"); - whoIsTyping.setVisible(false); - VerticalLayout footer = new VerticalLayout(whoIsTyping, messageInput); + this.messageInput = new MessageInput(); + this.messageInput.setWidthFull(); + this.messageInput.setMaxHeight("80px"); + this.messageInput.getStyle().set("padding", "0"); + this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener((se) -> this.sendMessage( + (T) Message.builder().messageTime( + LocalDateTime.now()).name("User").content(se.getValue()).build())); + this.whoIsTyping = new Span(); + this.whoIsTyping.setClassName("chat-assistant-who-is-typing"); + this.whoIsTyping.setVisible(false); + VerticalLayout footer = new VerticalLayout(new Component[]{this.whoIsTyping, this.messageInput}); footer.setWidthFull(); footer.setSpacing(false); footer.setMargin(false); footer.setPadding(false); - footerContainer = footer; + this.footerContainer = footer; } @SuppressWarnings("unchecked") private void initializeContent(boolean markdownEnabled) { - content.setRenderer(new ComponentRenderer<>(message -> new ChatMessage(message, markdownEnabled), - (component, message) -> { - ((ChatMessage) component).setMessage(message); - return component; - })); - content.setItems(messages); - content.setSizeFull(); - container = new VerticalLayout(headerComponent, content, footerContainer); - container.setClassName("chat-assistant-container-vertical-layout"); - container.setPadding(false); - container.setMargin(false); - container.setSpacing(false); - container.setSizeFull(); - container.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); - container.setFlexGrow(1, content); + this.content.setRenderer(new ComponentRenderer((message) -> new ChatMessage((Message) message, markdownEnabled), (component, message) -> { + ((ChatMessage)component).setMessage((Message) message); + return component; + })); + this.content.setItems(this.messages); + this.content.setSizeFull(); + this.container.add(this.headerComponent, this.content, this.footerContainer); + this.container.setPadding(true); + this.container.setMargin(false); + this.container.setSpacing(false); + this.container.setSizeFull(); + this.container.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); + this.container.setFlexGrow((double)1.0F, new HasElement[]{this.content}); } private void initializeChatWindow() { - VerticalLayout resizableVL = new VerticalLayout(); - resizableVL.setClassName("chat-assistant-resizable-vertical-layout"); - resizableVL.add(container); - chatWindow = new Popover(); - chatWindow.add(resizableVL); - chatWindow.setOpenOnClick(false); - chatWindow.setCloseOnOutsideClick(false); - chatWindow.addOpenedChangeListener(ev -> minimized = !ev.isOpened()); - chatWindow.addAttachListener(e -> e.getUI().getPage() - .executeJs("window.Vaadin.Flow.fcChatAssistantConnector.observePopoverResize($0)", chatWindow.getElement())); - - this.getElement().addEventListener("avatar-clicked", ev ->{ - if (this.minimized) { - chatWindow.open(); - } else { - chatWindow.close(); - } - }); + this.chatWindow.setOpenOnClick(false); + this.chatWindow.setCloseOnOutsideClick(false); } - private void initializeAvatar() { - if (avatar!=null) { - avatar.removeFromParent(); - } - avatar = avatarProvider.get(); - this.getElement().appendChild(avatar.getElement()); - this.addAttachListener(ev -> this.getElement().executeJs("return;") - .then(ev2 -> this.getElement().executeJs("this.childNodes[1].childNodes[0].childNodes[0].appendChild($0)", avatar.getElement()) - .then(ev3 -> { - chatWindow.setTarget(avatar); - avatar.setSizeFull(); - }))); - } - - /** - * Sets the data provider of the internal VirtualList. - * - * @param dataProvider the data provider to be used - */ + @SuppressWarnings("unchecked") public void setDataProvider(DataProvider dataProvider) { - content.setDataProvider(dataProvider); + this.content.setDataProvider(dataProvider); } /** * Uses the provided string as the text shown over the message input to indicate that someone is typing. - * + * * @param whoIsTyping string to be shown as an indication of someone typing */ public void setWhoIsTyping(String whoIsTyping) { this.whoIsTyping.setText(whoIsTyping); this.whoIsTyping.setVisible(true); } - + /** * Returns the current text shown over the message input to indicate that someone is typing. - * + * * @return the current text or null if not configured */ public String getWhoIsTyping() { return whoIsTyping.getText(); } - + /** * Clears the text shown over the message input to indicate that someone is typing. */ public void clearWhoIsTyping() { - this.whoIsTyping.setText(null); + this.whoIsTyping.setText((String)null); this.whoIsTyping.setVisible(false); } - + /** * Sets the SubmitListener that will be notified when the user submits a message on the underlying messageInput. - * + * * @param listener the listener that will be notified when the SubmitEvent is fired * @return registration for removal of the listener */ - public Registration setSubmitListener(ComponentEventListener listener) { - defaultSubmitListenerRegistration.remove(); - return messageInput.addSubmitListener(listener); + public Registration setSubmitListener(ComponentEventListener listener) { + if(this.defaultSubmitListenerRegistration != null) { + this.defaultSubmitListenerRegistration.remove(); + } + this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener(listener); + return this.defaultSubmitListenerRegistration; } private void refreshContent() { - content.getDataProvider().refreshAll(); - content.scrollToEnd(); + this.content.getDataProvider().refreshAll(); + this.content.scrollToEnd(); } /** @@ -261,14 +526,14 @@ private void refreshContent() { * @param message the message to be sent programmatically */ public void sendMessage(T message) { - messages.add(message); - content.getDataProvider().refreshAll(); - content.scrollToEnd(); + this.messages.add(message); + this.content.getDataProvider().refreshAll(); + this.content.scrollToEnd(); } - + /** * Updates a previously entered message. - * + * * @param message the message to be updated */ public void updateMessage(T message) { @@ -277,76 +542,71 @@ public void updateMessage(T message) { /** * Shows or hides chat window. - * + * * @param minimized true for hiding the chat window and false for displaying it */ public void setMinimized(boolean minimized) { - if (this.minimized != minimized) { - this.minimized = minimized; - if (!minimized) { - refreshContent(); - } - } - if (minimized && chatWindow.isOpened()) { - chatWindow.close(); - } else if (!minimized && !chatWindow.isOpened()) { - chatWindow.open(); + if (minimized && this.chatWindow.isOpened()) { + this.chatWindow.close(); + } else if (!minimized && !this.chatWindow.isOpened()) { + this.chatWindow.open(); } } - + /** * Returns the visibility of the chat window. - * + * * @return true if the chat window is minimized false otherwise */ public boolean isMinimized() { - return minimized; + return !chatWindow.isOpened(); } - + /** * Allows changing the header of the chat window. - * + * * @param component to be used as a replacement for the header */ public void setHeaderComponent(Component component) { - if (headerComponent != null) { - container.remove(headerComponent); + if (this.headerComponent != null) { + this.container.remove(new Component[]{this.headerComponent}); } - component.addClassName(CHAT_HEADER_CLASS_NAME); - headerComponent = component; - container.addComponentAsFirst(headerComponent); + + component.addClassName("chat-header"); + this.headerComponent = component; + this.container.addComponentAsFirst(this.headerComponent); } - + /** * Returns the current component configured as the header of the chat window. - * + * * @return component used as the header of the chat window */ public Component getHeaderComponent() { - return headerComponent; + return this.headerComponent; } - + /** * Allows changing the footer of the chat window. - * + * * @param component to be used as a replacement for the footer, it cannot be null */ public void setFooterComponent(Component component) { Objects.requireNonNull(component, "Component cannot not be null"); - container.remove(footerContainer); - footerContainer = component; - container.add(footerContainer); + this.container.remove(new Component[]{this.footerContainer}); + this.footerContainer = component; + this.container.add(new Component[]{this.footerContainer}); } - + /** * Returns the current component configured as the footer of the chat window. - * - * @return component used as the footer of the chat window + * + * @return component used as the footer of the chat window */ public Component getFooterComponent() { - return footerContainer; + return this.footerContainer; } - + /** * Scrolls to the given position. Scrolls so that the element is shown at * the start of the visible area whenever possible. @@ -360,21 +620,21 @@ public Component getFooterComponent() { public void scrollToIndex(int position) { this.content.scrollToIndex(position); } - + /** * Scrolls to the first element. */ public void scrollToStart() { this.content.scrollToStart(); } - + /** * Scrolls to the last element of the list. */ public void scrollToEnd() { this.content.scrollToEnd(); } - + /** * Allows changing the renderer used to display messages in the chat window. * @@ -382,26 +642,28 @@ public void scrollToEnd() { */ public void setMessagesRenderer(Renderer renderer) { Objects.requireNonNull(renderer, "Renderer cannot not be null"); - content.setRenderer(renderer); + this.content.setRenderer(renderer); } - + /** * Sets the avatar provider that will be used to create the avatar - * + * * @param avatarProvider + * @deprecated use {@link #setFabIcon(Component)} instead */ + @Deprecated(since = "5.0.0", forRemoval = true) public void setAvatarProvider(SerializableSupplier avatarProvider) { - this.avatarProvider = avatarProvider; - this.initializeAvatar(); + Objects.requireNonNull(avatarProvider, "Avatar provider cannot be null"); + Avatar avatar = avatarProvider.get(); + setFabIcon(avatar); } - /** - * Return the number of unread messages to be displayed in the chat assistant. - * @return the number of unread messages - */ + /** + * Return the number of unread messages to be displayed in the chat assistant. + * @return the number of unread messages + */ public int getUnreadMessages() { - Integer unreadMessages = getState("unreadMessages", Integer.class); - return unreadMessages==null?0:unreadMessages; + return Math.max(unreadMessages, 0); } /** @@ -409,7 +671,13 @@ public int getUnreadMessages() { * @param unreadMessages */ public void setUnreadMessages(int unreadMessages) { - setState("unreadMessages",unreadMessages); + this.unreadMessages = unreadMessages >= 0 ? Math.min(unreadMessages, 99) : 0; + unreadBadge.setText(String.valueOf(this.unreadMessages)); + if(this.unreadMessages > 0) { + unreadBadge.getStyle().setScale("1"); + } + else { + unreadBadge.getStyle().setScale("0"); + } } - } diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java index f1e4582..1fd1b7e 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java @@ -38,7 +38,7 @@ @SuppressWarnings("serial") @JsModule("@vaadin/message-list/src/vaadin-message.js") @Tag("vaadin-message") -@CssImport("./styles/chat-message-styles.css") +@CssImport("./styles/fc-chat-message-styles.css") @EqualsAndHashCode(callSuper=false) public class ChatMessage extends Component implements HasComponents { diff --git a/src/main/resources/META-INF/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/frontend/fc-chat-assistant-movement.js new file mode 100644 index 0000000..bba1ad7 --- /dev/null +++ b/src/main/resources/META-INF/frontend/fc-chat-assistant-movement.js @@ -0,0 +1,138 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensitivityRaw) => { + const margin = parseFloat(marginRaw); + const sensitivity = parseFloat(sensitivityRaw); + const sizeTransition = 'transform 0.2s ease'; + const snapTransition = 'all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)'; + const position = { x: margin, y: margin }; + const initialPosition = { x: margin, y: margin }; + + let screenWidth = window.innerWidth; + let screenHeight = window.innerHeight; + let isDragging = false; + + item.style.transition = sizeTransition; + + window.addEventListener("resize", (_) => { + screenWidth = window.innerWidth; + screenHeight = window.innerHeight; + + // Adjust container dimensions to fit within screen bounds + if (container) { + const rect = container.getBoundingClientRect(); + let widthAdjustment = 0; + let heightAdjustment = 0; + if (rect.left < 0) { + widthAdjustment = Math.abs(rect.left); + } + if (rect.right > screenWidth) { + widthAdjustment = Math.max(widthAdjustment, rect.right - screenWidth); + } + if (rect.top < 0) { + heightAdjustment = Math.abs(rect.top); + } + if (rect.bottom > screenHeight) { + heightAdjustment = Math.max(heightAdjustment, rect.bottom - screenHeight); + } + // Apply adjustments + if (widthAdjustment > 0) { + const minWidth = parseFloat(container.style.minWidth) || 0; + const newWidth = Math.max(minWidth, rect.width - widthAdjustment); + container.style.width = newWidth + 'px'; + } + if (heightAdjustment > 0) { + const minHeight = parseFloat(container.style.minHeight) || 0; + const newHeight = Math.max(minHeight, rect.height - heightAdjustment); + container.style.height = newHeight + 'px'; + } + } + + // Reposition the item to ensure it stays within the new screen bounds + snapToBoundary(); + }); + + // Update FAB position + function updatePosition() { + item.style.right = position.x + 'px'; + item.style.bottom = position.y + 'px'; + } + + // Ensure the item stays within the screen and margin bounds + function snapToBoundary() { + // Get current dimensions to account for transforms + const itemRect = fab.getBoundingClientRect(); + + const xMax = screenWidth - itemRect.width - margin; + const yMax = screenHeight - itemRect.height - margin; + const x = position.x; + const y = position.y; + if (x < margin) position.x = margin; + if (x > xMax) position.x = xMax; + if (y < margin) position.y = margin; + if (y > yMax) position.y = yMax; + updatePosition(); + } + + // Determine if the pointer event should be treated as a click (no significant movement, based on sensitivity threshold) + function isClickOnlyEvent() { + const dx = Math.abs(position.x - initialPosition.x); + const dy = Math.abs(position.y - initialPosition.y); + return dx < sensitivity && dy < sensitivity; + } + + item.addEventListener('pointerdown', (e) => { + isDragging = true; + fab.classList.add('dragging'); + item.setPointerCapture(e.pointerId); + item.style.transition = sizeTransition; + initialPosition.x = position.x; + initialPosition.y = position.y; + }); + + item.addEventListener('pointermove', (e) => { + if (!isDragging) return; + const itemRect = fab.getBoundingClientRect(); + // Calculate position from right and bottom edges + position.x = screenWidth - e.clientX - (itemRect.width / 2); + position.y = screenHeight - e.clientY - (itemRect.height / 2); + + updatePosition(); + }); + + item.addEventListener('pointerup', (e) => stopDragging(e)); + item.addEventListener('pointerleave', (e) => stopDragging(e)); + item.addEventListener('pointercancel', (e) => stopDragging(e)); + + function stopDragging(e) { + if(isDragging) { + isDragging = false; + item.style.transition = snapTransition + ', ' + sizeTransition; + fab.classList.remove('dragging'); + item.releasePointerCapture(e.pointerId); + snapToBoundary(); + if (isClickOnlyEvent()) { + root.$server?.onClick(); + } + } + } + + updatePosition(); +}; diff --git a/src/main/resources/META-INF/frontend/fc-chat-assistant-resize.js b/src/main/resources/META-INF/frontend/fc-chat-assistant-resize.js new file mode 100644 index 0000000..e7d84a2 --- /dev/null +++ b/src/main/resources/META-INF/frontend/fc-chat-assistant-resize.js @@ -0,0 +1,290 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +// Combined resize functionality for all directions +window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw, direction) => { + const size = parseFloat(sizeRaw); + const maxSize = parseFloat(maxSizeRaw); + const overlayTag = "vaadin-popover-overlay".toUpperCase(); + + let minWidth = 0; + let minHeight = 0; + let maxWidth = Infinity; + let maxHeight = Infinity; + let overlay; + let isDragging = false; + + const directionConfig = { + 'top': { + shouldDrag: () => overlay && overlay.style.bottom && !overlay.style.top, + handleResize: (e) => { + const offsetY = container.getBoundingClientRect().top - e.clientY; + const newHeight = offsetY + container.clientHeight; + if (newHeight >= minHeight && newHeight <= maxHeight) { + container.style.height = newHeight + 'px'; + } + }, + setupDrag: () => { + item.style.height = maxSize + 'px'; + item.style.marginTop = -(maxSize / 2) + 'px'; + }, + cleanupDrag: () => { + item.style.height = size + 'px'; + item.style.marginTop = ''; + } + }, + 'top-right': { + shouldDrag: () => { + const topRule = overlay && overlay.style.bottom && !overlay.style.top; + const rightRule = overlay && overlay.style.left && !overlay.style.right; + return topRule && rightRule; + }, + handleResize: (e) => { + const offsetY = container.getBoundingClientRect().top - e.clientY; + const newHeight = offsetY + container.clientHeight; + if(newHeight >= minHeight && newHeight <= maxHeight) { + container.style.height = newHeight + 'px'; + } + const offsetX = e.clientX - container.getBoundingClientRect().right; + const newWidth = offsetX + container.clientWidth; + if (newWidth >= minWidth && newWidth <= maxWidth) { + container.style.width = newWidth + 'px'; + } + }, + setupDrag: () => { + item.style.height = maxSize + 'px'; + item.style.width = maxSize + 'px'; + item.style.marginTop = -(maxSize / 2) + 'px'; + item.style.marginRight = -(maxSize / 2) + 'px'; + }, + cleanupDrag: () => { + item.style.height = size + 'px'; + item.style.width = size + 'px'; + item.style.marginTop = ''; + item.style.marginRight = ''; + } + }, + 'right': { + shouldDrag: () => overlay && overlay.style.left && !overlay.style.right, + handleResize: (e) => { + const offsetX = e.clientX - container.getBoundingClientRect().right; + const newWidth = offsetX + container.clientWidth; + if (newWidth >= minWidth && newWidth <= maxWidth) { + container.style.width = newWidth + 'px'; + } + }, + setupDrag: () => { + item.style.width = maxSize + 'px'; + item.style.marginRight = -(maxSize / 2) + 'px'; + }, + cleanupDrag: () => { + item.style.width = size + 'px'; + item.style.marginRight = ''; + } + }, + 'bottom-right': { + shouldDrag: () => { + const bottomRule = overlay && !overlay.style.bottom && overlay.style.top; + const rightRule = overlay && overlay.style.left && !overlay.style.right; + return bottomRule && rightRule; + }, + handleResize: (e) => { + const offsetY = e.clientY - container.getBoundingClientRect().bottom; + const newHeight = offsetY + container.clientHeight; + if (newHeight >= minHeight && newHeight <= maxHeight) { + container.style.height = newHeight + 'px'; + } + const offsetX = e.clientX - container.getBoundingClientRect().right; + const newWidth = offsetX + container.clientWidth; + if (newWidth >= minWidth && newWidth <= maxWidth) { + container.style.width = newWidth + 'px'; + } + }, + setupDrag: () => { + item.style.height = maxSize + 'px'; + item.style.width = maxSize + 'px'; + item.style.marginBottom = -(maxSize / 2) + 'px'; + item.style.marginRight = -(maxSize / 2) + 'px'; + }, + cleanupDrag: () => { + item.style.height = size + 'px'; + item.style.width = size + 'px'; + item.style.marginBottom = ''; + item.style.marginRight = ''; + } + }, + 'bottom': { + shouldDrag: () => overlay && !overlay.style.bottom && overlay.style.top, + handleResize: (e) => { + const offsetY = e.clientY - container.getBoundingClientRect().bottom; + const newHeight = offsetY + container.clientHeight; + if (newHeight >= minHeight && newHeight <= maxHeight) { + container.style.height = newHeight + 'px'; + } + }, + setupDrag: () => { + item.style.height = maxSize + 'px'; + item.style.marginBottom = -(maxSize / 2) + 'px'; + }, + cleanupDrag: () => { + item.style.height = size + 'px'; + item.style.marginBottom = ''; + } + }, + 'bottom-left': { + shouldDrag: () => { + const bottomRule = overlay && !overlay.style.bottom && overlay.style.top; + const leftRule = overlay && overlay.style.right && !overlay.style.left; + return bottomRule && leftRule; + }, + handleResize: (e) => { + const offsetY = e.clientY - container.getBoundingClientRect().bottom; + const newHeight = offsetY + container.clientHeight; + if(newHeight >= minHeight && newHeight <= maxHeight) { + container.style.height = newHeight + 'px'; + } + const offsetX = container.getBoundingClientRect().left - e.clientX; + const newWidth = offsetX + container.clientWidth; + if (newWidth >= minWidth && newWidth <= maxWidth) { + container.style.width = newWidth + 'px'; + } + }, + setupDrag: () => { + item.style.height = maxSize + 'px'; + item.style.width = maxSize + 'px'; + item.style.marginBottom = -(maxSize / 2) + 'px'; + item.style.marginLeft = -(maxSize / 2) + 'px'; + }, + cleanupDrag: () => { + item.style.height = size + 'px'; + item.style.width = size + 'px'; + item.style.marginBottom = ''; + item.style.marginLeft = ''; + } + }, + 'left': { + shouldDrag: () => overlay && overlay.style.right && !overlay.style.left, + handleResize: (e) => { + const offsetX = container.getBoundingClientRect().left - e.clientX; + const newWidth = offsetX + container.clientWidth; + if (newWidth >= minWidth && newWidth <= maxWidth) { + container.style.width = newWidth + 'px'; + } + }, + setupDrag: () => { + item.style.width = maxSize + 'px'; + item.style.marginLeft = -(maxSize / 2) + 'px'; + }, + cleanupDrag: () => { + item.style.width = size + 'px'; + item.style.marginLeft = ''; + } + }, + 'top-left': { + shouldDrag: () => { + const topRule = overlay && overlay.style.bottom && !overlay.style.top; + const leftRule = overlay && overlay.style.right && !overlay.style.left; + return topRule && leftRule; + }, + handleResize: (e) => { + const offsetY = container.getBoundingClientRect().top - e.clientY; + const newHeight = offsetY + container.clientHeight; + if(newHeight >= minHeight && newHeight <= maxHeight) { + container.style.height = newHeight + 'px'; + } + const offsetX = container.getBoundingClientRect().left - e.clientX; + const newWidth = offsetX + container.clientWidth; + if (newWidth >= minWidth && newWidth <= maxWidth) { + container.style.width = newWidth + 'px'; + } + }, + setupDrag: () => { + item.style.height = maxSize + 'px'; + item.style.width = maxSize + 'px'; + item.style.marginTop = -(maxSize / 2) + 'px'; + item.style.marginLeft = -(maxSize / 2) + 'px'; + }, + cleanupDrag: () => { + item.style.height = size + 'px'; + item.style.width = size + 'px'; + item.style.marginTop = ''; + item.style.marginLeft = ''; + } + } + }; + + const config = directionConfig[direction]; + if (!config) { + console.error(`Invalid direction: ${direction}. Valid directions: ${Object.keys(directionConfig).join(', ')}`); + return; + } + + window.requestAnimationFrame(fetchOverlay); + setTimeout(fetchOverlay, 2000); // in case the overlay is not available immediately, check again after 2 seconds + + // Fetch the root overlay component + function fetchOverlay() { + if (!overlay) { + overlay = document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag); + if(!overlay) { + overlay = [...document.getElementsByClassName(popoverTag)].find(p => p.tagName == overlayTag); + } + } + } + + item.addEventListener('pointerenter', (e) => { + if (config.shouldDrag()) { + item.classList.add('active'); + minHeight = container.style.minHeight ? parseFloat(container.style.minHeight) : 0; + minWidth = container.style.minWidth ? parseFloat(container.style.minWidth) : 0; + maxWidth = container.style.maxWidth ? parseFloat(container.style.maxWidth) : Infinity; + maxHeight = container.style.maxHeight ? parseFloat(container.style.maxHeight) : Infinity; + } + else { + item.classList.remove('active'); + } + }); + + item.addEventListener('pointerdown', (e) => { + isDragging = config.shouldDrag(); + if (isDragging) { + item.setPointerCapture(e.pointerId); + config.setupDrag(); + } + }); + + item.addEventListener('pointermove', (e) => { + if (!isDragging) return; + config.handleResize(e); + }); + + item.addEventListener('pointerup', (e) => stopDragging(e)); + item.addEventListener('pointerleave', (e) => stopDragging(e)); + item.addEventListener('pointercancel', (e) => stopDragging(e)); + + function stopDragging(e) { + isDragging = false; + item.classList.remove('active'); + config.cleanupDrag(); + if (item.hasPointerCapture(e.pointerId)) { + item.releasePointerCapture(e.pointerId); + } + } +}; diff --git a/src/main/resources/META-INF/frontend/react/animated-fab.tsx b/src/main/resources/META-INF/frontend/react/animated-fab.tsx deleted file mode 100644 index 4941d6e..0000000 --- a/src/main/resources/META-INF/frontend/react/animated-fab.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import {useState} from 'react'; -import Draggable from 'react-draggable'; -import Fab from '@mui/material/Fab'; -import Badge from '@mui/material/Badge'; -import { createTheme, ThemeProvider } from '@mui/material/styles'; -import { ReactAdapterElement, type RenderHooks } from 'Frontend/generated/flow/ReactAdapter'; - -const animatedFabTheme = createTheme({ - palette: { - primary: { - main: 'var(--lumo-primary-color)', - light: 'var(--lumo-primary-color-50pct)', - dark: 'var(--lumo-primary-color-20pct)', - contrastText: 'rgb(var(--lumo-primary-contrast-color))', - }, - warning: { - main: 'var(--lumo-warning-color, var(--aura-yellow))', - light: 'var(--lumo-warning-color-50pct)', - dark: 'var(--lumo-warning-color-20pct)', - contrastText: 'rgb(var(--lumo-warning-contrast-color, var(--aura-accent-color-light)))', - } - }, - components: { - MuiFab: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - '&:hover': { - backgroundColor: 'var(--lumo-primary-color-50pct)', - }, - }), - }, - }, - } - }); - - -class AnimatedFABElement extends ReactAdapterElement { - private draggableNodeRef = React.createRef(); - - protected override render(hooks: RenderHooks): ReactElement | null { - const [isDragging, setIsDragging] = useState(false); - const [unreadMessages] = hooks.useState('unreadMessages'); - const eventControl = (event: { type: any; }) => { - if (event.type === 'mousemove' || event.type === 'touchmove') { - setIsDragging(true) - } - if (event.type === 'mouseup' || event.type === 'touchend') { - setTimeout(() => { - setIsDragging(false); - }, 100); - } - } - return ( - - -
{if (!isDragging) {this.dispatchEvent(new CustomEvent('avatar-clicked'));}}} - onTouchEndCapture={(event) => {if (!isDragging) {this.dispatchEvent(new CustomEvent('avatar-clicked'));}}} - ref={this.draggableNodeRef} - style={{ - position: 'fixed', - bottom: 16, - right: 16 - }} - > - - - - -
-
-
- ); - } -} - -customElements.define('animated-fab', AnimatedFABElement); diff --git a/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css b/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css deleted file mode 100644 index 13737f8..0000000 --- a/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css +++ /dev/null @@ -1,75 +0,0 @@ -/*- - * #%L - * Chat Assistant Add-on - * %% - * Copyright (C) 2023 - 2026 Flowing Code - * %% - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * #L% - */ -vaadin-vertical-layout.chat-assistant-resizable-vertical-layout { - transform: rotate(180deg); /* This rotation, along with the one below, is a "double rotation trick." */ - margin: 0; - padding: 10px; - overflow: hidden; - resize: both; - min-height: 30vh; - min-width: 310px; - width: var(--fc-chat-assistant-popover-width, 400px); - height: var(--fc-chat-assistant-popover-height, 400px); -} - -vaadin-vertical-layout.chat-assistant-container-vertical-layout { - flex-grow: 1; - gap: var(--lumo-space-s, var(--vaadin-gap-s)); - transform: rotate(180deg); /* This second rotation completes the "double rotation trick." - Together, these two rotations position the resize handle in the upper-left corner. - This new position is more suitable for resizing the chat window because the chat bubble - is positioned by default in the bottom-right of the view. */ -} - -.MuiBadge-badge { - z-index: 2000 !important; -} - -vaadin-popover-overlay::part(content){ - padding: 0; -} - -vaadin-message::part(message) { - word-break: break-word; -} - -vaadin-popover-overlay::part(overlay) { - max-width: 100vw; /* Prevent width beyond viewport */ - max-height: 100vh; /* Prevent height beyond viewport */ -} - -/* Mobile breakpoint */ -@media (max-width: 768px) { - vaadin-popover-overlay::part(overlay) { - width: 100%; - height: 100%; - } - - vaadin-popover-overlay::part(content) { - width: 100%; - height: 100%; - } - - vaadin-vertical-layout.chat-assistant-resizable-vertical-layout { - resize: none; - height: 100%; - width: 100%; - } -} \ No newline at end of file diff --git a/src/main/resources/META-INF/frontend/styles/fc-chat-assistant-style.css b/src/main/resources/META-INF/frontend/styles/fc-chat-assistant-style.css new file mode 100644 index 0000000..8468fcc --- /dev/null +++ b/src/main/resources/META-INF/frontend/styles/fc-chat-assistant-style.css @@ -0,0 +1,54 @@ +/* + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +/* Base styles */ +.fc-chat-assistant-fab { + cursor: pointer; + transform: scale(1); + box-shadow: var(--lumo-box-shadow-m); +} + +/* Expansion state when being dragged */ +.fc-chat-assistant-fab.dragging { + cursor: grabbing; + transform: scale(1.15); + box-shadow: var(--lumo-box-shadow-xl); +} + +.fc-chat-assistant-popover { + --vaadin-popover-offset-top : 15px; + --vaadin-popover-offset-bottom : 15px; +} + +.fc-chat-assistant-unread-badge { + transition: all 0.15s ease-out; +} + +/* Specific cursors for each corner */ +.fc-chat-assistant-resize-bottom-left.active { cursor: sw-resize; } +.fc-chat-assistant-resize-bottom-right.active { cursor: se-resize; } +.fc-chat-assistant-resize-top-left.active { cursor: nw-resize; } +.fc-chat-assistant-resize-top-right.active { cursor: ne-resize; } + +/* Specific cursors for the sides */ +.fc-chat-assistant-resize-bottom.active { cursor: s-resize; } +.fc-chat-assistant-resize-top.active { cursor: n-resize; } +.fc-chat-assistant-resize-left.active { cursor: w-resize; } +.fc-chat-assistant-resize-right.active { cursor: e-resize; } diff --git a/src/main/resources/META-INF/frontend/styles/chat-message-styles.css b/src/main/resources/META-INF/frontend/styles/fc-chat-message-styles.css similarity index 99% rename from src/main/resources/META-INF/frontend/styles/chat-message-styles.css rename to src/main/resources/META-INF/frontend/styles/fc-chat-message-styles.css index fbc737c..03712b9 100644 --- a/src/main/resources/META-INF/frontend/styles/chat-message-styles.css +++ b/src/main/resources/META-INF/frontend/styles/fc-chat-message-styles.css @@ -1,4 +1,4 @@ -/*- +/* * #%L * Chat Assistant Add-on * %% @@ -101,4 +101,4 @@ .language-mermaid { padding: 0px !important; -} \ No newline at end of file +} diff --git a/src/main/resources/META-INF/resources/frontend/fcChatAssistantConnector.js b/src/main/resources/META-INF/resources/frontend/fcChatAssistantConnector.js deleted file mode 100644 index 7f4b888..0000000 --- a/src/main/resources/META-INF/resources/frontend/fcChatAssistantConnector.js +++ /dev/null @@ -1,178 +0,0 @@ -/*- - * #%L - * Chat Assistant Add-on - * %% - * Copyright (C) 2025 Flowing Code - * %% - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * #L% - */ -(function () { - window.Vaadin.Flow.fcChatAssistantConnector = { - observePopoverResize: (popover) => { - // Skip the following logic on mobile devices by checking viewport width. - if (window.innerWidth <= 768) { - return; - } - - if (popover.$connector) { - return; - } - - popover.$connector = {}; - - // Find the resizable container inside the popover - const resizableContainer = popover.querySelector('.chat-assistant-resizable-vertical-layout'); - if (!resizableContainer) return; - - popover.addEventListener('opened-changed', e => { - if (e.detail.value) { - const popoverOverlay = resizableContainer.parentElement; - const overlay = popoverOverlay.shadowRoot?.querySelector('[part="overlay"]'); - // Track overlay position changes and keep container inside viewport - trackOverlayPosition(overlay, resizableContainer, () => clampToViewport(resizableContainer)); - } - }); - - // On drag/resize start (mouse), reset size restrictions so user can freely resize - resizableContainer.addEventListener("mousedown", e => { - resizableContainer.style.maxHeight = ''; - resizableContainer.style.maxWidth = ''; - }); - // On drag/resize start (touch), reset size restrictions so user can freely resize - resizableContainer.addEventListener("touchstart", e => { - resizableContainer.style.maxHeight = ''; - resizableContainer.style.maxWidth = ''; - }); - - // Debounce calls to avoid excessive recalculations on rapid resize - const debouncedClamp = debounce(() => clampToViewport(resizableContainer)); - - new ResizeObserver(() => { - const popoverOverlay = resizableContainer.parentElement; - const overlay = popoverOverlay.shadowRoot?.querySelector('[part="overlay"]'); - if (!overlay) return; - - debouncedClamp(); - }).observe(resizableContainer); - - - function debounce(callback) { - let rafId; - return () => { - cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(callback); - }; - } - - /** - * Restricts the size and position of a resizable container so that it remains fully visible - * within the browser's viewport, applying a small padding to keep it from touching the edges. - * - * This function calculates how much space is available on each side of the container - * (top, bottom, left, right) relative to the viewport. If the container would overflow - * on a given side, it adjusts `maxWidth`/`maxHeight` and aligns it to the opposite side - * with a fixed padding. - * - * - If there isn't enough space on the right, it clamps width and aligns to the left. - * - If there isn't enough space on the left, it clamps width and aligns to the right. - * - If there isn't enough space at the bottom, it clamps height and aligns to the top. - * - If there isn't enough space at the top, it clamps height and aligns to the bottom. - * - * @param {HTMLElement} resizableContainer - The element whose size and position should be clamped to the viewport. - */ - function clampToViewport(resizableContainer) { - const boundingClientRect = resizableContainer.getBoundingClientRect(); - - const containerWidthRight = boundingClientRect.width + (window.innerWidth - boundingClientRect.right); - const containerWidthLeft = boundingClientRect.left + boundingClientRect.width; - const containerHeightBottom = boundingClientRect.height + (window.innerHeight - boundingClientRect.bottom); - const containerHeightTop = boundingClientRect.top + boundingClientRect.height; - - const padding = 5; - const paddingPx = padding + "px"; - - if (containerWidthRight >= window.innerWidth) { - resizableContainer.style.maxWidth = (boundingClientRect.right - padding) + "px"; - resizableContainer.style.left = paddingPx; - } else if (containerWidthLeft >= window.innerWidth) { - resizableContainer.style.maxWidth = (window.innerWidth - boundingClientRect.left - padding) + "px"; - resizableContainer.style.right = paddingPx; - } - - if (containerHeightBottom >= window.innerHeight) { - resizableContainer.style.maxHeight = (boundingClientRect.bottom - padding) + "px"; - resizableContainer.style.top = paddingPx; - } else if (containerHeightTop >= window.innerHeight) { - resizableContainer.style.maxHeight = (window.innerHeight - boundingClientRect.top - padding) + "px"; - resizableContainer.style.bottom = paddingPx; - } - } - - /** - * Continuously tracks the position of an overlay element and triggers a callback - * when the overlay's position has stabilized (i.e., changes are within the given buffer). - * - * This function uses `requestAnimationFrame` to check the overlay's position every frame. - * If the overlay moves more than `positionBuffer` pixels horizontally or vertically, - * tracking continues without calling the callback. - * Once the position changes are smaller than `positionBuffer`, the callback is invoked. - * - * @param {HTMLElement} overlay - The overlay element to track. Must support `.checkVisibility()`. - * @param {HTMLElement} resizableContainer - The container related to the overlay (not used directly here, - * but often used by the callback to adjust size). - * @param {Function} callback - Function to call when the overlay position is stable. - * @param {number} [positionBuffer=10] - The minimum pixel movement threshold before considering the overlay stable. - */ - function trackOverlayPosition(overlay, resizableContainer, callback, positionBuffer = 10) { - let lastTop = 0; - let lastLeft = 0; - let frameId; - - function checkPosition() { - if (!isVisible(overlay)) { - cancelAnimationFrame(frameId); - return; - } - - const rect = overlay.getBoundingClientRect(); - const deltaTop = Math.abs(rect.top - lastTop); - const deltaLeft = Math.abs(rect.left - lastLeft); - if (deltaTop > positionBuffer || deltaLeft > positionBuffer) { - lastTop = rect.top; - lastLeft = rect.left; - } else { - callback(); - } - - frameId = requestAnimationFrame(checkPosition); - } - - frameId = requestAnimationFrame(checkPosition); - } - - function isVisible(el) { - if (!el) return false; - - if (typeof el.checkVisibility === 'function') { - // Use native checkVisibility if available - return el.checkVisibility(); - } - - // Fallback: check CSS display and visibility - const style = getComputedStyle(el); - return style.display !== 'none' && style.visibility !== 'hidden'; - } - }, - } -})(); diff --git a/src/main/resources/META-INF/resources/icons/chatbot.svg b/src/main/resources/META-INF/resources/icons/chatbot.svg new file mode 100644 index 0000000..b8a0851 --- /dev/null +++ b/src/main/resources/META-INF/resources/icons/chatbot.svg @@ -0,0 +1,137 @@ + + + + + Chatbot icon + + + + + + + + + + + + + image/svg+xml + + Chatbot icon + 2018-09-30 + + + m1981 + + + + + Public domain + + + + + Inkscape + + + + + icon + flat + IT + computer + chatbot + question + deeplearning + AI + artificial + intelligence + chat + bot + + + + An AI chatbot icon. + + + + + + + diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java index 5591522..c3a4e73 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java @@ -26,6 +26,8 @@ import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.html.Image; +import com.vaadin.flow.component.icon.SvgIcon; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -43,7 +45,9 @@ public class ChatAssistantDemo extends VerticalLayout { public ChatAssistantDemo() { ChatAssistant chatAssistant = new ChatAssistant<>(); - chatAssistant.setAvatarProvider(()->new Avatar("Chat Assistant","chatbot.png")); + chatAssistant.setFabIcon(new SvgIcon("chatbot.svg")); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); @@ -54,7 +58,7 @@ public ChatAssistantDemo() { }); message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); chatAssistant.setMessagesRenderer(new ComponentRenderer(m -> { - return new CustomChatMessage(m); + return new CustomChatMessage(m); }, (component, m) -> { ((CustomChatMessage) component).setMessage(m); @@ -97,7 +101,7 @@ public void run() { chatAssistant.sendMessage(CustomMessage.builder().content("Hello, I am here to assist you") .messageTime(LocalDateTime.now()) .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build()); - + chatAssistant.setUnreadMessages(4); add(message, chat, chatWithThinking, chatAssistant); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java index aa18f3e..37352dc 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java @@ -26,6 +26,8 @@ import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.html.Image; +import com.vaadin.flow.component.icon.SvgIcon; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -52,7 +54,9 @@ public ChatAssistantGenerativeDemo() { + " the next word. I hope this is useful for your demonstration!"; ChatAssistant chatAssistant = new ChatAssistant<>(); - chatAssistant.setAvatarProvider(()->new Avatar("Chat Assistant","chatbot.png")); + chatAssistant.setFabIcon(new SvgIcon("chatbot.svg")); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java index 30fe247..961bdb2 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java @@ -27,6 +27,7 @@ import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.SvgIcon; import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; @@ -125,6 +126,9 @@ public class ChatAssistantLazyLoadingDemo extends VerticalLayout { public ChatAssistantLazyLoadingDemo() { ChatAssistant chatAssistant = new ChatAssistant<>(); chatAssistant.setClassName("small"); + chatAssistant.setFabIcon(new SvgIcon("chatbot.svg")); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); Span lazyLoadingData = new Span(); DataProvider dataProvider = DataProvider.fromCallbacks(query->{ lazyLoadingData.setText("Loading messages from: " + query.getOffset() + ", with limit: " + query.getLimit()); @@ -133,6 +137,7 @@ public ChatAssistantLazyLoadingDemo() { return messages.size(); }); chatAssistant.setDataProvider(dataProvider); + chatAssistant.setFabIcon(new SvgIcon("chatbot.svg")); TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java index 59e9c06..6160e19 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java @@ -25,6 +25,7 @@ import com.google.common.base.Strings; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.icon.SvgIcon; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.router.PageTitle; @@ -40,6 +41,9 @@ public class ChatAssistantMarkdownDemo extends VerticalLayout { public ChatAssistantMarkdownDemo() { ChatAssistant chatAssistant = new ChatAssistant<>(true); + chatAssistant.setFabIcon(new SvgIcon("chatbot.svg")); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant (try using Markdown)"); message.setSizeFull(); diff --git a/src/test/resources/META-INF/resources/chatbot.svg b/src/test/resources/META-INF/resources/chatbot.svg new file mode 100644 index 0000000..b8a0851 --- /dev/null +++ b/src/test/resources/META-INF/resources/chatbot.svg @@ -0,0 +1,137 @@ + + + + + Chatbot icon + + + + + + + + + + + + + image/svg+xml + + Chatbot icon + 2018-09-30 + + + m1981 + + + + + Public domain + + + + + Inkscape + + + + + icon + flat + IT + computer + chatbot + question + deeplearning + AI + artificial + intelligence + chat + bot + + + + An AI chatbot icon. + + + + + + +