diff --git a/experiments/outline-editor/editor.js b/experiments/outline-editor/editor.js index a413597..f64cf94 100755 --- a/experiments/outline-editor/editor.js +++ b/experiments/outline-editor/editor.js @@ -1,2945 +1,2945 @@ -/********************************************************************** -* -* -* -**********************************************************************/ - - - -//--------------------------------------------------------------------- -// Helpers... - -/* -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 do a binary search?? -var getCharOffset = function(elem, x, y, data){ - data = data ?? {} - var r = document.createRange() - var elem_rect = data.elem_rect = - data.elem_rect - ?? elem.getBoundingClientRect() - for(var e of [...elem.childNodes]){ - var prev - var c = data.c = - data.c - ?? 0 - // text node... - if(e instanceof Text){ - var rect, cursor_line, line_start, offset - for(var i=0; i < e.length; i++){ - r.setStart(e, i) - r.setEnd(e, i) - prev = rect - ?? data.prev - rect = r.getBoundingClientRect() - // line change... - // NOTE: this is almost identical to .getTextOffsetAt(..) see - // that for more docs... - line_start = prev - && prev.y != rect.y - if(line_start){ - if(cursor_line){ - return offset - ?? c + i - 2 } - offset = undefined } - cursor_line = - rect.y <= y - && rect.bottom >= y - if(offset == null - && rect.x >= x){ - // get closest edge of element under cursor... - var dp = Math.abs( - ((!prev || line_start) ? - elem_rect - : prev).x - - x) - var dx = Math.abs(rect.x - x) - offset = dx <= dp ? - c + i - : c + i - 1 - if(cursor_line){ - return offset } } } - data.c += i - data.last = e.data[i-1] - // html node... - } else { - prev = data.prev = - prev - ?? data.prev - // special case: line break between cursor line and next element... - if(prev - // cursor line... - && prev.y <= y - && prev.bottom >= y - // line break... - && prev.y < e.getBoundingClientRect().y - // no whitespace at end, no compensation needed... (XXX test) - && ' \t\n'.includes(data.last)){ - return data.c - 1 } - - // handle the node... - data = getCharOffset(e, x, y, data) - - if(typeof(data) != 'object'){ - return data } } } - return arguments.length > 3 ? - data - // root call... - : data.c } - - -// Get offset in markdown relative to the resulting text... -// -// v <----- position -// text: 'Hea|ding' -// | -// +-+ <--- offset in markdown -// | -// markdown: '# Hea|ding' -// -// XXX should this be replaced with offsetAt(..)??? -var getMarkdownOffset = function(markdown, text, i){ - i = i ?? text.length - var m = 0 - // walk both strings skipping/counting non-matching stuff... - for(var t=0; t <= i; t++, m++){ - var c = text[t] - 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 - t } -var offsetAt = function(A, B, i){ - i ??= A.length-1 - var o = 0 - var p = 0 - for(var n=0; n <= i; n++){ - while(A[n] != B[n+o]){ - if(n+o >= B.length){ - o = p - break } - o++ } - p = o } - return o } - - -// Get element text content... -// -// NOTE: this is the same as .innerText but will not add extra "\n" after -// each block element... -var getTexts = function(elem, res=[]){ - for(var n of elem.childNodes){ - n.nodeType == n.TEXT_NODE ? - res.push(n.textContent) - : getTexts(n, res) } - return res } -var getText = function(elem){ - return getTexts(elem).join('') } - - - -//--------------------------------------------------------------------- -// 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) - // handler... - if(typeof(code) == 'function'){ - return code(...arguments) } - // explicit code... - if(code != null){ - return code } - // get first non-empty group... - var groups = [...arguments].slice(1, -2) - while(groups.length > 0 - && groups[0] == null){ - groups.shift() } - return groups[0] - ?? '' } }, -} - - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// Process attributes in code and update the attributes in element data... -// -// This does: -// - parse attributes -// - update element data (JSON) -// - filter attributes out (optionally) -// -var attributes = { - __proto__: plugin, - - // XXX should attr settings be set here or in the Outline??? - // ...this includes .__block_attrs__ and .__system_attrs__ - - // - // Parse attrs... - // .parseBlockAttrs([, ]) - // -> [, , ] - // - parseBlockAttrs: function(editor, text, elem={}){ - var system = editor.__block_attrs__ - var attrs = '' - var sysattrs = '' - elem.text = text - // XXX for some reason changing the first group into (?<= .. ) - // still eats up the whitespace... - // ...putting the same pattern in a normal group and - // returning it works fine... - //.replace(/(?<=[\n\h]*)(?:(?:\n|^)\s*\w*\s*::\s*[^\n]*\s*)*$/, - .replace(/([\n\t ]*)(?:(?:\n|^)[\t ]*\w+[\t ]*::[\t ]*[^\n]+[\t ]*)+$/, - function(match, ws){ - match = match - .trim() - .split(/(?:[\t ]*::[\t ]*|[\t ]*\n[\t ]*)/g) - while(match.length > 0){ - var [name, val] = match.splice(0, 2) - // ignore non-settable attrs... - if(editor.__system_attrs__.includes(name)){ - continue } - elem[name] = - val == 'true' ? - true - : val == 'false' ? - false - : val } - return ws }) - // build the attr strings... - // NOTE: we are not doing this in the loop above to include all - // the attributes that are in the elem but not explicitly - // given in code... - for(var name in elem){ - // ignore non-settable attrs... - if(editor.__system_attrs__.includes(name)){ - continue } - var val = elem[name] - if(!(name in system)){ - attrs += `\n${name}::${val}` - } else { - sysattrs += `\n${name}::${val}` } } - return [ - elem, - attrs, - sysattrs, - ] }, - - // generate code... - // - // this is controlled by the value of editor.__code_attrs__: - // false / undefined - strip attrs - // true - add attrs to code if available - // 'all' - add attrs, including system attrs to - // code if available, - __parse_code__: function(code, editor, elem){ - var [elem, attrs, system] = this.parseBlockAttrs(editor, code, elem) - return !editor.__code_attrs__ ? - elem.text - : editor.__code_attrs__ == 'all' ? - elem.text - + (attrs.length > 0 ? - '\n'+ attrs - : '') - + (system.length > 0 ? - '\n'+ system - : '') - : attrs.length > 0 ? - elem.text +'\n'+ attrs - : elem.text }, - - // generate view... - // - // this is controlled by the value of editor.__view_attrs__: - // false / undefined - strip attrs - // true - call the handler XXX - __pre_parse__: function(text, editor, elem){ - // NOTE: we are intentionally neglecting system attrs here... - var [elem, attrs, system] = this.parseBlockAttrs(editor, text, elem) - if(editor.__view_attrs__ - && attrs.length > 0){ - attrs = editor.threadPlugins('__parse_attrs__', attrs, editor, elem) - if(attrs && attrs.length > 0){ - return text +'\n'+ attrs } } - return elem.text }, - - // XXX - //__parse_attrs__: function(attrs, editor, elem){ - // return attrs } -} - - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// XXX revise headings... -var blocks = { - __proto__: plugin, - - __pre_parse__: function(text, editor, elem){ - return text - // markdown... - // style: headings... - /* XXX chose either this or auto headings -- move docs... - .replace(/^(?\s+([^]*)$/, this.style(editor, elem, 'quote')) - .replace(/^\s*(?')) } , -} - - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// XXX add actions... -var quoted = { - __proto__: plugin, - - // can be used in: - // .replace(quoted.pattern, quoted.handler) - quote_pattern: /(?${ this.encode(code) }` }, - - pre_pattern: /(?` - +`${ - this.preEncode(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 - // XXX move this to keyboard.js... - if(evt.key == 'Escape'){ - editor.focus(elem) } - // XXX not sure if the is needed with keyboard.js... - 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, - - status: [ - 'DONE', - 'REJECT', - //'TODO', - ], - // format: - // [ - // : , - // ... - // ] - __status_patterns: undefined, - __status_pattern_tpl: `^(?:\\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, false) - var data = editor.data(node) - 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) - // NOTE: status is updated via a timeout set in .__parse__(..)... - editor.setUndo( - editor.path(node), - 'update', - [editor.path(node), - data]) - 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) }, - // Status... - toggleStatus: function(editor, elem, status='next', patterns=this.status_patterns){ - var node = editor.get(elem) - if(node == null){ - return } - var data = editor.data(elem, false) - var text = node.querySelector('.code') - var value = text.value - var s = text.selectionStart - var e = text.selectionEnd - var l = text.value.length - - var p = Object.entries(patterns) - for(var i=0; i')) - .replace(/^\s*(?')) - // inline checkboxes... - .replace(/\s*(?')) - .replace(/\s*(?')) - // completion... - // XXX add support for being like a todo checkbox... - .replace(/(?') - // need to update status... - // XXX not sure if this is a good way to do this... - if(res != text && this.__update_checkboxes_timeout == null){ - var that = this - this.__update_checkboxes_timeout = setTimeout(function(){ - that.__update_checkboxes_timeout = undefined - that.updateAllStatus(editor) }, 200) } - return res }, - __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 do a better default id... -// XXX make this persistent -- add to code... -var toc = { - __proto__: plugin, - - __skip_local_root__: true, - - update: function(editor, elem){ - var that = this - var outline = editor.outline - var TOCs = [...outline.querySelectorAll('.TOC .view')] - var tocs = [...outline.querySelectorAll('.toc .view')] - if(TOCs.length + tocs.length == 0){ - return } - - var level = function(node, root=outline){ - var depth = 0 - var parent = node - while(parent !== root - && parent != null){ - if(parent.classList.contains('block') - && parent.classList.contains('heading')){ - depth++ } - parent = parent.parentElement } - return depth } - // XXX revise... - var seen = new Set() - var makeID = function(text){ - var id = encodeURI( - text - .trim() - .replace(/[#?$%:;.,]/g, '') - .replace(/\s+/g, '-')) - if(seen.has(id) - || document.getElementById(id)){ - var i = 1 - var candidate = id +'-'+ i - while(seen.has(candidate) - || document.getElementById(candidate)){ - candidate = id +'-'+ i++ } - id = id +'-'+ i } - seen.add(id) - return id } - var makeTOC = function(root=outline){ - var index = 0 - var lst = document.createElement('ul') - var list = lst - var depth = 1 - for(var e of [...root.querySelectorAll('.block.heading>.view')]){ - var block = editor.get(e) - // skip the root element??? - if(block.classList.contains('no-toc') - || (!that.__skip_local_root__ - && block === root)){ - continue } - var d = level(e, root) - // down... - if(d > depth){ - var sub = document.createElement('ul') - lst.append(sub) - lst = sub - depth++ - // up... - } else while(d < depth && depth > 0){ - lst = lst.parentElement ?? lst - depth-- } - var elem = document.createElement('li') - var id = block.id == '' ? - // XXX do a better default... - //'__'+ index++ - makeID(e.innerText) - : block.id - block.id = id - elem.innerHTML = `${e.innerHTML.trim()}` - lst.append(elem) } - return list } - - // global tocs... - var list = makeTOC() - for(var toc of TOCs){ - toc.innerHTML = '' - toc.append(list.cloneNode(true)) } - // local tocs... - for(var toc of tocs){ - toc.innerHTML = '' - toc.append( - makeTOC( - editor.get(toc, 'parent'))) } }, - - __setup__: function(editor){ - return this.update(editor) }, - __editedcode__: function(evt, editor, elem){ - return this.update(editor, elem) }, - - __parse__: function(text, editor, elem){ - return text - .replace(/^\s*toc\s*$/, - this.style(editor, elem, 'toc', '')) - .replace(/^\s*TOC\s*$/, - this.style(editor, elem, 'TOC', '')) }, -} - - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// 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, - - // XXX EXPERIMENTAL - __pre_parse__: function(text, editor, elem){ - return text - .replace(/^(--table--)$/m, this.style(editor, elem, 'table-2')) }, - - __parse__: function(text, editor, elem){ - return text - .replace(/^\s*(?${ - body - .trim() - .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(`(?: , - // ... - // } - __id_index: undefined, - - // format: - // Map([ - // [, ], - // ... - // ]) - __nodes: undefined, - - __path: undefined, - current: undefined, - - __iter: function*(node, path, mode){ - if(typeof(path) == 'string'){ - mode = path - path = null } - path ??= [] - yield [path, node] - if(mode == 'visible' - && node.collapsed){ - return } - var i = 0 - for(var e of node.children ?? []){ - yield* this.__iter(e, [...path, i++], mode) } }, - // XXX revise... - nodes: function*(node, mode){ - var i = 0 - // all nodes.. - if(node == null || node == 'all' || node == 'visible'){ - for(var e of this.json()){ - yield* this.__iter(e, [i++], node) } - // single node... - } else { - var args = [...arguments] - // XXX revise... - if(['all', 'visible'].includes(args.at(-1))){ - mode = args.pop() } - yield* this.__iter( - this.get(...args), - mode) } }, - [Symbol.iterator]: function*(mode='all'){ - for(var node of this.json()){ - for(var [_, n] of this.__iter(node, mode)){ - yield n } } }, - iter: function*(node, mode){ - for(var [_, n] of this.nodes(...arguments)){ - yield n } }, - - // XXX - path: function(){}, - get: function(node, offset){ - }, - focus: function(node, offset){ - return this.get( - this.__path = this.path(...arguments)) }, - - index: function(){}, - at: function(index){}, - - indent: function(){}, - shift: function(){}, - show: function(){}, - toggleCollapse: function(){}, - remove: function(){}, - clear: function(){}, - - crop: function(){}, - uncrop: function(){}, - - // NOTE: this is auto-populated by plugin.style(..)... - __styles: undefined, - - // block render... - // - // This will call plugins': - // .__pre_parse__(..) - // .__parse__(..) - // .__post_parse__(..) - // - // XXX PRE_POST_NEWLINE can we avoid explicitly patching for empty lines after pre??? - __code2html__: function(code, elem={}){ - var that = this - - // only whitespace -> keep element blank... - elem.text = code - if(code.trim() == ''){ - 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 format: - // [ 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) - // patch for showing trailing empty lines in dom... - elem.text = - (elem.text == '' - // XXX PRE_POST_NEWLINE can we avoid this?? - // ...simply .replace(/\n$/, '\n ') does not solve - // this -- doubles the single trailing empty line after pre... - // ...this will require a test for all block elements eventually (???) - || elem.text.trim().endsWith('')) ? - elem.text - // NOTE: adding a space here is done to prevent the browser - // from hiding the last newline... - : elem.text + ' ' - - return elem }, - - // output format... - __code2text__: function(code){ - return code - .replace(/(\n\s*)-/g, '$1\\-') }, - __text2code__: function(text){ - text = text - .replace(/(\n\s*)\\-/g, '$1-') - return this.trim_block_text ? - text.trim() - : text }, - - parse: function(text){ - var that = this - text = text - .replace(/^[ \t]*\n/, '') - text = ('\n' + text) - .split(/\n([ \t]*)(?:- |-\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 attrs = {} - attrs.text = that.__text2code__( - that.threadPlugins('__parse_code__', block, that, attrs) - // normalize indent... - .split(new RegExp('\n'+sep+' ', 'g')) - .join('\n')) - parent.push({ - collapsed: false, - focused: false, - ...attrs, - children: [], - }) - // indent... - } else { - parent.at(-1).children = level(lst, sep) } } - return parent } - return level(text) }, - - data: function(){}, - load: function(){}, - - // Format: - // ::= [ - // { - // text: , - // children: , - // ... - // }, - // ... - // ] - // XXX - json: function(){}, - - // XXX add plugin hooks... - // XXX add option to customize indent size... - text: function(node, indent, level){ - var that = this - // .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 +' ') - // attrs... - + (Object.keys(elem) - .reduce(function(res, attr){ - return that.__system_attrs__.includes(attr) ? - res - : res - + (elem[attr] ? - '\n'+level+' ' + `${ attr }:: ${ elem[attr] }` - : '') }, '')), - (elem.children - && elem.children.length > 0) ? - this.text(elem.children || [], indent, level+indent) - : [] ) } - return text - .flat() - .join('\n') }, - - // XXX add read-only option... - htmlBlock: function(data, options={}){ - var that = this - - var parsed = this.__code2html__(data.text, {...data}) - - var cls = parsed.style ?? [] - delete parsed.style - - var attrs = [] - - for(var [attr, value] of Object.entries({...data, ...parsed})){ - if(this.__system_attrs__.includes(attr)){ - continue } - var i - var type = this.__block_attrs__[attr] - if(type == 'cls'){ - value ? - cls.push(attr) - : (i = cls.indexOf(attr)) >= 0 ? - cls.splice(i, 1) - : undefined - } else if(type == 'attr' - || type == undefined){ - // special case: dataset attrs... - if(type == undefined){ - attr = 'data-'+ attr } - typeof(value) == 'boolean'? - (value ? - attrs.push(attr) - : (i = attrs.indexOf(attr)) >= 0 ? - attrs.splice(i, 1) - : undefined) - : value != null ? - attrs.push(`${attr}="${value}"`) - : (i = attrs.indexOf(attr)) >= 0 ? - attrs.splice(i, 1) - : undefined } } - - var children = (data.children ?? []) - .map(function(data){ - return that.htmlBlock(data) }) - .join('') - // NOTE: the '\n' at the start of the textarea body below helps - // preserve whitespace when parsing HTML... - return ( -`
\ -\ -${ parsed.text }\ -
${ children }
\ -
`) }, - html: function(data, options=false){ - var that = this - if(typeof(data) == 'boolean'){ - options = data - data = undefined } - data = data == null ? - this.json() - : typeof(data) == 'string' ? - this.parse(data) - : data instanceof Array ? - data - : [data] - options = - typeof(options) == 'boolean' ? - {full: options} - : (options - ?? {}) - - var nodes = data - .map(function(data){ - return that.htmlBlock(data) }) - .join('') - - return !options.full ? - nodes - : ( -`
\ -
\ -\ -
${ nodes }
\ -
`) }, -} - - - -// XXX experiment with a concatinative model... -// .get(..) -> Outline (view) -var Outline = { - __proto__: JSONOutline, - - dom: undefined, - - // config... - // - left_key_collapses: true, - right_key_expands: true, - change_interval: 1000, - tab_size: 4, - carot_jump_edge_then_block: false, - // XXX not sure what should the default be... - trim_block_text: false, - - pre_plugins: [ - ...JSONOutline.pre_plugins, - ], - norm_plugins: [ - ...JSONOutline.norm_plugins, - ], - post_plugins: [ - ...JSONOutline.post_plugins, - ], - - - get header(){ - return this.dom?.querySelector('.header') }, - get outline(){ - return this.dom?.querySelector('.outline') }, - get toolbar(){ - return this.dom?.querySelector('.toolbar') }, - - get code(){ - return this.dom?.querySelector('.code')?.value }, - set code(value){ - if(value == null){ - return } - var c = this.dom?.querySelector('.code') - if(c){ - c.value = value } }, - - - path: function(node='focused', mode='index'){ - if(['index', 'text', 'node', 'data'].includes(node)){ - mode = node - node = 'focused' } - var outline = this.outline - var path = [] - var node = this.get(node) - while(node != outline){ - path.unshift( - mode == 'index' ? - this.get(node, 'siblings').indexOf(node) - : mode == 'text' ? - node.querySelector('.view').innerText - : mode == 'data' ? - this.data(node) - : node) - node = this.get(node, 'parent') } - return path }, - - // - // .get()[, ] - // .get([, ]) - // .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('viewport') - // .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 - - // id... - if(typeof(node) == 'string' && node[0] == '#'){ - node = outline.querySelector(node) } - - // root nodes... - if(node == 'top'){ - return [...outline.children] } - // groups defaulting to .outline as base... - if(['all', 'visible', 'editable', 'selected', 'viewport'].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 instanceof Array ? - [node - .reduce(function(res, i){ - return that.get(res, 'children')[i] }, outline), - 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.querySelector('.view').offsetParent != null }) - return e.offsetParent != null }) - : offset == 'viewport' ? - [...node.querySelectorAll('.block')] - .filter(function(e){ - //return e.querySelector('.view').offsetParent != null - // && e.querySelector('.code').visibleInViewport() }) - return e.offsetParent != null - && e.visibleInViewport() }) - : offset == 'editable' ? - [...node.querySelectorAll('.block>.code')] - : offset == 'selected' ? - [...node.querySelectorAll('.block[selected]')] - .filter(function(e){ - //return e.querySelector('.view').offsetParent != null }) - 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) - ?? this.get(0) - if(elem){ - var cur = this.get() - var blocks = this.get('visible') - elem.focus({preventScroll: true}) - ;(elem.classList.contains('code') ? - elem - : elem.querySelector('.code')) - .scrollIntoView({ - block: 'nearest', - // smooth for long jumps and instant for short jumps... - behavior: (cur == null - || Math.abs(blocks.indexOf(cur) - blocks.indexOf(elem)) > 2) ? - 'smooth' - : 'instant' - }) } - return elem }, - edit: function(node='focused', offset){ - var elem = this.get(...arguments) - if(!elem.classList.contains('code')){ - elem = elem.querySelector('.code') } - elem?.focus() - return elem }, - - // 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(options={}){ - var that = this - - // handle undo... - options.undo - && this.setUndo(...options.undo) - - // long changes... - this.__change_requested = true - if(this.__change_timeout){ - return this } - - // do the action... - if(this.__change_requested){ - this.sync() - this.runPlugins('__change__', this) - this.__change_requested = false } - - this.__change_timeout = setTimeout( - function(){ - that.__change_timeout = undefined - that.__change_requested - && that.__change__() }, - that.change_interval || 1000) - return this }, - - /* XXX not used -- do we need this?? - // XXX UPDATE_CODE_SIZE this is a no-op at this point -- do we need this??? - _updateCodeSize: function(code, view){ - // XXX - return this - code.style.height = - getComputedStyle( - view - ?? code.nextSibling) - .height - return this }, - _updateViewSize: function(view, code){ - view.style.height = - getComputedStyle( - code - ?? view.previousSibling) - .height - return this }, - // XXX not a good solution... - _syncTextSize: function(code, view){ - code = code.classList.contains('block') ? - code.querySelector('.code') - : code - view = view - ?? code.nextSibling - code.updateSize() - return view.offsetHeight > code.offsetHeight ? - this._updateCodeSize(code, view) - : this._updateViewSize(view, code) }, - //*/ - - // Update node from data... - // - // NOTE: this does not internally handle undo as it would be too - // granular... - // NOTE: to remove an attribute set it's value to null, undefined, - // 'null', or 'undefined' - update: function(node='focused', data){ - var node = this.get(node) - data ??= this.data(node, false) - - var parsed = {} - if('text' in data){ - var code = node.querySelector('.code') - var html = node.querySelector('.view') - if(this.__code2html__){ - // NOTE: we are ignoring the .collapsed attr here - parsed = this.__code2html__(data.text, {...data}) - html.innerHTML = parsed.text - // heading... - this.__styles != null - && node.classList.remove(...this.__styles) - parsed.style - && node.classList.add(...parsed.style) - delete parsed.style - } else { - html.innerHTML = data.text } - code.value = data.text - code.updateSize() } - // NOTE: this will have no effect if the element is not attached... - //this._updateCodeSize(code) } - //this._syncTextSize(code, html) } - - for(var [attr, value] of Object.entries({...data, ...parsed})){ - if(this.__system_attrs__.includes(attr)){ - continue } - - // quoted value... - if(value && /^\s*([`'"])([^\1]*)\1\s*$/.test(value)){ - value = value.replace(/^\s*([`'"])([^\1]*)\1\s*$/, '$2') } - - var type = this.__block_attrs__[attr] - if(type == 'cls'){ - value ? - node.classList.add(attr) - : node.classList.remove(attr) - } else if(type == 'attr'){ - typeof(value) == 'boolean'? - (value ? - node.setAttribute(attr, '') - : node.removeAttribute(attr)) - : value != null ? - node.setAttribute(attr, value) - : node.removeAttribute(attr) - // dataset... - } else { - // remove attr... - if(value == null - || value == 'null' - || value == 'undefined'){ - delete node.dataset[attr] - } else { - node.dataset[attr] = value } } } - this.__change__() - return node }, - - // edit... - indent: function(node='focused', indent='in'){ - // .indent() - if(node === 'in' || node === 'out'){ - indent = node - node = 'focused' } - var cur = this.get(node) - if(!cur){ - return } - var prev = this.path(cur) - var siblings = this.get(node, 'siblings') - // deindent... - if(indent == 'out'){ - 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__({undo: [ - this.path(cur), - 'indent', - ['in'], - prev ]}) } - // indent... - } else { - var parent = siblings[siblings.indexOf(cur) - 1] - if(parent){ - parent.lastChild.append(cur) - this.__change__({undo: [ - this.path(cur), - 'indent', - ['out'], - prev ]})} } - return cur }, - shift: function(node='focused', direction){ - if(node == 'up' || node == 'down'){ - direction = node - node = 'focused' } - if(direction == null - || (direction !== 'up' - && direction != 'down')){ - 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) - } else if(direction == 'down' - && i < siblings.length-1){ - siblings[i+1].after(node) } - focused - && this.focus() - this.__change__({undo: [ - this.path(node), - 'shift', - [direction == 'up' ? - 'down' - : 'up'] ]}) - return this }, - // XXX make undo a bit more refined... - remove: function(node='focused'){ - var elem = this.get(...arguments) - // XXX HACK... - var data = this.json() - 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 - && this.focus(next) - this.__change__({undo: [ - undefined, - 'load', - // XXX HACK... - [data] ]}) - return this }, - clear: function(){ - var data = this.json() - this.outline.innerText = '' - this.__change__({undo: [ - undefined, - 'load', - [data] ]}) - return this }, - - // expand/collapse... - 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 - state ? - node.setAttribute('collapsed', '') - : node.removeAttribute('collapsed') - this.__change__() - return node }, - 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 }, - - // crop... - // XXX the header links are not component-compatible... - crop: function(node='focused'){ - this.dom.classList.add('crop') - for(var block of [...this.outline.querySelectorAll('[cropped]')]){ - block.removeAttribute('cropped') } - this.get(...arguments).setAttribute('cropped', '') - // build header path... - this.header.innerHTML = - `/ ` - + this.path(...arguments, 'text') - .slice(0, -1) - .map(function(s, i, {length}){ - return `${ - plugin.encode(s) - } ` }) - .join(' / ') - return this }, - uncrop: function(count=1){ - var outline = this.outline - var top = this.get(0) - for(var block of [...this.outline.querySelectorAll('[cropped]')]){ - block.removeAttribute('cropped') } - // crop parent if available... - while(count != 'all' - && count > 0 - && top !== outline){ - top = this.get(top, 'parent') - count-- } - if(count == 'all' || top === outline){ - this.dom.classList.remove('crop') - this.header.innerHTML = '' - } else { - this.crop(top) } - return this }, - - // undo... - // NOTE: calling .setUndo(..) will drop the redo stack, but this does - // not happen when calling a method via .undo(..)/.redo(..) as we - // are reassigning the stacks manually. - __undo_stack: undefined, - __redo_stack: undefined, - setUndo: function(path, action, args, next){ - ;(this.__undo_stack ??= []).push([path, action, args, next]) - this.__redo_stack = undefined - return this }, - mergeUndo: function(n, stack){ - stack ??= this.__undo_stack - if(stack == null || stack.length == 0){ - return this } - stack.push( - stack.splice(-n, n) - .map(function(e){ - return typeof(e[1]) == 'string' ? - [e] - : e }) - .flat()) - return this }, - clearUndo: function(){ - this.__undo_stack = undefined - this.__redo_stack = undefined - return this }, - __undo: function(from, to){ - if(from == null - || from.length == 0){ - return [from, to] } - var actions = from.pop() - actions = typeof(actions[1]) == 'string' ? - [actions] - : actions - while(actions.length > 0){ - var [path, action, args, next] = actions.pop() - var l = from.length - path != null - && this.focus(path) - this[action](...args) - next != null ? - this.focus(next) - : this.focus() } - if(l < from.length){ - to ??= [] - to.push( - ...from.splice(l, from.length)) } - if(from.length == 0){ - from = undefined } - return [from, to] }, - undo: function(){ - ;[this.__undo_stack, this.__redo_stack] = - this.__undo(this.__undo_stack, this.__redo_stack) - return this }, - redo: function(){ - ;[this.__redo_stack] = this.__undo(this.__redo_stack) - return this }, - - // serialization... - data: function(elem, deep=true){ - var that = this - elem = - // all elements... - (elem == 'all' || elem == 'root' || elem == '*') ? - [...this.outline.children] - : elem instanceof Array ? - elem - : this.get(elem) - // multiple nodes... - if(elem instanceof Array){ - return elem - .map(function(elem){ - return that.data(elem) }) } - // single node... - // XXX move these to config... - var attrs = this.__block_attrs__ - var cls_attrs = ['focused'] - return { - // NOTE: this is first to prevent it from overriding system attrs... - ...elem.dataset, - text: elem.querySelector('.code').value, - ...(Object.entries(attrs) - .reduce(function(res, [attr, type]){ - if(type == 'attr'){ - var val = elem.getAttribute(attr) - if(val != null){ - res[attr] = val == '' ? - true - : val } } - if(type == 'cls'){ - elem.classList.contains(attr) - && (res[attr] = true) } - return res }, {})), - ...(deep ? - {children: this.data([...elem.lastChild.children])} - : {}), - } }, - // Same as .data(..) but by default returns the root nodes. - // NOTE: this always returns an array - json: function(node='all'){ - return [this.data(...( - arguments.length == 0 ? - ['all'] - : arguments))] - .flat() }, - - // 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') - // XXX hack?? - block.setAttribute('cropped', '') - // 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 - // ... ... - // cur cur - // new new <- before the next after cur - // --- --- - // ... ... - ;(place == 'next' - // has children (uncollapsed)... - && (cur.querySelector('.block')?.offsetParent - // not last sibling... - || cur !== this.get('siblings').at(-1))) ? - this.get(place).before(block) - // ... - // --- - // cur - // new <- next after cur - // ... - : (place == 'next' - // last sibling... - && cur === this.get('siblings').at(-1)) ? - cur.after(block) - : (place == 'before' || place == 'after') ? - cur[place](block) - : undefined - - //this._updateCodeSize(code) - //this._syncTextSize(code, view) - - this.setUndo(this.path(cur), 'remove', [this.path(block)]) } - return block }, - /*/ XXX - load: function(data){ - var that = this - this.dom.classList.add('loading') - data = typeof(data) == 'string' ? - this.parse(data) - : data instanceof Array ? - data - : data == null ? - this.json() - : [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)... - //// NOTE: this is needed to make initial clicking into multi-line - //// blocks place the cursor into the clicked location. - //// ...this is done by expanding the textarea to the element - //// size and enabling it to intercept clicks correctly... - //setTimeout(function(){ - // var f = that._updateCodeSize - // //var f = that._syncTextSize.bind(that) - // for(var e of [...that.outline.querySelectorAll('textarea')]){ - // f(e) } }, 0) - this.dom.classList.remove('loading') - return this }, - /*/ // XXX JSON version... - load: function(data){ - var that = this - this.dom.classList.add('loading') - data = typeof(data) == 'string' ? - this.parse(data) - : data instanceof Array ? - data - : data == null ? - this.json() - : [data] - - this.outline.innerHTML = this.html(data) - - //// update sizes of all the textareas (transparent)... - //// NOTE: this is needed to make initial clicking into multi-line - //// blocks place the cursor into the clicked location. - //// ...this is done by expanding the textarea to the element - //// size and enabling it to intercept clicks correctly... - //// XXX this is a hack -- need to style the thing in such away - //// so as to not require this step... - //setTimeout(function(){ - // var f = that._updateCodeSize.bind(that) - // //var f = that._syncTextSize.bind(that) - // for(var e of [...that.outline.querySelectorAll('textarea')]){ - // f(e) } }, 0) - this.dom.classList.remove('loading') - return this }, - //*/ - - sync: function(){ - this.code = this.text() - return this }, - - - // Actions... - prev: function(){}, - next: function(){}, - above: function(){}, - below: function(){}, - - up: function(){}, - down: function(){}, - left: function(){}, - right: function(){}, - - __overtravel_timeout: undefined, - __caret_x: undefined, - // XXX move the code here into methods/actions... - // XXX use keyboard.js... - keyboard: { - // XXX might be a good feature to add to keyboard.js... - // ...might even be fun to extend this and add key classes, - // like: - // Modifier - // Function - // Letter - // Number - // ... - // Unhandled - Any: function(evt, key){ - if(this.__caret_x - && this.get('edited') - && key != 'ArrowUp' - && key != 'ArrowDown'){ - this.__caret_x = undefined } }, - - // 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 - - // overtravel... - var overtravel = - this.__overtravel_timeout != null - && this.get() === this.get(0) - this.__overtravel_timeout != null - && clearTimeout(this.__overtravel_timeout) - this.__overtravel_timeout = setTimeout(function(){ - that.__overtravel_timeout = undefined }, 100) - if(overtravel){ - return } - - var edited = this.get('edited') - if(edited){ - var g = edited.getTextGeometry() - if(g.line == 0){ - evt.preventDefault() - //var left = edited.getBoundingClientRect().x + g.offsetLeft - var left = this.__caret_x = - this.__caret_x - ?? edited.getBoundingClientRect().x + g.offsetLeft - edited = that.edit('prev') - // keep caret horizontally constrained... - var bottom = edited.getBoundingClientRect().bottom - /*/ XXX CARET_V_MOVE this is not correct yet... - var view = this.get(edited).querySelector('.view') - var c = getCharOffset(view, left, bottom - 1) - var m = getMarkdownOffset(edited.value, view.innerText, c) - console.log('---', c, m) - edited.selectionStart = - edited.selectionEnd = - c - m } - /*/ - edited.selectionStart = - edited.selectionEnd = - edited.getTextOffsetAt(left, bottom - 1) } - //*/ - } else { - evt.preventDefault() - this.focus('focused', -1) } }, - ArrowDown: function(evt){ - var that = this - - // overtravel... - var overtravel = - this.__overtravel_timeout != null - && this.get() === this.get(-1) - this.__overtravel_timeout != null - && clearTimeout(this.__overtravel_timeout) - this.__overtravel_timeout = setTimeout(function(){ - that.__overtravel_timeout = undefined }, 100) - if(overtravel){ - return } - - var edited = this.get('edited') - if(edited){ - var g = edited.getTextGeometry() - if(g.lines == 0 || g.line == g.lines - 1){ - evt.preventDefault() - //var left = edited.getBoundingClientRect().x + g.offsetLeft - var left = this.__caret_x = - this.__caret_x - ?? edited.getBoundingClientRect().x + g.offsetLeft - edited = that.edit('next') - // keep caret horizontally constrained... - var top = edited.getBoundingClientRect().y - /* XXX CARET_V_MOVE this needs fixing... - var view = this.get(edited).querySelector('.view') - var c = getCharOffset(view, left, top - 1) - var m = getMarkdownOffset(edited.value, view.innerText, c) - console.log('---', c, m) - edited.selectionStart = - edited.selectionEnd = - c - m } - /*/ - edited.selectionStart = - edited.selectionEnd = - edited.getTextOffsetAt(left, top + 1) } - //*/ - } 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 that = this - - // overtravel... - var overtravel = - this.__overtravel_timeout != null - && this.get() === this.get(-1) - this.__overtravel_timeout != null - && clearTimeout(this.__overtravel_timeout) - this.__overtravel_timeout = setTimeout(function(){ - that.__overtravel_timeout = undefined }, 100) - if(overtravel){ - return } - - 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') } }, - - Home: function(evt){ - if(this.get('edited') - && !evt.ctrlKey){ - return } - evt.preventDefault() - this.focus(0) }, - End: function(evt){ - if(this.get('edited') - && !evt.ctrlKey){ - return } - evt.preventDefault() - this.focus(-1) }, - PageUp: function(evt){ - var that = this - if(this.get('edited')){ - return } - if(evt.shiftKey - || evt.ctrlKey){ - evt.preventDefault() - this.shift('up') - } else { - var viewport = that.get('viewport') - viewport[0] === that.get(0) ? - that.focus(0) - : that.focus( - viewport[0], 'prev') } }, - PageDown: function(evt){ - var that = this - if(this.get('edited')){ - return } - if(evt.shiftKey - || evt.ctrlKey){ - evt.preventDefault() - this.shift('down') - } else { - var viewport = that.get('viewport') - viewport.at(-1) === that.get(-1) ? - that.focus(-1) - : that.focus( - that.get('viewport').at(-1), 'next') } }, - - // indent.. - Tab: function(evt){ - evt.preventDefault() - var edited = this.get('edited') - var node = this.show( - this.indent(evt.shiftKey ? - 'out' - : 'in')) - // 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') - if(edited){ - if(evt.ctrlKey - || evt.shiftKey){ - var that = this - // NOTE: setTimeout(..) because we need the input of - // the key... - setTimeout(function(){ - that.update(edited) }, 0) - return } - // split text... - evt.preventDefault() - var a = edited.selectionStart - var b = edited.selectionEnd - // position 0: focus empty node above... - if(a == 0 - && edited.value.trim() != ''){ - this.Block('prev') - this.edit('prev') - // focus new node... - } else { - var prev = edited.value.slice(0, a) - var next = edited.value.slice(b) - edited.value = prev - this.Block({text: next}, 'next') - edited = this.edit('next') - edited.selectionStart = 0 - edited.selectionEnd = 0 - this.mergeUndo(2) } - return } - // view -> edit... - evt.preventDefault() - this.edit() }, - Escape: function(evt){ - if(this.get('edited')){ - this.focus() - } else { - this.uncrop() } }, - s_Escape: function(evt){ - if(this.get('edited')){ - this.focus() - } else { - this.uncrop('all') } }, - c: function(evt){ - if(!this.get('edited')){ - this.crop() } }, - c_z: function(evt){ - if(!this.get('edited')){ - evt.preventDefault() - this.undo() } }, - c_s_z: function(evt){ - if(!this.get('edited')){ - evt.preventDefault() - this.redo() } }, - U: function(evt){ - if(!this.get('edited')){ - this.redo() } }, - u: function(evt){ - if(!this.get('edited')){ - this.undo() } }, - - 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 } }, - - a_s: function(evt){ - // toggle done... - evt.preventDefault() - tasks.toggleStatus(this) }, - a_x: function(evt){ - // toggle done... - evt.preventDefault() - tasks.toggleDone(this) }, - a_r: function(evt){ - // toggle done... - evt.preventDefault() - tasks.toggleReject(this) }, - - // selection... - // XXX need more work... - // - should we select the .block or .text??? - // - we should remember the first state and apply it (a-la FAR) - // and not simply toggle on/off per node... - Shift: function(evt){ - if(this.get('edited')){ - return } - // XXX set selection mode - // ...need to reset this when shift key is released... - // one way to do this is to save a press id and reset - // it each call -- if the id has changed since lass s-up - // is pressed then reset mode... - }, - s_ArrowUp: function(evt){ - if(this.get('edited')){ - return } - var elem = this.get() - elem.hasAttribute('selected') ? - elem.removeAttribute('selected') - : elem.setAttribute('selected', '') - this.keyboard.ArrowUp.call(this, evt) }, - s_ArrowDown: function(evt){ - if(this.get('edited')){ - return } - var elem = this.get() - elem.hasAttribute('selected') ? - elem.removeAttribute('selected') - : elem.setAttribute('selected', '') - this.keyboard.ArrowDown.call(this, evt) }, - c_d: function(evt){ - if(this.get('edited')){ - return } - evt.preventDefault() - for(var e of this.get('selected')){ - e.removeAttribute('selected') } }, - c_a: function(evt){ - if(this.get('edited')){ - return } - evt.preventDefault() - for(var e of this.get('all')){ - e.setAttribute('selected', '') } }, - - // 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... - // XXX support selection from first click... (see: mousemove handler) - var selecting, start - outline.addEventListener('mousedown', - function(evt){ - var elem = evt.target - // prevent clicking through children to parent elements... - if(elem.classList.contains('children')){ - evt.preventDefault() - outline.focus() - return } - // place the cursor where the user clicked in code/text... - if(elem.classList.contains('code') - && document.activeElement !== elem){ - that.__caret_x = undefined - var view = that.get(elem).querySelector('.view') - var initial = elem.selectionStart - var c = getCharOffset(view, evt.clientX, evt.clientY) - var m = getMarkdownOffset(elem.value, getText(view), c) - // selecting an element with text offset by markup... - if(m != 0){ - evt.preventDefault() - selecting = elem } - start = c == null ? - elem.value.length - : c + m - // NOTE: this is done on next frame to allow the - // browser to place the caret before we correct - // its position... (if .preventDefault() was not called) - setTimeout(function(){ - elem.focus() - elem.selectionStart = - elem.selectionEnd = - start }, 0) } }) - outline.addEventListener('mousemove', - function(evt){ - // handle selection in element with text offset by markup... - if(selecting != null){ - var c = selecting.getTextOffsetAt(evt.clientX, evt.clientY) - if(c > start){ - selecting.selectionStart = start - selecting.selectionEnd = c - } else { - selecting.selectionStart = c - selecting.selectionEnd = start } } }) - outline.addEventListener('mouseup', - function(evt){ - selecting = undefined }) - 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() } - - // focus viewport... - // XXX this does not work because by this point there is - // no focused element... - if(elem === outline){ - var cur = that.get() - var viewport = that.get('viewport') - if(!viewport.includes(cur)){ - var visible = that.get('visible') - var i = visible.indexOf(cur) - var v = visible.indexOf(viewport[0]) - i < v ? - that.focus(viewport[0]) - : that.focus(viewport.at(-1)) } } - - 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 } - - // handle keyboard... - // 'Any' key... - if('Any' in that.keyboard){ - if(that.keyboard.Any.call(that, evt, evt.key) === false){ - return } } - // keys/mods... - var keys = [] - evt.ctrlKey - && keys.push('c_' + evt.key) - evt.ctrlKey && evt.altKey - && keys.push('c_a_' + evt.key) - evt.ctrlKey && evt.shiftKey - && keys.push('c_s_' + evt.key) - evt.altKey && evt.ctrlKey && evt.shiftKey - && keys.push('c_a_s_' + evt.key) - evt.altKey - && keys.push('a_' + evt.key) - evt.altKey && evt.shiftKey - && keys.push('a_s_' + evt.key) - evt.shiftKey - && keys.push('s_' + evt.key) - keys.push(evt.key) - for(var k of keys){ - if(k in that.keyboard){ - that.keyboard[k].call(that, evt, k) - break } } }) - // update code block... - outline.addEventListener('keyup', - function(evt){ - var elem = evt.target - // update element state... - if(elem.classList.contains('code')){ - // NOTE: for some reason setting the timeout here to 0 - // makes FF sometimes not see the updated text... - setTimeout(function(){ - that.update(elem.parentElement) }, 0) } - that.runPlugins('__keyup__', evt, that, elem) }) - - // 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... - if(elem !== that.outline){ - 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.dataset.original = elem.value - elem.updateSize() } - - // XXX do we need this??? - that.runPlugins('__focusin__', evt, that, elem) }) - outline.addEventListener('focusout', - function(evt){ - var elem = evt.target - // update code... - if(elem.classList.contains('code')){ - var block = that.get(elem) - // clean out attrs... - elem.value = - that.trim_block_text ? - that.threadPlugins('__parse_code__', elem.value, that).trim() - : that.threadPlugins('__parse_code__', elem.value, that) - that.update(block) - // undo... - if(elem.value != elem.dataset.original){ - that.setUndo( - that.path(elem), - 'update', - [that.path(elem), { - ...that.data(elem), - text: elem.dataset.original, - }]) - delete elem.dataset.original } - // give the browser a chance to update the DOM... - // XXX revise... - setTimeout(function(){ - that.runPlugins('__editedcode__', evt, that, elem) - // this will resize the text to fill the available area... - elem.style.removeProperty('height') }, 0) } - - that.runPlugins('__focusout__', evt, that, elem) }) - // update .code... - outline.addEventListener('change', - function(evt){ - that.__change__() }) - - // header... - var header = this.header - header.addEventListener('click', - function(evt){ - var elem = evt.target - if(elem.classList.contains('path-item')){ - that.uncrop(elem.getAttribute('uncrop') ?? 'all') } }) - - // 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('.code').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 - .replace(/</g, '<') - .replace(/>/g, '>')) - console.log(`Parse: ${Date.now() - t}ms`) } - this.clearUndo() - - this.runPlugins('__setup__', this) - - // autofocus... - if(this.dom.getAttribute('autofocus') != null){ - /* - setTimeout(function(){ - that.focus() }, 0) } - /*/ - // XXX this for some reason takes lots of time at this point... - this.focus() } - //*/ - - return this }, -} - - - -//--------------------------------------------------------------------- -// Custom element... - - -window.customElements.define('outline-editor', -window.OutlineEditor = -Object.assign( - function(){ - var obj = Reflect.construct(HTMLElement, [...arguments], OutlineEditor) - - var shadow = obj.attachShadow({mode: 'open'}) - - var style = document.createElement('link'); - style.setAttribute('rel', 'stylesheet'); - style.setAttribute('href', 'editor.css'); - - // XXX it is not rational to have this... - var editor = obj.dom = document.createElement('div') - editor.classList.add('editor') - - var header = document.createElement('div') - header.classList.add('header') - - var outline = document.createElement('div') - outline.classList.add('outline') - outline.setAttribute('tabindex', '0') - - //var toolbar = document.createElement('div') - //toolbar.classList.add('toolbar') - - // XXX can't yet get rid of the editor element here... - // - handling autofocus of host vs. shadow??? - // - CSS not working correctly yet... - // ...is this feasible??? - editor.append( - style, - header, - outline) - shadow.append(editor) - - console.log('SETUP') - obj.setup(editor) - - return obj }, - // constructor stuff... - { - observedAttributes: [ - 'value', - - 'session-storage', - 'local-storage', - ], - - prototype: Object.assign( - { - __proto__: HTMLElement.prototype, - - // XXX HACK these are copies from Outline, use - // object.mixin(...) instead... - get header(){ - return this.dom?.querySelector('.header') }, - set header(val){}, - get outline(){ - return this.dom?.querySelector('.outline') }, - set outline(val){}, - get toolbar(){ - return this.dom?.querySelector('.toolbar') }, - set toolbar(val){}, - - // NOTE: this is here to break recursion of trying to set - // html's value both in .code that is called both when - // setting .value and from .attributeChangedCallback(..) - get __code(){ - return this.code }, - set __code(value){ - if(value == null){ - return } - // XXX is this the right way to do this??? - this.__sessionStorage - && (sessionStorage[this.__sessionStorage] = value) - this.__localStorage - && (localStorage[this.__localStorage] = value) }, - get code(){ - return this.hasAttribute('value') ? - this.getAttribute('value') - : HTMLElement.decode(this.innerHTML) }, - set code(value){ - if(value == null){ - return } - // XXX this can break in conjunction with .attributeChangedCallback(..) - if(this.hasAttribute('value')){ - this.setAttribute('value', value) - } else { - this.innerHTML = HTMLElement.encode(value) } - this.__code = value }, - - // XXX do we need this??? - // ...rename .code -> .value ??? - get value(){ - return this.code }, - set value(value){ - this.code = value }, - - connectedCallback: function(){ - var that = this - // load the data... - setTimeout(function(){ - that.load(that.code) }, 0) }, - - // XXX do we need to before == after check??? - attributeChangedCallback(name, before, after){ - if(name == 'local-storage'){ - this.__localStorage = after - // NOTE: we setting .code here because we will - // .load(..) at .setup(..) - sessionStorage[after] - && (this.code = sessionStorage[after]) } - - if(name == 'session-storage'){ - this.__sessionStorage = after - sessionStorage[after] - && (this.code = sessionStorage[after]) } - - // NOTE: if other sources are active but unset this - // should provide the default, otherwise it will - // get overwritten by the value in .code by .load(..) - if(name == 'value'){ - // see notes for .__code - this.__code = after } - }, - - }, - // XXX this will fail due to all the getters/setters -- use object.mixin(..)... - Outline), - })) - - - - -/********************************************************************** -* vim:set ts=4 sw=4 : */ +/********************************************************************** +* +* +* +**********************************************************************/ + + + +//--------------------------------------------------------------------- +// Helpers... + +/* +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 do a binary search?? +var getCharOffset = function(elem, x, y, data){ + data = data ?? {} + var r = document.createRange() + var elem_rect = data.elem_rect = + data.elem_rect + ?? elem.getBoundingClientRect() + for(var e of [...elem.childNodes]){ + var prev + var c = data.c = + data.c + ?? 0 + // text node... + if(e instanceof Text){ + var rect, cursor_line, line_start, offset + for(var i=0; i < e.length; i++){ + r.setStart(e, i) + r.setEnd(e, i) + prev = rect + ?? data.prev + rect = r.getBoundingClientRect() + // line change... + // NOTE: this is almost identical to .getTextOffsetAt(..) see + // that for more docs... + line_start = prev + && prev.y != rect.y + if(line_start){ + if(cursor_line){ + return offset + ?? c + i - 2 } + offset = undefined } + cursor_line = + rect.y <= y + && rect.bottom >= y + if(offset == null + && rect.x >= x){ + // get closest edge of element under cursor... + var dp = Math.abs( + ((!prev || line_start) ? + elem_rect + : prev).x + - x) + var dx = Math.abs(rect.x - x) + offset = dx <= dp ? + c + i + : c + i - 1 + if(cursor_line){ + return offset } } } + data.c += i + data.last = e.data[i-1] + // html node... + } else { + prev = data.prev = + prev + ?? data.prev + // special case: line break between cursor line and next element... + if(prev + // cursor line... + && prev.y <= y + && prev.bottom >= y + // line break... + && prev.y < e.getBoundingClientRect().y + // no whitespace at end, no compensation needed... (XXX test) + && ' \t\n'.includes(data.last)){ + return data.c - 1 } + + // handle the node... + data = getCharOffset(e, x, y, data) + + if(typeof(data) != 'object'){ + return data } } } + return arguments.length > 3 ? + data + // root call... + : data.c } + + +// Get offset in markdown relative to the resulting text... +// +// v <----- position +// text: 'Hea|ding' +// | +// +-+ <--- offset in markdown +// | +// markdown: '# Hea|ding' +// +// XXX should this be replaced with offsetAt(..)??? +var getMarkdownOffset = function(markdown, text, i){ + i = i ?? text.length + var m = 0 + // walk both strings skipping/counting non-matching stuff... + for(var t=0; t <= i; t++, m++){ + var c = text[t] + 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 - t } +var offsetAt = function(A, B, i){ + i ??= A.length-1 + var o = 0 + var p = 0 + for(var n=0; n <= i; n++){ + while(A[n] != B[n+o]){ + if(n+o >= B.length){ + o = p + break } + o++ } + p = o } + return o } + + +// Get element text content... +// +// NOTE: this is the same as .innerText but will not add extra "\n" after +// each block element... +var getTexts = function(elem, res=[]){ + for(var n of elem.childNodes){ + n.nodeType == n.TEXT_NODE ? + res.push(n.textContent) + : getTexts(n, res) } + return res } +var getText = function(elem){ + return getTexts(elem).join('') } + + + +//--------------------------------------------------------------------- +// 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) + // handler... + if(typeof(code) == 'function'){ + return code(...arguments) } + // explicit code... + if(code != null){ + return code } + // get first non-empty group... + var groups = [...arguments].slice(1, -2) + while(groups.length > 0 + && groups[0] == null){ + groups.shift() } + return groups[0] + ?? '' } }, +} + + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Process attributes in code and update the attributes in element data... +// +// This does: +// - parse attributes +// - update element data (JSON) +// - filter attributes out (optionally) +// +var attributes = { + __proto__: plugin, + + // XXX should attr settings be set here or in the Outline??? + // ...this includes .__block_attrs__ and .__system_attrs__ + + // + // Parse attrs... + // .parseBlockAttrs([, ]) + // -> [, , ] + // + parseBlockAttrs: function(editor, text, elem={}){ + var system = editor.__block_attrs__ + var attrs = '' + var sysattrs = '' + elem.text = text + // XXX for some reason changing the first group into (?<= .. ) + // still eats up the whitespace... + // ...putting the same pattern in a normal group and + // returning it works fine... + //.replace(/(?<=[\n\h]*)(?:(?:\n|^)\s*\w*\s*::\s*[^\n]*\s*)*$/, + .replace(/([\n\t ]*)(?:(?:\n|^)[\t ]*\w+[\t ]*::[\t ]*[^\n]+[\t ]*)+$/, + function(match, ws){ + match = match + .trim() + .split(/(?:[\t ]*::[\t ]*|[\t ]*\n[\t ]*)/g) + while(match.length > 0){ + var [name, val] = match.splice(0, 2) + // ignore non-settable attrs... + if(editor.__system_attrs__.includes(name)){ + continue } + elem[name] = + val == 'true' ? + true + : val == 'false' ? + false + : val } + return ws }) + // build the attr strings... + // NOTE: we are not doing this in the loop above to include all + // the attributes that are in the elem but not explicitly + // given in code... + for(var name in elem){ + // ignore non-settable attrs... + if(editor.__system_attrs__.includes(name)){ + continue } + var val = elem[name] + if(!(name in system)){ + attrs += `\n${name}::${val}` + } else { + sysattrs += `\n${name}::${val}` } } + return [ + elem, + attrs, + sysattrs, + ] }, + + // generate code... + // + // this is controlled by the value of editor.__code_attrs__: + // false / undefined - strip attrs + // true - add attrs to code if available + // 'all' - add attrs, including system attrs to + // code if available, + __parse_code__: function(code, editor, elem){ + var [elem, attrs, system] = this.parseBlockAttrs(editor, code, elem) + return !editor.__code_attrs__ ? + elem.text + : editor.__code_attrs__ == 'all' ? + elem.text + + (attrs.length > 0 ? + '\n'+ attrs + : '') + + (system.length > 0 ? + '\n'+ system + : '') + : attrs.length > 0 ? + elem.text +'\n'+ attrs + : elem.text }, + + // generate view... + // + // this is controlled by the value of editor.__view_attrs__: + // false / undefined - strip attrs + // true - call the handler XXX + __pre_parse__: function(text, editor, elem){ + // NOTE: we are intentionally neglecting system attrs here... + var [elem, attrs, system] = this.parseBlockAttrs(editor, text, elem) + if(editor.__view_attrs__ + && attrs.length > 0){ + attrs = editor.threadPlugins('__parse_attrs__', attrs, editor, elem) + if(attrs && attrs.length > 0){ + return text +'\n'+ attrs } } + return elem.text }, + + // XXX + //__parse_attrs__: function(attrs, editor, elem){ + // return attrs } +} + + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// XXX revise headings... +var blocks = { + __proto__: plugin, + + __pre_parse__: function(text, editor, elem){ + return text + // markdown... + // style: headings... + /* XXX chose either this or auto headings -- move docs... + .replace(/^(?\s+([^]*)$/, this.style(editor, elem, 'quote')) + .replace(/^\s*(?')) } , +} + + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// XXX add actions... +var quoted = { + __proto__: plugin, + + // can be used in: + // .replace(quoted.pattern, quoted.handler) + quote_pattern: /(?${ this.encode(code) }` }, + + pre_pattern: /(?` + +`${ + this.preEncode(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 + // XXX move this to keyboard.js... + if(evt.key == 'Escape'){ + editor.focus(elem) } + // XXX not sure if the is needed with keyboard.js... + 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, + + status: [ + 'DONE', + 'REJECT', + //'TODO', + ], + // format: + // [ + // : , + // ... + // ] + __status_patterns: undefined, + __status_pattern_tpl: `^(?:\\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, false) + var data = editor.data(node) + 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) + // NOTE: status is updated via a timeout set in .__parse__(..)... + editor.setUndo( + editor.path(node), + 'update', + [editor.path(node), + data]) + 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) }, + // Status... + toggleStatus: function(editor, elem, status='next', patterns=this.status_patterns){ + var node = editor.get(elem) + if(node == null){ + return } + var data = editor.data(elem, false) + var text = node.querySelector('.code') + var value = text.value + var s = text.selectionStart + var e = text.selectionEnd + var l = text.value.length + + var p = Object.entries(patterns) + for(var i=0; i')) + .replace(/^\s*(?')) + // inline checkboxes... + .replace(/\s*(?')) + .replace(/\s*(?')) + // completion... + // XXX add support for being like a todo checkbox... + .replace(/(?') + // need to update status... + // XXX not sure if this is a good way to do this... + if(res != text && this.__update_checkboxes_timeout == null){ + var that = this + this.__update_checkboxes_timeout = setTimeout(function(){ + that.__update_checkboxes_timeout = undefined + that.updateAllStatus(editor) }, 200) } + return res }, + __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 do a better default id... +// XXX make this persistent -- add to code... +var toc = { + __proto__: plugin, + + __skip_local_root__: true, + + update: function(editor, elem){ + var that = this + var outline = editor.outline + var TOCs = [...outline.querySelectorAll('.TOC .view')] + var tocs = [...outline.querySelectorAll('.toc .view')] + if(TOCs.length + tocs.length == 0){ + return } + + var level = function(node, root=outline){ + var depth = 0 + var parent = node + while(parent !== root + && parent != null){ + if(parent.classList.contains('block') + && parent.classList.contains('heading')){ + depth++ } + parent = parent.parentElement } + return depth } + // XXX revise... + var seen = new Set() + var makeID = function(text){ + var id = encodeURI( + text + .trim() + .replace(/[#?$%:;.,]/g, '') + .replace(/\s+/g, '-')) + if(seen.has(id) + || document.getElementById(id)){ + var i = 1 + var candidate = id +'-'+ i + while(seen.has(candidate) + || document.getElementById(candidate)){ + candidate = id +'-'+ i++ } + id = id +'-'+ i } + seen.add(id) + return id } + var makeTOC = function(root=outline){ + var index = 0 + var lst = document.createElement('ul') + var list = lst + var depth = 1 + for(var e of [...root.querySelectorAll('.block.heading>.view')]){ + var block = editor.get(e) + // skip the root element??? + if(block.classList.contains('no-toc') + || (!that.__skip_local_root__ + && block === root)){ + continue } + var d = level(e, root) + // down... + if(d > depth){ + var sub = document.createElement('ul') + lst.append(sub) + lst = sub + depth++ + // up... + } else while(d < depth && depth > 0){ + lst = lst.parentElement ?? lst + depth-- } + var elem = document.createElement('li') + var id = block.id == '' ? + // XXX do a better default... + //'__'+ index++ + makeID(e.innerText) + : block.id + block.id = id + elem.innerHTML = `${e.innerHTML.trim()}` + lst.append(elem) } + return list } + + // global tocs... + var list = makeTOC() + for(var toc of TOCs){ + toc.innerHTML = '' + toc.append(list.cloneNode(true)) } + // local tocs... + for(var toc of tocs){ + toc.innerHTML = '' + toc.append( + makeTOC( + editor.get(toc, 'parent'))) } }, + + __setup__: function(editor){ + return this.update(editor) }, + __editedcode__: function(evt, editor, elem){ + return this.update(editor, elem) }, + + __parse__: function(text, editor, elem){ + return text + .replace(/^\s*toc\s*$/, + this.style(editor, elem, 'toc', '')) + .replace(/^\s*TOC\s*$/, + this.style(editor, elem, 'TOC', '')) }, +} + + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// 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, + + // XXX EXPERIMENTAL + __pre_parse__: function(text, editor, elem){ + return text + .replace(/^(--table--)$/m, this.style(editor, elem, 'table-2')) }, + + __parse__: function(text, editor, elem){ + return text + .replace(/^\s*(?${ + body + .trim() + .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(`(?: , + // ... + // } + __id_index: undefined, + + // format: + // Map([ + // [, ], + // ... + // ]) + __nodes: undefined, + + __path: undefined, + current: undefined, + + __iter: function*(node, path, mode){ + if(typeof(path) == 'string'){ + mode = path + path = null } + path ??= [] + yield [path, node] + if(mode == 'visible' + && node.collapsed){ + return } + var i = 0 + for(var e of node.children ?? []){ + yield* this.__iter(e, [...path, i++], mode) } }, + // XXX revise... + nodes: function*(node, mode){ + var i = 0 + // all nodes.. + if(node == null || node == 'all' || node == 'visible'){ + for(var e of this.json()){ + yield* this.__iter(e, [i++], node) } + // single node... + } else { + var args = [...arguments] + // XXX revise... + if(['all', 'visible'].includes(args.at(-1))){ + mode = args.pop() } + yield* this.__iter( + this.get(...args), + mode) } }, + [Symbol.iterator]: function*(mode='all'){ + for(var node of this.json()){ + for(var [_, n] of this.__iter(node, mode)){ + yield n } } }, + iter: function*(node, mode){ + for(var [_, n] of this.nodes(...arguments)){ + yield n } }, + + // XXX + path: function(){}, + get: function(node, offset){ + }, + focus: function(node, offset){ + return this.get( + this.__path = this.path(...arguments)) }, + + index: function(){}, + at: function(index){}, + + indent: function(){}, + shift: function(){}, + show: function(){}, + toggleCollapse: function(){}, + remove: function(){}, + clear: function(){}, + + crop: function(){}, + uncrop: function(){}, + + // NOTE: this is auto-populated by plugin.style(..)... + __styles: undefined, + + // block render... + // + // This will call plugins': + // .__pre_parse__(..) + // .__parse__(..) + // .__post_parse__(..) + // + // XXX PRE_POST_NEWLINE can we avoid explicitly patching for empty lines after pre??? + __code2html__: function(code, elem={}){ + var that = this + + // only whitespace -> keep element blank... + elem.text = code + if(code.trim() == ''){ + 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 format: + // [ 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) + // patch for showing trailing empty lines in dom... + elem.text = + (elem.text == '' + // XXX PRE_POST_NEWLINE can we avoid this?? + // ...simply .replace(/\n$/, '\n ') does not solve + // this -- doubles the single trailing empty line after pre... + // ...this will require a test for all block elements eventually (???) + || elem.text.trim().endsWith('')) ? + elem.text + // NOTE: adding a space here is done to prevent the browser + // from hiding the last newline... + : elem.text + ' ' + + return elem }, + + // output format... + __code2text__: function(code){ + return code + .replace(/(\n\s*)-/g, '$1\\-') }, + __text2code__: function(text){ + text = text + .replace(/(\n\s*)\\-/g, '$1-') + return this.trim_block_text ? + text.trim() + : text }, + + parse: function(text){ + var that = this + text = text + .replace(/^[ \t]*\n/, '') + text = ('\n' + text) + .split(/\n([ \t]*)(?:- |-\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 attrs = {} + attrs.text = that.__text2code__( + that.threadPlugins('__parse_code__', block, that, attrs) + // normalize indent... + .split(new RegExp('\n'+sep+' ', 'g')) + .join('\n')) + parent.push({ + collapsed: false, + focused: false, + ...attrs, + children: [], + }) + // indent... + } else { + parent.at(-1).children = level(lst, sep) } } + return parent } + return level(text) }, + + data: function(){}, + load: function(){}, + + // Format: + // ::= [ + // { + // text: , + // children: , + // ... + // }, + // ... + // ] + // XXX + json: function(){}, + + // XXX add plugin hooks... + // XXX add option to customize indent size... + text: function(node, indent, level){ + var that = this + // .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 +' ') + // attrs... + + (Object.keys(elem) + .reduce(function(res, attr){ + return that.__system_attrs__.includes(attr) ? + res + : res + + (elem[attr] ? + '\n'+level+' ' + `${ attr }:: ${ elem[attr] }` + : '') }, '')), + (elem.children + && elem.children.length > 0) ? + this.text(elem.children || [], indent, level+indent) + : [] ) } + return text + .flat() + .join('\n') }, + + // XXX add read-only option... + htmlBlock: function(data, options={}){ + var that = this + + var parsed = this.__code2html__(data.text, {...data}) + + var cls = parsed.style ?? [] + delete parsed.style + + var attrs = [] + + for(var [attr, value] of Object.entries({...data, ...parsed})){ + if(this.__system_attrs__.includes(attr)){ + continue } + var i + var type = this.__block_attrs__[attr] + if(type == 'cls'){ + value ? + cls.push(attr) + : (i = cls.indexOf(attr)) >= 0 ? + cls.splice(i, 1) + : undefined + } else if(type == 'attr' + || type == undefined){ + // special case: dataset attrs... + if(type == undefined){ + attr = 'data-'+ attr } + typeof(value) == 'boolean'? + (value ? + attrs.push(attr) + : (i = attrs.indexOf(attr)) >= 0 ? + attrs.splice(i, 1) + : undefined) + : value != null ? + attrs.push(`${attr}="${value}"`) + : (i = attrs.indexOf(attr)) >= 0 ? + attrs.splice(i, 1) + : undefined } } + + var children = (data.children ?? []) + .map(function(data){ + return that.htmlBlock(data) }) + .join('') + // NOTE: the '\n' at the start of the textarea body below helps + // preserve whitespace when parsing HTML... + return ( +`
\ +\ +${ parsed.text }\ +
${ children }
\ +
`) }, + html: function(data, options=false){ + var that = this + if(typeof(data) == 'boolean'){ + options = data + data = undefined } + data = data == null ? + this.json() + : typeof(data) == 'string' ? + this.parse(data) + : data instanceof Array ? + data + : [data] + options = + typeof(options) == 'boolean' ? + {full: options} + : (options + ?? {}) + + var nodes = data + .map(function(data){ + return that.htmlBlock(data) }) + .join('') + + return !options.full ? + nodes + : ( +`
\ +
\ +\ +
${ nodes }
\ +
`) }, +} + + + +// XXX experiment with a concatinative model... +// .get(..) -> Outline (view) +var Outline = { + __proto__: JSONOutline, + + dom: undefined, + + // config... + // + left_key_collapses: true, + right_key_expands: true, + change_interval: 1000, + tab_size: 4, + carot_jump_edge_then_block: false, + // XXX not sure what should the default be... + trim_block_text: false, + + pre_plugins: [ + ...JSONOutline.pre_plugins, + ], + norm_plugins: [ + ...JSONOutline.norm_plugins, + ], + post_plugins: [ + ...JSONOutline.post_plugins, + ], + + + get header(){ + return this.dom?.querySelector('.header') }, + get outline(){ + return this.dom?.querySelector('.outline') }, + get toolbar(){ + return this.dom?.querySelector('.toolbar') }, + + get code(){ + return this.dom?.querySelector('.code')?.value }, + set code(value){ + if(value == null){ + return } + var c = this.dom?.querySelector('.code') + if(c){ + c.value = value } }, + + + path: function(node='focused', mode='index'){ + if(['index', 'text', 'node', 'data'].includes(node)){ + mode = node + node = 'focused' } + var outline = this.outline + var path = [] + var node = this.get(node) + while(node != outline){ + path.unshift( + mode == 'index' ? + this.get(node, 'siblings').indexOf(node) + : mode == 'text' ? + node.querySelector('.view').innerText + : mode == 'data' ? + this.data(node) + : node) + node = this.get(node, 'parent') } + return path }, + + // + // .get()[, ] + // .get([, ]) + // .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('viewport') + // .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 + + // id... + if(typeof(node) == 'string' && node[0] == '#'){ + node = outline.querySelector(node) } + + // root nodes... + if(node == 'top'){ + return [...outline.children] } + // groups defaulting to .outline as base... + if(['all', 'visible', 'editable', 'selected', 'viewport'].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 instanceof Array ? + [node + .reduce(function(res, i){ + return that.get(res, 'children')[i] }, outline), + 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.querySelector('.view').offsetParent != null }) + return e.offsetParent != null }) + : offset == 'viewport' ? + [...node.querySelectorAll('.block')] + .filter(function(e){ + //return e.querySelector('.view').offsetParent != null + // && e.querySelector('.code').visibleInViewport() }) + return e.offsetParent != null + && e.visibleInViewport() }) + : offset == 'editable' ? + [...node.querySelectorAll('.block>.code')] + : offset == 'selected' ? + [...node.querySelectorAll('.block[selected]')] + .filter(function(e){ + //return e.querySelector('.view').offsetParent != null }) + 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) + ?? this.get(0) + if(elem){ + var cur = this.get() + var blocks = this.get('visible') + elem.focus({preventScroll: true}) + ;(elem.classList.contains('code') ? + elem + : elem.querySelector('.code')) + .scrollIntoView({ + block: 'nearest', + // smooth for long jumps and instant for short jumps... + behavior: (cur == null + || Math.abs(blocks.indexOf(cur) - blocks.indexOf(elem)) > 2) ? + 'smooth' + : 'instant' + }) } + return elem }, + edit: function(node='focused', offset){ + var elem = this.get(...arguments) + if(!elem.classList.contains('code')){ + elem = elem.querySelector('.code') } + elem?.focus() + return elem }, + + // 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(options={}){ + var that = this + + // handle undo... + options.undo + && this.setUndo(...options.undo) + + // long changes... + this.__change_requested = true + if(this.__change_timeout){ + return this } + + // do the action... + if(this.__change_requested){ + this.sync() + this.runPlugins('__change__', this) + this.__change_requested = false } + + this.__change_timeout = setTimeout( + function(){ + that.__change_timeout = undefined + that.__change_requested + && that.__change__() }, + that.change_interval || 1000) + return this }, + + /* XXX not used -- do we need this?? + // XXX UPDATE_CODE_SIZE this is a no-op at this point -- do we need this??? + _updateCodeSize: function(code, view){ + // XXX + return this + code.style.height = + getComputedStyle( + view + ?? code.nextSibling) + .height + return this }, + _updateViewSize: function(view, code){ + view.style.height = + getComputedStyle( + code + ?? view.previousSibling) + .height + return this }, + // XXX not a good solution... + _syncTextSize: function(code, view){ + code = code.classList.contains('block') ? + code.querySelector('.code') + : code + view = view + ?? code.nextSibling + code.updateSize() + return view.offsetHeight > code.offsetHeight ? + this._updateCodeSize(code, view) + : this._updateViewSize(view, code) }, + //*/ + + // Update node from data... + // + // NOTE: this does not internally handle undo as it would be too + // granular... + // NOTE: to remove an attribute set it's value to null, undefined, + // 'null', or 'undefined' + update: function(node='focused', data){ + var node = this.get(node) + data ??= this.data(node, false) + + var parsed = {} + if('text' in data){ + var code = node.querySelector('.code') + var html = node.querySelector('.view') + if(this.__code2html__){ + // NOTE: we are ignoring the .collapsed attr here + parsed = this.__code2html__(data.text, {...data}) + html.innerHTML = parsed.text + // heading... + this.__styles != null + && node.classList.remove(...this.__styles) + parsed.style + && node.classList.add(...parsed.style) + delete parsed.style + } else { + html.innerHTML = data.text } + code.value = data.text + code.updateSize() } + // NOTE: this will have no effect if the element is not attached... + //this._updateCodeSize(code) } + //this._syncTextSize(code, html) } + + for(var [attr, value] of Object.entries({...data, ...parsed})){ + if(this.__system_attrs__.includes(attr)){ + continue } + + // quoted value... + if(value && /^\s*([`'"])([^\1]*)\1\s*$/.test(value)){ + value = value.replace(/^\s*([`'"])([^\1]*)\1\s*$/, '$2') } + + var type = this.__block_attrs__[attr] + if(type == 'cls'){ + value ? + node.classList.add(attr) + : node.classList.remove(attr) + } else if(type == 'attr'){ + typeof(value) == 'boolean'? + (value ? + node.setAttribute(attr, '') + : node.removeAttribute(attr)) + : value != null ? + node.setAttribute(attr, value) + : node.removeAttribute(attr) + // dataset... + } else { + // remove attr... + if(value == null + || value == 'null' + || value == 'undefined'){ + delete node.dataset[attr] + } else { + node.dataset[attr] = value } } } + this.__change__() + return node }, + + // edit... + indent: function(node='focused', indent='in'){ + // .indent() + if(node === 'in' || node === 'out'){ + indent = node + node = 'focused' } + var cur = this.get(node) + if(!cur){ + return } + var prev = this.path(cur) + var siblings = this.get(node, 'siblings') + // deindent... + if(indent == 'out'){ + 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__({undo: [ + this.path(cur), + 'indent', + ['in'], + prev ]}) } + // indent... + } else { + var parent = siblings[siblings.indexOf(cur) - 1] + if(parent){ + parent.lastChild.append(cur) + this.__change__({undo: [ + this.path(cur), + 'indent', + ['out'], + prev ]})} } + return cur }, + shift: function(node='focused', direction){ + if(node == 'up' || node == 'down'){ + direction = node + node = 'focused' } + if(direction == null + || (direction !== 'up' + && direction != 'down')){ + 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) + } else if(direction == 'down' + && i < siblings.length-1){ + siblings[i+1].after(node) } + focused + && this.focus() + this.__change__({undo: [ + this.path(node), + 'shift', + [direction == 'up' ? + 'down' + : 'up'] ]}) + return this }, + // XXX make undo a bit more refined... + remove: function(node='focused'){ + var elem = this.get(...arguments) + // XXX HACK... + var data = this.json() + 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 + && this.focus(next) + this.__change__({undo: [ + undefined, + 'load', + // XXX HACK... + [data] ]}) + return this }, + clear: function(){ + var data = this.json() + this.outline.innerText = '' + this.__change__({undo: [ + undefined, + 'load', + [data] ]}) + return this }, + + // expand/collapse... + 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 + state ? + node.setAttribute('collapsed', '') + : node.removeAttribute('collapsed') + this.__change__() + return node }, + 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 }, + + // crop... + // XXX the header links are not component-compatible... + crop: function(node='focused'){ + this.dom.classList.add('crop') + for(var block of [...this.outline.querySelectorAll('[cropped]')]){ + block.removeAttribute('cropped') } + this.get(...arguments).setAttribute('cropped', '') + // build header path... + this.header.innerHTML = + `/ ` + + this.path(...arguments, 'text') + .slice(0, -1) + .map(function(s, i, {length}){ + return `${ + plugin.encode(s) + } ` }) + .join(' / ') + return this }, + uncrop: function(count=1){ + var outline = this.outline + var top = this.get(0) + for(var block of [...this.outline.querySelectorAll('[cropped]')]){ + block.removeAttribute('cropped') } + // crop parent if available... + while(count != 'all' + && count > 0 + && top !== outline){ + top = this.get(top, 'parent') + count-- } + if(count == 'all' || top === outline){ + this.dom.classList.remove('crop') + this.header.innerHTML = '' + } else { + this.crop(top) } + return this }, + + // undo... + // NOTE: calling .setUndo(..) will drop the redo stack, but this does + // not happen when calling a method via .undo(..)/.redo(..) as we + // are reassigning the stacks manually. + __undo_stack: undefined, + __redo_stack: undefined, + setUndo: function(path, action, args, next){ + ;(this.__undo_stack ??= []).push([path, action, args, next]) + this.__redo_stack = undefined + return this }, + mergeUndo: function(n, stack){ + stack ??= this.__undo_stack + if(stack == null || stack.length == 0){ + return this } + stack.push( + stack.splice(-n, n) + .map(function(e){ + return typeof(e[1]) == 'string' ? + [e] + : e }) + .flat()) + return this }, + clearUndo: function(){ + this.__undo_stack = undefined + this.__redo_stack = undefined + return this }, + __undo: function(from, to){ + if(from == null + || from.length == 0){ + return [from, to] } + var actions = from.pop() + actions = typeof(actions[1]) == 'string' ? + [actions] + : actions + while(actions.length > 0){ + var [path, action, args, next] = actions.pop() + var l = from.length + path != null + && this.focus(path) + this[action](...args) + next != null ? + this.focus(next) + : this.focus() } + if(l < from.length){ + to ??= [] + to.push( + ...from.splice(l, from.length)) } + if(from.length == 0){ + from = undefined } + return [from, to] }, + undo: function(){ + ;[this.__undo_stack, this.__redo_stack] = + this.__undo(this.__undo_stack, this.__redo_stack) + return this }, + redo: function(){ + ;[this.__redo_stack] = this.__undo(this.__redo_stack) + return this }, + + // serialization... + data: function(elem, deep=true){ + var that = this + elem = + // all elements... + (elem == 'all' || elem == 'root' || elem == '*') ? + [...this.outline.children] + : elem instanceof Array ? + elem + : this.get(elem) + // multiple nodes... + if(elem instanceof Array){ + return elem + .map(function(elem){ + return that.data(elem) }) } + // single node... + // XXX move these to config... + var attrs = this.__block_attrs__ + var cls_attrs = ['focused'] + return { + // NOTE: this is first to prevent it from overriding system attrs... + ...elem.dataset, + text: elem.querySelector('.code').value, + ...(Object.entries(attrs) + .reduce(function(res, [attr, type]){ + if(type == 'attr'){ + var val = elem.getAttribute(attr) + if(val != null){ + res[attr] = val == '' ? + true + : val } } + if(type == 'cls'){ + elem.classList.contains(attr) + && (res[attr] = true) } + return res }, {})), + ...(deep ? + {children: this.data([...elem.lastChild.children])} + : {}), + } }, + // Same as .data(..) but by default returns the root nodes. + // NOTE: this always returns an array + json: function(node='all'){ + return [this.data(...( + arguments.length == 0 ? + ['all'] + : arguments))] + .flat() }, + + // 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') + // XXX hack?? + block.setAttribute('cropped', '') + // 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 + // ... ... + // cur cur + // new new <- before the next after cur + // --- --- + // ... ... + ;(place == 'next' + // has children (uncollapsed)... + && (cur.querySelector('.block')?.offsetParent + // not last sibling... + || cur !== this.get('siblings').at(-1))) ? + this.get(place).before(block) + // ... + // --- + // cur + // new <- next after cur + // ... + : (place == 'next' + // last sibling... + && cur === this.get('siblings').at(-1)) ? + cur.after(block) + : (place == 'before' || place == 'after') ? + cur[place](block) + : undefined + + //this._updateCodeSize(code) + //this._syncTextSize(code, view) + + this.setUndo(this.path(cur), 'remove', [this.path(block)]) } + return block }, + /*/ XXX + load: function(data){ + var that = this + this.dom.classList.add('loading') + data = typeof(data) == 'string' ? + this.parse(data) + : data instanceof Array ? + data + : data == null ? + this.json() + : [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)... + //// NOTE: this is needed to make initial clicking into multi-line + //// blocks place the cursor into the clicked location. + //// ...this is done by expanding the textarea to the element + //// size and enabling it to intercept clicks correctly... + //setTimeout(function(){ + // var f = that._updateCodeSize + // //var f = that._syncTextSize.bind(that) + // for(var e of [...that.outline.querySelectorAll('textarea')]){ + // f(e) } }, 0) + this.dom.classList.remove('loading') + return this }, + /*/ // XXX JSON version... + load: function(data){ + var that = this + this.dom.classList.add('loading') + data = typeof(data) == 'string' ? + this.parse(data) + : data instanceof Array ? + data + : data == null ? + this.json() + : [data] + + this.outline.innerHTML = this.html(data) + + //// update sizes of all the textareas (transparent)... + //// NOTE: this is needed to make initial clicking into multi-line + //// blocks place the cursor into the clicked location. + //// ...this is done by expanding the textarea to the element + //// size and enabling it to intercept clicks correctly... + //// XXX this is a hack -- need to style the thing in such away + //// so as to not require this step... + //setTimeout(function(){ + // var f = that._updateCodeSize.bind(that) + // //var f = that._syncTextSize.bind(that) + // for(var e of [...that.outline.querySelectorAll('textarea')]){ + // f(e) } }, 0) + this.dom.classList.remove('loading') + return this }, + //*/ + + sync: function(){ + this.code = this.text() + return this }, + + + // Actions... + prev: function(){}, + next: function(){}, + above: function(){}, + below: function(){}, + + up: function(){}, + down: function(){}, + left: function(){}, + right: function(){}, + + __overtravel_timeout: undefined, + __caret_x: undefined, + // XXX move the code here into methods/actions... + // XXX use keyboard.js... + keyboard: { + // XXX might be a good feature to add to keyboard.js... + // ...might even be fun to extend this and add key classes, + // like: + // Modifier + // Function + // Letter + // Number + // ... + // Unhandled + Any: function(evt, key){ + if(this.__caret_x + && this.get('edited') + && key != 'ArrowUp' + && key != 'ArrowDown'){ + this.__caret_x = undefined } }, + + // 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 + + // overtravel... + var overtravel = + this.__overtravel_timeout != null + && this.get() === this.get(0) + this.__overtravel_timeout != null + && clearTimeout(this.__overtravel_timeout) + this.__overtravel_timeout = setTimeout(function(){ + that.__overtravel_timeout = undefined }, 100) + if(overtravel){ + return } + + var edited = this.get('edited') + if(edited){ + var g = edited.getTextGeometry() + if(g.line == 0){ + evt.preventDefault() + //var left = edited.getBoundingClientRect().x + g.offsetLeft + var left = this.__caret_x = + this.__caret_x + ?? edited.getBoundingClientRect().x + g.offsetLeft + edited = that.edit('prev') + // keep caret horizontally constrained... + var bottom = edited.getBoundingClientRect().bottom + /*/ XXX CARET_V_MOVE this is not correct yet... + var view = this.get(edited).querySelector('.view') + var c = getCharOffset(view, left, bottom - 1) + var m = getMarkdownOffset(edited.value, view.innerText, c) + console.log('---', c, m) + edited.selectionStart = + edited.selectionEnd = + c - m } + /*/ + edited.selectionStart = + edited.selectionEnd = + edited.getTextOffsetAt(left, bottom - 1) } + //*/ + } else { + evt.preventDefault() + this.focus('focused', -1) } }, + ArrowDown: function(evt){ + var that = this + + // overtravel... + var overtravel = + this.__overtravel_timeout != null + && this.get() === this.get(-1) + this.__overtravel_timeout != null + && clearTimeout(this.__overtravel_timeout) + this.__overtravel_timeout = setTimeout(function(){ + that.__overtravel_timeout = undefined }, 100) + if(overtravel){ + return } + + var edited = this.get('edited') + if(edited){ + var g = edited.getTextGeometry() + if(g.lines == 0 || g.line == g.lines - 1){ + evt.preventDefault() + //var left = edited.getBoundingClientRect().x + g.offsetLeft + var left = this.__caret_x = + this.__caret_x + ?? edited.getBoundingClientRect().x + g.offsetLeft + edited = that.edit('next') + // keep caret horizontally constrained... + var top = edited.getBoundingClientRect().y + /* XXX CARET_V_MOVE this needs fixing... + var view = this.get(edited).querySelector('.view') + var c = getCharOffset(view, left, top - 1) + var m = getMarkdownOffset(edited.value, view.innerText, c) + console.log('---', c, m) + edited.selectionStart = + edited.selectionEnd = + c - m } + /*/ + edited.selectionStart = + edited.selectionEnd = + edited.getTextOffsetAt(left, top + 1) } + //*/ + } 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 that = this + + // overtravel... + var overtravel = + this.__overtravel_timeout != null + && this.get() === this.get(-1) + this.__overtravel_timeout != null + && clearTimeout(this.__overtravel_timeout) + this.__overtravel_timeout = setTimeout(function(){ + that.__overtravel_timeout = undefined }, 100) + if(overtravel){ + return } + + 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') } }, + + Home: function(evt){ + if(this.get('edited') + && !evt.ctrlKey){ + return } + evt.preventDefault() + this.focus(0) }, + End: function(evt){ + if(this.get('edited') + && !evt.ctrlKey){ + return } + evt.preventDefault() + this.focus(-1) }, + PageUp: function(evt){ + var that = this + if(this.get('edited')){ + return } + if(evt.shiftKey + || evt.ctrlKey){ + evt.preventDefault() + this.shift('up') + } else { + var viewport = that.get('viewport') + viewport[0] === that.get(0) ? + that.focus(0) + : that.focus( + viewport[0], 'prev') } }, + PageDown: function(evt){ + var that = this + if(this.get('edited')){ + return } + if(evt.shiftKey + || evt.ctrlKey){ + evt.preventDefault() + this.shift('down') + } else { + var viewport = that.get('viewport') + viewport.at(-1) === that.get(-1) ? + that.focus(-1) + : that.focus( + that.get('viewport').at(-1), 'next') } }, + + // indent.. + Tab: function(evt){ + evt.preventDefault() + var edited = this.get('edited') + var node = this.show( + this.indent(evt.shiftKey ? + 'out' + : 'in')) + // 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') + if(edited){ + if(evt.ctrlKey + || evt.shiftKey){ + var that = this + // NOTE: setTimeout(..) because we need the input of + // the key... + setTimeout(function(){ + that.update(edited) }, 0) + return } + // split text... + evt.preventDefault() + var a = edited.selectionStart + var b = edited.selectionEnd + // position 0: focus empty node above... + if(a == 0 + && edited.value.trim() != ''){ + this.Block('prev') + this.edit('prev') + // focus new node... + } else { + var prev = edited.value.slice(0, a) + var next = edited.value.slice(b) + edited.value = prev + this.Block({text: next}, 'next') + edited = this.edit('next') + edited.selectionStart = 0 + edited.selectionEnd = 0 + this.mergeUndo(2) } + return } + // view -> edit... + evt.preventDefault() + this.edit() }, + Escape: function(evt){ + if(this.get('edited')){ + this.focus() + } else { + this.uncrop() } }, + s_Escape: function(evt){ + if(this.get('edited')){ + this.focus() + } else { + this.uncrop('all') } }, + c: function(evt){ + if(!this.get('edited')){ + this.crop() } }, + c_z: function(evt){ + if(!this.get('edited')){ + evt.preventDefault() + this.undo() } }, + c_s_z: function(evt){ + if(!this.get('edited')){ + evt.preventDefault() + this.redo() } }, + U: function(evt){ + if(!this.get('edited')){ + this.redo() } }, + u: function(evt){ + if(!this.get('edited')){ + this.undo() } }, + + 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 } }, + + a_s: function(evt){ + // toggle done... + evt.preventDefault() + tasks.toggleStatus(this) }, + a_x: function(evt){ + // toggle done... + evt.preventDefault() + tasks.toggleDone(this) }, + a_r: function(evt){ + // toggle done... + evt.preventDefault() + tasks.toggleReject(this) }, + + // selection... + // XXX need more work... + // - should we select the .block or .text??? + // - we should remember the first state and apply it (a-la FAR) + // and not simply toggle on/off per node... + Shift: function(evt){ + if(this.get('edited')){ + return } + // XXX set selection mode + // ...need to reset this when shift key is released... + // one way to do this is to save a press id and reset + // it each call -- if the id has changed since lass s-up + // is pressed then reset mode... + }, + s_ArrowUp: function(evt){ + if(this.get('edited')){ + return } + var elem = this.get() + elem.hasAttribute('selected') ? + elem.removeAttribute('selected') + : elem.setAttribute('selected', '') + this.keyboard.ArrowUp.call(this, evt) }, + s_ArrowDown: function(evt){ + if(this.get('edited')){ + return } + var elem = this.get() + elem.hasAttribute('selected') ? + elem.removeAttribute('selected') + : elem.setAttribute('selected', '') + this.keyboard.ArrowDown.call(this, evt) }, + c_d: function(evt){ + if(this.get('edited')){ + return } + evt.preventDefault() + for(var e of this.get('selected')){ + e.removeAttribute('selected') } }, + c_a: function(evt){ + if(this.get('edited')){ + return } + evt.preventDefault() + for(var e of this.get('all')){ + e.setAttribute('selected', '') } }, + + // 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... + // XXX support selection from first click... (see: mousemove handler) + var selecting, start + outline.addEventListener('mousedown', + function(evt){ + var elem = evt.target + // prevent clicking through children to parent elements... + if(elem.classList.contains('children')){ + evt.preventDefault() + outline.focus() + return } + // place the cursor where the user clicked in code/text... + if(elem.classList.contains('code') + && document.activeElement !== elem){ + that.__caret_x = undefined + var view = that.get(elem).querySelector('.view') + var initial = elem.selectionStart + var c = getCharOffset(view, evt.clientX, evt.clientY) + var m = getMarkdownOffset(elem.value, getText(view), c) + // selecting an element with text offset by markup... + if(m != 0){ + evt.preventDefault() + selecting = elem } + start = c == null ? + elem.value.length + : c + m + // NOTE: this is done on next frame to allow the + // browser to place the caret before we correct + // its position... (if .preventDefault() was not called) + setTimeout(function(){ + elem.focus() + elem.selectionStart = + elem.selectionEnd = + start }, 0) } }) + outline.addEventListener('mousemove', + function(evt){ + // handle selection in element with text offset by markup... + if(selecting != null){ + var c = selecting.getTextOffsetAt(evt.clientX, evt.clientY) + if(c > start){ + selecting.selectionStart = start + selecting.selectionEnd = c + } else { + selecting.selectionStart = c + selecting.selectionEnd = start } } }) + outline.addEventListener('mouseup', + function(evt){ + selecting = undefined }) + 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() } + + // focus viewport... + // XXX this does not work because by this point there is + // no focused element... + if(elem === outline){ + var cur = that.get() + var viewport = that.get('viewport') + if(!viewport.includes(cur)){ + var visible = that.get('visible') + var i = visible.indexOf(cur) + var v = visible.indexOf(viewport[0]) + i < v ? + that.focus(viewport[0]) + : that.focus(viewport.at(-1)) } } + + 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 } + + // handle keyboard... + // 'Any' key... + if('Any' in that.keyboard){ + if(that.keyboard.Any.call(that, evt, evt.key) === false){ + return } } + // keys/mods... + var keys = [] + evt.ctrlKey + && keys.push('c_' + evt.key) + evt.ctrlKey && evt.altKey + && keys.push('c_a_' + evt.key) + evt.ctrlKey && evt.shiftKey + && keys.push('c_s_' + evt.key) + evt.altKey && evt.ctrlKey && evt.shiftKey + && keys.push('c_a_s_' + evt.key) + evt.altKey + && keys.push('a_' + evt.key) + evt.altKey && evt.shiftKey + && keys.push('a_s_' + evt.key) + evt.shiftKey + && keys.push('s_' + evt.key) + keys.push(evt.key) + for(var k of keys){ + if(k in that.keyboard){ + that.keyboard[k].call(that, evt, k) + break } } }) + // update code block... + outline.addEventListener('keyup', + function(evt){ + var elem = evt.target + // update element state... + if(elem.classList.contains('code')){ + // NOTE: for some reason setting the timeout here to 0 + // makes FF sometimes not see the updated text... + setTimeout(function(){ + that.update(elem.parentElement) }, 0) } + that.runPlugins('__keyup__', evt, that, elem) }) + + // 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... + if(elem !== that.outline){ + 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.dataset.original = elem.value + elem.updateSize() } + + // XXX do we need this??? + that.runPlugins('__focusin__', evt, that, elem) }) + outline.addEventListener('focusout', + function(evt){ + var elem = evt.target + // update code... + if(elem.classList.contains('code')){ + var block = that.get(elem) + // clean out attrs... + elem.value = + that.trim_block_text ? + that.threadPlugins('__parse_code__', elem.value, that).trim() + : that.threadPlugins('__parse_code__', elem.value, that) + that.update(block) + // undo... + if(elem.value != elem.dataset.original){ + that.setUndo( + that.path(elem), + 'update', + [that.path(elem), { + ...that.data(elem), + text: elem.dataset.original, + }]) + delete elem.dataset.original } + // give the browser a chance to update the DOM... + // XXX revise... + setTimeout(function(){ + that.runPlugins('__editedcode__', evt, that, elem) + // this will resize the text to fill the available area... + elem.style.removeProperty('height') }, 0) } + + that.runPlugins('__focusout__', evt, that, elem) }) + // update .code... + outline.addEventListener('change', + function(evt){ + that.__change__() }) + + // header... + var header = this.header + header.addEventListener('click', + function(evt){ + var elem = evt.target + if(elem.classList.contains('path-item')){ + that.uncrop(elem.getAttribute('uncrop') ?? 'all') } }) + + // 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('.code').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 + .replace(/</g, '<') + .replace(/>/g, '>')) + console.log(`Parse: ${Date.now() - t}ms`) } + this.clearUndo() + + this.runPlugins('__setup__', this) + + // autofocus... + if(this.dom.getAttribute('autofocus') != null){ + /* + setTimeout(function(){ + that.focus() }, 0) } + /*/ + // XXX this for some reason takes lots of time at this point... + this.focus() } + //*/ + + return this }, +} + + + +//--------------------------------------------------------------------- +// Custom element... + + +window.customElements.define('outline-editor', +window.OutlineEditor = +Object.assign( + function(){ + var obj = Reflect.construct(HTMLElement, [...arguments], OutlineEditor) + + var shadow = obj.attachShadow({mode: 'open'}) + + var style = document.createElement('link'); + style.setAttribute('rel', 'stylesheet'); + style.setAttribute('href', 'editor.css'); + + // XXX it is not rational to have this... + var editor = obj.dom = document.createElement('div') + editor.classList.add('editor') + + var header = document.createElement('div') + header.classList.add('header') + + var outline = document.createElement('div') + outline.classList.add('outline') + outline.setAttribute('tabindex', '0') + + //var toolbar = document.createElement('div') + //toolbar.classList.add('toolbar') + + // XXX can't yet get rid of the editor element here... + // - handling autofocus of host vs. shadow??? + // - CSS not working correctly yet... + // ...is this feasible??? + editor.append( + style, + header, + outline) + shadow.append(editor) + + console.log('SETUP') + obj.setup(editor) + + return obj }, + // constructor stuff... + { + observedAttributes: [ + 'value', + + 'session-storage', + 'local-storage', + ], + + prototype: Object.assign( + { + __proto__: HTMLElement.prototype, + + // XXX HACK these are copies from Outline, use + // object.mixin(...) instead... + get header(){ + return this.dom?.querySelector('.header') }, + set header(val){}, + get outline(){ + return this.dom?.querySelector('.outline') }, + set outline(val){}, + get toolbar(){ + return this.dom?.querySelector('.toolbar') }, + set toolbar(val){}, + + // NOTE: this is here to break recursion of trying to set + // html's value both in .code that is called both when + // setting .value and from .attributeChangedCallback(..) + get __code(){ + return this.code }, + set __code(value){ + if(value == null){ + return } + // XXX is this the right way to do this??? + this.__sessionStorage + && (sessionStorage[this.__sessionStorage] = value) + this.__localStorage + && (localStorage[this.__localStorage] = value) }, + get code(){ + return this.hasAttribute('value') ? + this.getAttribute('value') + : HTMLElement.decode(this.innerHTML) }, + set code(value){ + if(value == null){ + return } + // XXX this can break in conjunction with .attributeChangedCallback(..) + if(this.hasAttribute('value')){ + this.setAttribute('value', value) + } else { + this.innerHTML = HTMLElement.encode(value) } + this.__code = value }, + + // XXX do we need this??? + // ...rename .code -> .value ??? + get value(){ + return this.code }, + set value(value){ + this.code = value }, + + connectedCallback: function(){ + var that = this + // load the data... + setTimeout(function(){ + that.load(that.code) }, 0) }, + + // XXX do we need to before == after check??? + attributeChangedCallback(name, before, after){ + if(name == 'local-storage'){ + this.__localStorage = after + // NOTE: we setting .code here because we will + // .load(..) at .setup(..) + sessionStorage[after] + && (this.code = sessionStorage[after]) } + + if(name == 'session-storage'){ + this.__sessionStorage = after + sessionStorage[after] + && (this.code = sessionStorage[after]) } + + // NOTE: if other sources are active but unset this + // should provide the default, otherwise it will + // get overwritten by the value in .code by .load(..) + if(name == 'value'){ + // see notes for .__code + this.__code = after } + }, + + }, + // XXX this will fail due to all the getters/setters -- use object.mixin(..)... + Outline), + })) + + + + +/********************************************************************** +* vim:set ts=4 sw=4 nowrap : */