Fossil SCM
chat: ported in the hyperlink and @username parser from the older chat.tcl script. This is an intermediary workaround until we decide how/whether to do server-side markup handling.
Commit
c5095283fb44031f95972dcc61955c766d2644e47298c145855dd1972331729b
Parent
f315268e2c7036d…
1 file changed
+79
-4
+79
-4
| --- src/chat.js | ||
| +++ src/chat.js | ||
| @@ -144,12 +144,11 @@ | ||
| 144 | 144 | file selection element. */ |
| 145 | 145 | document.onpaste = function(event){ |
| 146 | 146 | const items = event.clipboardData.items, |
| 147 | 147 | item = items[0]; |
| 148 | 148 | if(!item || !item.type) return; |
| 149 | - //console.debug("pasted item =",item); | |
| 150 | - if('file'===item.kind){ | |
| 149 | + else if('file'===item.kind){ | |
| 151 | 150 | updateDropZoneContent(false/*clear prev state*/); |
| 152 | 151 | updateDropZoneContent(items[0].getAsFile()); |
| 153 | 152 | }else if(false && 'string'===item.kind){ |
| 154 | 153 | /* ----^^^^^ disabled for now: the intent here is that if |
| 155 | 154 | form.msg is not active, populate it with this text, but |
| @@ -276,10 +275,87 @@ | ||
| 276 | 275 | const pRect = f.popup.e.getBoundingClientRect(); |
| 277 | 276 | x -= pRect.width/3*2; |
| 278 | 277 | } |
| 279 | 278 | f.popup.show(x, y); |
| 280 | 279 | }; |
| 280 | + | |
| 281 | + /** | |
| 282 | + Parses the given chat message string for hyperlinks and @NAME | |
| 283 | + references, replacing them with markup, then stuffs the parsed | |
| 284 | + content into the given target DOM element. | |
| 285 | + | |
| 286 | + This is an intermediary step until we get some basic markup | |
| 287 | + support coming from the server side. | |
| 288 | + */ | |
| 289 | + const messageToDOM = function f(str, tgtElem){ | |
| 290 | + "use strict"; | |
| 291 | + if(!f.rxUrl){ | |
| 292 | + f.rxUrl = /\b(?:https?|ftp):\/\/[a-z0-9-+&@#\/%?=~_|!:,.;]*[a-z0-9-+&@#\/%=~_|]/gim; | |
| 293 | + f.rxAt = /@\w+/gmi; | |
| 294 | + f.rxNS = /\S/; | |
| 295 | + f.ce = (T)=>document.createElement(T); | |
| 296 | + f.ct = (T)=>document.createTextNode(T); | |
| 297 | + f.replaceUrls = function ff(sub, offset, whole){ | |
| 298 | + if(offset > ff.prevStart){ | |
| 299 | + f.accum.push((ff.prevStart?' ':'')+whole.substring(ff.prevStart, offset-1)+' '); | |
| 300 | + } | |
| 301 | + const a = f.ce('a'); | |
| 302 | + a.setAttribute('href',sub); | |
| 303 | + a.setAttribute('target','_blank'); | |
| 304 | + a.appendChild(f.ct(sub)); | |
| 305 | + f.accum.push(a); | |
| 306 | + ff.prevStart = offset + sub.length + 1; | |
| 307 | + }; | |
| 308 | + f.replaceAtName = function ff(sub, offset,whole){ | |
| 309 | + if(offset > ff.prevStart){ | |
| 310 | + ff.accum.push((ff.prevStart?' ':'')+whole.substring(ff.prevStart, offset-1)+' '); | |
| 311 | + }else if(offset && f.rxNS.test(whole[offset-1])){ | |
| 312 | + // Sigh: https://stackoverflow.com/questions/52655367 | |
| 313 | + ff.accum.push(sub); | |
| 314 | + return; | |
| 315 | + } | |
| 316 | + const e = f.ce('span'); | |
| 317 | + e.classList.add('at-name'); | |
| 318 | + e.appendChild(f.ct(sub)); | |
| 319 | + ff.accum.push(e); | |
| 320 | + ff.prevStart = offset + sub.length + 1; | |
| 321 | + }; | |
| 322 | + } | |
| 323 | + f.accum = []; // accumulate strings and DOM elements here. | |
| 324 | + f.rxUrl.lastIndex = f.replaceUrls.prevStart = 0; // reset regex cursor | |
| 325 | + str.replace(f.rxUrl, f.replaceUrls); | |
| 326 | + // Push remaining non-URL part of the string to the queue... | |
| 327 | + if(f.replaceUrls.prevStart < str.length){ | |
| 328 | + f.accum.push((f.replaceUrls.prevStart?' ':'')+str.substring(f.replaceUrls.prevStart)); | |
| 329 | + } | |
| 330 | + // Pass 2: process @NAME references... | |
| 331 | + // TODO: only match NAME if it's the name of a currently participating | |
| 332 | + // user. Add a second class if NAME == current user, and style that one | |
| 333 | + // differently so that people can more easily see when they're spoken to. | |
| 334 | + const accum2 = f.replaceAtName.accum = []; | |
| 335 | + f.accum.forEach(function(v){ | |
| 336 | + if('string'===typeof v){ | |
| 337 | + f.rxAt.lastIndex = f.replaceAtName.prevStart = 0; | |
| 338 | + v.replace(f.rxAt, f.replaceAtName); | |
| 339 | + if(f.replaceAtName.prevStart < v.length){ | |
| 340 | + accum2.push((f.replaceAtName.prevStart?' ':'')+v.substring(f.replaceAtName.prevStart)); | |
| 341 | + } | |
| 342 | + }else{ | |
| 343 | + accum2.push(v); | |
| 344 | + } | |
| 345 | + }); | |
| 346 | + delete f.accum; | |
| 347 | + const theTgt = tgtElem || f.ce('div'); | |
| 348 | + const strings = []; | |
| 349 | + accum2.forEach(function(e){ | |
| 350 | + if('string'===typeof e) strings.push(e); | |
| 351 | + else strings.push(e.outerHTML); | |
| 352 | + }); | |
| 353 | + D.parseHtml(theTgt, strings.join('')); | |
| 354 | + return theTgt; | |
| 355 | + }/*end messageToDOM()*/; | |
| 356 | + | |
| 281 | 357 | /** Callback for poll() to inject new content into the page. */ |
| 282 | 358 | function newcontent(jx){ |
| 283 | 359 | var i; |
| 284 | 360 | for(i=0; i<jx.msgs.length; ++i){ |
| 285 | 361 | const m = jx.msgs[i]; |
| @@ -332,12 +408,11 @@ | ||
| 332 | 408 | const br = D.br(); |
| 333 | 409 | br.style.clear = "both"; |
| 334 | 410 | eContent.appendChild(br); |
| 335 | 411 | } |
| 336 | 412 | if(m.xmsg){ |
| 337 | - try{D.moveChildrenTo(eContent, D.parseHtml(m.xmsg))} | |
| 338 | - catch(e){console.error(e)} | |
| 413 | + messageToDOM(m.xmsg, eContent); | |
| 339 | 414 | } |
| 340 | 415 | eContent.classList.add('chat-message'); |
| 341 | 416 | } |
| 342 | 417 | } |
| 343 | 418 | async function poll(){ |
| 344 | 419 |
| --- src/chat.js | |
| +++ src/chat.js | |
| @@ -144,12 +144,11 @@ | |
| 144 | file selection element. */ |
| 145 | document.onpaste = function(event){ |
| 146 | const items = event.clipboardData.items, |
| 147 | item = items[0]; |
| 148 | if(!item || !item.type) return; |
| 149 | //console.debug("pasted item =",item); |
| 150 | if('file'===item.kind){ |
| 151 | updateDropZoneContent(false/*clear prev state*/); |
| 152 | updateDropZoneContent(items[0].getAsFile()); |
| 153 | }else if(false && 'string'===item.kind){ |
| 154 | /* ----^^^^^ disabled for now: the intent here is that if |
| 155 | form.msg is not active, populate it with this text, but |
| @@ -276,10 +275,87 @@ | |
| 276 | const pRect = f.popup.e.getBoundingClientRect(); |
| 277 | x -= pRect.width/3*2; |
| 278 | } |
| 279 | f.popup.show(x, y); |
| 280 | }; |
| 281 | /** Callback for poll() to inject new content into the page. */ |
| 282 | function newcontent(jx){ |
| 283 | var i; |
| 284 | for(i=0; i<jx.msgs.length; ++i){ |
| 285 | const m = jx.msgs[i]; |
| @@ -332,12 +408,11 @@ | |
| 332 | const br = D.br(); |
| 333 | br.style.clear = "both"; |
| 334 | eContent.appendChild(br); |
| 335 | } |
| 336 | if(m.xmsg){ |
| 337 | try{D.moveChildrenTo(eContent, D.parseHtml(m.xmsg))} |
| 338 | catch(e){console.error(e)} |
| 339 | } |
| 340 | eContent.classList.add('chat-message'); |
| 341 | } |
| 342 | } |
| 343 | async function poll(){ |
| 344 |
| --- src/chat.js | |
| +++ src/chat.js | |
| @@ -144,12 +144,11 @@ | |
| 144 | file selection element. */ |
| 145 | document.onpaste = function(event){ |
| 146 | const items = event.clipboardData.items, |
| 147 | item = items[0]; |
| 148 | if(!item || !item.type) return; |
| 149 | else if('file'===item.kind){ |
| 150 | updateDropZoneContent(false/*clear prev state*/); |
| 151 | updateDropZoneContent(items[0].getAsFile()); |
| 152 | }else if(false && 'string'===item.kind){ |
| 153 | /* ----^^^^^ disabled for now: the intent here is that if |
| 154 | form.msg is not active, populate it with this text, but |
| @@ -276,10 +275,87 @@ | |
| 275 | const pRect = f.popup.e.getBoundingClientRect(); |
| 276 | x -= pRect.width/3*2; |
| 277 | } |
| 278 | f.popup.show(x, y); |
| 279 | }; |
| 280 | |
| 281 | /** |
| 282 | Parses the given chat message string for hyperlinks and @NAME |
| 283 | references, replacing them with markup, then stuffs the parsed |
| 284 | content into the given target DOM element. |
| 285 | |
| 286 | This is an intermediary step until we get some basic markup |
| 287 | support coming from the server side. |
| 288 | */ |
| 289 | const messageToDOM = function f(str, tgtElem){ |
| 290 | "use strict"; |
| 291 | if(!f.rxUrl){ |
| 292 | f.rxUrl = /\b(?:https?|ftp):\/\/[a-z0-9-+&@#\/%?=~_|!:,.;]*[a-z0-9-+&@#\/%=~_|]/gim; |
| 293 | f.rxAt = /@\w+/gmi; |
| 294 | f.rxNS = /\S/; |
| 295 | f.ce = (T)=>document.createElement(T); |
| 296 | f.ct = (T)=>document.createTextNode(T); |
| 297 | f.replaceUrls = function ff(sub, offset, whole){ |
| 298 | if(offset > ff.prevStart){ |
| 299 | f.accum.push((ff.prevStart?' ':'')+whole.substring(ff.prevStart, offset-1)+' '); |
| 300 | } |
| 301 | const a = f.ce('a'); |
| 302 | a.setAttribute('href',sub); |
| 303 | a.setAttribute('target','_blank'); |
| 304 | a.appendChild(f.ct(sub)); |
| 305 | f.accum.push(a); |
| 306 | ff.prevStart = offset + sub.length + 1; |
| 307 | }; |
| 308 | f.replaceAtName = function ff(sub, offset,whole){ |
| 309 | if(offset > ff.prevStart){ |
| 310 | ff.accum.push((ff.prevStart?' ':'')+whole.substring(ff.prevStart, offset-1)+' '); |
| 311 | }else if(offset && f.rxNS.test(whole[offset-1])){ |
| 312 | // Sigh: https://stackoverflow.com/questions/52655367 |
| 313 | ff.accum.push(sub); |
| 314 | return; |
| 315 | } |
| 316 | const e = f.ce('span'); |
| 317 | e.classList.add('at-name'); |
| 318 | e.appendChild(f.ct(sub)); |
| 319 | ff.accum.push(e); |
| 320 | ff.prevStart = offset + sub.length + 1; |
| 321 | }; |
| 322 | } |
| 323 | f.accum = []; // accumulate strings and DOM elements here. |
| 324 | f.rxUrl.lastIndex = f.replaceUrls.prevStart = 0; // reset regex cursor |
| 325 | str.replace(f.rxUrl, f.replaceUrls); |
| 326 | // Push remaining non-URL part of the string to the queue... |
| 327 | if(f.replaceUrls.prevStart < str.length){ |
| 328 | f.accum.push((f.replaceUrls.prevStart?' ':'')+str.substring(f.replaceUrls.prevStart)); |
| 329 | } |
| 330 | // Pass 2: process @NAME references... |
| 331 | // TODO: only match NAME if it's the name of a currently participating |
| 332 | // user. Add a second class if NAME == current user, and style that one |
| 333 | // differently so that people can more easily see when they're spoken to. |
| 334 | const accum2 = f.replaceAtName.accum = []; |
| 335 | f.accum.forEach(function(v){ |
| 336 | if('string'===typeof v){ |
| 337 | f.rxAt.lastIndex = f.replaceAtName.prevStart = 0; |
| 338 | v.replace(f.rxAt, f.replaceAtName); |
| 339 | if(f.replaceAtName.prevStart < v.length){ |
| 340 | accum2.push((f.replaceAtName.prevStart?' ':'')+v.substring(f.replaceAtName.prevStart)); |
| 341 | } |
| 342 | }else{ |
| 343 | accum2.push(v); |
| 344 | } |
| 345 | }); |
| 346 | delete f.accum; |
| 347 | const theTgt = tgtElem || f.ce('div'); |
| 348 | const strings = []; |
| 349 | accum2.forEach(function(e){ |
| 350 | if('string'===typeof e) strings.push(e); |
| 351 | else strings.push(e.outerHTML); |
| 352 | }); |
| 353 | D.parseHtml(theTgt, strings.join('')); |
| 354 | return theTgt; |
| 355 | }/*end messageToDOM()*/; |
| 356 | |
| 357 | /** Callback for poll() to inject new content into the page. */ |
| 358 | function newcontent(jx){ |
| 359 | var i; |
| 360 | for(i=0; i<jx.msgs.length; ++i){ |
| 361 | const m = jx.msgs[i]; |
| @@ -332,12 +408,11 @@ | |
| 408 | const br = D.br(); |
| 409 | br.style.clear = "both"; |
| 410 | eContent.appendChild(br); |
| 411 | } |
| 412 | if(m.xmsg){ |
| 413 | messageToDOM(m.xmsg, eContent); |
| 414 | } |
| 415 | eContent.classList.add('chat-message'); |
| 416 | } |
| 417 | } |
| 418 | async function poll(){ |
| 419 |