mirror of
				https://github.com/flynx/pWiki.git
				synced 2025-10-31 02:50:08 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			2049 lines
		
	
	
		
			56 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			2049 lines
		
	
	
		
			56 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
| /**********************************************************************
 | |
| * 
 | |
| *
 | |
| *
 | |
| **********************************************************************/
 | |
| 
 | |
| 
 | |
| //---------------------------------------------------------------------
 | |
| // Helpers...
 | |
| 
 | |
| // XXX do a caret api...
 | |
| 
 | |
| // XXX only for text areas...
 | |
| var atLine = function(elem, index){
 | |
| 	// XXX add support for range...
 | |
| 	var text = elem.value
 | |
| 	var lines = text.split(/\n/g).length
 | |
| 	var line = elem.caretLine 
 | |
| 
 | |
| 	// XXX STUB index handling...
 | |
| 	if((index == -1 && line == lines) 
 | |
| 			|| (index == 0 && line == 1)){
 | |
| 		return true }
 | |
| 	return false }
 | |
| 
 | |
| 
 | |
| /*
 | |
| function clickPoint(x,y){
 | |
| 	document
 | |
| 		.elementFromPoint(x, y)
 | |
| 		.dispatchEvent(
 | |
| 			new MouseEvent( 'click', { 
 | |
| 				view: window,
 | |
| 				bubbles: true,
 | |
| 				cancelable: true,
 | |
| 				screenX: x, 
 | |
| 				screenY: y, 
 | |
| 			} )) }
 | |
| //*/
 | |
| 
 | |
| 
 | |
| // Get the character offset at coordinates...
 | |
| //
 | |
| // This is done by moving a range down the element until its bounding 
 | |
| // box corresponds the to desired coordinates. This accounts for nested 
 | |
| // elements.
 | |
| //
 | |
| // XXX it would be a better idea to do a binary search instead of a liner 
 | |
| // 		pass... but at this point this is not critical (unless we get 
 | |
| // 		gigantic blocks)
 | |
| // XXX HACK -- is there a better way to do this???
 | |
| var getCharOffset = function(elem, x, y, c){
 | |
| 	c = c ?? 0
 | |
| 	var r = document.createRange()
 | |
| 	for(var e of [...elem.childNodes]){
 | |
| 		// text node...
 | |
| 		if(e instanceof Text){
 | |
| 			var prev, b
 | |
| 			for(var i=0; i <= e.length; i++){
 | |
| 				r.setStart(e, i)
 | |
| 				r.setEnd(e, i)
 | |
| 				prev = b
 | |
| 				b = r.getBoundingClientRect()
 | |
| 				// found target...
 | |
| 				if(b.x >= x 
 | |
| 						&& b.y <= y 
 | |
| 						&& b.bottom >= y){
 | |
| 					// get the closest gap between chars to the click...
 | |
| 					return Math.abs(b.x - x) <= Math.abs(prev.x - x) ?
 | |
| 						c + i
 | |
| 						: c + i - 1 } }
 | |
| 			c += i
 | |
| 		// html node...
 | |
| 		} else {
 | |
| 			var res = getCharOffset(e, x, y, c)
 | |
| 			if(!(res instanceof Array)){
 | |
| 				return res } 
 | |
| 			;[c, res] = res } }
 | |
| 	// no result was found...
 | |
| 	return arguments.length > 3 ?
 | |
| 		[c, null]
 | |
| 		: null }
 | |
| 
 | |
| 
 | |
| // Get offset in markdown relative to the resulting text...
 | |
| //                     
 | |
| //					    v <----- position
 | |
| //		text:		'Hea|ding'
 | |
| //					    |
 | |
| //		                +-+ <--- offset in markdown
 | |
| //		                  |
 | |
| //		markdown:	'# Hea|ding'
 | |
| //
 | |
| var getMarkdownOffset = function(markdown, text, i){
 | |
| 	i = i ?? text.length
 | |
| 	var m = 0
 | |
| 	// walk both strings skipping/counting non-matching stuff...
 | |
| 	for(var n=0; n <= i; n++, m++){
 | |
| 		var c = text[n]
 | |
| 		var p = m
 | |
| 		// walk to next match...
 | |
| 		while(c != markdown[m] && m < markdown.length){
 | |
| 			m++ } 
 | |
| 		// reached something unrepresentable directly in markdown (html 
 | |
| 		// entity, symbol, ...)
 | |
| 		if(m >= markdown.length){
 | |
| 			m = p } }
 | |
| 	return m - n }
 | |
| 
 | |
| 
 | |
| 
 | |
| //---------------------------------------------------------------------
 | |
| // Plugins...
 | |
| 
 | |
| // general helpers and utils...
 | |
| var plugin = {
 | |
| 	encode: function(text){
 | |
| 		return text
 | |
| 			.replace(/(?<!\\)&/g, '&')
 | |
| 			.replace(/(?<!\\)</g, '<')
 | |
| 			.replace(/(?<!\\)>/g, '>')
 | |
| 			.replace(/\\(?!`)/g, '\\\\') },
 | |
| 
 | |
| 	// XXX make this more generic...
 | |
| 	style: function(editor, elem, style, code=undefined){
 | |
| 		style = [style].flat()
 | |
| 		editor.__styles = [...new Set([
 | |
| 			...(editor.__styles ?? []),
 | |
| 			...style,
 | |
| 		])]
 | |
| 		return function(_, text){
 | |
| 			elem.style ??= []
 | |
| 			elem.style.push(...style)
 | |
| 			return code 
 | |
| 				?? text } },
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
 | |
| 
 | |
| // 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 + `<br>${key}: ${value}` }, '') },
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
 | |
| 
 | |
| var blocks = {
 | |
| 	__proto__: plugin,
 | |
| 
 | |
| 	__pre_parse__: function(text, editor, elem){
 | |
| 		return text 
 | |
| 			// markdown...
 | |
| 			// style: headings...
 | |
| 			.replace(/^(?<!\\)######\s+(.*)$/m, this.style(editor, elem, 'heading-6'))
 | |
| 			.replace(/^(?<!\\)#####\s+(.*)$/m, this.style(editor, elem, 'heading-5'))
 | |
| 			.replace(/^(?<!\\)####\s+(.*)$/m, this.style(editor, elem, 'heading-4'))
 | |
| 			.replace(/^(?<!\\)###\s+(.*)$/m, this.style(editor, elem, 'heading-3'))
 | |
| 			.replace(/^(?<!\\)##\s+(.*)$/m, this.style(editor, elem, 'heading-2'))
 | |
| 			.replace(/^(?<!\\)#\s+(.*)$/m, this.style(editor, elem, 'heading-1'))
 | |
| 			// style: list...
 | |
| 			//.replace(/^(?<!\\)[-\*]\s+(.*)$/m, style('list-item'))
 | |
| 			.replace(/^\s*(.*)(?<!\\):\s*$/m, this.style(editor, elem, 'list'))
 | |
| 			.replace(/^\s*(.*)(?<!\\)#\s*$/m, this.style(editor, elem, 'numbered-list'))
 | |
| 			// style: misc...
 | |
| 			.replace(/^\s*(?<!\\)>\s+(.*)$/m, this.style(editor, elem, 'quote'))
 | |
| 			.replace(/^\s*(?<!\\)((\/\/|;)\s+.*)$/m, this.style(editor, elem, 'comment'))
 | |
| 			.replace(/^\s*(?<!\\)NOTE:?\s*(.*)$/m, this.style(editor, elem, 'NOTE'))
 | |
| 			.replace(/^\s*(?<!\\)XXX\s+(.*)$/m, this.style(editor, elem, 'XXX'))
 | |
| 			.replace(/^(.*)\s*(?<!\\)XXX$/m, this.style(editor, elem, 'XXX')) } ,
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
 | |
| 
 | |
| // XXX add actions...
 | |
| var quoted = {
 | |
| 	__proto__: plugin,
 | |
| 
 | |
| 	// can be used in:
 | |
| 	// 		<string>.replace(quoted.pattern, quoted.handler)
 | |
| 	quote_pattern: /(?<!\\)`(?=[^\s])(([^`]|\\`)*[^\s])(?<!\\)`/gm,
 | |
| 	quote: function(_, code){
 | |
| 		return `<code>${ this.encode(code) }</code>` },
 | |
| 
 | |
| 	pre_pattern: /(?<!\\)```(.*\s*\n)((\n|.)*?)\h*(?<!\\)```/g,
 | |
| 	pre: function(_, language, code){
 | |
| 		language = language.trim()
 | |
| 		language = language ?
 | |
| 			'language-'+language
 | |
| 			: language
 | |
| 		return `<pre>`
 | |
| 				+`<code contenteditable="true" class="${language}">${ 
 | |
| 					this.encode(code)
 | |
| 				}</code>`
 | |
| 			+`</pre>` },
 | |
| 
 | |
| 	map: function(text, func){
 | |
| 		return text.replace(this.pre_pattern, func) },
 | |
| 	replace: function(text, index, updated){
 | |
| 		return this.map(text, 
 | |
| 			function(match, language, code){
 | |
| 				return index-- != 0 ?
 | |
| 					match
 | |
| 					: ('```'+language
 | |
| 						+ (typeof(updated) == 'function' ?
 | |
| 							updated(code)
 | |
| 							: updated)
 | |
| 						+'```') }) },
 | |
| 	toHTML: function(text){
 | |
| 		return this.map(text, this.handler) },
 | |
| 
 | |
| 	__pre_parse__: function(text, editor, elem){
 | |
| 		return text
 | |
| 			.replace(this.pre_pattern, this.pre.bind(this)) 
 | |
| 			.replace(this.quote_pattern, this.quote.bind(this)) },
 | |
| 
 | |
| 	// XXX is this a good strategy???
 | |
| 	__state: undefined,
 | |
| 	__keydown__: function(evt, editor, elem){
 | |
| 		// code editing...
 | |
| 		if(elem.nodeName == 'CODE' 
 | |
| 				&& elem.getAttribute('contenteditable') == 'true'){
 | |
| 			// XXX can keydown and keyup be triggered from different elements???
 | |
| 			this.__state = elem.innerText
 | |
| 			return false } },
 | |
| 	// defined <plugin>.__editedview__(..) handler
 | |
| 	__keyup__: function(evt, editor, elem){
 | |
| 		var elem = evt.target
 | |
| 		if(elem.nodeName == 'CODE' 
 | |
| 				&& elem.getAttribute('contenteditable') == 'true'){
 | |
| 			// trigger if state actually changed..
 | |
| 			this.__state != elem.innerText
 | |
| 				&& editor.runPlugins('__editedview__', evt, editor, elem) } },
 | |
| 	__focusout__: function(){
 | |
| 		this.__state = undefined },
 | |
| 	__editedview__: function(evt, editor, elem){
 | |
| 		// editable code...
 | |
| 		var block = editor.get(elem)
 | |
| 		var code = block.querySelector('.code')
 | |
| 
 | |
| 		var update = elem.innerText
 | |
| 		var i = [...block
 | |
| 				.querySelectorAll('.view code[contenteditable=true]')]
 | |
| 			.indexOf(elem)
 | |
| 		// update element content...
 | |
| 		code.value = quoted.replace(code.value, i, update)
 | |
| 
 | |
| 		return this },
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
 | |
| 
 | |
| var tasks = {
 | |
| 	__proto__: plugin,
 | |
| 
 | |
| 	done_patterns: [
 | |
| 		/^\s*(?<!\\)DONE\s+(.*)$/m,
 | |
| 		/^(.*)\s*(?<!\\)DONE\s*$/m,
 | |
| 	],
 | |
| 
 | |
| 	// State...
 | |
| 	updateStatus: function(editor, node){
 | |
| 		node = editor.get(node)
 | |
| 		if(node == null){
 | |
| 			return this }
 | |
| 		var state = node
 | |
| 			.querySelector('.view')
 | |
| 				.querySelector('.completion')
 | |
| 		if(state){
 | |
| 			var c = 
 | |
| 				((node.querySelectorAll('input[type=checkbox]:checked').length
 | |
| 						/ node.querySelectorAll('input[type=checkbox]').length)
 | |
| 					* 100)
 | |
| 				.toFixed(0)
 | |
| 			!isNaN(c)
 | |
| 				&& state.setAttribute('completion', c +'%') }
 | |
| 		return this },
 | |
| 	updateBranchStatus: function(editor, node){
 | |
| 		if(!node){
 | |
| 			return this }
 | |
| 		var outline = editor.outline
 | |
| 		var p = node
 | |
| 		while(p !== outline){
 | |
| 			this.updateStatus(editor, p)
 | |
| 			p = editor.get(p, 'parent') } 
 | |
| 		return this },
 | |
| 	updateAllStatus: function(editor){
 | |
| 		for(var e of [...editor.outline.querySelectorAll('.block>.view .completion')]){
 | |
| 			this.updateStatus(editor, e) }
 | |
| 		return this },
 | |
| 	// Checkboxes...
 | |
| 	getCheckbox: function(editor, elem, offset=0){
 | |
| 		elem = elem 
 | |
| 			?? editor.get()
 | |
| 		if(elem == null 
 | |
| 				|| (offset == 0
 | |
| 					&& elem.type == 'checkbox')){
 | |
| 			return elem }
 | |
| 		var node = editor.get(elem)
 | |
| 		var view = node.querySelector('.view')
 | |
| 		var cur = view.querySelector('input[type=checkbox].selected') 
 | |
| 			?? view.querySelector('input[type=checkbox]') 
 | |
| 		if(offset == 0 && cur == null){
 | |
| 			return}
 | |
| 		var checkboxes = [...editor.outline.querySelectorAll('.view input[type=checkbox]')]
 | |
| 		if(checkboxes.length == 0){
 | |
| 			return }
 | |
| 		// no checkbox in node -> get closest to cur in offset direction...
 | |
| 		if(cur == null){
 | |
| 			var nodes = [...editor.outline.querySelectorAll('.block')]
 | |
| 			var checkbox_nodes = checkboxes
 | |
| 				.map(function(e){ 
 | |
| 					return editor.get(e) })
 | |
| 			var i = nodes.indexOf(node)
 | |
| 			var p, n
 | |
| 			for(var c of checkbox_nodes){
 | |
| 				p = n
 | |
| 				var j = nodes.indexOf(c)
 | |
| 				if(j >= i){
 | |
| 					n = j
 | |
| 					break } }
 | |
| 			cur = offset < 0 ?
 | |
| 				nodes[p] 
 | |
| 				: nodes[n] }
 | |
| 		var elem = cur == null ?
 | |
| 			checkboxes.at(
 | |
| 				offset > 0 ? 
 | |
| 					offset -1 
 | |
| 					: offset)
 | |
| 			: checkboxes.at(
 | |
| 				(checkboxes.indexOf(cur) + offset) % checkboxes.length)
 | |
| 		return elem },
 | |
| 	updateCheckboxes: function(editor, elem){
 | |
| 		elem = this.getCheckbox(editor, elem)
 | |
| 		var node = editor.get(elem)
 | |
| 		var text = node.querySelector('.code')
 | |
| 		// get the checkbox order...
 | |
| 		var i = [...node.querySelectorAll('input[type=checkbox]')].indexOf(elem)
 | |
| 		var to = elem.checked ?
 | |
| 			'[X]'
 | |
| 			: '[_]'
 | |
| 		var toggle = function(m){
 | |
| 			return i-- == 0 ?
 | |
| 				to
 | |
| 				: m }
 | |
| 		text.value = text.value.replace(/\[[Xx_]\]/g, toggle) 
 | |
| 		return elem },
 | |
| 	toggleCheckbox: function(editor, checkbox, offset){
 | |
| 		checkbox = this.getCheckbox(editor, checkbox, offset)
 | |
| 		if(checkbox){
 | |
| 			checkbox.checked = !checkbox.checked
 | |
| 			this.updateCheckboxes(editor, checkbox) 
 | |
| 			this.updateBranchStatus(editor, checkbox) }
 | |
| 		return checkbox },
 | |
| 	selectCheckbox: function(editor, checkbox, offset){
 | |
| 		checkbox = this.getCheckbox(editor, checkbox, offset)
 | |
| 		if(checkbox == null){
 | |
| 			return }
 | |
| 		var checkboxes = editor.get(checkbox)
 | |
| 			.querySelector('.view')
 | |
| 				.querySelectorAll('input[type=checkbox]')
 | |
| 		if(checkboxes.length == 0){
 | |
| 			return }
 | |
| 		for(var c of checkboxes){
 | |
| 			c.classList.remove('selected') }
 | |
| 		checkbox.classList.add('selected')
 | |
| 		editor.show(checkbox)
 | |
| 		return checkbox },
 | |
| 	nextCheckbox: function(editor, node='focused', offset=1){
 | |
| 		node = this.selectCheckbox(editor, node, offset)
 | |
| 		editor.focus(node)
 | |
| 		return node },
 | |
| 	prevCheckbox: function(editor, node='focused', offset=-1){
 | |
| 		return this.nextCheckbox(editor, node, offset) },
 | |
| 	// DONE...
 | |
| 	toggleDone: function(editor, elem){
 | |
| 		var node = editor.get(elem)
 | |
| 		if(node == null){
 | |
| 			return }
 | |
| 		var text = node.querySelector('.code')
 | |
| 		var value = text.value
 | |
| 		var s = text.selectionStart
 | |
| 		var e = text.selectionEnd
 | |
| 		var l = text.value.length
 | |
| 		if(this.done_patterns
 | |
| 				.reduce(function(res, p){ 
 | |
| 					return res 
 | |
| 						|| p.test(text.value) } , false)){
 | |
| 			for(var p of this.done_patterns){
 | |
| 				value = value.replace(p, '$1') }
 | |
| 		} else {
 | |
| 			value = 'DONE ' + value }
 | |
| 		text.value = value
 | |
| 		text.selectionStart = s + (value.length - l)
 | |
| 		text.selectionEnd = e + (value.length - l)
 | |
| 		editor.update(node)
 | |
| 		return node },
 | |
| 
 | |
| 	__setup__: function(editor){
 | |
| 		return this.updateAllStatus(editor) },
 | |
| 	__pre_parse__: function(text, editor, elem){
 | |
| 		// handle done..
 | |
| 		var handler = this.style(editor, elem, 'DONE')
 | |
| 		for(var p of this.done_patterns){
 | |
| 			text = text
 | |
| 				.replace(p, handler) }
 | |
| 		return text },
 | |
| 	__parse__: function(text, editor, elem){
 | |
| 		return text
 | |
| 			// block checkboxes...
 | |
| 			// NOTE: these are separate as we need to align block text 
 | |
| 			// 		to leading chekbox...
 | |
| 			.replace(/^\s*(?<!\\)\[[_ ]\]\s*/m, 
 | |
| 				this.style(editor, elem, 'todo', '<input type="checkbox">'))
 | |
| 			.replace(/^\s*(?<!\\)\[[Xx]\]\s*/m, 
 | |
| 				this.style(editor, elem, 'todo', '<input type="checkbox" checked>'))
 | |
| 			// inline checkboxes...
 | |
| 			.replace(/\s*(?<!\\)\[[_ ]\]\s*/gm, 
 | |
| 				this.style(editor, elem, 'check', '<input type="checkbox">'))
 | |
| 			.replace(/\s*(?<!\\)\[[Xx]\]\s*/gm, 
 | |
| 				this.style(editor, elem, 'check', '<input type="checkbox" checked>'))
 | |
| 			// completion...
 | |
| 			// XXX add support for being like a todo checkbox...
 | |
| 			.replace(/(?<!\\)\[[%]\]/gm, '<span class="completion"></span>') },
 | |
| 	__focusin__: function(evt, editor, elem){
 | |
| 		elem.classList.contains('block')
 | |
| 			&& this.selectCheckbox(editor, elem) },
 | |
| 	__editedcode__: function(evt, editor, elem){
 | |
| 		this.updateBranchStatus(editor, elem) 
 | |
| 		this.selectCheckbox(editor, elem) },
 | |
| 	__click__: function(evt, editor, elem){
 | |
| 		// toggle checkbox...
 | |
| 		if(elem.type == 'checkbox'){
 | |
| 			var node = editor.get(elem)
 | |
| 			this.updateCheckboxes(editor, elem)
 | |
| 			this.updateBranchStatus(editor, node) 
 | |
| 			this.selectCheckbox(editor, elem) 
 | |
| 			node.focus() } 
 | |
| 		return this },
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
 | |
| 
 | |
| // XXX Hackish...
 | |
| var syntax = {
 | |
| 	__proto__: plugin,
 | |
| 
 | |
| 	update: function(){
 | |
| 		window.hljs
 | |
| 			&& hljs.highlightAll() 
 | |
| 		return this },
 | |
| 
 | |
| 	__setup__: function(editor){
 | |
| 		return this.update() },
 | |
| 	// XXX make a local update...
 | |
| 	__editedcode__: function(evt, editor, elem){
 | |
| 		return this.update(elem) },
 | |
| 	__editedview__: function(evt, editor, elem){
 | |
| 		// XXX should we also clear the syntax???
 | |
| 		delete elem.dataset.highlighted
 | |
| 		return this },
 | |
| 	// XXX this removes highlighting, can we make it update live???
 | |
| 	__focusin__: function(evt, editor, elem){
 | |
| 		if(elem.nodeName == 'CODE' 
 | |
| 				&& elem.getAttribute('contenteditable') == 'true'){
 | |
| 			elem.classList.remove('hljs') } },
 | |
| 	__focusout__: function(evt, editor, elem){
 | |
| 		if(elem.nodeName == 'CODE' 
 | |
| 				&& elem.getAttribute('contenteditable') == 'true'){
 | |
| 			this.update(elem) }
 | |
| 		return this },	
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
 | |
| 
 | |
| var tables = {
 | |
| 	__proto__: plugin,
 | |
| 
 | |
| 	__parse__: function(text, editor, elem){
 | |
| 		return text
 | |
| 			.replace(/^\s*(?<!\\)\|\s*((.|\n)*)\s*\|\s*$/, 
 | |
| 				function(_, body){
 | |
| 					return `<table><tr><td>${
 | |
| 						body
 | |
| 							.replace(/\s*\|\s*\n\s*\|\s*/gm, '</td></tr>\n<tr><td>')
 | |
| 							.replace(/\s*\|\s*/gm, '</td><td>')
 | |
| 					}</td></td></table>` }) },
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
 | |
| 
 | |
| var styling = {
 | |
| 	__proto__: plugin,
 | |
| 
 | |
| 	__parse__: function(text, editor, elem){
 | |
| 		return text
 | |
| 			// markers...
 | |
| 			.replace(/(\s*)(?<!\\)(FEATURE[:?]|Q:|Question:|Note:)(\s*)/gm, 
 | |
| 				'$1<b class="$2">$2</b>$3')
 | |
| 			.replace(/(\s*)(?<!\\)(ASAP|BUG|FIX|HACK|STUB|WARNING|CAUTION)(\s*)/gm, 
 | |
| 				'$1<span class="highlight $2">$2</span>$3')
 | |
| 			// elements...
 | |
| 			.replace(/(\n|^)(?<!\\)---*\h*(\n|$)/m, '$1<hr>')
 | |
| 			// basic styling...
 | |
| 			.replace(/(?<!\\)\*(?=[^\s*])(([^*]|\\\*)*[^\s*])(?<!\\)\*/gm, '<b>$1</b>')
 | |
| 			.replace(/(?<!\\)~(?=[^\s~])(([^~]|\\~)*[^\s~])(?<!\\)~/gm, '<s>$1</s>')
 | |
| 			// XXX this can clash with '[_] .. [_]' checkboxes...
 | |
| 			.replace(/(?<!\\)_(?=[^\s_])(([^_]|\\_)*[^\s_])(?<!\\)_/gm, '<i>$1</i>') 
 | |
| 			// code/quoting...
 | |
| 			//.replace(/(?<!\\)`(?=[^\s])(([^`]|\\`)*[^\s])(?<!\\)`/gm, quote) 
 | |
| 			// XXX support "\==" in mark...
 | |
| 			.replace(/(?<!\\)==(?=[^\s])(.*[^\s])(?<!\\)==/gm, '<mark>$1</mark>') 
 | |
| 			// links...
 | |
| 			.replace(/(?<!\\)\[([^\]]*)\]\(([^)]*)\)/g, '<a href="$2">$1</a>')
 | |
| 			.replace(/((?:https?:|ftps?:)[^\s]*)(\s*)/g, '<a href="$1">$1</a>$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(`(?<!\\\\)(${ 
 | |
| 				Object.keys(this.symbols)
 | |
| 					.join('|') 
 | |
| 						.replace(/([\(\)\\\/])/g, '\\$1') })`, 'g') 
 | |
| 			: undefined },
 | |
| 
 | |
| 	__parse__: function(text, editor, elem){
 | |
| 		var that = this
 | |
| 		var p = this.symbols_pattern
 | |
| 		text = p ?
 | |
| 			text.replace(p,
 | |
| 				function(m){
 | |
| 					return that.symbols[m] })
 | |
| 			: text
 | |
| 		return text
 | |
| 			.replace(/(?<!\\)---(?!-)/gm, '—') 
 | |
| 			.replace(/(?<!\\)--(?!-)/gm, '–') },
 | |
| }
 | |
| 
 | |
| 
 | |
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -      
 | |
| 
 | |
| var escaping = {
 | |
| 	__proto__: plugin,
 | |
| 
 | |
| 	__post_parse__: function(text, editor, elem){
 | |
| 		return text
 | |
| 			// quoting...
 | |
| 			// NOTE: this must be last...
 | |
| 			.replace(/(?<!\\)\\(.)/gm, '$1') },
 | |
| }
 | |
| 
 | |
| 
 | |
| 
 | |
| //---------------------------------------------------------------------
 | |
| 
 | |
| var JSONOutline = {
 | |
| 	// Format:
 | |
| 	// 	<json> ::= [
 | |
| 	// 			{
 | |
| 	// 				text: <text>,
 | |
| 	// 				children: <json>,
 | |
| 	// 				...
 | |
| 	// 			},
 | |
| 	// 			...
 | |
| 	// 		]
 | |
| 	json: undefined,
 | |
| 
 | |
| 	// format:
 | |
| 	// 	{
 | |
| 	// 		<id>: <node>,
 | |
| 	// 		...
 | |
| 	// 	}
 | |
| 	__id_index: undefined,
 | |
| 
 | |
| 	// format:
 | |
| 	// 	Map([
 | |
| 	// 		[<node>, <parent>],
 | |
| 	// 		...
 | |
| 	// 	])
 | |
| 	__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(){},
 | |
| 
 | |
| 	parseBlockAttrs: function(){},
 | |
| 	parse: function(){},
 | |
| 
 | |
| 	data: function(){},
 | |
| 	load: function(){},
 | |
| 	text: function(){},
 | |
| }
 | |
| 
 | |
| 
 | |
| 
 | |
| // XXX experiment with a concatinative model...
 | |
| // 		.get(..) -> Outline (view)
 | |
| var Outline = {
 | |
| 	dom: undefined,
 | |
| 
 | |
| 	// config...
 | |
| 	//
 | |
| 	left_key_collapses: true,
 | |
| 	right_key_expands: true,
 | |
| 	change_interval: 1000,
 | |
| 	tab_size: 4,
 | |
| 	carot_jump_edge_then_block: false,
 | |
| 
 | |
| 
 | |
| 	// Plugins...
 | |
| 	//
 | |
| 	// The order of plugins can be significant in the following cases:
 | |
| 	// 	- parsing
 | |
| 	// 	- event dropping
 | |
| 	plugins: [
 | |
| 		blocks,
 | |
| 		quoted,
 | |
| 
 | |
| 		// NOTE: this needs to be before styling to prevent it from 
 | |
| 		// 		treating '[_] ... [_]' as italic...
 | |
| 		tasks,
 | |
| 		styling,
 | |
| 		// XXX
 | |
| 		//attributes,
 | |
| 		tables,
 | |
| 		symbols,
 | |
| 		//syntax,
 | |
| 
 | |
| 		// keep this last...
 | |
| 		// XXX revise -- should this be external???
 | |
| 		escaping,
 | |
| 	],
 | |
| 	// NOTE: if a handler returns false it will break plugin execution...
 | |
| 	// 		XXX is this the right way to go???
 | |
| 	runPlugins: function(method, ...args){
 | |
| 		for(var plugin of this.plugins){
 | |
| 			if(method in plugin){
 | |
| 				if(plugin[method](...args) === false){
 | |
| 					return false } } } 
 | |
| 		return true },
 | |
| 	threadPlugins: function(method, value, ...args){
 | |
| 		for(var plugin of this.plugins){
 | |
| 			method in plugin
 | |
| 				&& (value = plugin[method](value, ...args)) }
 | |
| 		return value },
 | |
| 
 | |
| 
 | |
| 	get header(){
 | |
| 		return this.dom.querySelector('.header') },
 | |
| 	get code(){
 | |
| 		return this.dom.querySelector('.code') },
 | |
| 	get outline(){
 | |
| 		return this.dom.querySelector('.outline') },
 | |
| 	get toolbar(){
 | |
| 		return this.dom.querySelector('.toolbar') },
 | |
| 
 | |
| 
 | |
| 	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(<index>)[, <offset>]
 | |
| 	// 	.get(<path>[, <offset>])
 | |
| 	// 	.get(<id>[, <offset>)
 | |
| 	// 		-> <node>
 | |
| 	//
 | |
| 	// 	.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('viewport')
 | |
| 	// 	.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
 | |
| 		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 })
 | |
| 			: offset == 'viewport' ?
 | |
| 				[...node.querySelectorAll('.block')] 
 | |
| 					.filter(function(e){
 | |
| 						return e.querySelector('.view').offsetParent != null 
 | |
| 							&& e.querySelector('.code').visibleInViewport() })
 | |
| 			: offset == 'editable' ?
 | |
| 				[...node.querySelectorAll('.block>.code')] 
 | |
| 			: offset == 'selected' ?
 | |
| 				[...node.querySelectorAll('.block[selected]')] 
 | |
| 					.filter(function(e){
 | |
| 						return e.querySelector('.view').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(){
 | |
| 		var that = this
 | |
| 		this.__change_requested = true
 | |
| 		if(this.__change_timeout){
 | |
| 			return this }
 | |
| 
 | |
| 		// do the action...
 | |
| 		if(this.__change_requested){
 | |
| 			this.sync() 
 | |
| 			this.runPlugins('__change__', that) 
 | |
| 			this.__change_requested = false }
 | |
| 
 | |
| 		this.__change_timeout = setTimeout(
 | |
| 			function(){
 | |
| 				that.__change_timeout = undefined
 | |
| 				that.__change_requested
 | |
| 					&& that.__change__() }, 
 | |
| 			that.change_interval || 1000) 
 | |
| 		return this },
 | |
| 
 | |
| 	__block_attrs__: {
 | |
| 		id: 'attr',
 | |
| 		collapsed: 'attr',
 | |
| 		focused: 'cls',
 | |
| 	},
 | |
| 	update: function(node='focused', data){
 | |
| 		var node = this.get(node)
 | |
| 		data ??= this.data(node, false)
 | |
| 
 | |
| 		var parsed = {}
 | |
| 		if('text' in data){
 | |
| 			var text = node.querySelector('textarea')
 | |
| 			var html = node.querySelector('span')
 | |
| 			if(this.__code2html__){
 | |
| 				// NOTE: we are ignoring the .collapsed attr here 
 | |
| 				parsed = this.__code2html__(data.text, {...data})
 | |
| 				html.innerHTML = parsed.text
 | |
| 				// heading...
 | |
| 				node.classList.remove(...this.__styles)
 | |
| 				parsed.style
 | |
| 					&& node.classList.add(...parsed.style)
 | |
| 				delete parsed.style
 | |
| 			} else {
 | |
| 				html.innerHTML = data.text }
 | |
| 			text.value = data.text
 | |
| 			// XXX this does not seem to work until we click in the textarea...
 | |
| 			text.autoUpdateSize() }
 | |
| 
 | |
| 		for(var [attr, value] of Object.entries({...data, ...parsed})){
 | |
| 			if(attr == 'children' || attr == 'text'){
 | |
| 				continue }
 | |
| 
 | |
| 			var type = this.__block_attrs__[attr]
 | |
| 			if(type == 'cls'){
 | |
| 				value ?
 | |
| 					node.classList.add(attr)
 | |
| 					: node.classList.remove(attr) 
 | |
| 
 | |
| 			} else if(type == 'attr' 
 | |
| 					|| type == undefined){
 | |
| 				typeof(value) == 'boolean'?
 | |
| 						(value ?
 | |
| 							node.setAttribute(attr, '')
 | |
| 							: node.removeAttribute(attr))
 | |
| 					: value != null ?
 | |
| 						node.setAttribute(attr, value)
 | |
| 					: node.removeAttribute(attr) } }
 | |
| 		this.__change__()
 | |
| 		return node },
 | |
| 
 | |
| 	// edit...
 | |
| 	indent: function(node='focused', indent='in'){
 | |
| 		// .indent(<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.setUndo(
 | |
| 					this.path(cur), 
 | |
| 					'indent', 
 | |
| 					['in'],
 | |
| 					prev)
 | |
| 				this.__change__() }
 | |
| 		// indent...
 | |
| 		} else {
 | |
| 			var parent = siblings[siblings.indexOf(cur) - 1]
 | |
| 			if(parent){
 | |
| 				parent.lastChild.append(cur) 
 | |
| 				this.setUndo(
 | |
| 					this.path(cur), 
 | |
| 					'indent', 
 | |
| 					['out'],
 | |
| 					prev)
 | |
| 				this.__change__()} } 
 | |
| 		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.setUndo(
 | |
| 			this.path(node), 
 | |
| 			'shift', 
 | |
| 			[direction == 'up' ? 
 | |
| 				'down' 
 | |
| 				: 'up'])
 | |
| 		this.__change__()
 | |
| 		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)
 | |
| 		// XXX HACK...
 | |
| 		this.setUndo(
 | |
| 			undefined, 
 | |
| 			'load', 
 | |
| 			[data])
 | |
| 		this.__change__()
 | |
| 		return this },
 | |
| 	clear: function(){
 | |
| 		this.setUndo(
 | |
| 			undefined, 
 | |
| 			'load', 
 | |
| 			[this.json()])
 | |
| 		this.outline.innerText = ''
 | |
| 		this.__change__()
 | |
| 		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(<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('.block')){
 | |
| 			return }
 | |
| 		state = state == 'next' ?
 | |
| 			node.getAttribute('collapsed') != ''
 | |
| 			: state
 | |
| 		if(state){
 | |
| 			node.setAttribute('collapsed', '')
 | |
| 		} else {
 | |
| 			node.removeAttribute('collapsed')
 | |
| 			for(var elem of [...node.querySelectorAll('textarea')]){
 | |
| 				elem.updateSize() } }
 | |
| 		this.__change__()
 | |
| 		return node },
 | |
| 	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...
 | |
| 	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 = 
 | |
| 			`<span class="path-item" onclick="editor.uncrop('all')">/</span> ` 
 | |
| 				+ this.path(...arguments, 'text')
 | |
| 					.slice(0, -1)
 | |
| 					.map(function(s, i, {length}){
 | |
| 						return `<span class="path-item" onclick="editor.uncrop(${ length-i })">${s}</span> ` })
 | |
| 					.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 },
 | |
| 	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 [path, action, args, next] = from.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 },
 | |
| 
 | |
| 	// block render...
 | |
| 	// NOTE: this is auto-populated by .__code2html__(..)
 | |
| 	__styles: undefined,
 | |
| 	__code2html__: function(code, elem={}){
 | |
| 		var that = this
 | |
| 
 | |
| 		// only whitespace -> keep element blank...
 | |
| 		if(code.trim() == ''){
 | |
| 			elem.text = ''
 | |
| 			return elem }
 | |
| 
 | |
| 		// helpers...
 | |
| 		var run = function(stage, text){
 | |
| 			var meth = {
 | |
| 				pre: '__pre_parse__',
 | |
| 				main: '__parse__',
 | |
| 				post: '__post_parse__',
 | |
| 			}[stage]
 | |
| 			return that.threadPlugins(meth, text, that, elem) }
 | |
| 
 | |
| 		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 fomat:
 | |
| 			// 	[ text <match> <type> <body>, ... ]
 | |
| 			.split(/(<(pre|code)(?:|\s[^>]*)>((?:\n|.)*)<\/\2>)/g)
 | |
| 		// sort out the sections...
 | |
| 		var parsable = [] 
 | |
| 		var quoted = []
 | |
| 		while(sections.length > 0){
 | |
| 			var [section, match] = sections.splice(0, 4)
 | |
| 			parsable.push(section)
 | |
| 			quoted.push(match) }
 | |
| 		// stage: main...
 | |
| 		text = run('main', 
 | |
| 				// parse only the parsable sections...
 | |
| 				parsable.join('\x00'))
 | |
| 			.split(/\x00/g)
 | |
| 			// merge the quoted sections back in...
 | |
| 			.map(function(section){
 | |
| 				return [section, quoted.shift() ?? '']	})
 | |
| 			.flat()
 | |
| 			.join('') 
 | |
| 		// stage: post...
 | |
| 		elem.text = run('post', text) 
 | |
| 
 | |
| 		return elem },
 | |
| 	// output format...
 | |
| 	__code2text__: function(code){
 | |
| 		return code 
 | |
| 			.replace(/(\n\s*)-/g, '$1\\-') },
 | |
| 	__text2code__: function(text){
 | |
| 		return text 
 | |
| 			.replace(/(\n\s*)\\-/g, '$1-') },
 | |
| 
 | |
| 	// serialization...
 | |
| 	data: function(elem, deep=true){
 | |
| 		elem = this.get(elem)	
 | |
| 		// XXX move these to config...
 | |
| 		var attrs = this.__block_attrs__
 | |
| 		var cls_attrs = ['focused']
 | |
| 		return {
 | |
| 			text: elem.querySelector('textarea').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.json(elem)}
 | |
| 				: {}),
 | |
| 		} },
 | |
| 	json: function(node){
 | |
| 		var that = this
 | |
| 		var children = [...(node ?
 | |
| 			node.lastChild.children
 | |
| 			: this.outline.children)]
 | |
| 		return children
 | |
| 			.map(function(elem){
 | |
| 				return that.data(elem) }) },
 | |
| 	// XXX add option to customize indent size...
 | |
| 	text: function(node, indent, level){
 | |
| 		// .text(<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 +'- '
 | |
| 					+ 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') },
 | |
| 
 | |
| 	//
 | |
| 	//	Parse attrs...
 | |
| 	//	.parseBlockAttrs(<text>[, <elem>])
 | |
| 	//		-> <elem>
 | |
| 	//
 | |
| 	//	Parse attrs keeping non-system attrs in .text...
 | |
| 	//	.parseBlockAttrs(<text>, true[, <elem>])
 | |
| 	//		-> <elem>
 | |
| 	//
 | |
| 	//	Parse attrs keeping all attrs in .text...
 | |
| 	//	.parseBlockAttrs(<text>, 'all'[, <elem>])
 | |
| 	//		-> <elem>
 | |
| 	//
 | |
| 	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(/^\s*\n/, '')
 | |
| 		text = ('\n' + text)
 | |
| 			.split(/\n(\s*)(?:- |-\s*$)/gm)
 | |
| 			.slice(1)
 | |
| 		var tab = ' '.repeat(this.tab_size || 8)
 | |
| 		var level = function(lst, prev_sep=undefined, parent=[]){
 | |
| 			while(lst.length > 0){
 | |
| 				sep = lst[0].replace(/\t/gm, tab)
 | |
| 				// deindent...
 | |
| 				if(prev_sep != null 
 | |
| 						&& sep.length < prev_sep.length){
 | |
| 					break }
 | |
| 				prev_sep ??= sep
 | |
| 				// same level...
 | |
| 				if(sep.length == prev_sep.length){
 | |
| 					var [_, block] = lst.splice(0, 2)
 | |
| 					var 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) },
 | |
| 
 | |
| 	// 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
 | |
| 			;(place == 'next' 
 | |
| 					&& (cur.querySelector('.block')
 | |
| 						|| cur !== this.get(-1))) ?
 | |
| 				this.get(place).before(block)
 | |
| 			: (place == 'next' 
 | |
| 					&& cur === this.get(-1)) ?
 | |
| 				cur.after(block)
 | |
| 			: (place == 'before' || place == 'after') ?
 | |
| 				cur[place](block)
 | |
| 			: undefined }
 | |
| 		return block },
 | |
| 	// XXX see inside...
 | |
| 	load: function(data){
 | |
| 		var that = this
 | |
| 		data = typeof(data) == 'string' ?
 | |
| 				this.parse(data)
 | |
| 			: data instanceof Array ?
 | |
| 				data
 | |
| 			: [data]
 | |
| 		// generate dom...
 | |
| 		var level = function(lst){
 | |
| 			return lst
 | |
| 				.map(function(data){
 | |
| 					var elem = that.Block(data) 
 | |
| 					if((data.children || []).length > 0){
 | |
| 						elem.lastChild
 | |
| 							.append(...level(data.children)) }
 | |
| 					return elem }) }
 | |
| 		this
 | |
| 			.clear()
 | |
| 			.outline
 | |
| 				.append(...level(data))
 | |
| 		/* XXX do we actually need this???
 | |
| 		// update sizes of all the textareas (transparent)...
 | |
| 		setTimeout(function(){
 | |
| 			for(var e of [...that.outline.querySelectorAll('textarea')]){
 | |
| 				e.updateSize() } }, 0)
 | |
| 		//*/
 | |
| 		// restore focus...
 | |
| 		this.focus()
 | |
| 		return this },
 | |
| 
 | |
| 	sync: function(){
 | |
| 		var code = this.code
 | |
| 		if(code){
 | |
| 			code.value = this.text() }
 | |
| 		return this },
 | |
| 
 | |
| 
 | |
| 	// Actions...
 | |
| 	prev: function(){},
 | |
| 	next: function(){},
 | |
| 	above: function(){},
 | |
| 	below: function(){},
 | |
| 
 | |
| 	up: function(){},
 | |
| 	down: function(){},
 | |
| 	left: function(){},
 | |
| 	right: function(){},
 | |
| 
 | |
| 	// XXX move the code here into methods/actions...
 | |
| 	// XXX use keyboard.js...
 | |
| 	__overtravel_timeout: undefined,
 | |
| 	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
 | |
| 
 | |
| 			// 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 line = edited.getTextGeometry().line
 | |
| 				if(line == 0){
 | |
| 					evt.preventDefault() 
 | |
| 					that.focus('edited', 'prev') }
 | |
| 			} else {
 | |
| 				evt.preventDefault() 
 | |
| 				this.focus('focused', -1) } },
 | |
| 		ArrowDown: function(evt){
 | |
| 			var that = this
 | |
| 
 | |
| 			// 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 {line, lines} = edited.getTextGeometry()
 | |
| 				if(line == lines - 1){
 | |
| 					evt.preventDefault() 
 | |
| 					that.focus('edited', 'next') }
 | |
| 			} else {
 | |
| 				evt.preventDefault() 
 | |
| 				this.focus('focused', 1) } },
 | |
| 
 | |
| 		// horizontal navigation / collapse...
 | |
| 		ArrowLeft: function(evt){
 | |
| 			var edited = this.get('edited')
 | |
| 			if(edited){
 | |
| 				// move caret to prev element...
 | |
| 				if(edited.selectionStart == edited.selectionEnd
 | |
| 						&& edited.selectionStart == 0){
 | |
| 					evt.preventDefault()
 | |
| 					edited = this.focus('edited', 'prev') 
 | |
| 					edited.selectionStart = 
 | |
| 						edited.selectionEnd = edited.value.length + 1 }
 | |
| 				return }
 | |
| 			if(evt.ctrlKey){
 | |
| 				evt.preventDefault()
 | |
| 				tasks.prevCheckbox(this)
 | |
| 				return }
 | |
| 			;((this.left_key_collapses 
 | |
| 						|| evt.shiftKey)
 | |
| 					&& this.get().getAttribute('collapsed') == null
 | |
| 					&& this.get('children').length > 0) ?
 | |
| 				this.toggleCollapse(true)
 | |
| 				: this.focus('parent') },
 | |
| 		ArrowRight: function(evt){
 | |
| 			var 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')
 | |
| 			// edit -> split text...
 | |
| 			if(edited){
 | |
| 				if(evt.ctrlKey
 | |
| 						|| evt.shiftKey){
 | |
| 					return }
 | |
| 				evt.preventDefault()
 | |
| 				var a = edited.selectionStart
 | |
| 				var b = edited.selectionEnd
 | |
| 				var prev = edited.value.slice(0, a)
 | |
| 				var next = edited.value.slice(b)
 | |
| 				edited.value = prev
 | |
| 				this.Block('next')
 | |
| 				edited = this.edit('next')
 | |
| 				edited.value = next
 | |
| 				edited.selectionStart = 0
 | |
| 				edited.selectionEnd = 0
 | |
| 				return }
 | |
| 			// view -> edit...
 | |
| 			evt.preventDefault()
 | |
| 			this.edit() },
 | |
| 		Escape: function(evt){
 | |
| 			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 } },
 | |
| 
 | |
| 		c_d: function(evt){
 | |
| 			// toggle done...
 | |
| 			evt.preventDefault()
 | |
| 			tasks.toggleDone(this) },
 | |
| 
 | |
| 		// toggle checkbox...
 | |
| 		' ': function(evt){
 | |
| 			if(this.get('edited') != null){
 | |
| 				return }
 | |
| 			evt.preventDefault()
 | |
| 			tasks.toggleCheckbox(this) },
 | |
| 	},
 | |
| 
 | |
| 	setup: function(dom){
 | |
| 		var that = this
 | |
| 		this.dom = dom
 | |
| 
 | |
| 		// outline...
 | |
| 		var outline = this.outline
 | |
| 		// update stuff already in DOM...
 | |
| 		for(var elem of [...outline.querySelectorAll('textarea')]){
 | |
| 			elem.autoUpdateSize() } 
 | |
| 		// click...
 | |
| 		// XXX revise...
 | |
| 		// XXX tap support...
 | |
| 		outline.addEventListener('mousedown', 
 | |
| 			function(evt){
 | |
| 				var elem = evt.target
 | |
| 				// place the cursor where the user clicked in code/text...
 | |
| 				if(elem.classList.contains('code') 
 | |
| 						&& document.activeElement !== elem){
 | |
| 					evt.preventDefault()
 | |
| 					var view = that.get(elem).querySelector('.view')
 | |
| 					var c = getCharOffset(view, evt.clientX, evt.clientY)
 | |
| 					if(c == null){
 | |
| 						elem.focus()
 | |
| 						elem.selectionStart = elem.value.length
 | |
| 						elem.selectionEnd = elem.value.length 
 | |
| 					} else {
 | |
| 						var m = getMarkdownOffset(elem.value, view.innerText, c)
 | |
| 						elem.focus()
 | |
| 						elem.selectionStart = c + m
 | |
| 						elem.selectionEnd = c + m } } })
 | |
| 		outline.addEventListener('click', 
 | |
| 			function(evt){
 | |
| 				var elem = evt.target
 | |
| 
 | |
| 				// prevent focusing parent by clicking between blocks...
 | |
| 				if(elem.classList.contains('children')){
 | |
| 					return }
 | |
| 
 | |
| 				// empty outline -> create new eleemnt...
 | |
| 				if(elem.classList.contains('outline')
 | |
| 						&& elem.children.length == 0){
 | |
| 					// create new eleemnt and edit it...
 | |
| 					var block = that.Block()
 | |
| 					that.outline.append(block)
 | |
| 					that.edit(block)
 | |
| 					return }
 | |
| 
 | |
| 				// expand/collapse
 | |
| 				if(elem.classList.contains('view')){
 | |
| 					// click: left of elem (outside)
 | |
| 					if(evt.offsetX < 0){
 | |
| 						// XXX item menu?
 | |
| 					
 | |
| 					// click: right of elem (outside)
 | |
| 					} else if(elem.offsetWidth < evt.offsetX){
 | |
| 						that.toggleCollapse(that.get(elem))
 | |
| 
 | |
| 					// click inside element...
 | |
| 					} else {
 | |
| 						// XXX 
 | |
| 					} }
 | |
| 
 | |
| 				// edit of focus...
 | |
| 				// NOTE: this is useful if element text is hidden but the 
 | |
| 				// 		frame is still visible...
 | |
| 				if(elem.classList.contains('block')){
 | |
| 					elem.querySelector('.code').focus() }
 | |
| 
 | |
| 				// 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...
 | |
| 				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)
 | |
| 						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)
 | |
| 						elem.updateSize() }, 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.parseBlockAttrs(elem.value).text
 | |
| 					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) }, 0) } 
 | |
| 
 | |
| 				that.runPlugins('__focusout__', evt, that, elem) })
 | |
| 		// update .code...
 | |
| 		outline.addEventListener('change', 
 | |
| 			function(evt){
 | |
| 				that.__change__() })
 | |
| 
 | |
| 		// toolbar...
 | |
| 		var toolbar = this.toolbar
 | |
| 		if(toolbar){
 | |
| 			// handle return of focus when clicking toolbar...
 | |
| 			var focus_textarea
 | |
| 			var cahceNodeType = function(){
 | |
| 				// NOTE: for some reason .activeElement returns an element
 | |
| 				// 		that is not in the DOM after the action is done...
 | |
| 				focus_textarea = document.activeElement.nodeName == 'TEXTAREA' }
 | |
| 			var refocusNode = function(){
 | |
| 				focus_textarea ?
 | |
| 					editor.get().querySelector('.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.value
 | |
| 				.replace(/</g, '<')
 | |
| 				.replace(/>/g, '>')) 
 | |
| 			console.log(`Parse: ${Date.now() - t}ms`) }
 | |
| 		this.clearUndo()
 | |
| 
 | |
| 		this.runPlugins('__setup__', this)
 | |
| 
 | |
| 		// autofocus...
 | |
| 		if(this.dom.getAttribute('autofocus') != null){
 | |
| 			this.focus() }
 | |
| 		
 | |
| 		return this },
 | |
| }
 | |
| 
 | |
| 
 | |
| 
 | |
| /**********************************************************************
 | |
| * vim:set ts=4 sw=4 :                                                */
 |