Assistants on the same origin share the saved position unless they have different ids, so + * set the id before the assistant is attached. The timeout has millisecond precision. + * {@link Duration#ZERO}, the default, turns the memory off; a position saved earlier is then + * ignored. + * + * @since 5.2.0 + * @param timeout how long the position outlives the page; not {@code null}, not negative and, + * unless zero, at least one millisecond + * @throws IllegalArgumentException if the timeout is negative or under one millisecond + * @throws ArithmeticException if the timeout does not fit in a {@code long} of milliseconds + */ + public void setFabPositionMemory(final Duration timeout) { + Objects.requireNonNull(timeout, "Timeout cannot be null"); + if (timeout.isNegative()) { + throw new IllegalArgumentException("Timeout cannot be negative"); + } + final long millis = timeout.toMillis(); + if (millis == 0 && !timeout.isZero()) { + throw new IllegalArgumentException("Timeout cannot be under one millisecond"); + } + this.fabPositionMemory = timeout; + if (millis == 0) { + fab.getElement().removeAttribute("position-memory"); + } else { + fab.getElement().setAttribute("position-memory", String.valueOf(millis)); + } + } + + /** + * Returns how long the FAB keeps a dragged position after the user leaves the page. + * + * @since 5.2.0 + * @see #setFabPositionMemory(Duration) + */ + public Duration getFabPositionMemory() { + return fabPositionMemory; + } + /** * Returns the FAB's configured corner. * @@ -1155,11 +1213,8 @@ protected void initializeHeader() { @SuppressWarnings("unchecked") protected void initializeFooter() { this.messageInput = new MessageInput(); - this.messageInput - .getStyle() - .setMaxHeight("80px") - .set("width", "100%") - .setPadding("0 2px"); // Account for border when focused (it will get cropped otherwise) + this.messageInput.addClassName(DEFAULT_MESSAGE_INPUT_CLASS); + this.messageInput.getStyle().set("width", "100%"); this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener( diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index d60f9f8..ce85373 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -51,10 +51,12 @@ if (!customElements.get('animated-fab')) { // Lifts the FAB wrapper to
while it is anchored to the viewport, so its position:fixed // resolves against the viewport rather than an ancestor containing block. Any ancestor with // transform/filter/backdrop-filter/perspective/contain/will-change (e.g. Aura's AppLayout navbar) -// would otherwise trap the wrapper. When it is not anchored, the wrapper is returned to its home -// slot so position:absolute stays relative to its container. Idempotent. +// would otherwise trap the wrapper. Inside a shadow root the wrapper stays in place, as the portal +// would strip its scoped styles, so such an ancestor can still trap it there. When it is not +// anchored, the wrapper is returned to its home slot so position:absolute stays relative to its +// container. Idempotent. window.fcChatAssistantPortalFab = (item, anchored) => { - if (anchored) { + if (anchored && (item.__fcHome ? item.__fcHome.parent : item.parentNode)?.getRootNode() === document) { if (item.parentNode !== document.body) { // Remember where the wrapper lived so it can be put back on teardown / un-anchor. item.__fcHome = item.__fcHome || { parent: item.parentNode, next: item.nextSibling }; @@ -115,6 +117,76 @@ function fcChatAssistantCornerPosition(item, fab, corner, margin) { } } +function fcChatAssistantClamp01(value) { + return Math.min(1, Math.max(0, Number(value) || 0)); +} + +// Remembers where the user dragged the FAB, in the tab's session storage, while the FAB has a +// position-memory timeout in milliseconds. Refreshing it on pagehide makes the timeout measure the +// time away from the page. +function fcChatAssistantPositionMemory(root, fab) { + // FABs on the same origin share the entry unless their hosts have an id + const key = 'fc-chat-assistant-fab-position' + (root.id ? ':' + root.id : ''); + const timeout = () => Number.parseFloat(fab.getAttribute('position-memory')) || 0; + // Storage may be blocked or full; that must never interrupt the FAB + const attempt = (action) => { + try { + return action(window.sessionStorage); + } catch (e) { + return null; + } + }; + // Whether this page saved or restored the entry; only then does leaving the page refresh it + let held = false; + const forget = () => { + held = false; + attempt(storage => storage.removeItem(key)); + }; + const isValid = (entry) => entry !== null && typeof entry === 'object' + && Number.isFinite(entry.x) && Number.isFinite(entry.y) + && Number.isFinite(entry.at) && entry.at <= Date.now(); + const readValid = () => { + const entry = attempt(storage => JSON.parse(storage.getItem(key))); + return isValid(entry) ? entry : null; + }; + // The entry if it is well formed and within the timeout; anything else is dropped + const read = () => { + const entry = readValid(); + if (entry && Date.now() - entry.at <= timeout()) { + return entry; + } + forget(); + return null; + }; + const write = (entry) => attempt(storage => storage.setItem(key, JSON.stringify(entry))); + return { + save(ratio) { + if (timeout() > 0) { + write({ x: ratio.x, y: ratio.y, at: Date.now() }); + held = true; + } + }, + // The timeout runs from leaving the page, so a long stay must not expire the entry here + touch() { + const entry = timeout() > 0 && held ? readValid() : null; + if (entry) { + write({ ...entry, at: Date.now() }); + } + }, + restore(ratio) { + const entry = timeout() > 0 ? read() : null; + if (!entry) { + return false; + } + ratio.x = fcChatAssistantClamp01(entry.x); + ratio.y = fcChatAssistantClamp01(entry.y); + held = true; + return true; + }, + forget, + }; +} + window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, positionRaw) => { // Prevent duplicate initialization const guard = `__fcChatAssistantMovement`; @@ -128,8 +200,10 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po 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 }; - // Expose the live position so the reset hook can move the FAB after initialization. - item.__fcPosition = position; + // Ratios run from 0 at the right/bottom edge to 1 at the left/top one, so a resize keeps the + // FAB's place + const ratio = { x: 0, y: 0 }; + const memory = fcChatAssistantPositionMemory(root, fab); let screenWidth = window.innerWidth; let screenHeight = window.innerHeight; @@ -143,12 +217,20 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po // The popover content part clamps itself to the viewport (max-height/width: 100%), so no // manual container shrinking is needed here. Just keep the FAB within the new screen bounds. - snapToBoundary(); + // Only an anchored FAB is placed against the viewport; one in a container keeps its offsets. + if (fab.hasAttribute('anchored')) { + applyRatio(); + } else { + snapToBoundary(); + } }; window.addEventListener("resize", resizeHandler); + const pageHideHandler = () => memory.touch(); + window.addEventListener("pagehide", pageHideHandler); (root.__fcCleanups = root.__fcCleanups || []).push(() => { window.removeEventListener("resize", resizeHandler); + window.removeEventListener("pagehide", pageHideHandler); window.fcChatAssistantMobileModeOff?.(root); window.fcChatAssistantScreenSizeOffAll?.(root); // Put the wrapper back in its home slot so it is removed with the detached host, not left @@ -159,7 +241,8 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po root['fc-chat-assistant-drag-listener'] = null; }); - // Escape any ancestor containing block by portaling the wrapper to while anchored. + // Escape any ancestor containing block by portaling the wrapper to while anchored, + // outside a shadow root. window.fcChatAssistantPortalFab(item, fab.hasAttribute('anchored')); // Update FAB position @@ -168,6 +251,36 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po item.style.bottom = position.y + 'px'; } + function room() { + const size = fcChatAssistantSize(fab); + return { + x: Math.max(0, screenWidth - size.width - 2 * margin), + y: Math.max(0, screenHeight - size.height - 2 * margin), + }; + } + + function syncRatio() { + const available = room(); + ratio.x = available.x > 0 ? fcChatAssistantClamp01((position.x - margin) / available.x) : 0; + ratio.y = available.y > 0 ? fcChatAssistantClamp01((position.y - margin) / available.y) : 0; + } + + function applyRatio() { + const available = room(); + position.x = margin + ratio.x * available.x; + position.y = margin + ratio.y * available.y; + updatePosition(); + } + + item.__fcMovement = { + moveTo(x, y) { + position.x = x; + position.y = y; + syncRatio(); + memory.forget(); + }, + }; + // Ensure the item stays within the screen and margin bounds function snapToBoundary() { // Get current dimensions to account for transforms @@ -234,6 +347,9 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po snapToBoundary(); if (isClickOnlyEvent()) { root.$server?.onClick(); + } else { + syncRatio(); + memory.save(ratio); } } } @@ -252,8 +368,12 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po const start = fcChatAssistantCornerPosition(item, fab, positionRaw, margin); position.x = start.x; position.y = start.y; - initialPosition.x = start.x; - initialPosition.y = start.y; + syncRatio(); + if (fab.hasAttribute('movable') && fab.hasAttribute('anchored') && memory.restore(ratio)) { + applyRatio(); + } + initialPosition.x = position.x; + initialPosition.y = position.y; updatePosition(); } if (fcChatAssistantSize(fab).width > 0) { @@ -286,11 +406,10 @@ window.fcChatAssistantResetPosition = (item, marginRaw, positionRaw) => { item.style.transition = resetTransition; item.style.right = target.x + 'px'; item.style.bottom = target.y + 'px'; - // Keep the live drag state in sync so the next drag starts from the reset position. - if (item.__fcPosition) { - item.__fcPosition.x = target.x; - item.__fcPosition.y = target.y; - } + // Keep the live drag state in sync so the next drag starts from the reset position. Before the + // movement is initialized the saved position is kept on purpose: a corner configured while the + // view is built must not discard it on every page load. + item.__fcMovement?.moveTo(target.x, target.y); }; // Removes any active media-query listener, freezing the component in its current mode. Also clears diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js index 2c9f537..4014186 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js @@ -297,7 +297,7 @@ window.fcChatAssistantResize = (root, item, container, popoverTag, sizeRaw, maxS function fetchOverlay() { if (!overlay || !overlay.isConnected) { contentPart = null; // a new overlay means the old content part is stale too - overlay = resolveOverlay(popoverTag); + overlay = resolveOverlay(popoverTag, container); if (overlay) { observeOverlayStyle(); } @@ -385,21 +385,23 @@ window.fcChatAssistantResize = (root, item, container, popoverTag, sizeRaw, maxS // Resolves the popover's overlay element across Vaadin 24 (the overlay carries the class directly) and // Vaadin 25 (the overlay lives inside the popover's shadow root). Shared by fetchOverlay() and // fcChatAssistantContentPart() so the selector chain is defined in one place. -function resolveOverlay(popoverTag) { +function resolveOverlay(popoverTag, from) { const overlayTag = "vaadin-popover-overlay".toUpperCase(); - return document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) - || [...document.getElementsByClassName(popoverTag)].find(p => p.tagName === overlayTag); + // The popover may live in a shadow root, which document queries do not reach + const scope = from?.getRootNode?.() ?? document; + return scope.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) + || [...scope.querySelectorAll(`.${popoverTag}`)].find(p => p.tagName === overlayTag); } // Resolves the popover overlay's [part='content'] element, retrying briefly because the overlay is -// (re)created lazily when the popover opens. -function fcChatAssistantContentPart(popoverTag, callback, attempts = 0) { - const overlay = resolveOverlay(popoverTag); +// (re)created lazily when the popover opens. `from` is any element of the chat, used as the lookup root. +function fcChatAssistantContentPart(popoverTag, from, callback, attempts = 0) { + const overlay = resolveOverlay(popoverTag, from); const contentPart = overlay?.shadowRoot?.querySelector('[part="content"]'); if (contentPart) { callback(contentPart); } else if (attempts < 20) { - setTimeout(() => fcChatAssistantContentPart(popoverTag, callback, attempts + 1), 100); + setTimeout(() => fcChatAssistantContentPart(popoverTag, from, callback, attempts + 1), 100); } } @@ -446,7 +448,7 @@ window.fcChatAssistantApplyConstraints = (overlayDiv, popoverTag) => { return result + 'px'; }; - fcChatAssistantContentPart(popoverTag, (contentPart) => { + fcChatAssistantContentPart(popoverTag, overlayDiv, (contentPart) => { if (minWidth) contentPart.style.minWidth = minWidth; if (minHeight) contentPart.style.minHeight = minHeight; if (maxWidth) contentPart.style.maxWidth = maxWidth; diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css index ae879c3..881e434 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css @@ -42,6 +42,12 @@ transition: all 0.12s ease-out; } +/* A long message scrolls inside the text area; capping the whole input instead pushed the text + and the send button out of it */ +.fc-chat-assistant-message-input > vaadin-text-area { + max-height: 80px; +} + /* Scoped with the add-on class so this @CssImport does not restyle every popover in the host application. The Vaadin 24 (vaadin-popover-overlay) and Vaadin 25 (vaadin-popover) selectors share one rule. */ diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java index 2ecc261..3aec361 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java @@ -42,7 +42,7 @@ public class ChatAssistantBoxDemo extends VerticalLayout { public ChatAssistantBoxDemo() { SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); + icon.setColor("currentColor"); // With fabAnchoredToViewport(false) the FAB is positioned relative to its container instead of // the viewport, so it lives inside the box below rather than floating over the whole screen. @@ -77,8 +77,8 @@ public ChatAssistantBoxDemo() { Div box = new Div(chatAssistant); box.getStyle() .set("position", "relative") - .set("border", "2px dashed var(--lumo-contrast-30pct)") - .set("border-radius", "var(--lumo-border-radius-l)") + .set("border", "2px dashed var(--vaadin-border-color, var(--lumo-contrast-30pct))") + .set("border-radius", "var(--vaadin-radius-l, var(--lumo-border-radius-l))") .setWidth("600px") .setHeight("400px"); 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 eade5fe..7cb621e 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java @@ -66,7 +66,7 @@ public ChatAssistantGenerativeDemo() { // Create the assistant with all defaults and give it a custom FAB icon. ChatAssistant