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 44c2812..10315ee 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -34,6 +34,7 @@ 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; @@ -44,6 +45,7 @@ import com.vaadin.flow.data.renderer.Renderer; import com.vaadin.flow.function.SerializableSupplier; import com.vaadin.flow.shared.Registration; + import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -62,6 +64,7 @@ @NpmPackage(value = "@emotion/react", version = "11.14.0") @NpmPackage(value = "@emotion/styled", version = "11.14.0") @JsModule("./react/animated-fab.tsx") +@JsModule("./fcChatAssistantConnector.js") @Tag("animated-fab") @CssImport("./styles/chat-assistant-styles.css") public class ChatAssistant extends ReactAdapterComponent implements ClickNotifier> { @@ -126,8 +129,9 @@ private void initializeHeader() { @SuppressWarnings("unchecked") private void initializeFooter() { messageInput = new MessageInput(); - messageInput.setSizeFull(); - messageInput.getStyle().set("padding", PADDING_SMALL); + 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()); @@ -136,7 +140,7 @@ private void initializeFooter() { whoIsTyping.setClassName("chat-assistant-who-is-typing"); whoIsTyping.setVisible(false); VerticalLayout footer = new VerticalLayout(whoIsTyping, messageInput); - footer.setSizeFull(); + footer.setWidthFull(); footer.setSpacing(false); footer.setMargin(false); footer.setPadding(false); @@ -151,14 +155,15 @@ private void initializeContent(boolean markdownEnabled) { return component; })); content.setItems(messages); - content.setMinHeight("400px"); - content.setMinWidth("400px"); + 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); } private void initializeChatWindow() { @@ -169,9 +174,10 @@ private void initializeChatWindow() { chatWindow.add(resizableVL); chatWindow.setOpenOnClick(false); chatWindow.setCloseOnOutsideClick(false); - chatWindow.addOpenedChangeListener(ev->{ - minimized = !ev.isOpened(); - }); + 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(); @@ -404,5 +410,5 @@ public int getUnreadMessages() { public void setUnreadMessages(int unreadMessages) { setState("unreadMessages",unreadMessages); } - + } 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 index fb61b54..8644844 100644 --- a/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css +++ b/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css @@ -19,16 +19,19 @@ */ 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.125em -0.375em 0; + margin: 0; + padding: 10px; overflow: hidden; - width: 439px; resize: both; - min-height: min-content; - min-width: min-content; + 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 { - min-height: min-content; + flex-grow: 1; + gap: var(--lumo-space-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 @@ -37,4 +40,36 @@ vaadin-vertical-layout.chat-assistant-container-vertical-layout { .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/resources/frontend/fcChatAssistantConnector.js b/src/main/resources/META-INF/resources/frontend/fcChatAssistantConnector.js new file mode 100644 index 0000000..7f4b888 --- /dev/null +++ b/src/main/resources/META-INF/resources/frontend/fcChatAssistantConnector.js @@ -0,0 +1,178 @@ +/*- + * #%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'; + } + }, + } +})();