// ==UserScript==
// @name OzBargain Markdown Toolbar
// @namespace nategasm
// @version 1.13
// @description Creates 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: 0px;
font-size: var(--page-font);
line-height: 1;
font-weight: bold;
padding: 4px 6px;
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);
margin-bottom: 3px;
user-select: none;
}
.mdBtn:hover {
background: var(--shade3-bg) !important;
color: #fff !important;
}
.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 preloaded textboxes
addFeatures(document.querySelector('textarea').parentNode);
//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 config = {attributes: false, childList: true, subtree: true};
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], config);
}
//Add quote button to node content
let textarea = document.querySelector('textarea');
let node = document.querySelector('.node:not(.messages)');
if (node && textarea) {
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');
//Add buttons
btnMake(n, '<b>B</b>', 'Bold', '**');
btnMake(n, '<i>I </i>', 'Italic', '*');
btnMake(n, '<s>S</s>', 'Strikethrough', '~~');
btnMake(n, 'H', 'Add Heading level on selected text','#', '', false, true, true);
btnMake(n, '---', 'Horizontal line', '\n\n---\n\n', '', true);
btnMake(n, '• List', 'Unordered list on selected text',
function(e) {
try {edList('* ', edInit(e.target,'* '));}
catch(e) {}
});
btnMake(n, '# List', 'Ordered list on selected text',
function(e) {
try {edList('1', edInit(e.target,'1'), true);}
catch(e) {}
});
btnMake(n, 'URL', 'Add URL to selected text',
function(e) {
try {edWrapInTag('[', ']('+prompt('URL:')+')', edInit(e.target,'['));}
catch(e) {}
});
btnMake(n, 'Quote', 'Quote selected text','>', '', false, true, true);
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) {}
});
btnMake(n, 'Code', 'Apply CODE markdown to selected text',
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);
}
});
}
function btnMake(afterNode, label, title, tag1, tag2, noWrap, prefix, gap) {
let a = document.createElement('a');
a.className = 'mdBtn';
a.innerHTML = label;
a.title = title;
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;
afterNode.insertBefore(a, afterNode.textAreaNode);
}
function edInit(btn, tag) {
let ed = { node: btn.parentNode.textAreaNode };
ed.sel1 = ed.node.selectionStart;
ed.sel2 = ed.node.selectionEnd;
//Trim Spaces from start of selection, bad for some markdown
for (let i = 1;ed.sel1 !== ed.sel2 && ed.node.value.substring(ed.sel1, ed.sel1 + 1) === ' '; i++) {
ed.sel1 = ed.node.selectionStart + i
}
//Trim Spaces from end of selection, bad for some markdown
for (let i = 1;ed.sel1 !== ed.sel2 && ed.node.value.substring(ed.sel2 - 1, ed.sel2) === ' '; i++) {
ed.sel2 = ed.node.selectionEnd - i
}
ed.text = ed.node.value;
ed.sel = ed.text.substring(ed.sel1, ed.sel2);
//Check if there is a valid gap before and after selection, gaps required for some Markdown
if (ed.sel1 === 0 || ed.sel1 - tag.length === 0 || ['\n',tag].includes(ed.text.substring(ed.sel1 - tag.length - 1, ed.sel1 - tag.length))) {ed.gapBefore = true}
if (ed.text.substring(ed.sel2 + 1, ed.sel2 + 2) === '\n' || (!tag && ed.sel2 === ed.text.length)) {ed.gapAfter = true}
return ed;
}
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
ed.node.value = ed.text.substr(0, ed.sel1) + tag1 + ed.sel + (tag2?tag2:tag1) + ed.text.substr(ed.sel2);
ed.node.setSelectionRange(ed.sel1 + tag1.length, ed.sel1 + tag1.length + ed.sel.length);
}
ed.node.focus();
}
function edInsertText(text, ed) {
//Insert at cursor
ed.node.value = ed.text.substr(0, ed.sel2) + text + ed.text.substr(ed.sel2);
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
let 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 {
//Prefix syntax - Note Firefox and Chrome handle getSelection() differently for textboxes
if (getSelectionText().length > 0 && getSelection().anchorNode.className !== 'form-item' && tag === '>') {//Text selected on page for blockquote
let selection = trimWhitespaceFromLines(getSelectionText()).replace(/^(?=\S)/gm, tag).trim();
if (!ed.gapBefore && gap) {selection = '\n' + selection}; //Gap required before tag
if (!ed.gapAfter && gap) {selection = selection + '\n\n'}; //Gap required after tag
ed.node.value = ed.text.substr(0, ed.sel1) + ed.sel + selection + ed.text.substr(ed.sel2);
ed.node.setSelectionRange(ed.sel1 + selection.length, ed.sel1 + selection.length);
} else {//Text selected in textarea
let selection;
if (ed.sel.length > 0) {selection = trimWhitespaceFromLines(ed.sel).replace(/^(?=\S)/gm, tag)
}
else {selection = tag};
if (!ed.gapBefore && gap && selection !== tag && selection.length > 0) {selection = '\n' + selection} //Gap required before tag
if (!ed.gapAfter && gap && selection !== tag && selection.length > 0) {selection = selection + '\n'} //Gap required after tag
ed.node.value = ed.text.substr(0, ed.sel1) + selection + ed.text.substr(ed.sel2);
if (selection === tag){ //Set resulting selection
ed.node.setSelectionRange(ed.sel2 + tag.length, ed.sel2 + tag.length);
} else {
ed.node.setSelectionRange(ed.sel1 + tag.length, ed.sel1 + selection.length - (ed.gapAfter||!gap?0:1)); //It just works
}
}
}
ed.node.focus();
}
function edList(tag, ed, ordered) {
//Add what to do when no selection
if (ed.sel.startsWith(tag)) {
// Remove the syntax if it's already prefixed
let selection = trimWhitespaceFromLines(removeCharAtStartOfLines(ed.sel, tag, ordered));
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 {
// Wrap syntax
var selection = insertAtStartOfLines(trimWhitespaceFromLines(ed.sel), {charToInsert: tag, numberLines: ordered, skipEmptyLines: true});
if (!ed.gapBefore) {selection = '\n' + selection}; //Gap required before lists
if (!ed.gapAfter) {selection = selection + '\n'}; //Gap required after lists
ed.node.value = ed.text.substr(0, ed.sel1) + selection + ed.text.substr(ed.sel2);
ed.node.setSelectionRange(ed.gapBefore?ed.sel1:ed.sel1+1, ed.sel1 + selection.length - (ed.gapAfter?0:1));
}
ed.node.focus();
}
function getSelectionText() {
if (window.getSelection) {
return window.getSelection().toString();
}
}
function createMarkdownTable(columns, rows) {
const cellWidth = 9; //Define uniform cell width for better alignment
//Helper to pad text to fixed width
function pad(text) {
return text.padEnd(cellWidth, ' ');
}
//Generate rows
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(' | ')} |`);
}
//Combine everything
return [headerRow, separatorRow, ...dataRows].join('\n');
}
function insertAtStartOfLines(inputString, options = {}) {
const { charToInsert = '', numberLines = false, skipEmptyLines = true } = options;
let lineNumber = 1;
return inputString
.split('\n')
.map(line => {
if (skipEmptyLines && line.trim() === '') {
return line;
}
if (numberLines) {
return `${lineNumber++}. ${line}`;
} else {
return charToInsert + line;
}
})
.join('\n');
}
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) {
return inputString
.split('\n')
.map(line => line.trim())
.join('\n');
}