/**********************************************************************
* 
*
*
**********************************************************************/
//---------------------------------------------------------------------
// 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 can we avoid tracking "virtual" newlines between text/block 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){
			// count "virtual" newlines between text and block elements... 
			if(data.prev_elem == 'block'){
				data.c += 1 }
			data.prev_elem = '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 }
			// count "virtual" newlines between text and block elements... 
			var type = getComputedStyle(e).display
			var block = [
				'block', 
				// XXX these do not add up yet...
				//'table', 
				//'table-row', 
				//'table-cell', 
				'flex', 
				'grid',
			].includes(type)
			if(block 
					&& data.prev_elem
					&& data.prev_elem != 'block'){
				data.c += 1 }
			data.prev_elem = block ? 
				'block' 
				: 'elem'
			// handle the node...
			data = getCharOffset(e, x, y, data)
			// compensate for table stuff...
			if(type == 'table-row'){
				data.c -= 1 }
			if(type == 'table-cell'){
				data.c += 1 }
			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 we are not checking both lengths of markdown AND text...
// XXX
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 }
/*/
// XXX when one string is guaranteed to be a strict subset of the other
// 		this is trivial, but in the general case we can have differences 
// 		both ways, for example the "virtual" newlines added by block 
// 		elements mess up the text...
// 		...so this in the current form is fundamentally broken as skipping 
// 		chars in text can lead to false positives and lots of potential 
// 		(not implemented) backtracking...
// 		...needs thought...
// 		Q: can we cheat with this? =)
// XXX BUG: clicking right of last line places the caret before the last char...
var getMarkdownOffset = function(markdown, text, i){
	i = i ?? text.length
	var map = []
    for(var t=0, m=0; t <= text.length; t++, m++){
        var o = 0
        while(text[t] != markdown[m+o] 
				&& m+o < markdown.length){
            o++ }
        if(m+o >= markdown.length){
            m--
        } else {
            m += o } 
		map[t] = m - t
	}
    return map[i] }
//*/
/* XXX this is not needed...
var getText = function(elem, res=[]){
    for(var n of elem.childNodes){
        n.nodeType == n.TEXT_NODE ?
            res.push(n.textContent)
            : getText(n, res) }
    return res }
//*/
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 }
//
// 	offsetMap(
// 			'abMcdefg',
// 			'abcdeXfg')
// 		-> [0, 0, , -1, -1, -1, 0, 0]
// 
// XXX this is still wrong -- the problem is that in more complex cases 
// 		this finds a non-optimal solution...
// 			m = `text text text
//			
//			block element
//			
//			this line, and above placement of completely broken`
//			t = 'text text text\n\n\nblock element\n\n\nthis line, and above placement of completely broken '
//			o = offsetMap(m, t)
//			// this should reproduce common sections...
//			console.log('---', o.map(function(e, i){ return m[i+e] }).join(''))
// XXX can we cheat here???
var offsetMap = function(A, B, m=[]){
    var o = 0
    var p = 0
    for(var n=0; n < A.length; n++){
        while(A[n] != B[n+o]){
            if(n+o >= B.length){
				m.length += 1
                o = p-1
                break }
            o++ }
        A[n] == B[n+o]
			&& m.push(o)
        p = o }
    return m }
//---------------------------------------------------------------------
// Plugins...
// general helpers and utils...
var plugin = {
	encode: function(text){
		return text
			.replace(/(?/g, '>')
			.replace(/\\(?!`)/g, '\\\\') },
	// XXX make this more generic...
	style: function(editor, elem, style, code=undefined){
		style = [style].flat()
		editor.__styles = [...new Set([
			...(editor.__styles ?? []),
			...style,
		])]
		return function(_, text){
			elem.style ??= []
			elem.style.push(...style)
			return typeof(code) == 'function' ?
					code(...arguments)
				: code != null ?
					code
				: text } },
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
// XXX style attributes... 
var attributes = {
	__proto__: plugin,
	__parse__: function(text, editor, elem){
		var skip = new Set([
			'text', 
			'focused',
			'collapsed',
			'id',
			'children', 
			'style',
		])
		return text 
			+ Object.entries(elem)
				.reduce(function(res, [key, value]){
					return skip.has(key) ?
						res
						: res + `
${key}: ${value}` }, '') },
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
var blocks = {
	__proto__: plugin,
	__pre_parse__: function(text, editor, elem){
		return text 
			// markdown...
			// style: headings...
			.replace(/^(?\s+(.*)$/m, this.style(editor, elem, 'quote'))
			.replace(/^\s*(?')) } ,
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
// 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',
	],
	// 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 needs lots more work...
var toc = {
	__proto__: plugin,
	// XXX not sure on what to build the hierarchy...
	// 		this could be:
	// 			- heading level
	// 			- topology
	// 			- both
	// 			- ...
	update: function(editor, elem){
		var outline = editor.outline
		var tocs = [...outline.querySelectorAll('.toc .view')]
		if(tocs.length == 0){
			return }
		var level = function(node){
			var depth = 0
			var parent = node
			while(parent !== outline 
					&& parent != null){
				if(parent.classList.contains('block')){
					depth++ }
				parent = parent.parentElement }
			return depth }
		var headings = [...editor.outline.querySelectorAll('.block.heading>.view')]
			.map(function(e){
				return `${ e.innerText.split(/\n/)[0] }`
			})
		var lst = document.createElement('ul')
		lst.innerHTML = headings.join('\n')
		for(var toc of tocs){
			toc.append(lst) } },
	__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', '')) },
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
// 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...
	// 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...
		if(code.trim() == ''){
			elem.text = code
			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) }
		elem = this.parseBlockAttrs(code, elem)
		code = elem.text
		// 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 },
	__block_attrs__: {
		id: 'attr',
		collapsed: 'attr',
		focused: 'cls',
	},
	//
	//	Parse attrs...
	//	.parseBlockAttrs([, ])
	//		-> 
	//
	//	Parse attrs keeping non-system attrs in .text...
	//	.parseBlockAttrs(, true[, ])
	//		-> 
	//
	//	Parse attrs keeping all attrs in .text...
	//	.parseBlockAttrs(, 'all'[, ])
	//		-> 
	//
	parseBlockAttrs: function(text, keep=false, elem={}){
		if(typeof(keep) == 'object'){
			elem = keep
			keep = typeof(elem) == 'boolean' ?
				elem
				: false }
		var system = this.__block_attrs__
		var clean = 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){
					var attrs = match
						.trim()
						.split(/(?:[\t ]*::[\t ]*|[\t ]*\n[\t ]*)/g)
					while(attrs.length > 0){
						var [name, val] = attrs.splice(0, 2)
						elem[name] = 
							val == 'true' ?
				   				true
							: val == 'false' ?
								false
							: val 
						// keep non-system attrs...
						if(keep 
								&& !(name in system)){
							ws += `\n${name}::${val}` } } 
					return ws })
		elem.text = keep == 'all' ? 
			text 
			: clean
		return elem },
	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 = that.parseBlockAttrs(block)
					attrs.text = that.__text2code__(attrs.text
						// 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 option to customize indent size...
	text: function(node, indent, level){
		// .text(, )
		if(typeof(node) == 'string'){
			;[node, indent='  ', level=''] = [undefined, ...arguments] }
		node ??= this.json(node)
		indent ??= '  '
		level ??= ''
		var text = []
		for(var elem of node){
			text.push( 
				level +'- '
					+ this.__code2text__(elem.text)
						.replace(/\n/g, '\n'+ level +'  ') 
					// attrs... 
					+ (Object.keys(elem)
						.reduce(function(res, attr){
							return (attr == 'text' 
									|| attr == 'children') ?
								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(attr == 'children' || attr == 'text'){
				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){
				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 }
\