const TextBox = { autoSize: () => { textbox.style.height = `0px` textbox.style.height = `${textbox.scrollHeight}px` }, sendPost: () => { if(textbox.value) { wire({kind: 'post', value: textbox.value}) textbox.value = '' textbox.focus() TextBox.autoSize() } }, blockIndent: (text, iStart, iEnd, nLevels) => { const startLine = text.slice(0, iStart).split('\n').length - 1 const endLine = text.slice(0, iEnd).split('\n').length - 1 const newText = text .split('\n') .map((line, i) => { if(i < startLine || i > endLine || nLevels === 0) { newLine = line } else if(nLevels > 0) { newLine = line.replace(/^/, ' ') } else if(nLevels < 0) { newLine = line.replace(/^ /, '') } if(i === startLine) { iStart = iStart + newLine.length - line.length } iEnd = iEnd + newLine.length - line.length return newLine }) .join('\n') return [newText, Math.max(0, iStart), Math.max(0, iEnd)] }, hotKey: (e) => { // if isDesktop, Enter posts, unless Shift+Enter isDesktop = true if(e.key === 'Enter' && isDesktop && !e.shiftKey) { e.preventDefault() TextBox.sendPost() } // indent and dedent const modKey = e.ctrlKey || e.metaKey const {value: text, selectionStart: A, selectionEnd: B} = textbox if(e.key === 'Tab') { e.preventDefault() const regex = new RegExp(`([\\s\\S]{${A}})([\\s\\S]{${B - A}})`) textbox.value = text.replace(regex, (m, a, b) => a + ' '.repeat(4)) textbox.setSelectionRange(A + 4, A + 4) } if(']['.includes(e.key) && modKey) { e.preventDefault() const nLevels = {']': 1, '[': -1}[e.key] const [newText, newA, newB] = TextBox.blockIndent(text, A, B, nLevels) textbox.value = newText textbox.setSelectionRange(newA, newB) } }, view() { return m('.actions', m('textarea#textbox', { oncreate: (vnode) => { TextBox.autoSize() autoFocus(vnode) }, onkeydown: TextBox.hotKey, oninput: TextBox.autoSize, }), m('button', {onclick: TextBox.sendPost}, 'Send'), ) }, } const Post = { oncreate: ({dom}) => { dom.scrollIntoView() }, prettifyTime: (ts) => { const dt = new Date(ts) const H = `0${dt.getHours()}`.slice(-2) const M = `0${dt.getMinutes()}`.slice(-2) const S = `0${dt.getSeconds()}`.slice(-2) return `${H}:${M}:${S}` }, fixPost: ({dom}) => { dom.querySelectorAll('a').forEach(anchor => { anchor.target = '_blank' anchor.rel = 'noopener' }) }, view({attrs: {post}}) { return m('.post', m('.ts', this.prettifyTime(post.ts)), m('.source', post.source || '~'), m('.text', {oncreate: this.fixPost}, m.trust(DOMPurify.sanitize(marked(post.value)))), ) } } const ChatConfig = { view() { const onclick = () => {Chat.isOn = !Chat.isOn} const hot = Chat.unseenCount ? '.hot' : '' return m('button', {onclick}, 'chat ', m('.badge' + hot, Chat.unseenCount), ) }, } const Chat = { posts: [], unseenCount: 0, originalTitle: 'pico.chat', onupdate: () => { if(document.hasFocus() && Chat.isOn) { Chat.unseenCount = 0 textbox.focus() } const extra = Chat.unseenCount ? ` (${Chat.unseenCount})` : `` document.title = Chat.originalTitle + extra }, view() { return m('.chat', m('.posts', Chat.posts.map(post => m(Post, {post}))), m(TextBox), ) }, } addEventListener('focus', m.redraw) addEventListener('post', ({detail}) => { Chat.posts.push(detail) Chat.unseenCount += !(document.hasFocus() && ChatConfig.isOn) m.redraw() }) addEventListener('logout', () => { Chat.posts = [] Chat.unseenCount = 0 }) marked.setOptions({ breaks: true, })