/********************************************************************** * * * **********************************************************************/ //--------------------------------------------------------------------- // Helpers... // XXX do a caret api... // XXX only for text areas... var atLine = function(elem, index){ // XXX add support for range... var text = elem.value var lines = text.split(/\n/g).length var line = elem.caretLine // XXX STUB index handling... if((index == -1 && line == lines) || (index == 0 && line == 1)){ return true } return false } /* function clickPoint(x,y){ document .elementFromPoint(x, y) .dispatchEvent( new MouseEvent( 'click', { view: window, bubbles: true, cancelable: true, screenX: x, screenY: y, } )) } //*/ // Get the character offset at coordinates... // // This is done by moving a range down the element until its bounding // box corresponds the to desired coordinates. This accounts for nested // elements. // // XXX it would be a better idea to do a binary search instead of a liner // pass... but at this point this is not critical (unless we get // gigantic blocks) // XXX HACK -- is there a better way to do this??? var getCharOffset = function(elem, x, y, c){ c = c ?? 0 var r = document.createRange() for(var e of [...elem.childNodes]){ // text node... if(e instanceof Text){ var prev, b for(var i=0; i <= e.length; i++){ r.setStart(e, i) r.setEnd(e, i) prev = b b = r.getBoundingClientRect() // found target... if(b.x >= x && b.y <= y && b.bottom >= y){ // get the closest gap between chars to the click... return Math.abs(b.x - x) <= Math.abs(prev.x - x) ? c + i : c + i - 1 } } c += i // html node... } else { var res = getCharOffset(e, x, y, c) if(!(res instanceof Array)){ return res } ;[c, res] = res } } // no result was found... return arguments.length > 3 ? [c, null] : null } // Get offset in markdown relative to the resulting text... // // v <----- position // text: 'Hea|ding' // | // +-+ <--- offset in markdown // | // markdown: '# Hea|ding' // var getMarkdownOffset = function(markdown, text, i){ i = i ?? text.length var m = 0 // walk both strings skipping/counting non-matching stuff... for(var n=0; n <= i; n++, m++){ var c = text[n] var p = m // walk to next match... while(c != markdown[m] && m < markdown.length){ m++ } // reached something unrepresentable directly in markdown (html // entity, symbol, ...) if(m >= markdown.length){ m = p } } return m - n } //--------------------------------------------------------------------- // Plugins... // general helpers and utils... var plugin = { encode: function(text){ return text .replace(/(?/g, '>') .replace(/\\(?!`)/g, '\\\\') }, // XXX make this more generic... style: function(editor, elem, style, code=undefined){ style = [style].flat() editor.__styles = [...new Set([ ...(editor.__styles ?? []), ...style, ])] return function(_, text){ elem.style ??= [] elem.style.push(...style) return code ?? text } }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var attributes = { __proto__: plugin, __pre_parse__: function(text, editor, elem){ return text // hidden attributes... // XXX make this generic... // collapsed... .replace(/(\n|^)\s*collapsed::\s*(.*)\s*(\n|$)/, function(_, value){ elem.collapsed = value.trim() == 'true' return '' }) // id... .replace(/(\n|^)\s*id::\s*(.*)\s*(\n|$)/, function(_, value){ elem.id = value.trim() return '' }) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var blocks = { __proto__: plugin, __pre_parse__: function(text, editor, elem){ return text // markdown... // style: headings... .replace(/^(?\s+(.*)$/m, this.style(editor, elem, 'quote')) .replace(/^\s*(?.replace(quoted.pattern, quoted.handler) quote_pattern: /(?${ this.encode(code) }` }, pre_pattern: /(?` +`${ this.encode(code) }` +`` }, map: function(text, func){ return text.replace(this.pre_pattern, func) }, replace: function(text, index, updated){ return this.map(text, function(match, language, code){ return index-- != 0 ? match : ('```'+language + (typeof(updated) == 'function' ? updated(code) : updated) +'```') }) }, toHTML: function(text){ return this.map(text, this.handler) }, __pre_parse__: function(text, editor, elem){ return text .replace(this.pre_pattern, this.pre.bind(this)) .replace(this.quote_pattern, this.quote.bind(this)) }, // XXX is this a good strategy??? __state: undefined, __keydown__: function(evt, editor, elem){ // code editing... if(elem.nodeName == 'CODE' && elem.getAttribute('contenteditable') == 'true'){ // XXX can keydown and keyup be triggered from different elements??? this.__state = elem.innerText return false } }, // defined .__editedview__(..) handler __keyup__: function(evt, editor, elem){ var elem = evt.target if(elem.nodeName == 'CODE' && elem.getAttribute('contenteditable') == 'true'){ // trigger if state actually changed.. this.__state != elem.innerText && editor.runPlugins('__editedview__', evt, editor, elem) } }, __focusout__: function(){ this.__state = undefined }, __editedview__: function(evt, editor, elem){ // editable code... var block = editor.get(elem) var code = block.querySelector('.code') var update = elem.innerText var i = [...block .querySelectorAll('.view code[contenteditable=true]')] .indexOf(elem) // update element content... code.value = quoted.replace(code.value, i, update) return this }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var tasks = { __proto__: plugin, done_patterns: [ /^\s*(?.view .completion')]){ this.updateStatus(editor, e) } return this }, // Checkboxes... getCheckbox: function(editor, elem, offset=0){ elem = elem ?? editor.get() if(elem == null || (offset == 0 && elem.type == 'checkbox')){ return elem } var node = editor.get(elem) var view = node.querySelector('.view') var cur = view.querySelector('input[type=checkbox].selected') ?? view.querySelector('input[type=checkbox]') if(offset == 0 && cur == null){ return} var checkboxes = [...editor.outline.querySelectorAll('.view input[type=checkbox]')] if(checkboxes.length == 0){ return } // no checkbox in node -> get closest to cur in offset direction... if(cur == null){ var nodes = [...editor.outline.querySelectorAll('.block')] var checkbox_nodes = checkboxes .map(function(e){ return editor.get(e) }) var i = nodes.indexOf(node) var p, n for(var c of checkbox_nodes){ p = n var j = nodes.indexOf(c) if(j >= i){ n = j break } } cur = offset < 0 ? nodes[p] : nodes[n] } var elem = cur == null ? checkboxes.at( offset > 0 ? offset -1 : offset) : checkboxes.at( (checkboxes.indexOf(cur) + offset) % checkboxes.length) return elem }, updateCheckboxes: function(editor, elem){ elem = this.getCheckbox(editor, elem) var node = editor.get(elem) var text = node.querySelector('.code') // get the checkbox order... var i = [...node.querySelectorAll('input[type=checkbox]')].indexOf(elem) var to = elem.checked ? '[X]' : '[_]' var toggle = function(m){ return i-- == 0 ? to : m } text.value = text.value.replace(/\[[Xx_]\]/g, toggle) return elem }, toggleCheckbox: function(editor, checkbox, offset){ checkbox = this.getCheckbox(editor, checkbox, offset) if(checkbox){ checkbox.checked = !checkbox.checked this.updateCheckboxes(editor, checkbox) this.updateBranchStatus(editor, checkbox) } return checkbox }, selectCheckbox: function(editor, checkbox, offset){ checkbox = this.getCheckbox(editor, checkbox, offset) if(checkbox == null){ return } var checkboxes = editor.get(checkbox) .querySelector('.view') .querySelectorAll('input[type=checkbox]') if(checkboxes.length == 0){ return } for(var c of checkboxes){ c.classList.remove('selected') } checkbox.classList.add('selected') editor.show(checkbox) return checkbox }, nextCheckbox: function(editor, node='focused', offset=1){ node = this.selectCheckbox(editor, node, offset) editor.focus(node) return node }, prevCheckbox: function(editor, node='focused', offset=-1){ return this.nextCheckbox(editor, node, offset) }, // DONE... toggleDone: function(editor, elem){ var node = editor.get(elem) if(node == null){ return } var text = node.querySelector('.code') var value = text.value var s = text.selectionStart var e = text.selectionEnd var l = text.value.length if(this.done_patterns .reduce(function(res, p){ return res || p.test(text.value) } , false)){ for(var p of this.done_patterns){ value = value.replace(p, '$1') } } else { value = 'DONE ' + value } text.value = value text.selectionStart = s + (value.length - l) text.selectionEnd = e + (value.length - l) editor.update(node) return node }, __setup__: function(editor){ return this.updateAllStatus(editor) }, __pre_parse__: function(text, editor, elem){ // handle done.. var handler = this.style(editor, elem, 'DONE') for(var p of this.done_patterns){ text = text .replace(p, handler) } return text }, __parse__: function(text, editor, elem){ return text // block checkboxes... // NOTE: these are separate as we need to align block text // to leading chekbox... .replace(/^\s*(?')) .replace(/^\s*(?')) // inline checkboxes... .replace(/\s*(?')) .replace(/\s*(?')) // completion... // XXX add support for being like a todo checkbox... .replace(/(?') }, __focusin__: function(evt, editor, elem){ elem.classList.contains('block') && this.selectCheckbox(editor, elem) }, __editedcode__: function(evt, editor, elem){ this.updateBranchStatus(editor, elem) this.selectCheckbox(editor, elem) }, __click__: function(evt, editor, elem){ // toggle checkbox... if(elem.type == 'checkbox'){ var node = editor.get(elem) this.updateCheckboxes(editor, elem) this.updateBranchStatus(editor, node) this.selectCheckbox(editor, elem) node.focus() } return this }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // XXX Hackish... var syntax = { __proto__: plugin, update: function(){ window.hljs && hljs.highlightAll() return this }, __setup__: function(editor){ return this.update() }, // XXX make a local update... __editedcode__: function(evt, editor, elem){ return this.update(elem) }, __editedview__: function(evt, editor, elem){ // XXX should we also clear the syntax??? delete elem.dataset.highlighted return this }, // XXX this removes highlighting, can we make it update live??? __focusin__: function(evt, editor, elem){ if(elem.nodeName == 'CODE' && elem.getAttribute('contenteditable') == 'true'){ elem.classList.remove('hljs') } }, __focusout__: function(evt, editor, elem){ if(elem.nodeName == 'CODE' && elem.getAttribute('contenteditable') == 'true'){ this.update(elem) } return this }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var tables = { __proto__: plugin, __parse__: function(text, editor, elem){ return text .replace(/^\s*(?${ body .replace(/\s*\|\s*\n\s*\|\s*/gm, '\n') .replace(/\s*\|\s*/gm, '') }` }) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - var styling = { __proto__: plugin, __parse__: function(text, editor, elem){ return text // markers... .replace(/(\s*)(?$2$3') .replace(/(\s*)(?$2$3') // elements... .replace(/(\n|^)(?') // basic styling... .replace(/(?$1') .replace(/(?$1') // XXX this can clash with '[_] .. [_]' checkboxes... .replace(/(?$1') // code/quoting... //.replace(/(?$1') // links... .replace(/(?$1') .replace(/((?:https?:|ftps?:)[^\s]*)(\s*)/g, '$1$2') }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // XXX use ligatures for these??? var symbols = { __proto__: plugin, // XXX use a single regex with handler func to do these... symbols: { // XXX think these are better handled by ligatures... //'>>': '»', //'<<': '«', //'->': '→', //'<-': '←', //'=>': '⇒', //'<=': '⇐', '(i)': '🛈', '(c)': '©', '/!\\': '⚠', }, get symbols_pattern(){ return (this.symbols != null && Object.keys(this.symbols).length > 0) ? new RegExp(`(? Outline (view) var Outline = { dom: undefined, // config... // left_key_collapses: true, right_key_expands: true, change_interval: 1000, tab_size: 4, carot_jump_edge_then_block: false, // Plugins... // // The order of plugins can be significant in the following cases: // - parsing // - event dropping plugins: [ attributes, blocks, quoted, // NOTE: this needs to be before styling to prevent it from // treating '[_] ... [_]' as italic... tasks, styling, tables, symbols, //syntax, // keep this last... // XXX revise -- should this be external??? escaping, ], // NOTE: if a handler returns false it will break plugin execution... // XXX is this the right way to go??? runPlugins: function(method, ...args){ for(var plugin of this.plugins){ if(method in plugin){ if(plugin[method](...args) === false){ return false } } } return true }, threadPlugins: function(method, value, ...args){ for(var plugin of this.plugins){ method in plugin && (value = plugin[method](value, ...args)) } return value }, get code(){ return this.dom.querySelector('.code') }, get outline(){ return this.dom.querySelector('.outline') }, get toolbar(){ return this.dom.querySelector('.toolbar') }, // // .get([]) // .get('focused'[, ]) // -> // // .get('edited'[, ]) // -> // // .get('siblings') // .get('focused', 'siblings') // -> // // .get('children') // .get('focused', 'children') // -> // // .get('next') // .get('focused', 'next') // -> // // .get('prev') // .get('focused', 'prev') // -> // // .get('all') // .get('visible') // .get('editable') // .get('selected') // .get('top') // -> // // XXX add support for node ID... // XXX need to be able to get the next elem on same level... get: function(node='focused', offset){ var that = this offset = offset == 'next' ? 1 : offset == 'prev' ? -1 : offset var outline = this.outline // root nodes... if(node == 'top'){ return [...outline.children] } // groups defaulting to .outline as base... if(['all', 'visible', 'editable', 'selected'].includes(node)){ return this.get(outline, node) } // groups defaulting to .focused as base... if(['parent', 'next', 'prev', 'children', 'siblings'].includes(node)){ return this.get('focused', node) } // helpers... var parent = function(node){ return node === outline ? outline : node.parentElement === outline ? outline : node.parentElement.parentElement } var children = function(node){ return node === outline ? [...node.children] : [...node?.lastChild?.children] } // single base node... var edited ;[node, edited] = typeof(node) == 'number' ? [this.get('visible').at(node), edited] : (node == 'outline' || node == 'root') ? [outline, edited] : node == 'focused' ? [outline.querySelector(`.block:focus`) || outline.querySelector(`.code:focus`) || outline.querySelector('.block.focused'), edited] : node == 'edited' ? [outline.querySelector(`.code:focus`), outline.querySelector(`.code:focus`)] : [node , edited] // get the .block... if(node instanceof HTMLElement){ while(node !== outline && !node.classList.contains('block')){ node = node.parentElement } } // no reference node... if(node == null || typeof(node) == 'string'){ return undefined } // parent... if(offset == 'parent'){ return edited ? parent(node).querySelector('.code') : parent(node) } // node groups... var nodes = typeof(offset) == 'number' ? this.get('visible') : offset == 'all' ? [...node.querySelectorAll('.block')] : offset == 'visible' ? [...node.querySelectorAll('.block')] .filter(function(e){ return e.offsetParent != null }) : offset == 'editable' ? [...node.querySelectorAll('.block>.code')] : offset == 'selected' ? [...node.querySelectorAll('.block[selected]')] .filter(function(e){ return e.offsetParent != null }) : offset == 'children' ? children(node) : offset == 'siblings' ? children(parent(node)) : undefined // get node by offset... if(typeof(offset) == 'number'){ node = nodes.at(nodes.indexOf(node) + offset) ?? nodes[0] edited = edited ? node.querySelector('.code') : edited nodes = undefined } return nodes !== undefined ? edited ? nodes .map(function(){ return node.querySelector('.code') }) : nodes : (edited ?? node) }, at: function(index, nodes='visible'){ return this.get(nodes).at(index) }, focus: function(node='focused', offset){ var elem = this.get(...arguments) elem?.focus() return elem }, edit: function(node='focused', offset){ var elem = this.get(...arguments) if(elem.nodeName != 'TEXTAREA'){ elem = elem.querySelector('textarea') } elem?.focus() return elem }, update: function(node='focused', data){ var node = this.get(node) data ??= this.data(node, false) typeof(data.collapsed) == 'boolean' && (data.collapsed ? node.setAttribute('collapsed', '') : node.removeAttribute('collapsed')) if(data.text != null){ var text = node.querySelector('textarea') var html = node.querySelector('span') if(this.__code2html__){ // NOTE: we are ignoring the .collapsed attr here var parsed = this.__code2html__(data.text) html.innerHTML = parsed.text // heading... node.classList.remove(...this.__styles) parsed.style && node.classList.add(...parsed.style) } else { html.innerHTML = data.text } text.value = data.text // XXX this does not see to work until we click in the textarea... text.autoUpdateSize() } return node }, // This will prevent spamming the .sync() by limiting calls to one // per .change_interval // // XXX should we call plugin's __change__ live or every second??? __change_timeout: undefined, __change_requested: false, __change__: function(){ var that = this this.__change_requested = true if(this.__change_timeout){ return this } // do the action... if(this.__change_requested){ this.sync() this.runPlugins('__change__', that) this.__change_requested = false } this.__change_timeout = setTimeout( function(){ that.__change_timeout = undefined that.__change_requested && that.__change__() }, that.change_interval || 1000) return this }, // edit... indent: function(node='focused', indent=true){ // .indent() if(node === true || node === false){ indent = node node = 'focused' } var cur = this.get(node) if(!cur){ return } var siblings = this.get(node, 'siblings') // deindent... if(!indent){ var parent = this.get(node, 'parent') if(parent != this.outline){ var children = siblings .slice(siblings.indexOf(cur)+1) parent.after(cur) children.length > 0 && cur.lastChild.append(...children) this.__change__() } // indent... } else { var parent = siblings[siblings.indexOf(cur) - 1] if(parent){ parent.lastChild.append(cur) this.__change__()} } return cur }, deindent: function(node='focused', indent=false){ return this.indent(node, indent) }, shift: function(node='focused', direction){ if(node == 'up' || node == 'down'){ direction = node node = 'focused' } if(direction == null){ return } node = this.get(node) var focused = node.classList.contains('focused') var siblings = this.get(node, 'siblings') var i = siblings.indexOf(node) if(direction == 'up' && i > 0){ siblings[i-1].before(node) focused && this.focus() } else if(direction == 'down' && i < siblings.length-1){ siblings[i+1].after(node) focused && this.focus() } this.__change__() return this }, show: function(node='focused', offset){ var node = this.get(...arguments) var outline = this.outline var parent = node var changes = false do{ parent = parent.parentElement changes = changes || parent.getAttribute('collapsed') == '' parent.removeAttribute('collapsed') } while(parent !== outline) changes && this.__change__() return node }, toggleCollapse: function(node='focused', state='next'){ var that = this if(node == 'all'){ return this.get('all') .map(function(node){ return that.toggleCollapse(node, state) }) } // .toggleCollapse() if(['next', true, false].includes(node)){ state = node node = 'focused' } node = this.get(node) if(!node // only nodes with children can be collapsed... || !node.querySelector('.block')){ return } state = state == 'next' ? node.getAttribute('collapsed') != '' : state if(state){ node.setAttribute('collapsed', '') } else { node.removeAttribute('collapsed') for(var elem of [...node.querySelectorAll('textarea')]){ elem.updateSize() } } this.__change__() return node }, remove: function(node='focused', offset){ var elem = this.get(...arguments) var next if(elem.classList.contains('focused')){ // XXX need to be able to get the next elem on same level... this.toggleCollapse(elem, true) next = elem === this.get(-1) ? this.get(elem, 'prev') : this.get(elem, 'next') } elem?.remove() next?.focus() this.__change__() return this }, clear: function(){ this.outline.innerText = '' this.__change__() return this }, // block serialization... // XXX need a way to filter input text... // use-case: hidden attributes... // NOTE: this is auto-populated by .__code2html__(..) __styles: undefined, __code2html__: function(code){ var that = this var elem = { collapsed: false, } // only whitespace -> keep element blank... if(code.trim() == ''){ elem.text = '' return elem } // helpers... var run = function(stage, text){ var meth = { pre: '__pre_parse__', main: '__parse__', post: '__post_parse__', }[stage] return that.threadPlugins(meth, text, that, elem) } // stage: pre... var text = run('pre', // pre-sanitize... code.replace(/\x00/g, '')) // split text into parsable and non-parsable sections... var sections = text // split fomat: // [ text , ... ] .split(/(<(pre|code)(?:|\s[^>]*)>((?:\n|.)*)<\/\2>)/g) // sort out the sections... var parsable = [] var quoted = [] while(sections.length > 0){ var [section, match] = sections.splice(0, 4) parsable.push(section) quoted.push(match) } // stage: main... text = run('main', // parse only the parsable sections... parsable.join('\x00')) .split(/\x00/g) // merge the quoted sections back in... .map(function(section){ return [section, quoted.shift() ?? ''] }) .flat() .join('') // stage: post... elem.text = run('post', text) return elem }, // output format... __code2text__: function(code){ return code .replace(/(\n\s*)-/g, '$1\\-') }, __text2code__: function(text){ return text .replace(/(\n\s*)\\-/g, '$1-') }, // serialization... data: function(elem, deep=true){ elem = this.get(elem) return { text: elem.querySelector('textarea').value, collapsed: elem.getAttribute('collapsed') != null, ...(deep ? {children: this.json(elem)} : {}), } }, json: function(node){ var that = this var children = [...(node ? node.lastChild.children : this.outline.children)] return children .map(function(elem){ return that.data(elem) }) }, // XXX add option to customize indent size... text: function(node, indent, level){ // .text(, ) if(typeof(node) == 'string'){ ;[node, indent=' ', level=''] = [undefined, ...arguments] } node ??= this.json(node) indent ??= ' ' level ??= '' var text = [] for(var elem of node){ text.push( level +'- ' + this.__code2text__(elem.text) .replace(/\n/g, '\n'+ level +' ') + (elem.collapsed ? '\n'+level+' ' + 'collapsed:: true' : ''), (elem.children && elem.children.length > 0) ? this.text(elem.children || [], indent, level+indent) : [] ) } return text .flat() .join('\n') }, parse: function(text){ var that = this text = text .replace(/^\s*\n/, '') text = ('\n' + text) .split(/\n(\s*)(?:- |-\s*$)/gm) .slice(1) var tab = ' '.repeat(this.tab_size || 8) var level = function(lst, prev_sep=undefined, parent=[]){ while(lst.length > 0){ sep = lst[0].replace(/\t/gm, tab) // deindent... if(prev_sep != null && sep.length < prev_sep.length){ break } prev_sep ??= sep // same level... if(sep.length == prev_sep.length){ var [_, block] = lst.splice(0, 2) var collapsed = false block = block .replace(/\n\s*collapsed::\s*(.*)\s*$/, function(_, value){ collapsed = value == 'true' return '' }) parent.push({ text: that.__text2code__(block // normalize indent... .split(new RegExp('\n'+sep+' ', 'g')) .join('\n')), collapsed, children: [], }) // indent... } else { parent.at(-1).children = level(lst, sep) } } return parent } return level(text) }, // XXX should this handle children??? // XXX revise name... Block: function(data={}, place=null){ var that = this if(typeof(data) != 'object'){ place = data data = {} } // block... var block = document.createElement('div') block.classList.add('block') block.setAttribute('tabindex', '0') // code... var code = document.createElement('textarea') .autoUpdateSize() code.classList.add('code', 'text') // view... var html = document.createElement('span') html.classList.add('view', 'text') // children... var children = document.createElement('div') children.classList.add('children') children.setAttribute('tabindex', '-1') block.append(code, html, children) this.update(block, data) // place... var cur = this.get() if(place && cur){ place = place == 'prev' ? 'before' : place ;(place == 'next' && (cur.querySelector('.block') || cur.nextElementSibling)) ? this.get(place).before(block) : (place == 'next' && !cur.nextElementSibling) ? cur.after(block) : (place == 'before' || place == 'after') ? cur[place](block) : undefined } return block }, load: function(data){ var that = this data = typeof(data) == 'string' ? this.parse(data) : data // generate dom... var level = function(lst){ return lst .map(function(data){ var elem = that.Block(data) if((data.children || []).length > 0){ elem.lastChild .append(...level(data.children)) } return elem }) } this .clear() .outline .append(...level(data)) // update sizes of all the textareas (transparent)... for(var e of [...this.outline.querySelectorAll('textarea')]){ e.updateSize() } return this }, sync: function(){ var code = this.code if(code){ code.value = this.text() } return this }, // Actions... prev: function(){}, next: function(){}, above: function(){}, below: function(){}, up: function(){}, down: function(){}, left: function(){}, right: function(){}, // XXX move the code here into methods/actions... // XXX add scrollIntoView(..) to nav... // XXX use keyboard.js... keyboard: { // vertical navigation... // XXX this is a bit hacky but it works -- the caret blinks at // start/end of block before switching to next, would be // nice po prevent this... ArrowUp: function(evt){ var that = this var edited = this.get('edited') if(edited){ var line = edited.getTextGeometry().line if(line == 0){ evt.preventDefault() that.focus('edited', 'prev') } } else { evt.preventDefault() this.focus('focused', -1) } }, ArrowDown: function(evt){ var that = this var edited = this.get('edited') if(edited){ var {line, lines} = edited.getTextGeometry() if(line == lines -1){ evt.preventDefault() that.focus('edited', 'next') } } else { evt.preventDefault() this.focus('focused', 1) } }, // horizontal navigation / collapse... ArrowLeft: function(evt){ var edited = this.get('edited') if(edited){ // move caret to prev element... if(edited.selectionStart == edited.selectionEnd && edited.selectionStart == 0){ evt.preventDefault() edited = this.focus('edited', 'prev') edited.selectionStart = edited.selectionEnd = edited.value.length + 1 } return } if(evt.ctrlKey){ evt.preventDefault() tasks.prevCheckbox(this) return } ;((this.left_key_collapses || evt.shiftKey) && this.get().getAttribute('collapsed') == null && this.get('children').length > 0) ? this.toggleCollapse(true) : this.focus('parent') }, ArrowRight: function(evt){ var edited = this.get('edited') if(edited){ // move caret to next element... if(edited.selectionStart == edited.selectionEnd && edited.selectionStart == edited.value.length){ evt.preventDefault() edited = this.focus('edited', 'next') edited.selectionStart = edited.selectionEnd = 0 } return } if(evt.ctrlKey){ evt.preventDefault() tasks.nextCheckbox(this) return } if(this.right_key_expands){ this.toggleCollapse(false) this.focus('next') } else { evt.shiftKey ? this.toggleCollapse(false) : this.focus('next') } }, PageUp: function(evt){ var edited = this.get('edited') if(!edited && (evt.shiftKey || evt.ctrlKey)){ evt.preventDefault() this.shift('up') } }, PageDown: function(evt){ var edited = this.get('edited') if(!edited && (evt.shiftKey || evt.ctrlKey)){ evt.preventDefault() this.shift('down') } }, // indent... Tab: function(evt){ evt.preventDefault() var edited = this.get('edited') var node = this.show( this.indent(!evt.shiftKey)) // keep focus in node... ;(edited ? edited : node)?.focus() }, // edit mode... O: function(evt){ if(!this.get('edited')){ evt.preventDefault() this.edit( this.Block('before')) } }, o: function(evt){ if(!this.get('edited')){ evt.preventDefault() this.edit( this.Block('next')) } }, Enter: function(evt){ var edited = this.get('edited') // edit -> split text... if(edited){ if(evt.ctrlKey || evt.shiftKey){ return } evt.preventDefault() var a = edited.selectionStart var b = edited.selectionEnd var prev = edited.value.slice(0, a) var next = edited.value.slice(b) edited.value = prev this.Block('next') edited = this.edit('next') edited.value = next edited.selectionStart = 0 edited.selectionEnd = 0 return } // view -> edit... evt.preventDefault() this.edit() }, Escape: function(evt){ this.focus() }, Delete: function(evt){ var edited = this.get('edited') if(edited){ if(edited.selectionStart == edited.value.length){ var next = this.get('edited', 'next') // can't reclaim nested children... if(this.get(next, 'children').length > 0){ return } // do not delete past the top element... if(this.get(0).querySelector('.code') === next){ return } evt.preventDefault() var i = edited.value.length edited.value += next.value edited.selectionStart = i edited.selectionEnd = i this.remove(next) } return } this.remove() }, Backspace: function(evt){ var edited = this.get('edited') if(edited && edited.selectionEnd == 0 // can't reclaim nested children... && this.get(edited, 'children').length == 0){ var prev = this.get('edited', 'prev') // do not delete past the bottom element... if(this.get(-1).querySelector('.code') === prev){ return } evt.preventDefault() var i = prev.value.length prev.value += edited.value this.edit(prev) prev.selectionStart = i prev.selectionEnd = i this.remove(edited) return } }, d: function(evt){ // toggle done... if(evt.ctrlKey){ evt.preventDefault() tasks.toggleDone(this) } }, // toggle checkbox... ' ': function(evt){ if(this.get('edited') != null){ return } evt.preventDefault() tasks.toggleCheckbox(this) }, }, setup: function(dom){ var that = this this.dom = dom // outline... var outline = this.outline // update stuff already in DOM... for(var elem of [...outline.querySelectorAll('textarea')]){ elem.autoUpdateSize() } // click... // XXX revise... // XXX tap support... outline.addEventListener('mousedown', function(evt){ var elem = evt.target // place the cursor where the user clicked in code/text... if(elem.classList.contains('code') && document.activeElement !== elem){ evt.preventDefault() var view = that.get(elem).querySelector('.view') var c = getCharOffset(view, evt.clientX, evt.clientY) if(c == null){ elem.focus() elem.selectionStart = elem.value.length elem.selectionEnd = elem.value.length } else { var m = getMarkdownOffset(elem.value, view.innerText, c) elem.focus() elem.selectionStart = c + m elem.selectionEnd = c + m } } }) outline.addEventListener('click', function(evt){ var elem = evt.target // prevent focusing parent by clicking between blocks... if(elem.classList.contains('children')){ return } // empty outline -> create new eleemnt... if(elem.classList.contains('outline') && elem.children.length == 0){ // create new eleemnt and edit it... var block = that.Block() that.outline.append(block) that.edit(block) return } // expand/collapse if(elem.classList.contains('view')){ // click: left of elem (outside) if(evt.offsetX < 0){ // XXX item menu? // click: right of elem (outside) } else if(elem.offsetWidth < evt.offsetX){ that.toggleCollapse(that.get(elem)) // click inside element... } else { // XXX } } // edit of focus... // NOTE: this is useful if element text is hidden but the // frame is still visible... if(elem.classList.contains('block')){ elem.querySelector('.code').focus() } that.runPlugins('__click__', evt, that, elem) }) // keyboard handling... outline.addEventListener('keydown', function(evt){ var elem = evt.target if(that.runPlugins('__keydown__', evt, that, evt.target) !== true){ return } // update element state... if(elem.classList.contains('code')){ setTimeout(function(){ that.update(elem.parentElement) elem.updateSize() }, 0) } // handle keyboard... evt.key in that.keyboard && that.keyboard[evt.key].call(that, evt) }) // update code block... outline.addEventListener('keyup', function(evt){ that.runPlugins('__keyup__', evt, that, evt.target) }) // toggle view/code of nodes... outline.addEventListener('focusin', function(evt){ var elem = evt.target // ignore children container... if(elem.classList.contains('children')){ return } // handle focus... for(var e of [...that.dom.querySelectorAll('.focused')]){ e.classList.remove('focused') } that.get('focused')?.classList?.add('focused') // textarea... if(elem.classList.contains('code')){ elem.updateSize() } /*/ scroll... that.get(node).querySelector('view') ?.scrollIntoView({ block: 'nearest', behavior: 'smooth', }) //*/ // XXX do we need this??? that.runPlugins('__focusin__', evt, that, elem) }) outline.addEventListener('focusout', function(evt){ var elem = evt.target if(elem.classList.contains('code')){ var block = elem.parentElement that.update(block, { text: elem.value }) // give the browser a chance to update the DOM... // XXX revise... setTimeout(function(){ that.runPlugins('__editedcode__', evt, that, elem) }, 0) } that.runPlugins('__focusout__', evt, that, elem) }) // update .code... outline.addEventListener('change', function(evt){ this.__change__() }) // toolbar... var toolbar = this.toolbar if(toolbar){ // handle return of focus when clicking toolbar... var focus_textarea var cahceNodeType = function(){ // NOTE: for some reason .activeElement returns an element // that is not in the DOM after the action is done... focus_textarea = document.activeElement.nodeName == 'TEXTAREA' } var refocusNode = function(){ focus_textarea ? editor.get().querySelector('textarea').focus() : editor.focus() focus_textarea = undefined } // cache the focused node type before focus changes... toolbar.addEventListener('mousedown', cahceNodeType) // refocus the node after we are done... toolbar.addEventListener('click', refocusNode) } // code... var code = this.code if(code){ var t = Date.now() this.load(code.value .replace(/</g, '<') .replace(/>/g, '>')) console.log(`Parse: ${Date.now() - t}ms`) } this.runPlugins('__setup__', this) return this }, } /********************************************************************** * vim:set ts=4 sw=4 : */