UNPKG

chat-bubble

Version:

Simple chatbot UI for Web with JSON scripting. Zero dependencies.

343 lines (317 loc) 12.6 kB
// core function function Bubbles(container, self, options) { // options options = typeof options !== "undefined" ? options : {} animationTime = options.animationTime || 200 // how long it takes to animate chat bubble, also set in CSS typeSpeed = options.typeSpeed || 5 // delay per character, to simulate the machine "typing" widerBy = options.widerBy || 2 // add a little extra width to bubbles to make sure they don't break sidePadding = options.sidePadding || 6 // padding on both sides of chat bubbles recallInteractions = options.recallInteractions || 0 // number of interactions to be remembered and brought back upon restart inputCallbackFn = options.inputCallbackFn || false // should we display an input field? responseCallbackFn = options.responseCallbackFn || false // is there a callback function for when a user clicks on a bubble button var standingAnswer = "ice" // remember where to restart convo if interrupted var _convo = {} // local memory for conversation JSON object //--> NOTE that this object is only assigned once, per session and does not change for this // constructor name during open session. // local storage for recalling conversations upon restart var localStorageCheck = function() { var test = "chat-bubble-storage-test" try { localStorage.setItem(test, test) localStorage.removeItem(test) return true } catch (error) { console.error( "Your server does not allow storing data locally. Most likely it's because you've opened this page from your hard-drive. For testing you can disable your browser's security or start a localhost environment." ) return false } } var localStorageAvailable = localStorageCheck() && recallInteractions > 0 var interactionsLS = "chat-bubble-interactions" var interactionsHistory = (localStorageAvailable && JSON.parse(localStorage.getItem(interactionsLS))) || [] // prepare next save point interactionsSave = function(say, reply) { if (!localStorageAvailable) return // limit number of saves if (interactionsHistory.length > recallInteractions) interactionsHistory.shift() // removes the oldest (first) save to make space // do not memorize buttons; only user input gets memorized: if ( // `bubble-button` class name signals that it's a button say.includes("bubble-button") && // if it is not of a type of textual reply reply !== "reply reply-freeform" && // if it is not of a type of textual reply or memorized user choice reply !== "reply reply-pick" ) // ...it shan't be memorized return // save to memory interactionsHistory.push({ say: say, reply: reply }) } // commit save to localStorage interactionsSaveCommit = function() { if (!localStorageAvailable) return localStorage.setItem(interactionsLS, JSON.stringify(interactionsHistory)) } // set up the stage container.classList.add("bubble-container") var bubbleWrap = document.createElement("div") bubbleWrap.className = "bubble-wrap" container.appendChild(bubbleWrap) // install user input textfield this.typeInput = function(callbackFn) { var inputWrap = document.createElement("div") inputWrap.className = "input-wrap" var inputText = document.createElement("textarea") inputText.setAttribute("placeholder", "Ask me anything...") inputWrap.appendChild(inputText) inputText.addEventListener("keypress", function(e) { // register user input if (e.keyCode == 13) { e.preventDefault() typeof bubbleQueue !== false ? clearTimeout(bubbleQueue) : false // allow user to interrupt the bot var lastBubble = document.querySelectorAll(".bubble.say") lastBubble = lastBubble[lastBubble.length - 1] lastBubble.classList.contains("reply") && !lastBubble.classList.contains("reply-freeform") ? lastBubble.classList.add("bubble-hidden") : false addBubble( '<span class="bubble-button bubble-pick">' + this.value + "</span>", function() {}, "reply reply-freeform" ) // callback typeof callbackFn === "function" ? callbackFn({ input: this.value, convo: _convo, standingAnswer: standingAnswer }) : false this.value = "" } }) container.appendChild(inputWrap) bubbleWrap.style.paddingBottom = "100px" inputText.focus() } inputCallbackFn ? this.typeInput(inputCallbackFn) : false // init typing bubble var bubbleTyping = document.createElement("div") bubbleTyping.className = "bubble-typing imagine" for (dots = 0; dots < 3; dots++) { var dot = document.createElement("div") dot.className = "dot_" + dots + " dot" bubbleTyping.appendChild(dot) } bubbleWrap.appendChild(bubbleTyping) // accept JSON & create bubbles this.talk = function(convo, here) { // all further .talk() calls will append the conversation with additional blocks defined in convo parameter _convo = Object.assign(_convo, convo) // POLYFILL REQUIRED FOR OLDER BROWSERS this.reply(_convo[here]) here ? (standingAnswer = here) : false } var iceBreaker = false // this variable holds answer to whether this is the initative bot interaction or not this.reply = function(turn) { iceBreaker = typeof turn === "undefined" turn = !iceBreaker ? turn : _convo.ice questionsHTML = "" if (!turn) return if (turn.reply !== undefined) { turn.reply.reverse() for (var i = 0; i < turn.reply.length; i++) { ;(function(el, count) { questionsHTML += '<span class="bubble-button" style="animation-delay: ' + animationTime / 2 * count + 'ms" onClick="' + self + ".answer('" + el.answer + "', '" + el.question + "');this.classList.add('bubble-pick')\">" + el.question + "</span>" })(turn.reply[i], i) } } orderBubbles(turn.says, function() { bubbleTyping.classList.remove("imagine") questionsHTML !== "" ? addBubble(questionsHTML, function() {}, "reply") : bubbleTyping.classList.add("imagine") }) } // navigate "answers" this.answer = function(key, content) { var func = function(key, content) { typeof window[key] === "function" ? window[key](content) : false } _convo[key] !== undefined ? (this.reply(_convo[key]), (standingAnswer = key)) : (typeof responseCallbackFn === 'function' ? responseCallbackFn({input: key,convo: _convo,standingAnswer: standingAnswer}, key) : func(key, content)) // add re-generated user picks to the history stack if (_convo[key] !== undefined && content !== undefined) { interactionsSave( '<span class="bubble-button reply-pick">' + content + "</span>", "reply reply-pick" ) } } // api for typing bubble this.think = function() { bubbleTyping.classList.remove("imagine") this.stop = function() { bubbleTyping.classList.add("imagine") } } // "type" each message within the group var orderBubbles = function(q, callback) { var start = function() { setTimeout(function() { callback() }, animationTime) } var position = 0 for ( var nextCallback = position + q.length - 1; nextCallback >= position; nextCallback-- ) { ;(function(callback, index) { start = function() { addBubble(q[index], callback) } })(start, nextCallback) } start() } // create a bubble var bubbleQueue = false var addBubble = function(say, posted, reply, live) { reply = typeof reply !== "undefined" ? reply : "" live = typeof live !== "undefined" ? live : true // bubbles that are not "live" are not animated and displayed differently var animationTime = live ? this.animationTime : 0 var typeSpeed = live ? this.typeSpeed : 0 // create bubble element var bubble = document.createElement("div") var bubbleContent = document.createElement("span") bubble.className = "bubble imagine " + (!live ? " history " : "") + reply bubbleContent.className = "bubble-content" bubbleContent.innerHTML = say bubble.appendChild(bubbleContent) bubbleWrap.insertBefore(bubble, bubbleTyping) // answer picker styles if (reply !== "") { var bubbleButtons = bubbleContent.querySelectorAll(".bubble-button") for (var z = 0; z < bubbleButtons.length; z++) { ;(function(el) { if (!el.parentNode.parentNode.classList.contains("reply-freeform")) el.style.width = el.offsetWidth - sidePadding * 2 + widerBy + "px" })(bubbleButtons[z]) } bubble.addEventListener("click", function(e) { if (e.target.classList.contains('bubble-button')) { for (var i = 0; i < bubbleButtons.length; i++) { ;(function(el) { el.style.width = 0 + "px" el.classList.contains("bubble-pick") ? (el.style.width = "") : false el.removeAttribute("onclick") })(bubbleButtons[i]) } this.classList.add("bubble-picked") } }) } // time, size & animate wait = live ? animationTime * 2 : 0 minTypingWait = live ? animationTime * 6 : 0 if (say.length * typeSpeed > animationTime && reply == "") { wait += typeSpeed * say.length wait < minTypingWait ? (wait = minTypingWait) : false setTimeout(function() { bubbleTyping.classList.remove("imagine") }, animationTime) } live && setTimeout(function() { bubbleTyping.classList.add("imagine") }, wait - animationTime * 2) bubbleQueue = setTimeout(function() { bubble.classList.remove("imagine") var bubbleWidthCalc = bubbleContent.offsetWidth + widerBy + "px" bubble.style.width = reply == "" ? bubbleWidthCalc : "" bubble.style.width = say.includes("<img src=") ? "50%" : bubble.style.width bubble.classList.add("say") posted() // save the interaction interactionsSave(say, reply) !iceBreaker && interactionsSaveCommit() // save point // animate scrolling containerHeight = container.offsetHeight scrollDifference = bubbleWrap.scrollHeight - bubbleWrap.scrollTop scrollHop = scrollDifference / 200 var scrollBubbles = function() { for (var i = 1; i <= scrollDifference / scrollHop; i++) { ;(function() { setTimeout(function() { bubbleWrap.scrollHeight - bubbleWrap.scrollTop > containerHeight ? (bubbleWrap.scrollTop = bubbleWrap.scrollTop + scrollHop) : false }, i * 5) })() } } setTimeout(scrollBubbles, animationTime / 2) }, wait + animationTime * 2) } // recall previous interactions for (var i = 0; i < interactionsHistory.length; i++) { addBubble( interactionsHistory[i].say, function() {}, interactionsHistory[i].reply, false ) } } // below functions are specifically for WebPack-type project that work with import() // this function automatically adds all HTML and CSS necessary for chat-bubble to function function prepHTML(options) { // options var options = typeof options !== "undefined" ? options : {} var container = options.container || "chat" // id of the container HTML element var relative_path = options.relative_path || "./node_modules/chat-bubble/" // make HTML container element window[container] = document.createElement("div") window[container].setAttribute("id", container) document.body.appendChild(window[container]) // style everything var appendCSS = function(file) { var link = document.createElement("link") link.href = file link.type = "text/css" link.rel = "stylesheet" link.media = "screen,print" document.getElementsByTagName("head")[0].appendChild(link) } appendCSS(relative_path + "component/styles/input.css") appendCSS(relative_path + "component/styles/reply.css") appendCSS(relative_path + "component/styles/says.css") appendCSS(relative_path + "component/styles/setup.css") appendCSS(relative_path + "component/styles/typing.css") } // exports for es6 if (typeof exports !== "undefined") { exports.Bubbles = Bubbles exports.prepHTML = prepHTML }