-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathBubbles.js
339 lines (314 loc) · 11.9 KB
/
Bubbles.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
// 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?
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) {
typeof window[key] === "function" ? window[key]() : false
}
_convo[key] !== undefined
? (this.reply(_convo[key]), (standingAnswer = key))
: func(key)
// 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() {
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
}