OzBargain Markdown Toolbar

Displays a Markdown toolbar for textboxes on OzBargain and ChoiceCheapies

// ==UserScript==
// @name		OzBargain Markdown Toolbar
// @namespace	nategasm
// @version		1.22
// @description	Displays a Markdown toolbar for textboxes on OzBargain and ChoiceCheapies
// @author		wOxxOm, darkred, nategasm
// @license		MIT
// @include		https://www.ozbargain.com.au/deals/submit
// @include		https://www.ozbargain.com.au/node/*
// @include		https://www.ozbargain.com.au/comment/edit/*
// @include		https://www.ozbargain.com.au/privatemsg/*
// @include		https://www.cheapies.nz/deals/submit
// @include		https://www.cheapies.nz/node/*
// @include		https://www.cheapies.nz/comment/edit/*
// @include		https://www.cheapies.nz/privatemsg/*
// @icon		https://www.ozbargain.com.au/favicon.ico
// @grant		GM_addStyle
// ==/UserScript==

//Add button styles
GM_addStyle(`
	.mdBtn {
		display: inline-block;
		cursor: pointer;
		margin: 0 0.5px 4px 0.5px;
		padding: 4px;
		background: var(--input-bg);
		border: 1px solid #999;
		border-radius: 2px;
		white-space: pre;
		box-shadow: 0px 1px 0px #FFF inset, 0px -1px 2px #BBB inset;
		color: var(--page-fg);
		user-select: none;
	}
	.mdBtn:hover {
		background: var(--shade3-bg) !important;
		color: #fff !important;
	}
	.mdBtn svg {
		vertical-align: middle;
		pointer-events: none;
	}
	.qtBtn {
		font-weight: bold;
		position: fixed;
		display: none;
		line-height: 100%;
		padding: 3px 5px;
		border: var(--shade3-bg) solid 2px;
		opacity: 85%;
		user-select: none;
	}
	.qtBtn:hover {
		opacity: 100%;
	}
`);

//Add toolbar to main preloaded textboxes
const textarea = document.querySelector('textarea');
if (textarea) {
	addFeatures(textarea.parentNode);
} else {
	return;
}

//Add more features to nodes
if (location.href.indexOf('/node/') > 0) {
	//Observe and add toolbar to expanded reply boxes
	let targetNode = document.querySelectorAll(".comment.level0"); //Need All to capture pinned comments
	let callback = (mutationList, observer) => {
		for (const mutation of mutationList) {
			if (mutation.addedNodes.length > 0 && mutation.addedNodes[0].className === "comment" && mutation.addedNodes[0].nodeName === "FORM") {
				addFeatures(mutation.addedNodes[0].querySelector('textarea').parentNode);
			}
		}
	};
	let observer = new MutationObserver(callback);
	for (let i = 0; i < targetNode.length; i++) {
		  observer.observe(targetNode[i], {attributes: false, childList: true, subtree: true});
	}
	//Add quote button to node content
	let node = document.querySelector('.node:not(.messages)');
	if (node) {
		let a = document.createElement('a');
		a.className = 'btn qtBtn';
		a.innerHTML = 'Quote Selection';
		a.addEventListener('click',
						   function(e){edPrefixTag('>', true, edInit(e.target,'>'));});
		a.addEventListener('mousedown',function(e){event.preventDefault()});
		node.textAreaNode = textarea;
		node.appendChild(a);
		['mouseup', 'touchend'].forEach(function(e) {
			node.addEventListener(e, (event) => {
				let selectedText = getSelectionText().trim();
				if (selectedText.length > 0) {
					const range = window.getSelection().getRangeAt(0);
					const rect = range.getBoundingClientRect();
					//Position the button near the selection
					a.style.top = `${event.clientY + 20}px`;
					a.style.left = `${event.clientX - 65}px`;
					a.style.display = 'inline-block';
				} else {
					a.style.display = 'none';
				}
			});
		});
		document.addEventListener("selectionchange", () => {
			//Remove button if unselected
			if (getSelectionText().length === 0) {
				a.style.display = 'none';
			}
		});
	}
}

function addFeatures(n) {
	n.textAreaNode = n.querySelector('textarea');
	const ctrlKey = isMacOS() ? 'Cmd' : 'Ctrl';
	//Add buttons
	btnMake(n, '<b>B</b>', 'Bold ('+ctrlKey+'+B)', '**', '', false, false, false, 'b',
		   'M4 2h4.5a3.501 3.501 0 0 1 2.852 5.53A3.499 3.499 0 0 1 9.5 14H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm1 7v3h4.5a1.5 1.5 0 0 0 0-3Zm3.5-2a1.5 1.5 0 0 0 0-3H5v3Z');

	btnMake(n, '<i>I </i>', 'Italic ('+ctrlKey+'+I)', '*', '', false, false, false, 'i',
		   'M6 2.75A.75.75 0 0 1 6.75 2h6.5a.75.75 0 0 1 0 1.5h-2.505l-3.858 9H9.25a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.505l3.858-9H6.75A.75.75 0 0 1 6 2.75Z');

	btnMake(n, '<s>S</s>', 'Strikethrough ('+ctrlKey+'+`)', '~~', '', false, false, false, '`',
		   'M6.333 5.686c0 .31.083.581.27.814H5.166a2.8 2.8 0 0 1-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607zm2.194 7.478c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5H1v-1h14v1h-3.504c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967');

	btnMake(n, 'H', 'Add Heading ('+ctrlKey+'+3)','#', '', false, true, true, '3',
		   'M3.75 2a.75.75 0 0 1 .75.75V7h7V2.75a.75.75 0 0 1 1.5 0v10.5a.75.75 0 0 1-1.5 0V8.5h-7v4.75a.75.75 0 0 1-1.5 0V2.75A.75.75 0 0 1 3.75 2Z');

	btnMake(n, '---', 'Horizontal rule ('+ctrlKey+'+-)', '\n\n---\n\n', '', true, false, false, '-',
		   'M2 8a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11A.5.5 0 0 1 2 8');

	btnMake(n, 'Quote', 'Quote text ('+ctrlKey+'+.)','>', '', false, true, true, '.',
		   'M12 12a1 1 0 0 0 1-1V8.558a1 1 0 0 0-1-1h-1.388q0-.527.062-1.054.093-.558.31-.992t.559-.683q.34-.279.868-.279V3q-.868 0-1.52.372a3.3 3.3 0 0 0-1.085.992 4.9 4.9 0 0 0-.62 1.458A7.7 7.7 0 0 0 9 7.558V11a1 1 0 0 0 1 1zm-6 0a1 1 0 0 0 1-1V8.558a1 1 0 0 0-1-1H4.612q0-.527.062-1.054.094-.558.31-.992.217-.434.559-.683.34-.279.868-.279V3q-.868 0-1.52.372a3.3 3.3 0 0 0-1.085.992 4.9 4.9 0 0 0-.62 1.458A7.7 7.7 0 0 0 3 7.558V11a1 1 0 0 0 1 1z');

	btnMake(n, '• List', 'Unordered list ('+ctrlKey+'+8)',
			function(e) {
		try {edList('* ', edInit(e.target,'* '));}
		catch(e) {}
	}, '', false, false, false, '8',
		   'M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z');

	btnMake(n, '# List', 'Numbered list ('+ctrlKey+'+/)',
			function(e) {
		try {edList('1. ', edInit(e.target,'1. '), true);}
		catch(e) {}
	}, '', false, false, false, '/',
		   'M5 3.25a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 5 3.25Zm0 5a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 5 8.25Zm0 5a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1-.75-.75ZM.924 10.32a.5.5 0 0 1-.851-.525l.001-.001.001-.002.002-.004.007-.011c.097-.144.215-.273.348-.384.228-.19.588-.392 1.068-.392.468 0 .858.181 1.126.484.259.294.377.673.377 1.038 0 .987-.686 1.495-1.156 1.845l-.047.035c-.303.225-.522.4-.654.597h1.357a.5.5 0 0 1 0 1H.5a.5.5 0 0 1-.5-.5c0-1.005.692-1.52 1.167-1.875l.035-.025c.531-.396.8-.625.8-1.078a.57.57 0 0 0-.128-.376C1.806 10.068 1.695 10 1.5 10a.658.658 0 0 0-.429.163.835.835 0 0 0-.144.153ZM2.003 2.5V6h.503a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1h.503V3.308l-.28.14a.5.5 0 0 1-.446-.895l1.003-.5a.5.5 0 0 1 .723.447Z');

	btnMake(n, 'URL', 'Add URL ('+ctrlKey+'+K)',
			function(e) {
		try {edWrapInTag('[', '](URL)', edInit(e.target,'['));}
		catch(e) {}
	}, '', false, false, false, 'k',
		   'm7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z');

	btnMake(n, 'Table', 'Insert table template',
			function(e) {
		try {
			const columns = parseInt(prompt('Enter number of columns:'), 10);
			const rows = parseInt(prompt('Enter number of rows:'), 10);

			if (isNaN(columns) || isNaN(rows) || columns <= 0 || rows <= 0) {
				alert('Please enter valid positive numbers');
				return;
			}
			edInsertText(createMarkdownTable(columns, rows), edInit(e.target,'|'));
		}
		catch(e) {}
	}, '', false, false, false, '',
	'M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 2h-4v3h4zm0 4h-4v3h4zm0 4h-4v3h3a1 1 0 0 0 1-1zm-5 3v-3H6v3zm-5 0v-3H1v2a1 1 0 0 0 1 1zm-4-4h4V8H1zm0-4h4V4H1zm5-3v3h4V4zm4 4H6v3h4z');

	btnMake(n, 'Code', 'Code Block',
			function(e){
		let ed = edInit(e.target,'`');
		if (ed.sel.indexOf('\n') < 0) {
			edWrapInTag('`', '`', ed);
		}
		else {
			edWrapInTag(((ed.sel1==0) || (ed.text.charAt(ed.sel1-1) == '\n') ? '' : '\n') + '~~~' + (ed.sel.charAt(0) == '\n' ? '' : '\n'),
						(ed.sel.substr(-1) == '\n' ? '' : '\n') + '~~~' + (ed.text.substr(ed.sel2,1) == '\n' ? '' : '\n'),
						ed);
		}
	}, '', false, false, false, '',
	'M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8z');
	bindCtrlKeyToTextareaAction(n.textAreaNode);
	//Update hover text for Preview button shortcut
	let preview = n.parentNode.querySelector('input.form-submit[value="Preview"]');
	if (preview) { preview.title = ctrlKey+'+P'}
}

function btnMake(afterNode, label, title, tag1, tag2, noWrap, prefix, gap, shortcut, svgPath) {
	let a = document.createElement('a');
	a.className = 'mdBtn';
	if (!svgPath) { a.innerHTML = label };
	a.title = title;
	a.dataset.key = shortcut;
	a.addEventListener('click',
					   typeof(tag1) === 'function'
					   ? tag1
					   : noWrap ? function(e){ edInsertText(tag1, edInit(e.target,tag1)); }
					   : prefix ? function(e){ edPrefixTag(tag1, gap, edInit(e.target,tag1)); }
					   : function(e){ edWrapInTag(tag1, tag2, edInit(e.target,tag1)); });
	if (prefix) { a.addEventListener('mousedown',function(e){ event.preventDefault() }) }
	if (label === 'Quote') {
		document.addEventListener("selectionchange", () => {
			//Highlight quote button if text selected outside of textbox
			if (getSelectionText().length > 0 && getSelection().anchorNode.className !== 'form-item') {
				a.style.background = 'var(--shade3-bg)';
			} else {
				a.style.background = 'var(--input-bg)';
			}
		});
	}
	a.textAreaNode = afterNode.textAreaNode;
	let newA = afterNode.insertBefore(a, afterNode.textAreaNode);
	//Insert button SVG
	if (svgPath) {
		const svgNS = "http://www.w3.org/2000/svg";
		const svg = document.createElementNS(svgNS, "svg");
		svg.setAttribute("width", "18");
		svg.setAttribute("height", "18");
		if(['<s>S</s>','---','Code','Quote'].includes(label)) {
			svg.setAttribute("viewBox", "1 1 14 14");
		} else {
			svg.setAttribute("viewBox", "0 0 16 16");
		}
		const path = document.createElementNS(svgNS, "path");
		path.setAttribute("d", svgPath);
		path.setAttribute("fill", "currentColor");
		svg.appendChild(path);
		newA.appendChild(svg);
	}
}

function edInit(btn, tag) {
	let ed = { node: btn.parentNode.textAreaNode };
	ed.sel1 = ed.node.selectionStart;
	ed.sel2 = ed.node.selectionEnd;
	//Trim Whitespace and line breaks from start/end of selection, also improves gap detection
	trimSelection(ed, tag);
	ed.text = ed.node.value;
	ed.sel = ed.text.substring(ed.sel1, ed.sel2);
	return ed;
}

function trimSelection(ed, tag) {
	while (ed.sel1 < ed.sel2) {
		const startChar = ed.node.value.charAt(ed.sel1);
		const endChar = ed.node.value.charAt(ed.sel2 - 1);
		const isListTag = ['* ', '1. ', '>'].includes(tag);
		const trimStart =
		(!isListTag && /\s/.test(startChar)) ||
		(isListTag && startChar === '\n');
		const trimEnd =
		(!isListTag && /\s/.test(endChar)) ||
		(isListTag && endChar === '\n');
		if (!trimStart && !trimEnd) break;
		if (trimStart) ed.sel1++;
		if (trimEnd) ed.sel2--;
	}
}

function detectGapBefore(selStart, text, tag) {
	if (selStart === 0 || selStart - tag.length === 0 || //Start of textbox
		text.substring(selStart - 2, selStart - 1) === '\n' || //Line break exists before selection
		text.substring(selStart - tag.length - 1, selStart - tag.length) === tag) { //Tag exists before selection
		return true;
	}
}

function detectGapAfter(selEnd, text, tag) {
	if (text.substring(selEnd + 1, selEnd + 2) === '\n' || //Line break exists after selection
		(!tag && selEnd === text.length)) { //End of textbox
		return true;
	}
}

function edWrapInTag(tag1, tag2, ed) {
	if (ed.sel.startsWith(tag1) && ed.sel.endsWith(tag2?tag2:tag1)) {
		// Remove the syntax if it's already wrapped
		ed.node.value = ed.text.substr(0, ed.sel1) + ed.sel.slice(tag1.length, (tag2?-tag2.length:-tag1.length)) + ed.text.substr(ed.sel2);
		ed.node.setSelectionRange(ed.sel1, ed.sel1 + ed.sel.length - tag1.length - (tag2?tag2.length:tag1.length));
	} else {
		// Wrap syntax
		if (ed.sel.length === 0) {
			replaceTextWithUndo(ed, tag1 + (tag2?tag2:tag1), true);
		} else {
			if (tag2 === '](URL)' && ed.sel.substr(0,4) === 'http'){ //Check if URL was selected
				tag1 = '[Label](';
				tag2 = ')';
			}
			replaceTextWithUndo(ed, ed.text.substr(0, ed.sel1) + tag1 + ed.sel + (tag2?tag2:tag1) + ed.text.substr(ed.sel2));
		}
		//If URL tag select the URL or Label
		if (tag2 === '](URL)' && ed.sel.length > 0) {
			ed.node.setSelectionRange(ed.sel1 + tag1.length + ed.sel.length + 2, ed.sel1 + tag1.length + ed.sel.length + 5);
		} else if (tag1 === '[Label](' && ed.sel.length > 0) {
			ed.node.setSelectionRange(ed.sel1 + 1, ed.sel1 + 6);
		}
		else {
			ed.node.setSelectionRange(ed.sel1 + tag1.length, ed.sel1 + tag1.length + ed.sel.length);
		}
	}
	ed.node.focus();
}

function edInsertText(text, ed) {
	//Insert at cursor
	replaceTextWithUndo(ed, text, true);
	ed.node.setSelectionRange(ed.sel2 + text.length, ed.sel2 + text.length);
	ed.node.focus();
}

function edPrefixTag(tag, gap, ed) {
	if (ed.sel.startsWith(tag)) {
		//Remove the syntax if it's already prefixed
		const selection = removeCharAtStartOfLines(ed.sel, tag);
		ed.node.value = ed.text.substr(0, ed.sel1) + selection + ed.text.substr(ed.sel2);
		ed.node.setSelectionRange(ed.sel1, ed.sel1 + selection.length);
	} else {
		const selPage = (getSelectionText().length > 0 && getSelection().anchorNode.className !== 'form-item' && tag === '>');
		let selection;
		//Prefix syntax - Note Firefox and Chrome handle getSelection() differently for textboxes
		if (selPage || ed.sel.length > 0) {
			//Text selected in page or textarea
			const selTrimmed = trimWhitespaceFromLines(selPage?getSelectionText():ed.sel);
			const selectionObj = insertAtStartOfLines(selTrimmed.trimmedString,
													{charToInsert: tag, numberLines: false, skipEmptyLines: true});
			selection = markdownGapDetection(ed, selectionObj, tag, selTrimmed.trimmedCount, selPage?selTrimmed.trimmedString.length:0, selPage);
		} else { //Add tag to the start of the line
			const lineInfo = getLineInfo(ed, tag);
			ed.sel1 = lineInfo.startOfLine;
			ed.sel2 = lineInfo.endOfLine;
			trimSelection(ed, tag);
			ed.sel = ed.text.substring(ed.sel1, ed.sel2);
			const selTrimmed = trimWhitespaceFromLines(ed.sel);
			selection = markdownGapDetection(ed, {text: (tag + selTrimmed.trimmedString), tagCount: tag.length}, tag, selTrimmed.trimmedCount, 0);
		}
		//Set the full textarea
		replaceTextWithUndo(ed, ed.text.substr(0, ed.sel1) + (selPage?ed.sel:'') + (selection.result??selection) + ed.text.substr(ed.sel2));
		if (selPage) {
			//Exclude first tag from selection so that tag can continously be added for nested markdown like blockquote
			ed.node.setSelectionRange(ed.sel1 + (selPage?ed.sel.length:0) + selection.result.length, ed.sel1
									  + (selPage?ed.sel.length:0) + selection.result.length);
		} else if (ed.sel.length === 0) {
			ed.node.setSelectionRange(ed.sel2 + tag.length, ed.sel2 + tag.length);
		} else {
			ed.node.setSelectionRange(ed.sel1 + tag.length + selection.iBefore, ed.sel1 + selection.result.length - selection.iAfter);
		}
	}
	ed.node.focus();
}

function edList(tag, ed, ordered) {
	//Add what to do when no selection
	let selection;
	if (ed.sel.startsWith(tag)) {
		// Remove the syntax if it's already prefixed
		selection = trimWhitespaceFromLines(removeCharAtStartOfLines(ed.sel, tag, ordered)).trimmedString;
		ed.node.value = ed.text.substr(0, ed.sel1) + selection + ed.text.substr(ed.sel2);
		ed.node.setSelectionRange(ed.sel1, ed.sel1 + selection.length);
	} else {
		if (ed.sel.length > 0) {
			// Wrap syntax
			const selTrimmed = trimWhitespaceFromLines(ed.sel);
			const selectionObj = insertAtStartOfLines(selTrimmed.trimmedString,
													{charToInsert: tag, numberLines: ordered, skipEmptyLines: true});
			selection = markdownGapDetection(ed, selectionObj, tag, selTrimmed.trimmedCount, 0);
		} else { //Nothing selected, just add tag
			const lineInfo = getLineInfo(ed, tag);
			ed.sel1 = lineInfo.startOfLine;
			ed.sel2 = lineInfo.endOfLine;
			trimSelection(ed, tag);
			ed.sel = ed.text.substring(ed.sel1, ed.sel2);
			let selTrimmed = trimWhitespaceFromLines(ed.sel);
			selection = markdownGapDetection(ed, {text: (tag + selTrimmed.trimmedString), tagCount: tag.length}, tag, selTrimmed.trimmedCount, 0);
		}
		replaceTextWithUndo(ed, (ed.text.substr(0, ed.sel1) + (selection.result??selection) + ed.text.substr(ed.sel2)));
		if (ed.sel.length === 0){ //Don't select anything if only tag was inserted
				ed.node.setSelectionRange(ed.sel2 + tag.length, ed.sel2 + tag.length);
		} else {
			ed.node.setSelectionRange(ed.sel1 + selection.iBefore, ed.sel1 + selection.result.length - selection.iAfter);
		}
	}
	ed.node.focus();
}

function markdownGapDetection(ed, selectionObj, tag, trimCount, addedLength, selPage) {
	let iBefore;
	let iAfter;
	let selection = selectionObj.text;
	//Check and add up to 2 line breaks before the selection
	for (iBefore = 0;selection !== tag && iBefore < 2
		 && !detectGapBefore(ed.sel1 + iBefore, ed.text.substr(0, ed.sel1) + selection, tag);iBefore++) {
		selection = '\n' + selection;
	}
	//Check and add up to 2 line breaks after the selection
	for (iAfter = 0;selection !== tag && iAfter < 2 && (ed.sel2 !== ed.text.length || selPage)
		 && !detectGapAfter(ed.sel2 + selectionObj.tagCount + iBefore - (selPage?0:trimCount) + addedLength,
							ed.text.substr(0, ed.sel1) + selection + ed.text.substr(ed.sel2), tag);iAfter++) {
		selection = selection + '\n';
		}
	return {
		result: selection,
		iBefore: iBefore,
		iAfter: iAfter
	}
}

function getSelectionText() {
	if (window.getSelection) {
		return window.getSelection().toString();
	}
}

function createMarkdownTable(columns, rows) {
	const cellWidth = 9; //Define uniform cell width for better alignment
	function pad(text) { //Helper to pad text to fixed width
		return text.padEnd(cellWidth, ' ');
	}
	const headers = Array.from({ length: columns }, (_, i) => `Head${i + 1}`);
	const headerRow = `| ${headers.join(' | ')} |`;
	const separatorRow = `| ${headers.map(() => '-'.repeat(cellWidth)).join(' | ')} |`;
	const dataRows = [];
	for (let r = 0; r < rows; r++) {
		const row = Array.from({ length: columns }, () => pad('  Cell'));
		dataRows.push(`| ${row.join(' | ')} |`);
	}
	return [headerRow, separatorRow, ...dataRows].join('\n');
}

function getLineInfo(ed, tag) {
	if (!ed || typeof ed.text !== 'string' || typeof ed.sel1 !== 'number') {
		return {
			startOfLine: ed?.sel1 ?? 0,
			endOfLine: ed?.sel1 ?? 0,
			line: ''
		};
	}
	const startOfLine = ed.text.lastIndexOf('\n', ed.sel1 - 1) + 1;
	let endOfLine = ed.text.indexOf('\n', ed.sel1);
	if (endOfLine === -1) endOfLine = ed.text.length;
	const line = ed.text.slice(startOfLine, endOfLine).trim();
	//If tag is given and the trimmed line is made up of repeated tags
	if (typeof tag === 'string' && tag.length > 0) {
		const repeated = tag.repeat(Math.ceil(line.length / tag.length));
		const trimmedRepeated = repeated.slice(0, line.length);
		if (line === trimmedRepeated) {
			return {
				startOfLine: endOfLine,
				endOfLine: endOfLine,
				line: ed.text.slice(startOfLine, endOfLine) //Preserve original line (untrimmed)
			};
		}
	}
	return {
		startOfLine,
		endOfLine,
		line: ed.text.slice(startOfLine, endOfLine)
	};
}

function getStartOfLine(ed) {
	if (!ed || typeof ed.text !== 'string' || typeof ed.sel1 !== 'number') {
		return ed?.sel1;
	}
	const beforeSel = ed.text.substring(0, ed.sel1);
	const lastNewline = beforeSel.lastIndexOf('\n');
	return lastNewline + 1;
}

function insertAtStartOfLines(inputString, options = {}) {
	const { charToInsert = '', numberLines = false, skipEmptyLines = true } = options;
	let lineNumber = 1;
	let count = 0;
	let result = inputString
		.split('\n')
		.map(line => {
		if (skipEmptyLines && line.trim() === '') {
			return line;
		}
		if (numberLines) {
			count = count + lineNumber.toString().length + 2;
			return `${lineNumber++}. ${line}`;
		} else {
			count = count + charToInsert.length;
			return charToInsert + line;
		}
	})
		.join('\n');
	return {
        text: result,
        tagCount: count
    }
}

function removeCharAtStartOfLines(inputString, charToRemove, removeNumbers) {
	return inputString
		.split('\n')
		.map(line => {
		let newLine = line;
		//Remove the specific character if needed
		if (charToRemove && newLine.startsWith(charToRemove) && !removeNumbers) {
			newLine = newLine.slice(1);
		}
		//Remove leading numbers (and optional dot/space after) if needed
		if (removeNumbers) {
			newLine = newLine.replace(/^\d+\.?\s*/, '');
		}
		return newLine;
	})
		.join('\n');
}

function trimWhitespaceFromLines(inputString) {
	let trimmedCount = 0;
	const trimmedLines = inputString.split('\n').map(line => {
		const originalLength = line.length;
		const trimmedLine = line.trim();
		trimmedCount += originalLength - trimmedLine.length;
		return trimmedLine;
	});
	return {
		trimmedString: trimmedLines.join('\n'),
		trimmedCount
	};
}

function replaceTextWithUndo(ed, newText, insert) {
	ed.node.focus();
	ed.node.setSelectionRange(insert?ed.sel2:0, insert?ed.sel2:ed.node.value.length);
	try { //Note: execCommand is needed for Undo functionality but is deprecated and could fail in the future
		document.execCommand('insertText', false, newText);
	} catch (err) { //Fallback if execCommand fails
		ed.node.setRangeText(newText, 0, ed.node.value.length, 'start');
		ed.node.dispatchEvent(new Event('input', { bubbles: true }));
	}
}

function bindCtrlKeyToTextareaAction(textareaEl) {
	if (!textareaEl) return;
	function handleKeydown(e) {
		const ctrlKey = isMacOS() ? e.metaKey : e.ctrlKey;
		if (!ctrlKey || e.altKey || e.shiftKey) return;
		const key = e.key.toLowerCase();
		let button;
		if (key === 'p') { //Hardcode Preview shortcut (Ctrl+P)
			button = textareaEl.parentNode.parentNode.querySelector('input.form-submit[value="Preview"]');
		}
		else {
			button = textareaEl.parentNode.querySelector(`a[data-key="${key}"]`);
		}
		if (button) {
			e.preventDefault();
			button.click();
		}
	}
	textareaEl.addEventListener('keydown', handleKeydown);
}

function isMacOS() {
  const platform = navigator.platform || "";
  const userAgent = navigator.userAgent || "";
  return platform.includes("Mac") || userAgent.includes("Macintosh");
}