Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: refactor smooth scrolling, handle drupal messages display #79

Merged
merged 3 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions modules/ocha_ai_chat/components/chat-form/chat-form.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
:root {
--oaic-outline: 2px; /* CD dependency */
--oaic-textarea-height: 76px;
--oaic-padding-block-start: 0px;
--oaic-chat-container-padding: 200%;
}

/**
Expand Down Expand Up @@ -50,6 +50,15 @@
height: calc(100% - 1.5rem - 24px - 7px); /* TODO: 7px is a magic number */
}

.ocha-ai-chat-popup .ocha-ai-chat-chat-form-wrapper {
display: flex;
flex-direction: column;
align-content: start;
}
.ocha-ai-chat-popup .ocha-ai-chat-chat-form-wrapper .ocha-ai-chat-chat-form {
overflow-y: hidden;
}

/**
* Title / Heading area
*/
Expand All @@ -74,11 +83,11 @@
flex-flow: column nowrap;
justify-content: space-between;
height: 100%;
padding-block-start: 1rem;
}

.ocha-ai-chat-chat-form [data-drupal-selector="edit-advanced"] {
.ocha-ai-chat-chat-form .form-wrapper[data-drupal-selector="edit-advanced"] {
flex: 0 0 auto;
margin: 0.5rem;
}

/* This element provides the top-level layout for the chat content. */
Expand All @@ -94,11 +103,16 @@
overflow-y: scroll;
flex-flow: column nowrap;
height: 100%;
padding-block-start: var(--oaic-padding-block-start);
padding-inline: 16px;
padding-block-end: 16px;
}

.ocha-ai-chat-chat-form [data-drupal-selector="edit-chat"] > .fieldset-wrapper::before {
display: block;
flex: 0 0 var(--oaic-chat-container-padding);
content: "";
}

/* The instructions margins provide crucial styles to bottom-align content so
they are included in this section instead of general styling. Auto-margin only
has the intended effect if the parent is flex with flex-direction:column */
Expand Down Expand Up @@ -552,3 +566,19 @@ button doesn't overlay any text */
left: 21px;
}
}

/* Drupal message. */
.ocha-ai-chat-popup .ocha-ai-chat-chat-form-wrapper .messages-list {
z-index: 10;
padding: 0.5rem 0.5rem 0.5rem 1rem;
background: white;
}
.ocha-ai-chat-popup .ocha-ai-chat-chat-form-wrapper .messages-list .messages {
margin: 0;
}
.ocha-ai-chat-popup .ocha-ai-chat-chat-form-wrapper .messages-list .messages + .messages {
margin-top: 1rem;
}
.ocha-ai-chat-popup .ocha-ai-chat-chat-form-wrapper .messages-list:has(+ .ocha-ai-chat-chat-form) {
box-shadow: 0 0 8px 4px rgba(0,0,0,0.5);
}
173 changes: 114 additions & 59 deletions modules/ocha_ai_chat/components/chat-form/chat-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,123 @@
(function () {
'use strict';

// Some data needs to survive between executions of Drupal's `attach` method
// so we instantiate it outside the Behavior itself.
var oldScrollHeight;

// Initialize. We do this outside Drupal.behaviors because it doesn't need to
// run each time ajax gets called.
window.parent.postMessage('ready', window.origin);

Drupal.behaviors.ochaAiChatForm = {
attach: function (context, settings) {
const createElement = Drupal.behaviors.ochaAiChatUtils.createElement;
const chatContainerSelector = '[data-drupal-selector="edit-chat"] > .fieldset-wrapper';

// Observe elements added to the form wrapper's parent to smooth scroll
// when the chat container is updated (ex: new question or answer).
once('ocha-ai-chat-form', '.ocha-ai-chat-chat-form-wrapper', context).forEach(element => {
const parent = element.parentNode;

// Check if the chat container is a child of an element in the given
// node list.
const hasChatContainer = (nodeList) => {
for (let node of nodeList) {
if (node.querySelector(chatContainerSelector)) {
return true;
}
}
};

// Check if the given node is the chat container.
const isChatContainer = (node) => {
return node.classList.contains('fieldset-wrapper') &&
node.parentNode.getAttribute('data-drupal-selector') === 'edit-chat';
};

// Scroll "smoohtly" to the bottom of the chat container when content
// is added for example.
const scrollChatContainer = (scrollToPrevious) => {
const chatContainer = document.querySelector(chatContainerSelector);

// There is some blank padding initially in the chat container to
// allow the smooth scrolling effect.
//
// If there is content, adjust the height of the `:before` pseudo
// element so that there is less scrollable blank area when the chat
// container is populated with real content.
if (chatContainer.firstElementChild) {
const sh = chatContainer.scrollHeight;
const ch = chatContainer.clientHeight;
const ot = chatContainer.firstElementChild.offsetTop;
// The initial height of the pseudo element is 200% (equivalent of
// 2 * client height.
const cp = Math.max((2 * ch) - (sh - ot), 0);

chatContainer.style.setProperty('--oaic-chat-container-padding', cp + 'px');
}

// Scroll to the previous position directly so the smooth scrolling
// doesn't start from the top of the chat container.
if (scrollToPrevious) {
const top = chatContainer.lastElementChild.offsetTop;
chatContainer.scrollTo({top: top, behavior: 'instant'});
}

// Delay a bit the smooth scrolling to give a less instant effect.
setTimeout(() => {
chatContainer.scrollTo({top: chatContainer.scrollHeight, behavior: 'smooth'});
}, 100);
};

// Mutation observer callback to determine if we should scroll to the
// bottom of the chat container.
const scrollObserverCallback = (mutationList, observer) => {
let scroll = false;
let scrollToPrevious = false;

for (const mutation of mutationList) {
if (!scroll && mutation.addedNodes.length > 0) {
// This is triggered when the question is added to the existing
// chat container.
if (isChatContainer(mutation.target)) {
scroll = true;
scrollToPrevious = false;
}
// This is triggered when the chat is recreated in which case
// the new chat container was added.
else if (mutation.target === parent && hasChatContainer(mutation.addedNodes)) {
scroll = true;
scrollToPrevious = true;
}
}
}

if (scroll) {
scrollChatContainer(scrollToPrevious);
}
};

// Observe DOM changes to the chat form.
const scrollObserver = new MutationObserver(scrollObserverCallback);
scrollObserver.observe(parent, {childList: true, subtree: true});

// Scroll to the bottom of the chat when the window is resized.
const resizeCallback = (elements) => {
console.log('resize');
scrollChatContainer(false);
};

// Observe resizing events of the chat form and scroll ot the bottom
// of the chat when that happens.
//
// There is an initial resizing when this behavior is attached. This
// allows to reveal the chat instructions smoothly as if given by the
// bot.
const resizeObserver = new ResizeObserver(resizeCallback);
resizeObserver.observe(parent);
});

// Handle chat interaction: sending, copying, rating.
once('ocha-ai-chat-form', '[data-drupal-selector="edit-chat"]', context).forEach(element => {
var chatContainer = document.querySelector('[data-drupal-selector="edit-chat"] .fieldset-wrapper');
var submitButton = document.querySelector('[data-drupal-selector="edit-submit"]');
var questionTextarea = document.querySelector('[data-drupal-selector="edit-question"]');
var chatHeight = this.padChatWindow();

// Do some calculations to decide where to start our smooth scroll.
if (oldScrollHeight) {
var smoothScrollStart = oldScrollHeight - chatHeight;

// Jump to where the bottom of the previous container was before the DOM
// got updated. From there, we smooth-scroll to the bottom.
chatContainer.scrollTo({top: smoothScrollStart, behavior: 'instant'});
chatContainer.scrollTo({top: chatContainer.scrollHeight, behavior: 'smooth'});
}
else {
chatContainer.scrollTo({top: chatContainer.scrollHeight, behavior: 'smooth'});
}

/**
* Chat submission.
Expand Down Expand Up @@ -57,19 +146,19 @@

// Build DOM nodes to be inserted.
var chatContainer = document.querySelector('[data-drupal-selector="edit-chat"] .fieldset-wrapper');
var chatResult = Drupal.behaviors.ochaAiChatUtils.createElement('div', {
var chatResult = createElement('div', {
'class': 'ocha-ai-chat-result'
}, {});
var questionDl = Drupal.behaviors.ochaAiChatUtils.createElement('dl', {
var questionDl = createElement('dl', {
'class': 'chat'
}, {});
var questionWrapper = Drupal.behaviors.ochaAiChatUtils.createElement('div', {
var questionWrapper = createElement('div', {
'class': 'chat__q chat__q--loading'
}, {});
var questionDt = Drupal.behaviors.ochaAiChatUtils.createElement('dt', {
var questionDt = createElement('dt', {
'class': 'visually-hidden'
}, 'Question');
var questionDd = Drupal.behaviors.ochaAiChatUtils.createElement('dd', {}, questionValue);
var questionDd = createElement('dd', {}, questionValue);

// Prep all the DOM nodes for insertion.
questionWrapper.append(questionDt);
Expand All @@ -81,16 +170,6 @@
setTimeout(() => {
chatContainer.append(chatResult);

// In this instance we use smooth scrolling. It won't be smooth
// unless the continer can be scrolled to begin with, but if padding
// was able to be added when the window opened, then it should work
// from the very beginning of the chat history.
chatContainer.scrollTo({top: chatContainer.scrollHeight, behavior: 'smooth'});

// Store the scroll position so that we can attempt to smooth-scroll
// when the form reloads with new data attached.
oldScrollHeight = chatContainer.scrollHeight;

// Remove old question from textarea.
document.querySelector('[data-drupal-selector="edit-question"]').value = '';
}, 200);
Expand Down Expand Up @@ -137,33 +216,9 @@

// Initialize the button to toggle detailed feedback.
this.toggleFeedback();

// Listen for window resizes and recalculate the amount of padding needed
// within the chat history.
window.addEventListener('resize', Drupal.debounce(this.padChatWindow, 33));
});
},

/**
* Pad chat window
*
* Calculates the size of the chat window and adds padding to ensure there
* is always a scrollable area. This allows the smooth-scroll code to create
* the illusion of a chat UI like SMS or WhatsApp.
*
* @return int height of chat container minus some padding
*/
padChatWindow: function (ev) {
var chatContainerOuter = document.querySelector('[data-drupal-selector="edit-chat"]');
var chatContainer = document.querySelector('[data-drupal-selector="edit-chat"] .fieldset-wrapper');

// There's some bottom padding we have to subtract away.
var chatHeight = chatContainerOuter.getBoundingClientRect().height - 16;
chatContainer.style.setProperty('--oaic-padding-block-start', chatHeight + 'px');

return chatHeight;
},

/**
* Detailed feedback toggle
*
Expand Down Expand Up @@ -307,6 +362,6 @@
}
});
});
},
}
};
})();
Loading