mirror of
				https://github.com/flynx/pWiki.git
				synced 2025-11-04 04:50:09 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			798 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			798 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
/**********************************************************************
 | 
						|
* 
 | 
						|
*
 | 
						|
*
 | 
						|
**********************************************************************/
 | 
						|
 | 
						|
 | 
						|
//---------------------------------------------------------------------
 | 
						|
 | 
						|
// XXX do a caret api...
 | 
						|
 | 
						|
// XXX only for text areas...
 | 
						|
var atLine = function(elem, index){
 | 
						|
	// XXX add support for range...
 | 
						|
	var text = elem.value
 | 
						|
	var lines = text.split(/\n/g).length
 | 
						|
	var line = elem.caretLine 
 | 
						|
 | 
						|
	// XXX STUB index handling...
 | 
						|
	if((index == -1 && line == lines) 
 | 
						|
			|| (index == 0 && line == 1)){
 | 
						|
		return true }
 | 
						|
	return false }
 | 
						|
 | 
						|
 | 
						|
 | 
						|
 | 
						|
//---------------------------------------------------------------------
 | 
						|
 | 
						|
// XXX experiment with a concatinative model...
 | 
						|
// 		.get(..) -> Outline (view)
 | 
						|
var Outline = {
 | 
						|
	dom: undefined,
 | 
						|
 | 
						|
	// config...
 | 
						|
	//
 | 
						|
	left_key_collapses: true,
 | 
						|
	right_key_expands: true,
 | 
						|
	code_update_interval: 5000,
 | 
						|
	tab_size: 4,
 | 
						|
	carot_jump_edge_then_block: false,
 | 
						|
 | 
						|
 | 
						|
	get code(){
 | 
						|
		return this.dom.querySelector('.code') },
 | 
						|
	get outline(){
 | 
						|
		return this.dom.querySelector('.outline') },
 | 
						|
	get toolbar(){
 | 
						|
		return this.dom.querySelector('.toolbar') },
 | 
						|
 | 
						|
 | 
						|
	//
 | 
						|
	// 	.get([<offset>])
 | 
						|
	// 	.get('focused'[, <offset>])
 | 
						|
	// 		-> <node>
 | 
						|
	//
 | 
						|
	// 	.get('edited'[, <offset>])
 | 
						|
	// 		-> <node>
 | 
						|
	//
 | 
						|
	// 	.get('siblings')
 | 
						|
	// 	.get('focused', 'siblings')
 | 
						|
	// 		-> <nodes>
 | 
						|
	//
 | 
						|
	// 	.get('children')
 | 
						|
	// 	.get('focused', 'children')
 | 
						|
	// 		-> <nodes>
 | 
						|
	//
 | 
						|
	// 	.get('next')
 | 
						|
	// 	.get('focused', 'next')
 | 
						|
	// 		-> <node>
 | 
						|
	//
 | 
						|
	// 	.get('prev')
 | 
						|
	// 	.get('focused', 'prev')
 | 
						|
	// 		-> <node>
 | 
						|
	//
 | 
						|
	// 	.get('all')
 | 
						|
	// 	.get('visible')
 | 
						|
	// 	.get('editable')
 | 
						|
	// 	.get('selected')
 | 
						|
	// 	.get('top')
 | 
						|
	// 		-> <nodes>
 | 
						|
	//
 | 
						|
	// 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
 | 
						|
 | 
						|
		// shorthands...
 | 
						|
		if(node == 'next'){
 | 
						|
			return this.get('focused', 1) }
 | 
						|
		if(node == 'prev' || node == 'previous'){
 | 
						|
			return this.get('focused', -1) }
 | 
						|
 | 
						|
		var outline = this.outline
 | 
						|
 | 
						|
		// node lists...
 | 
						|
		var NO_NODES = {}
 | 
						|
		var nodes = 
 | 
						|
			node == 'all' ?
 | 
						|
				[...outline.querySelectorAll('[tabindex]')] 
 | 
						|
			: node == 'visible' ?
 | 
						|
				[...outline.querySelectorAll('[tabindex]')] 
 | 
						|
					.filter(function(e){
 | 
						|
						return e.offsetParent != null })
 | 
						|
			: node == 'editable' ?
 | 
						|
				[...outline.querySelectorAll('[tabindex]>textarea')] 
 | 
						|
			: node == 'selected' ?
 | 
						|
				[...outline.querySelectorAll('[tabindex][selected]')]
 | 
						|
			: node == 'top' ?
 | 
						|
				[...outline.children]
 | 
						|
					.filter(function(elem){ 
 | 
						|
						return elem.getAttribute('tabindex') != null })
 | 
						|
			: ['siblings', 'children'].includes(node) ?
 | 
						|
				this.get('focused', node) 
 | 
						|
			: node instanceof Array ?
 | 
						|
				node
 | 
						|
			: NO_NODES
 | 
						|
		if(nodes !== NO_NODES){
 | 
						|
			return offset == null ?
 | 
						|
					nodes
 | 
						|
				: typeof(offset) == 'number' ?
 | 
						|
					nodes.at(offset)
 | 
						|
				: nodes
 | 
						|
					.map(function(elem){
 | 
						|
						return that.get(elem, offset) }) }
 | 
						|
 | 
						|
		// single base node...
 | 
						|
		node = 
 | 
						|
			typeof(node) == 'number' ?
 | 
						|
				this.at(node)
 | 
						|
			: node == 'focused' ?
 | 
						|
				(outline.querySelector(`[tabindex]:focus`)
 | 
						|
					|| outline.querySelector(`textarea:focus`)?.parentElement
 | 
						|
					|| outline.querySelector('[tabindex].focused'))
 | 
						|
			: node == 'parent' ?
 | 
						|
				this.get('focused')?.parentElement
 | 
						|
			: node 
 | 
						|
		var edited
 | 
						|
		if(node == 'edited'){
 | 
						|
			edited = outline.querySelector(`textarea:focus`)
 | 
						|
			node = edited?.parentElement }
 | 
						|
 | 
						|
		if(!node || typeof(node) == 'string'){
 | 
						|
			return undefined }
 | 
						|
 | 
						|
		// children...
 | 
						|
		if(offset == 'children'){
 | 
						|
			return [...node.children]
 | 
						|
				.filter(function(elem){
 | 
						|
					return elem.getAttribute('tabindex') != null }) }
 | 
						|
 | 
						|
		// siblings...
 | 
						|
		if(offset == 'siblings'){
 | 
						|
			return [...node.parentElement.children]
 | 
						|
				.filter(function(elem){
 | 
						|
					return elem.getAttribute('tabindex') != null }) }
 | 
						|
 | 
						|
		// offset...
 | 
						|
		offset = 
 | 
						|
			offset == 'next' ? 
 | 
						|
				1
 | 
						|
			: offset == 'prev' ?
 | 
						|
				-1
 | 
						|
			: offset
 | 
						|
		if(typeof(offset) == 'number'){
 | 
						|
			nodes = this.get('visible')
 | 
						|
			var i = nodes.indexOf(node) + offset
 | 
						|
			i = i < 0 ?
 | 
						|
				nodes.length + i
 | 
						|
				: i % nodes.length
 | 
						|
			node = nodes[i] 
 | 
						|
			edited = edited 
 | 
						|
				&& node.querySelector('textarea') }
 | 
						|
		return edited 
 | 
						|
			|| node },
 | 
						|
	at: function(index, nodes='visible'){
 | 
						|
		return this.get(nodes).at(index) },
 | 
						|
	focus: function(node='focused', offset){
 | 
						|
		var elem = this.get(...arguments)
 | 
						|
		elem?.focus()
 | 
						|
		return elem },
 | 
						|
	edit: function(node='focused', offset){
 | 
						|
		var elem = this.get(...arguments)
 | 
						|
		if(elem.nodeName != 'TEXTAREA'){
 | 
						|
			elem = elem.querySelector('textarea') }
 | 
						|
		elem?.focus()
 | 
						|
		return elem },
 | 
						|
 | 
						|
	update: function(node='focused', data){
 | 
						|
		var node = this.get(node)
 | 
						|
		data ??= this.data(node, false)
 | 
						|
		typeof(data.collapsed) == 'boolean'
 | 
						|
			&& (data.collapsed ?
 | 
						|
				node.setAttribute('collapsed', '')
 | 
						|
				: node.removeAttribute('collapsed'))
 | 
						|
		if(data.text){
 | 
						|
			var text = node.querySelector('textarea')
 | 
						|
			var html = node.querySelector('span')
 | 
						|
			if(this.__code2html__){
 | 
						|
				// NOTE: we are ignoring the .collapsed attr here 
 | 
						|
				var parsed = this.__code2html__(data.text)
 | 
						|
				html.innerHTML = parsed.text
 | 
						|
				// heading...
 | 
						|
				node.classList.remove(...this.__styles)
 | 
						|
				parsed.style
 | 
						|
					&& node.classList.add(...parsed.style)
 | 
						|
			} else {
 | 
						|
				html.innerHTML = data.text }
 | 
						|
			text.value = data.text
 | 
						|
			// XXX this does not see to work until we click in the textarea...
 | 
						|
			text.autoUpdateSize() }
 | 
						|
		return node },
 | 
						|
 | 
						|
	indent: function(node='focused', indent=true){
 | 
						|
		// .indent(<indent>)
 | 
						|
		if(node === true || node === false){
 | 
						|
			indent = node
 | 
						|
			node = 'focused' }
 | 
						|
		var cur = this.get(node) 
 | 
						|
		if(!cur){
 | 
						|
			return }
 | 
						|
		var siblings = this.get(node, 'siblings')
 | 
						|
		// deindent...
 | 
						|
		if(!indent){
 | 
						|
			var parent = cur.parentElement
 | 
						|
			if(!parent.classList.contains('.outline')){
 | 
						|
				var children = siblings.slice(siblings.indexOf(cur)+1)
 | 
						|
				parent.after(cur)
 | 
						|
				children.length > 0
 | 
						|
					&& cur.append(...children) }
 | 
						|
		// indent...
 | 
						|
		} else {
 | 
						|
			var parent = siblings[siblings.indexOf(cur) - 1]
 | 
						|
			if(parent){
 | 
						|
				parent.append(cur) } } 
 | 
						|
		return cur },
 | 
						|
	deindent: function(node='focused', indent=false){
 | 
						|
		return this.indent(node, indent) },
 | 
						|
	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(<state>)
 | 
						|
		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('[tabindex]')){
 | 
						|
			return }
 | 
						|
		state = state == 'next' ?
 | 
						|
			node.getAttribute('collapsed') != ''
 | 
						|
			: state
 | 
						|
		if(state){
 | 
						|
			node.setAttribute('collapsed', '')
 | 
						|
		} else {
 | 
						|
			node.removeAttribute('collapsed')
 | 
						|
			for(var elem of [...node.querySelectorAll('textarea')]){
 | 
						|
				elem.updateSize() } }
 | 
						|
		return node },
 | 
						|
	remove: function(node='focused', offset){
 | 
						|
		var elem = this.get(...arguments)
 | 
						|
		var next 
 | 
						|
		if(elem.classList.contains('focused')){
 | 
						|
			// XXX need to be able to get the next elem on same level...
 | 
						|
			this.toggleCollapse(elem, true)
 | 
						|
			next = elem === this.get(-1) ?
 | 
						|
				this.get(elem, 'prev') 
 | 
						|
				: this.get(elem, 'next') }
 | 
						|
		elem?.remove()
 | 
						|
		next?.focus()
 | 
						|
		return this },
 | 
						|
 | 
						|
	clear: function(){
 | 
						|
		this.outline.innerText = ''
 | 
						|
		return this },
 | 
						|
 | 
						|
	// block serialization...
 | 
						|
	// XXX split this up into a generic handler + plugins...
 | 
						|
	// XXX need a way to filter input text...
 | 
						|
	// 		use-case: hidden attributes...
 | 
						|
	// NOTE: this is auto-populated by .__code2html__(..)
 | 
						|
	__styles: undefined,
 | 
						|
	__code2html__: function(code){
 | 
						|
		var that = this
 | 
						|
		var elem = {
 | 
						|
			collapsed: false,
 | 
						|
		}
 | 
						|
		// only whitespace -> keep element blank...
 | 
						|
		if(code.trim() == ''){
 | 
						|
			elem.text = ''
 | 
						|
			return elem }
 | 
						|
		var style = function(style, code=undefined){
 | 
						|
			style = [style].flat()
 | 
						|
			that.__styles = [...new Set([
 | 
						|
				...(that.__styles ?? []),
 | 
						|
				...style,
 | 
						|
			])]
 | 
						|
			return function(_, text){
 | 
						|
				elem.style ??= []
 | 
						|
				elem.style.push(...style)
 | 
						|
				return code 
 | 
						|
					?? text } }
 | 
						|
		elem.text = code 
 | 
						|
			// hidden attributes...
 | 
						|
			// XXX make this generic...
 | 
						|
			// XXX should these be hidden from code too???
 | 
						|
			// collapsed...
 | 
						|
			.replace(/(\n|^)\s*collapsed::\s*(.*)\s*(\n|$)/, 
 | 
						|
				function(_, value){
 | 
						|
					elem.collapsed = value.trim() == 'true'
 | 
						|
					return '' })
 | 
						|
			// id...
 | 
						|
			.replace(/(\n|^)\s*id::\s*(.*)\s*(\n|$)/, 
 | 
						|
				function(_, value){
 | 
						|
					elem.id = value.trim()
 | 
						|
					return '' })
 | 
						|
			// markdown...
 | 
						|
			// style: headings...
 | 
						|
			.replace(/^(?<!\\)######\s+(.*)$/m, style('heading-6'))
 | 
						|
			.replace(/^(?<!\\)#####\s+(.*)$/m, style('heading-5'))
 | 
						|
			.replace(/^(?<!\\)####\s+(.*)$/m, style('heading-4'))
 | 
						|
			.replace(/^(?<!\\)###\s+(.*)$/m, style('heading-3'))
 | 
						|
			.replace(/^(?<!\\)##\s+(.*)$/m, style('heading-2'))
 | 
						|
			.replace(/^(?<!\\)#\s+(.*)$/m, style('heading-1'))
 | 
						|
			// style: list...
 | 
						|
			//.replace(/^(?<!\\)[-\*]\s+(.*)$/m, style('list-item'))
 | 
						|
			.replace(/^\s*(.*)(?<!\\):\s*$/m, style('list'))
 | 
						|
			// style: misc...
 | 
						|
			.replace(/^\s*(?<!\\)>\s+(.*)$/m, style('quote'))
 | 
						|
			.replace(/^\s*(?<!\\)((\/\/|;)\s+.*)$/m, style('comment'))
 | 
						|
			.replace(/^\s*(?<!\\)NOTE:?\s*(.*)$/m, style('NOTE'))
 | 
						|
			.replace(/^\s*(?<!\\)XXX\s+(.*)$/m, style('XXX'))
 | 
						|
			.replace(/^(.*)\s*(?<!\\)XXX$/m, style('XXX'))
 | 
						|
			.replace(/(\s*)(?<!\\)(ASAP|BUG|FIX|HACK|STUB|WARNING|CAUTION)(\s*)/m, 
 | 
						|
				'$1<span class="highlight $2">$2</span>$3')
 | 
						|
			// elements...
 | 
						|
			.replace(/(\n|^)(?<!\\)---*\h*(\n|$)/m, '$1<hr>')
 | 
						|
			// ToDo...
 | 
						|
			// NOTE: these are separate as we need to align block text 
 | 
						|
			// 		to leading chekbox...
 | 
						|
			.replace(/^\s*(?<!\\)\[[_ ]\]\s*/m, 
 | 
						|
				style('todo', '<input type="checkbox">'))
 | 
						|
			.replace(/^\s*(?<!\\)\[[Xx]\]\s*/m, 
 | 
						|
				style('todo', '<input type="checkbox" checked>'))
 | 
						|
			// inline checkboxes...
 | 
						|
			.replace(/\s*(?<!\\)\[[_ ]\]\s*/gm, 
 | 
						|
				style('check', '<input type="checkbox">'))
 | 
						|
			.replace(/\s*(?<!\\)\[[Xx]\]\s*/gm, 
 | 
						|
				style('check', '<input type="checkbox" checked>'))
 | 
						|
			// basic styling...
 | 
						|
			.replace(/(?<!\\)\*(?=[^\s*])(([^*]|\\\*)*[^\s*])(?<!\\)\*/gm, '<b>$1</b>')
 | 
						|
			.replace(/(?<!\\)~(?=[^\s~])(([^~]|\\~)*[^\s~])(?<!\\)~/gm, '<s>$1</s>')
 | 
						|
			.replace(/(?<!\\)_(?=[^\s_])(([^_]|\\_)*[^\s_])(?<!\\)_/gm, '<i>$1</i>') 
 | 
						|
			.replace(/(?<!\\)`(?=[^\s_])(([^`]|\\`)*[^\s_])(?<!\\)`/gm, '<code>$1</code>') 
 | 
						|
			// characters...
 | 
						|
			// XXX use ligatures for these???
 | 
						|
			.replace(/(?<!\\)---(?!-)/gm, '—') 
 | 
						|
			.replace(/(?<!\\)--(?!-)/gm, '–') 
 | 
						|
			// quoting...
 | 
						|
			// NOTE: this must be last...
 | 
						|
			.replace(/(?<!\\)\\(.)/gm, '$1') 
 | 
						|
		return elem },
 | 
						|
	// XXX essentially here we need to remove service stuff like some 
 | 
						|
	// 		attributes (collapsed, id, ...)...
 | 
						|
	// XXX also need to quote leading '- ' in block text here...
 | 
						|
	// 		e.g.
 | 
						|
	// 			- block
 | 
						|
	// 			  some text
 | 
						|
	// 			  - text in the above block ('-' needs to be quoted)
 | 
						|
	// 			- next block
 | 
						|
	__code2text__: function(code){
 | 
						|
		// XXX
 | 
						|
	},
 | 
						|
	__text2code__: function(text){
 | 
						|
		// XXX
 | 
						|
	},
 | 
						|
 | 
						|
	// serialization...
 | 
						|
	data: function(elem, deep=true){
 | 
						|
		elem = this.get(elem)	
 | 
						|
		return {
 | 
						|
			text: elem.querySelector('textarea').value,
 | 
						|
			collapsed: elem.getAttribute('collapsed') != null,
 | 
						|
			...(deep ? 
 | 
						|
				{children: this.json(elem)}
 | 
						|
				: {}),
 | 
						|
		} },
 | 
						|
	json: function(node){
 | 
						|
		var that = this
 | 
						|
		node ??= this.outline
 | 
						|
		return [...node.children]
 | 
						|
			.map(function(elem){
 | 
						|
				return elem.nodeName != 'DIV' ?
 | 
						|
					[]
 | 
						|
					: [that.data(elem)] })
 | 
						|
			.flat() },
 | 
						|
	// XXX add option to customize indent size...
 | 
						|
	text: function(node, indent, level){
 | 
						|
		// .text(<indent>, <level>)
 | 
						|
		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 +'- '
 | 
						|
					+ elem.text
 | 
						|
						.replace(/\n/g, '\n'+ level +'  ') 
 | 
						|
					+ (elem.collapsed ?
 | 
						|
						'\n'+level+'  ' + 'collapsed:: true'
 | 
						|
						: ''),
 | 
						|
				(elem.children 
 | 
						|
						&& elem.children.length > 0) ?
 | 
						|
					this.text(elem.children || [], indent, level+indent) 
 | 
						|
					: [] ) }
 | 
						|
		return text
 | 
						|
			.flat()
 | 
						|
			.join('\n') },
 | 
						|
 | 
						|
	parse: function(text){
 | 
						|
		text = text
 | 
						|
			.replace(/^\s*\n/, '')
 | 
						|
		text = ('\n' + text)
 | 
						|
			.split(/\n(\s*)- /g)
 | 
						|
			.slice(1)
 | 
						|
		var tab = ' '.repeat(this.tab_size || 8)
 | 
						|
		var level = function(lst, prev_sep=undefined, parent=[]){
 | 
						|
			while(lst.length > 0){
 | 
						|
				sep = lst[0].replace(/\t/gm, tab)
 | 
						|
				// deindent...
 | 
						|
				if(prev_sep != null 
 | 
						|
						&& sep.length < prev_sep.length){
 | 
						|
					break }
 | 
						|
				prev_sep ??= sep
 | 
						|
				// same level...
 | 
						|
				if(sep.length == prev_sep.length){
 | 
						|
					var [_, block] = lst.splice(0, 2)
 | 
						|
					var collapsed = false
 | 
						|
					block = block
 | 
						|
						.replace(/\n\s*collapsed::\s*(.*)\s*$/, 
 | 
						|
							function(_, value){
 | 
						|
								collapsed = value == 'true'
 | 
						|
								return '' })
 | 
						|
					parent.push({ 
 | 
						|
						text: block
 | 
						|
							// normalize indent...
 | 
						|
							.split(new RegExp('\n'+sep+'  ', 'g'))
 | 
						|
							.join('\n'),
 | 
						|
						collapsed,
 | 
						|
						children: [],
 | 
						|
					})
 | 
						|
				// indent...
 | 
						|
				} else {
 | 
						|
					parent.at(-1).children = level(lst, sep) } }
 | 
						|
			return parent }
 | 
						|
		return level(text) },
 | 
						|
 | 
						|
	// XXX should this handle children???
 | 
						|
	// XXX revise name...
 | 
						|
	Block: function(data={}, place=null){
 | 
						|
		if(typeof(data) != 'object'){
 | 
						|
			place = data
 | 
						|
			data = {} }
 | 
						|
		var block = document.createElement('div')
 | 
						|
		block.setAttribute('tabindex', '0')
 | 
						|
		var text = document.createElement('textarea')
 | 
						|
			.autoUpdateSize()
 | 
						|
		var html = document.createElement('span')
 | 
						|
		block.append(text, html)
 | 
						|
		this.update(block, data)
 | 
						|
		// place...
 | 
						|
		var cur = this.get()
 | 
						|
		if(place && cur){
 | 
						|
			place = place == 'prev' ?
 | 
						|
				'before'
 | 
						|
				: place
 | 
						|
			;(place == 'next' 
 | 
						|
					&& (cur.querySelector('[tabindex]')
 | 
						|
						|| cur.nextElementSibling)) ?
 | 
						|
				this.get(place).before(block)
 | 
						|
			: (place == 'next' 
 | 
						|
					&& !cur.nextElementSibling) ?
 | 
						|
				cur.after(block)
 | 
						|
			: (place == 'before' || place == 'after') ?
 | 
						|
				cur[place](block)
 | 
						|
			: undefined }
 | 
						|
		return block },
 | 
						|
	load: function(data){
 | 
						|
		var that = this
 | 
						|
		data = typeof(data) == 'string' ?
 | 
						|
			this.parse(data)
 | 
						|
			: data
 | 
						|
		// generate dom...
 | 
						|
		var level = function(lst){
 | 
						|
			return lst
 | 
						|
				.map(function(data){
 | 
						|
					var elem = that.Block(data) 
 | 
						|
					if((data.children || []).length > 0){
 | 
						|
						elem.append(...level(data.children)) }
 | 
						|
					return elem }) }
 | 
						|
		this
 | 
						|
			.clear()
 | 
						|
			.outline
 | 
						|
				.append(...level(data))
 | 
						|
		// update sizes of all the textareas (transparent)...
 | 
						|
		for(var e of [...this.outline.querySelectorAll('textarea')]){
 | 
						|
			e.updateSize() }
 | 
						|
		return this },
 | 
						|
 | 
						|
	sync: function(){
 | 
						|
		var code = this.code
 | 
						|
		code 
 | 
						|
			&& (code.innerHTML = this.text())
 | 
						|
		return this },
 | 
						|
 | 
						|
	// XXX add scrollIntoView(..) to nav...
 | 
						|
	keyboard: {
 | 
						|
		// vertical navigation...
 | 
						|
		// XXX this is a bit hacky but it works -- the caret blinks at 
 | 
						|
		// 		start/end of block before switching to next, would be 
 | 
						|
		// 		nice po prevent this...
 | 
						|
		ArrowUp: function(evt){
 | 
						|
			var that = this
 | 
						|
			var edited = this.get('edited')
 | 
						|
			if(edited){
 | 
						|
				var c = edited.selectionStart
 | 
						|
				var jump = function(){
 | 
						|
					if(edited.selectionStart == 0){
 | 
						|
						// needed to remember the position...
 | 
						|
						edited.selectionStart = c
 | 
						|
						edited.selectionEnd = c
 | 
						|
						that.focus('edited', -1) } }
 | 
						|
				this.carot_jump_edge_then_block ?
 | 
						|
					jump()
 | 
						|
					: setTimeout(jump, 0)
 | 
						|
			} else {
 | 
						|
				evt.preventDefault() 
 | 
						|
				this.focus('focused', -1) } },
 | 
						|
		ArrowDown: function(evt){
 | 
						|
			var that = this
 | 
						|
			var edited = this.get('edited')
 | 
						|
			if(edited){
 | 
						|
				var c = edited.selectionStart
 | 
						|
				var jump = function(){
 | 
						|
					if(edited.selectionStart == edited.value.length){
 | 
						|
						// needed to remember the position...
 | 
						|
						edited.selectionStart = c
 | 
						|
						edited.selectionEnd = c
 | 
						|
						that.focus('edited', 1) } }
 | 
						|
				this.carot_jump_edge_then_block ?
 | 
						|
					jump()
 | 
						|
					: setTimeout(jump, 0)
 | 
						|
			} 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 }
 | 
						|
			;((this.left_key_collapses 
 | 
						|
						|| evt.shiftKey)
 | 
						|
					&& this.get().getAttribute('collapsed') == null
 | 
						|
					&& this.get('children').length > 0) ?
 | 
						|
				this.toggleCollapse(true)
 | 
						|
				: this.focus('parent') },
 | 
						|
		ArrowRight: function(evt){
 | 
						|
			var edited = this.get('edited')
 | 
						|
			if(edited){
 | 
						|
				// move caret to next element...
 | 
						|
				if(edited.selectionStart == edited.selectionEnd
 | 
						|
						&& edited.selectionStart == edited.value.length){
 | 
						|
					evt.preventDefault()
 | 
						|
					edited = this.focus('edited', 'next') 
 | 
						|
					edited.selectionStart = 
 | 
						|
						edited.selectionEnd = 0 }
 | 
						|
				return }
 | 
						|
			if(this.right_key_expands){
 | 
						|
				this.toggleCollapse(false) 
 | 
						|
				var child = this.focus('children')[0]
 | 
						|
				if(!child){
 | 
						|
					this.focus('next') }
 | 
						|
			} else {
 | 
						|
				evt.shiftKey ?
 | 
						|
					this.toggleCollapse(false)
 | 
						|
					: this.get('children')[0]?.focus() } },
 | 
						|
 | 
						|
		// indent...
 | 
						|
		Tab: function(evt){
 | 
						|
			evt.preventDefault()
 | 
						|
			var edited = this.get('edited')
 | 
						|
			var node = this.indent(!evt.shiftKey)
 | 
						|
			;(edited ?
 | 
						|
				edited
 | 
						|
				: node)?.focus() },
 | 
						|
 | 
						|
		// edit mode...
 | 
						|
		O: function(evt){
 | 
						|
			if(evt.target.nodeName != 'TEXTAREA'){
 | 
						|
				evt.preventDefault()
 | 
						|
				this.Block('before')
 | 
						|
					?.querySelector('textarea')
 | 
						|
					?.focus() } },
 | 
						|
		o: function(evt){
 | 
						|
			if(evt.target.nodeName != 'TEXTAREA'){
 | 
						|
				evt.preventDefault()
 | 
						|
				this.Block('next')
 | 
						|
					?.querySelector('textarea')
 | 
						|
					?.focus() } },
 | 
						|
		Enter: function(evt){
 | 
						|
			if(evt.ctrlKey
 | 
						|
					|| evt.shiftKey){
 | 
						|
				return }
 | 
						|
			evt.preventDefault()
 | 
						|
			evt.target.nodeName == 'TEXTAREA' ?
 | 
						|
				this.Block('next')
 | 
						|
					?.querySelector('textarea')
 | 
						|
					?.focus()
 | 
						|
				: this.get()
 | 
						|
					?.querySelector('textarea')
 | 
						|
					?.focus() },
 | 
						|
		Escape: function(evt){
 | 
						|
			this.outline.querySelector('textarea:focus')
 | 
						|
				?.parentElement
 | 
						|
				?.focus() },
 | 
						|
		Delete: function(evt){
 | 
						|
			if(this.get('edited')){
 | 
						|
				return }
 | 
						|
			this.remove() },
 | 
						|
 | 
						|
		// select...
 | 
						|
		// XXX add:
 | 
						|
		// 		ctrl-A
 | 
						|
		// 		ctrl-D
 | 
						|
		' ': function(evt){
 | 
						|
			if(this.get('edited') != null){
 | 
						|
				return }
 | 
						|
			evt.preventDefault()
 | 
						|
			var focused = this.get()
 | 
						|
			focused.getAttribute('selected') != null ?
 | 
						|
				focused.removeAttribute('selected')
 | 
						|
				: focused.setAttribute('selected', '') },
 | 
						|
	},
 | 
						|
 | 
						|
	// XXX might be a good idea to defer specific actions to event-like 
 | 
						|
	// 		handlers...
 | 
						|
	// 		e.g. clicking left if block -> .blockleft(..) ... etc.
 | 
						|
	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...
 | 
						|
		outline.addEventListener('click', 
 | 
						|
			function(evt){
 | 
						|
				var elem = evt.target
 | 
						|
 | 
						|
				// expand/collapse
 | 
						|
				if(elem.nodeName == 'SPAN' 
 | 
						|
						&& elem.parentElement.getAttribute('tabindex')){
 | 
						|
					// 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(elem.parentElement)
 | 
						|
 | 
						|
					// click inside element...
 | 
						|
					} else {
 | 
						|
						// XXX 
 | 
						|
					} }
 | 
						|
 | 
						|
				// edit of focus...
 | 
						|
				// NOTE: this is usefull if element text is hidden but the 
 | 
						|
				// 		frame is still visible...
 | 
						|
				if(elem.getAttribute('tabindex')){
 | 
						|
					elem.querySelector('textarea').focus() }
 | 
						|
 | 
						|
				// toggle checkbox...
 | 
						|
				if(elem.type == 'checkbox'){
 | 
						|
					var node = elem.parentElement.parentElement
 | 
						|
					var text = node.querySelector('textarea')
 | 
						|
					// 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) } })
 | 
						|
		// heboard handling...
 | 
						|
		outline.addEventListener('keydown', 
 | 
						|
			function(evt){
 | 
						|
				var elem = evt.target
 | 
						|
				// update element state...
 | 
						|
				if(elem.nodeName == 'TEXTAREA'){
 | 
						|
					setTimeout(function(){
 | 
						|
						that.update(elem.parentElement) 
 | 
						|
						elem.updateSize() }, 0) }
 | 
						|
				// handle keyboard...
 | 
						|
				evt.key in that.keyboard 
 | 
						|
					&& that.keyboard[evt.key].call(that, evt) })
 | 
						|
		// toggle view/code of nodes...
 | 
						|
		outline.addEventListener('focusin', 
 | 
						|
			function(evt){
 | 
						|
				var node = evt.target
 | 
						|
				// scroll...
 | 
						|
				// XXX a bit odd still and not smooth...
 | 
						|
				;((node.nodeName == 'SPAN' 
 | 
						|
							|| node.nodeName == 'TEXTAREA') ?
 | 
						|
						node
 | 
						|
						: node.querySelector('textarea+span'))
 | 
						|
					?.scrollIntoView({ 
 | 
						|
						block: 'nearest', 
 | 
						|
						behavior: 'smooth',
 | 
						|
					})
 | 
						|
				// handle focus...
 | 
						|
				for(var e of [...that.dom.querySelectorAll('.focused')]){
 | 
						|
					e.classList.remove('focused') }
 | 
						|
				that.get('focused')?.classList?.add('focused')
 | 
						|
				// textarea...
 | 
						|
				if(node.nodeName == 'TEXTAREA' 
 | 
						|
						&& node?.nextElementSibling?.nodeName == 'SPAN'){
 | 
						|
					node.updateSize() } })
 | 
						|
		outline.addEventListener('focusout', 
 | 
						|
			function(evt){
 | 
						|
				var node = evt.target
 | 
						|
				if(node.nodeName == 'TEXTAREA' 
 | 
						|
						&& node?.nextElementSibling?.nodeName == 'SPAN'){
 | 
						|
					var block = node.parentElement
 | 
						|
					that.update(block, { text: node.value }) } })
 | 
						|
		// update .code...
 | 
						|
		var update_code_timeout
 | 
						|
		outline.addEventListener('change', 
 | 
						|
			function(evt){
 | 
						|
				if(update_code_timeout){
 | 
						|
					return }
 | 
						|
				update_code_timeout = setTimeout(
 | 
						|
					function(){
 | 
						|
						update_code_timeout = undefined
 | 
						|
						that.sync() }, 
 | 
						|
					that.code_update_interval || 5000) })
 | 
						|
 | 
						|
		// toolbar...
 | 
						|
		var toolbar = this.toolbar
 | 
						|
		if(toolbar){
 | 
						|
			// handle return of focus when clicking toolbar...
 | 
						|
			var focus_textarea
 | 
						|
			var cahceNodeType = function(){
 | 
						|
				// NOTE: for some reason .activeElement returns an element
 | 
						|
				// 		that is not in the DOM after the action is done...
 | 
						|
				focus_textarea = document.activeElement.nodeName == 'TEXTAREA' }
 | 
						|
			var refocusNode = function(){
 | 
						|
				focus_textarea ?
 | 
						|
					editor.get().querySelector('textarea').focus() 
 | 
						|
					: editor.focus()
 | 
						|
				focus_textarea = undefined } 
 | 
						|
			// cache the focused node type before focus changes...
 | 
						|
			toolbar.addEventListener('mousedown', cahceNodeType)
 | 
						|
			// refocus the node after we are done...
 | 
						|
			toolbar.addEventListener('click', refocusNode) }
 | 
						|
 | 
						|
		// code...
 | 
						|
		var code = this.code
 | 
						|
		if(code){
 | 
						|
			this.load(code.innerHTML
 | 
						|
				.replace(/</g, '<')
 | 
						|
				.replace(/>/g, '>')) }
 | 
						|
 | 
						|
		return this },
 | 
						|
}
 | 
						|
 | 
						|
 | 
						|
 | 
						|
/**********************************************************************
 | 
						|
* vim:set ts=4 sw=4 :                                                */
 |