Merge branch 'menu'

This commit is contained in:
Nicolas Stepien 2012-07-04 21:54:56 +02:00
commit a63dad61b6
3 changed files with 900 additions and 143 deletions

View File

@ -77,7 +77,7 @@
*/
(function() {
var $, $$, Anonymize, AutoGif, Conf, Config, DeleteButton, ExpandComment, ExpandThread, Favicon, FileInfo, Filter, Get, ImageExpand, ImageHover, Keybinds, Main, Nav, Options, QR, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Redirect, ReplyHiding, ReportButton, RevealSpoilers, Sauce, StrikethroughQuotes, ThreadHiding, ThreadStats, Time, TitlePost, UI, Unread, Updater, Watcher, d, g, _base;
var $, $$, Anonymize, ArchiveLink, AutoGif, Conf, Config, DeleteLink, DownloadLink, ExpandComment, ExpandThread, Favicon, FileInfo, Filter, Get, ImageExpand, ImageHover, Keybinds, Main, Menu, Nav, Options, QR, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Redirect, ReplyHiding, ReportLink, RevealSpoilers, Sauce, StrikethroughQuotes, ThreadHiding, ThreadStats, Time, TitlePost, UI, Unread, Updater, Watcher, d, g, _base;
Config = {
main: {
@ -86,8 +86,6 @@
'Keybinds': [true, 'Binds actions to keys'],
'Time Formatting': [true, 'Arbitrarily formatted timestamps, using your local time'],
'File Info Formatting': [true, 'Reformats the file information'],
'Report Button': [true, 'Add report buttons'],
'Delete Button': [false, 'Add delete buttons'],
'Comment Expansion': [true, 'Expand too long comments'],
'Thread Expansion': [true, 'View all replies'],
'Index Navigation': [true, 'Navigate to previous / next thread'],
@ -110,6 +108,13 @@
'Reveal Spoilers': [false, 'Replace spoiler thumbnails by the original thumbnail'],
'Expand From Current': [false, 'Expand images from current position to thread end.']
},
Menu: {
'Menu': [true, 'Add a drop-down menu in posts.'],
'Report Link': [true, 'Add a report link to the menu.'],
'Delete Link': [true, 'Add a delete link to the menu.'],
'Download Link': [true, 'Add a download with original filename link to the menu. Chrome-only currently.'],
'Archive Link': [true, 'Add an archive link to the menu.']
},
Monitoring: {
'Thread Updater': [true, 'Update threads. Has more options in its own dialog.'],
'Unread Count': [true, 'Show unread post count in tab title'],
@ -419,7 +424,7 @@
},
nodes: function(nodes) {
var frag, node, _i, _len;
if (nodes instanceof Node) {
if (!(nodes instanceof Array)) {
return nodes;
}
frag = d.createDocumentFragment();
@ -712,8 +717,12 @@
filename: function(post) {
var file, fileInfo;
fileInfo = post.fileInfo;
if (fileInfo && (file = $('.fileText > span', fileInfo))) {
return file.title;
if (fileInfo) {
if (file = $('.fileText > span', fileInfo)) {
return file.title;
} else {
return fileInfo.firstElementChild.dataset.filename;
}
}
return false;
},
@ -740,6 +749,74 @@
return img.dataset.md5;
}
return false;
},
menuInit: function() {
var div, entry, type, _i, _len, _ref;
div = $.el('div', {
textContent: 'Filter'
});
entry = {
el: div,
open: function() {
return true;
},
children: []
};
_ref = [['Name', 'name'], ['Unique ID', 'uniqueid'], ['Tripcode', 'tripcode'], ['Admin/Mod', 'mod'], ['E-mail', 'email'], ['Subject', 'subject'], ['Comment', 'comment'], ['Country', 'country'], ['Filename', 'filename'], ['Image dimensions', 'dimensions'], ['Filesize', 'filesize'], ['Image MD5', 'md5']];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
type = _ref[_i];
entry.children.push(Filter.createSubEntry(type[0], type[1]));
}
return Menu.addEntry(entry);
},
createSubEntry: function(text, type) {
var el, onclick, open;
el = $.el('a', {
href: 'javascript:;',
textContent: text
});
onclick = null;
open = function(post) {
var value;
value = Filter[type](post);
if (value === false) {
return false;
}
$.off(el, 'click', onclick);
onclick = function() {
var re, save, select, ta, tl;
re = type === 'md5' ? value : value.replace(/\/|\\|\^|\$|\n|\.|\(|\)|\{|\}|\[|\]|\?|\*|\+|\|/g, function(c) {
if (c === '\n') {
return '\\n';
} else if (c === '\\') {
return '\\\\';
} else {
return "\\" + c;
}
});
re = type === 'md5' ? "/" + value + "/" : "/^" + re + "$/";
if (/\bop\b/.test(post["class"])) {
re += ';op:yes';
}
save = (save = $.get(type, '')) ? "" + save + "\n" + re : re;
$.set(type, save);
Options.dialog();
select = $('select[name=filter]', $.id('options'));
select.value = type;
$.event(select, new Event('change'));
$.id('filter_tab').checked = true;
ta = select.nextElementSibling;
tl = ta.textLength;
ta.setSelectionRange(tl, tl);
return ta.focus();
};
$.on(el, 'click', onclick);
return true;
};
return {
el: el,
open: open
};
}
};
@ -910,7 +987,7 @@
quote.href = "res/" + href;
}
id = reply.id.slice(2);
link = $('.postInfo > .postNum > a[title="Highlight this post"]', reply);
link = $('.postNum > a[title="Highlight this post"]', reply);
link.href = "res/" + threadID + "#p" + id;
link.nextSibling.href = "res/" + threadID + "#q" + id;
nodes.push(reply);
@ -951,7 +1028,7 @@
}
},
cb: function() {
return ThreadHiding.toggle(this.parentNode);
return ThreadHiding.toggle($.x('ancestor::div[parent::div[@class="board"]]', this));
},
toggle: function(thread) {
var hiddenThreads, id;
@ -967,7 +1044,7 @@
return $.set("hiddenThreads/" + g.BOARD + "/", hiddenThreads);
},
hide: function(thread, show_stub) {
var a, num, opInfo, span, text;
var a, menuButton, num, opInfo, span, stub, text;
if (show_stub == null) {
show_stub = Conf['Show Stubs'];
}
@ -986,19 +1063,24 @@
num += $$('.opContainer ~ .replyContainer', thread).length;
text = num === 1 ? '1 reply' : "" + num + " replies";
opInfo = $('.op > .postInfo > .nameBlock', thread).textContent;
a = $.el('a', {
stub = $.el('div', {
className: 'hide_thread_button hidden_thread',
innerHTML: '<span>[ + ]</span>',
href: 'javascript:;'
innerHTML: '<a href="javascript:;"><span>[ + ]</span> </a>'
});
$.add(a, $.tn(" " + opInfo + " (" + text + ")"));
a = stub.firstChild;
$.on(a, 'click', ThreadHiding.cb);
return $.prepend(thread, a);
$.add(a, $.tn("" + opInfo + " (" + text + ")"));
if (Conf['Menu']) {
menuButton = Menu.a.cloneNode(true);
$.on(menuButton, 'click', Menu.toggle);
$.add(stub, [$.tn(' '), menuButton]);
}
return $.prepend(thread, stub);
},
show: function(thread) {
var a;
if (a = $('.hidden_thread', thread)) {
$.rm(a);
var stub;
if (stub = $('.hidden_thread', thread)) {
$.rm(stub);
}
thread.hidden = false;
return thread.nextElementSibling.hidden = false;
@ -1046,7 +1128,7 @@
return $.set("hiddenReplies/" + g.BOARD + "/", g.hiddenReplies);
},
hide: function(root, show_stub) {
var a, el, side, stub;
var a, el, menuButton, side, stub;
if (show_stub == null) {
show_stub = Conf['Show Stubs'];
}
@ -1065,8 +1147,13 @@
innerHTML: '<a href="javascript:;"><span>[ + ]</span> </a>'
});
a = stub.firstChild;
$.add(a, $.tn($('.nameBlock', el).textContent));
$.on(a, 'click', ReplyHiding.toggle);
$.add(a, $.tn($('.nameBlock', el).textContent));
if (Conf['Menu']) {
menuButton = Menu.a.cloneNode(true);
$.on(menuButton, 'click', Menu.toggle);
$.add(stub, [$.tn(' '), menuButton]);
}
return $.prepend(root, stub);
},
show: function(root) {
@ -1079,6 +1166,186 @@
}
};
Menu = {
entries: [],
init: function() {
this.a = $.el('a', {
className: 'menu_button',
href: 'javascript:;',
innerHTML: '[<span></span>]'
});
this.el = $.el('div', {
className: 'reply dialog',
id: 'menu',
tabIndex: 0
});
$.on(this.el, 'click', function(e) {
return e.stopPropagation();
});
$.on(this.el, 'keydown', this.keybinds);
$.on(d, 'AddMenuEntry', function(e) {
return Menu.addEntry(e.detail);
});
return Main.callbacks.push(this.node);
},
node: function(post) {
var a;
if (post.isInlined && !post.isCrosspost) {
a = $('.menu_button', post.el);
} else {
a = Menu.a.cloneNode(true);
$.add($('.postInfo', post.el), a);
}
return $.on(a, 'click', Menu.toggle);
},
toggle: function(e) {
var lastOpener, post;
e.preventDefault();
e.stopPropagation();
if (Menu.el.parentNode) {
lastOpener = Menu.lastOpener;
Menu.close();
if (lastOpener === this) {
return;
}
}
Menu.lastOpener = this;
post = /\bhidden_thread\b/.test(this.parentNode.className) ? $.x('ancestor::div[parent::div[@class="board"]]/child::div[contains(@class,"opContainer")]', this) : $.x('ancestor::div[contains(@class,"postContainer")][1]', this);
return Menu.open(this, Main.preParse(post));
},
open: function(button, post) {
var bLeft, bRect, bTop, el, entry, funk, mRect, _i, _len, _ref;
el = Menu.el;
el.setAttribute('data-id', post.ID);
el.setAttribute('data-rootid', post.root.id);
funk = function(entry, parent) {
var child, children, subMenu, _i, _len;
children = entry.children;
if (!entry.open(post)) {
return;
}
$.add(parent, entry.el);
if (!children) {
return;
}
if (subMenu = $('.subMenu', entry.el)) {
$.rm(subMenu);
}
subMenu = $.el('div', {
className: 'reply dialog subMenu'
});
$.add(entry.el, subMenu);
for (_i = 0, _len = children.length; _i < _len; _i++) {
child = children[_i];
funk(child, subMenu);
}
};
_ref = Menu.entries;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
entry = _ref[_i];
funk(entry, el);
}
Menu.focus($('.entry', Menu.el));
$.on(d, 'click', Menu.close);
$.add(d.body, el);
mRect = el.getBoundingClientRect();
bRect = button.getBoundingClientRect();
bTop = d.documentElement.scrollTop + d.body.scrollTop + bRect.top;
bLeft = d.documentElement.scrollLeft + d.body.scrollLeft + bRect.left;
el.style.top = bRect.top + bRect.height + mRect.height < d.documentElement.clientHeight ? bTop + bRect.height + 2 + 'px' : bTop - mRect.height - 2 + 'px';
el.style.left = bRect.left + mRect.width < d.documentElement.clientWidth ? bLeft + 'px' : bLeft + bRect.width - mRect.width + 'px';
return el.focus();
},
close: function() {
var el, focused, _i, _len, _ref;
el = Menu.el;
$.rm(el);
_ref = $$('.focused.entry', el);
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
focused = _ref[_i];
$.rmClass(focused, 'focused');
}
el.innerHTML = null;
el.removeAttribute('style');
delete Menu.lastOpener;
delete Menu.focusedEntry;
return $.off(d, 'click', Menu.close);
},
keybinds: function(e) {
var el, next, subMenu;
el = Menu.focusedEntry;
switch (Keybinds.keyCode(e) || e.keyCode) {
case 'Esc':
Menu.lastOpener.focus();
Menu.close();
break;
case 13:
case 32:
el.click();
break;
case 'Up':
if (next = el.previousElementSibling) {
Menu.focus(next);
}
break;
case 'Down':
if (next = el.nextElementSibling) {
Menu.focus(next);
}
break;
case 'Right':
if ((subMenu = $('.subMenu', el)) && (next = subMenu.firstElementChild)) {
Menu.focus(next);
}
break;
case 'Left':
if (next = $.x('parent::*[contains(@class,"subMenu")]/parent::*', el)) {
Menu.focus(next);
}
break;
default:
return;
}
e.preventDefault();
return e.stopPropagation();
},
focus: function(el) {
var focused, _i, _len, _ref;
if (focused = $.x('parent::*/child::*[contains(@class,"focused")]', el)) {
$.rmClass(focused, 'focused');
}
_ref = $$('.focused', el);
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
focused = _ref[_i];
$.rmClass(focused, 'focused');
}
Menu.focusedEntry = el;
return $.addClass(el, 'focused');
},
addEntry: function(entry) {
var funk;
funk = function(entry) {
var child, children, el, _i, _len;
el = entry.el, children = entry.children;
$.addClass(el, 'entry');
$.on(el, 'focus mouseover', function(e) {
e.stopPropagation();
return Menu.focus(this);
});
if (!children) {
return;
}
$.addClass(el, 'hasSubMenu');
for (_i = 0, _len = children.length; _i < _len; _i++) {
child = children[_i];
funk(child);
}
};
funk(entry);
return Menu.entries.push(entry);
}
};
Keybinds = {
init: function() {
var node, _i, _len, _ref;
@ -1300,7 +1567,7 @@
},
qr: function(thread, quote) {
if (quote) {
QR.quote.call($('.postInfo > .postNum > a[title="Quote this post"]', $('.post.highlight', thread) || thread));
QR.quote.call($('.postNum > a[title="Quote this post"]', $('.post.highlight', thread) || thread));
} else {
QR.open();
}
@ -1455,7 +1722,7 @@
return $.on(d, 'dragstart dragend', QR.drag);
},
node: function(post) {
return $.on($('.postInfo > .postNum > a[title="Quote this post"]', post.el), 'click', QR.quote);
return $.on($('.postNum > a[title="Quote this post"]', post.el), 'click', QR.quote);
},
open: function() {
if (QR.el) {
@ -3078,6 +3345,7 @@
fullname: span.title,
shortname: span.textContent
};
node.setAttribute('data-filename', span.title);
return node.innerHTML = FileInfo.funk(FileInfo);
},
setFormats: function() {
@ -3211,7 +3479,7 @@
}
quote.href = "/" + board + "/res/" + href;
}
link = $('.postInfo > .postNum > a[title="Highlight this post"]', pc);
link = $('.postNum > a[title="Highlight this post"]', pc);
link.href = "/" + board + "/res/" + threadID + "#p" + postID;
link.nextSibling.href = "/" + board + "/res/" + threadID + "#q" + postID;
$.replace(root.firstChild, pc);
@ -3369,13 +3637,13 @@
}));
$.after((isOP ? piM : pi), file);
}
$.replace(root.firstChild, pc);
$.replace(root.firstChild, Get.cleanPost(pc));
if (cb) {
return cb();
}
},
cleanPost: function(root) {
var child, el, inline, inlined, now, post, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, _ref2, _ref3;
var child, el, els, inline, inlined, now, post, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, _ref2;
post = $('.post', root);
_ref = Array.prototype.slice.call(root.childNodes);
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
@ -3395,9 +3663,10 @@
$.rmClass(inlined, 'inlined');
}
now = Date.now();
_ref3 = $$('[id]', root);
for (_l = 0, _len3 = _ref3.length; _l < _len3; _l++) {
el = _ref3[_l];
els = $$('[id]', root);
els.push(root);
for (_l = 0, _len3 = els.length; _l < _len3; _l++) {
el = els[_l];
el.id = "" + now + "_" + el.id;
}
$.rmClass(root, 'forwarded');
@ -3754,7 +4023,7 @@
nodes.push($.tn(text));
}
id = quote.match(/\d+$/)[0];
board = (m = quote.match(/^>>>\/([a-z\d]+)/)) ? m[1] : $('.postInfo > .postNum > a[title="Highlight this post"]', post.el).pathname.split('/')[1];
board = (m = quote.match(/^>>>\/([a-z\d]+)/)) ? m[1] : $('.postNum > a[title="Highlight this post"]', post.el).pathname.split('/')[1];
nodes.push(a = $.el('a', {
textContent: "" + quote + "\u00A0(Dead)"
}));
@ -3782,46 +4051,44 @@
}
};
DeleteButton = {
DeleteLink = {
init: function() {
this.a = $.el('a', {
className: 'delete_button',
innerHTML: '[&nbsp;&times;&nbsp;]',
var a;
a = $.el('a', {
className: 'delete_link',
href: 'javascript:;'
});
return Main.callbacks.push(this.node);
},
node: function(post) {
var a;
if (!(a = $('.delete_button', post.el))) {
a = DeleteButton.a.cloneNode(true);
$.add($('.postInfo', post.el), a);
}
return $.on(a, 'click', DeleteButton["delete"]);
return Menu.addEntry({
el: a,
open: function(post) {
if (post.isArchived) {
return false;
}
a.textContent = 'Delete this post';
$.on(a, 'click', DeleteLink["delete"]);
return true;
}
});
},
"delete": function() {
var board, form, id, m, pwd, self;
$.off(this, 'click', DeleteButton["delete"]);
this.innerHTML = '[&nbsp;Deleting...&nbsp;]';
if (m = d.cookie.match(/4chan_pass=([^;]+)/)) {
pwd = decodeURIComponent(m[1]);
} else {
pwd = $.id('delPassword').value;
}
id = $.x('preceding-sibling::input', this).name;
board = $.x('preceding-sibling::span[1]/a', this).pathname.match(/\w+/)[0];
$.off(this, 'click', DeleteLink["delete"]);
this.textContent = 'Deleting...';
pwd = (m = d.cookie.match(/4chan_pass=([^;]+)/)) ? decodeURIComponent(m[1]) : $.id('delPassword').value;
id = this.parentNode.dataset.id;
board = $('.postNum > a[title="Highlight this post"]', $.id(this.parentNode.dataset.rootid)).pathname.split('/')[1];
self = this;
form = {
mode: 'usrdel',
pwd: pwd
};
form[id] = 'delete';
return $.ajax("https://sys.4chan.org/" + board + "/imgboard.php", {
return $.ajax($.id('delform').action.replace("/" + g.BOARD + "/", "/" + board + "/"), {
onload: function() {
return DeleteButton.load(self, this.response);
return DeleteLink.load(self, this.response);
},
onerror: function() {
return DeleteButton.error(self);
return DeleteLink.error(self);
}
}, {
form: $.formData(form)
@ -3835,44 +4102,93 @@
s = 'Banned!';
} else if (msg = doc.getElementById('errmsg')) {
s = msg.textContent;
$.on(self, 'click', DeleteButton["delete"]);
$.on(self, 'click', DeleteLink["delete"]);
} else {
s = 'Deleted';
}
return self.innerHTML = "[&nbsp;" + s + "&nbsp;]";
return self.textContent = s;
},
error: function(self) {
self.innerHTML = '[&nbsp;Connection error, please retry.&nbsp;]';
return $.on(self, 'click', DeleteButton["delete"]);
self.textContent = 'Connection error, please retry.';
return $.on(self, 'click', DeleteLink["delete"]);
}
};
ReportButton = {
ReportLink = {
init: function() {
this.a = $.el('a', {
className: 'report_button',
innerHTML: '[&nbsp;!&nbsp;]',
href: 'javascript:;'
});
return Main.callbacks.push(this.node);
},
node: function(post) {
var a;
if (!(a = $('.report_button', post.el))) {
a = ReportButton.a.cloneNode(true);
$.add($('.postInfo', post.el), a);
}
return $.on(a, 'click', ReportButton.report);
a = $.el('a', {
className: 'report_link',
href: 'javascript:;',
textContent: 'Report this post'
});
$.on(a, 'click', this.report);
return Menu.addEntry({
el: a,
open: function(post) {
return post.isArchived === false;
}
});
},
report: function() {
var id, set, url;
url = "//sys.4chan.org/" + g.BOARD + "/imgboard.php?mode=report&no=" + ($.x('preceding-sibling::input', this).name);
var a, id, set, url;
a = $('.postNum > a[title="Highlight this post"]', $.id(this.parentNode.dataset.rootid));
url = "//sys.4chan.org/" + (a.pathname.split('/')[1]) + "/imgboard.php?mode=report&no=" + this.parentNode.dataset.id;
id = Date.now();
set = "toolbar=0,scrollbars=0,location=0,status=1,menubar=0,resizable=1,width=685,height=200";
return window.open(url, id, set);
}
};
DownloadLink = {
init: function() {
var a;
if ($.el('a').download === void 0) {
return;
}
a = $.el('a', {
className: 'download_link',
textContent: 'Download file'
});
return Menu.addEntry({
el: a,
open: function(post) {
var fileText;
if (!post.img) {
return false;
}
a.href = post.img.parentNode.href;
fileText = post.fileInfo.firstElementChild;
a.download = Conf['File Info Formatting'] ? fileText.dataset.filename : $('span', fileText).title;
return true;
}
});
}
};
ArchiveLink = {
init: function() {
var a;
a = $.el('a', {
className: 'archive_link',
target: '_blank',
textContent: 'Archived post'
});
return Menu.addEntry({
el: a,
open: function(post) {
var href, path;
path = $('.postNum > a[title="Highlight this post"]', post.el).pathname.split('/');
if ((href = Redirect.thread(path[1], path[3], post.ID)) === ("//boards.4chan.org/" + path[1] + "/")) {
return false;
}
a.href = href;
return true;
}
});
}
};
ThreadStats = {
init: function() {
var dialog;
@ -4531,11 +4847,23 @@
if (Conf['Image Hover']) {
ImageHover.init();
}
if (Conf['Report Button']) {
ReportButton.init();
}
if (Conf['Delete Button']) {
DeleteButton.init();
if (Conf['Menu']) {
Menu.init();
if (Conf['Report Link']) {
ReportLink.init();
}
if (Conf['Delete Link']) {
DeleteLink.init();
}
if (Conf['Filter']) {
Filter.menuInit();
}
if (Conf['Download Link']) {
DownloadLink.init();
}
if (Conf['Archive Link']) {
ArchiveLink.init();
}
}
if (Conf['Resurrect Quotes']) {
Quotify.init();
@ -4799,6 +5127,60 @@ a[href="javascript:;"] {\
display: none !important;\
}\
\
.menu_button {\
display: inline-block;\
}\
.menu_button > span {\
border-top: .5em solid;\
border-right: .3em solid transparent;\
border-left: .3em solid transparent;\
display: inline-block;\
margin: 2px;\
vertical-align: middle;\
}\
#menu {\
position: absolute;\
outline: none;\
}\
.entry {\
border-bottom: 1px solid rgba(0, 0, 0, .25);\
cursor: pointer;\
display: block;\
outline: none;\
padding: 3px 7px;\
position: relative;\
text-decoration: none;\
white-space: nowrap;\
}\
.entry:last-child {\
border: none;\
}\
.focused.entry {\
background: rgba(255, 255, 255, .33);\
}\
.entry.hasSubMenu {\
padding-right: 1.5em;\
}\
.hasSubMenu::after {\
content: "";\
border-left: .5em solid;\
border-top: .3em solid transparent;\
border-bottom: .3em solid transparent;\
display: inline-block;\
margin: .3em;\
position: absolute;\
right: 3px;\
}\
.hasSubMenu:not(.focused) > .subMenu {\
display: none;\
}\
.subMenu {\
position: absolute;\
left: 100%;\
top: 0;\
margin-top: -1px;\
}\
\
h1 {\
text-align: center;\
}\

View File

@ -1,5 +1,11 @@
master
- Mayhem
New feature: Menu, which
- replaces and includes Report Button and Delete Button.
- add one-click Filter buttons.
- add download links to automatically save the file with its original filename. Chrome-only currently.
- add archive links.
- can integrate features from external userscripts/extensions, see https://github.com/MayhemYDG/4chan-x/wiki/Menu-API
The updater's refresh interval will now increase gradually in inactive threads.
The updater's refresh interval is now limited to 5 seconds minimum.

View File

@ -5,8 +5,6 @@ Config =
'Keybinds': [true, 'Binds actions to keys']
'Time Formatting': [true, 'Arbitrarily formatted timestamps, using your local time']
'File Info Formatting': [true, 'Reformats the file information']
'Report Button': [true, 'Add report buttons']
'Delete Button': [false, 'Add delete buttons']
'Comment Expansion': [true, 'Expand too long comments']
'Thread Expansion': [true, 'View all replies']
'Index Navigation': [true, 'Navigate to previous / next thread']
@ -26,6 +24,12 @@ Config =
'Sauce': [true, 'Add sauce to images']
'Reveal Spoilers': [false, 'Replace spoiler thumbnails by the original thumbnail']
'Expand From Current': [false, 'Expand images from current position to thread end.']
Menu:
'Menu': [true, 'Add a drop-down menu in posts.']
'Report Link': [true, 'Add a report link to the menu.']
'Delete Link': [true, 'Add a delete link to the menu.']
'Download Link': [true, 'Add a download with original filename link to the menu. Chrome-only currently.']
'Archive Link': [true, 'Add an archive link to the menu.']
Monitoring:
'Thread Updater': [true, 'Update threads. Has more options in its own dialog.']
'Unread Count': [true, 'Show unread post count in tab title']
@ -325,7 +329,10 @@ $.extend $,
tn: (s) ->
d.createTextNode s
nodes: (nodes) ->
if nodes instanceof Node
# In (at least) Chrome, elements created inside different
# scripts/window contexts inherit from unequal prototypes.
# window_ext1.Node !== window_ext2.Node
unless nodes instanceof Array
return nodes
frag = d.createDocumentFragment()
for node in nodes
@ -562,8 +569,11 @@ Filter =
false
filename: (post) ->
{fileInfo} = post
if fileInfo and file = $ '.fileText > span', fileInfo
return file.title
if fileInfo
if file = $ '.fileText > span', fileInfo
return file.title
else
return fileInfo.firstElementChild.dataset.filename
false
dimensions: (post) ->
{fileInfo} = post
@ -581,6 +591,99 @@ Filter =
return img.dataset.md5
false
menuInit: ->
div = $.el 'div',
textContent: 'Filter'
entry =
el: div
open: -> true
children: []
for type in [
['Name', 'name']
['Unique ID', 'uniqueid']
['Tripcode', 'tripcode']
['Admin/Mod', 'mod']
['E-mail', 'email']
['Subject', 'subject']
['Comment', 'comment']
['Country', 'country']
['Filename', 'filename']
['Image dimensions', 'dimensions']
['Filesize', 'filesize']
['Image MD5', 'md5']
]
# Add a sub entry for each filter type.
entry.children.push Filter.createSubEntry type[0], type[1]
Menu.addEntry entry
createSubEntry: (text, type) ->
el = $.el 'a',
href: 'javascript:;'
textContent: text
# Define the onclick var outside of open's scope to $.off it properly.
onclick = null
open = (post) ->
value = Filter[type] post
return false if value is false
$.off el, 'click', onclick
onclick = ->
# Convert value -> regexp, unless type is md5
re = if type is 'md5' then value else value.replace ///
/
| \\
| \^
| \$
| \n
| \.
| \(
| \)
| \{
| \}
| \[
| \]
| \?
| \*
| \+
| \|
///g, (c) ->
if c is '\n'
'\\n'
else if c is '\\'
'\\\\'
else
"\\#{c}"
re =
if type is 'md5'
"/#{value}/"
else
"/^#{re}$/"
if /\bop\b/.test post.class
re += ';op:yes'
# Add a new line before the regexp unless the text is empty.
save = if save = $.get type, '' then "#{save}\n#{re}" else re
$.set type, save
# Open the options and display & focus the relevant filter textarea.
Options.dialog()
select = $ 'select[name=filter]', $.id 'options'
select.value = type
$.event select, new Event 'change'
$.id('filter_tab').checked = true
ta = select.nextElementSibling
tl = ta.textLength
ta.setSelectionRange tl, tl
ta.focus()
$.on el, 'click', onclick
true
return el: el, open: open
StrikethroughQuotes =
init: ->
Main.callbacks.push @node
@ -696,7 +799,7 @@ ExpandThread =
continue if href[0] is '/' # Cross-board quote
quote.href = "res/#{href}" # Fix pathnames
id = reply.id[2..]
link = $ '.postInfo > .postNum > a[title="Highlight this post"]', reply
link = $ '.postNum > a[title="Highlight this post"]', reply
link.href = "res/#{threadID}#p#{id}"
link.nextSibling.href = "res/#{threadID}#q#{id}"
nodes.push reply
@ -724,7 +827,7 @@ ThreadHiding =
return
cb: ->
ThreadHiding.toggle @parentNode
ThreadHiding.toggle $.x 'ancestor::div[parent::div[@class="board"]]', @
toggle: (thread) ->
hiddenThreads = $.get "hiddenThreads/#{g.BOARD}/", {}
@ -752,17 +855,21 @@ ThreadHiding =
text = if num is 1 then '1 reply' else "#{num} replies"
opInfo = $('.op > .postInfo > .nameBlock', thread).textContent
a = $.el 'a',
stub = $.el 'div',
className: 'hide_thread_button hidden_thread'
innerHTML: '<span>[ + ]</span>'
href: 'javascript:;'
$.add a, $.tn " #{opInfo} (#{text})"
$.on a, 'click', ThreadHiding.cb
$.prepend thread, a
innerHTML: '<a href="javascript:;"><span>[ + ]</span> </a>'
a = stub.firstChild
$.on a, 'click', ThreadHiding.cb
$.add a, $.tn "#{opInfo} (#{text})"
if Conf['Menu']
menuButton = Menu.a.cloneNode true
$.on menuButton, 'click', Menu.toggle
$.add stub, [$.tn(' '), menuButton]
$.prepend thread, stub
show: (thread) ->
if a = $ '.hidden_thread', thread
$.rm a
if stub = $ '.hidden_thread', thread
$.rm stub
thread.hidden = false
thread.nextElementSibling.hidden = false
@ -810,8 +917,12 @@ ReplyHiding =
className: 'hide_reply_button stub'
innerHTML: '<a href="javascript:;"><span>[ + ]</span> </a>'
a = stub.firstChild
$.add a, $.tn $('.nameBlock', el).textContent
$.on a, 'click', ReplyHiding.toggle
$.add a, $.tn $('.nameBlock', el).textContent
if Conf['Menu']
menuButton = Menu.a.cloneNode true
$.on menuButton, 'click', Menu.toggle
$.add stub, [$.tn(' '), menuButton]
$.prepend root, stub
show: (root) ->
@ -820,6 +931,155 @@ ReplyHiding =
$('.sideArrows', root).hidden = false
$('.post', root).hidden = false
Menu =
entries: []
init: ->
@a = $.el 'a',
className: 'menu_button'
href: 'javascript:;'
innerHTML: '[<span></span>]'
@el = $.el 'div',
className: 'reply dialog'
id: 'menu'
tabIndex: 0
$.on @el, 'click', (e) -> e.stopPropagation()
$.on @el, 'keydown', @keybinds
# Doc here: https://github.com/MayhemYDG/4chan-x/wiki/Menu-API
$.on d, 'AddMenuEntry', (e) -> Menu.addEntry e.detail
Main.callbacks.push @node
node: (post) ->
if post.isInlined and !post.isCrosspost
a = $ '.menu_button', post.el
else
a = Menu.a.cloneNode true
$.add $('.postInfo', post.el), a
$.on a, 'click', Menu.toggle
toggle: (e) ->
e.preventDefault()
e.stopPropagation()
if Menu.el.parentNode
# Close if it's already opened.
# Reopen if we clicked on another button.
{lastOpener} = Menu
Menu.close()
return if lastOpener is @
Menu.lastOpener = @
post =
if /\bhidden_thread\b/.test @parentNode.className
$.x 'ancestor::div[parent::div[@class="board"]]/child::div[contains(@class,"opContainer")]', @
else
$.x 'ancestor::div[contains(@class,"postContainer")][1]', @
Menu.open @, Main.preParse post
open: (button, post) ->
{el} = Menu
# XXX GM/Scriptish require setAttribute
el.setAttribute 'data-id', post.ID
el.setAttribute 'data-rootid', post.root.id
funk = (entry, parent) ->
{children} = entry
return unless entry.open post
$.add parent, entry.el
return unless children
if subMenu = $ '.subMenu', entry.el
# Reset sub menu, remove irrelevant entries.
$.rm subMenu
subMenu = $.el 'div',
className: 'reply dialog subMenu'
$.add entry.el, subMenu
for child in children
funk child, subMenu
return
for entry in Menu.entries
funk entry, el
Menu.focus $ '.entry', Menu.el
$.on d, 'click', Menu.close
$.add d.body, el
# Position
mRect = el.getBoundingClientRect()
bRect = button.getBoundingClientRect()
bTop = d.documentElement.scrollTop + d.body.scrollTop + bRect.top
bLeft = d.documentElement.scrollLeft + d.body.scrollLeft + bRect.left
el.style.top =
if bRect.top + bRect.height + mRect.height < d.documentElement.clientHeight
bTop + bRect.height + 2 + 'px'
else
bTop - mRect.height - 2 + 'px'
el.style.left =
if bRect.left + mRect.width < d.documentElement.clientWidth
bLeft + 'px'
else
bLeft + bRect.width - mRect.width + 'px'
el.focus()
close: ->
{el} = Menu
$.rm el
for focused in $$ '.focused.entry', el
$.rmClass focused, 'focused'
el.innerHTML = null
el.removeAttribute 'style'
delete Menu.lastOpener
delete Menu.focusedEntry
$.off d, 'click', Menu.close
keybinds: (e) ->
el = Menu.focusedEntry
switch Keybinds.keyCode(e) or e.keyCode
when 'Esc'
Menu.lastOpener.focus()
Menu.close()
when 13, 32 # 'Enter', 'Space'
el.click()
when 'Up'
if next = el.previousElementSibling
Menu.focus next
when 'Down'
if next = el.nextElementSibling
Menu.focus next
when 'Right'
if (subMenu = $ '.subMenu', el) and next = subMenu.firstElementChild
Menu.focus next
when 'Left'
if next = $.x 'parent::*[contains(@class,"subMenu")]/parent::*', el
Menu.focus next
else
return
e.preventDefault()
e.stopPropagation()
focus: (el) ->
if focused = $.x 'parent::*/child::*[contains(@class,"focused")]', el
$.rmClass focused, 'focused'
for focused in $$ '.focused', el
$.rmClass focused, 'focused'
Menu.focusedEntry = el
$.addClass el, 'focused'
addEntry: (entry) ->
funk = (entry) ->
{el, children} = entry
$.addClass el, 'entry'
$.on el, 'focus mouseover', (e) ->
e.stopPropagation()
Menu.focus @
return unless children
$.addClass el, 'hasSubMenu'
for child in children
funk child
return
funk entry
Menu.entries.push entry
Keybinds =
init: ->
for node in $$ '[accesskey]'
@ -951,7 +1211,7 @@ Keybinds =
qr: (thread, quote) ->
if quote
QR.quote.call $ '.postInfo > .postNum > a[title="Quote this post"]', $('.post.highlight', thread) or thread
QR.quote.call $ '.postNum > a[title="Quote this post"]', $('.post.highlight', thread) or thread
else
QR.open()
$('textarea', QR.el).focus()
@ -1072,7 +1332,7 @@ QR =
$.on d, 'dragstart dragend', QR.drag
node: (post) ->
$.on $('.postInfo > .postNum > a[title="Quote this post"]', post.el), 'click', QR.quote
$.on $('.postNum > a[title="Quote this post"]', post.el), 'click', QR.quote
open: ->
if QR.el
@ -1358,9 +1618,9 @@ QR =
QR.characterCount.call $ 'textarea', QR.el
$('#spoiler', QR.el).checked = @spoiler
dragStart: ->
$.addClass @, 'drag'
$.addClass @, 'drag'
dragEnter: ->
$.addClass @, 'over'
$.addClass @, 'over'
dragLeave: ->
$.rmClass @, 'over'
dragOver: (e) ->
@ -2390,6 +2650,8 @@ FileInfo =
resolution: span.previousSibling.textContent.match(/\d+x\d+|PDF/)[0]
fullname: span.title
shortname: span.textContent
# XXX GM/Scriptish
node.setAttribute 'data-filename', span.title
node.innerHTML = FileInfo.funk FileInfo
setFormats: ->
code = Conf['fileInfo'].replace /%([BKlLMnNprs])/g, (s, c) ->
@ -2473,7 +2735,7 @@ Get =
href = quote.getAttribute 'href'
continue if href[0] is '/' # Cross-board quote, or board link
quote.href = "/#{board}/res/#{href}" # Fix pathnames
link = $ '.postInfo > .postNum > a[title="Highlight this post"]', pc
link = $ '.postNum > a[title="Highlight this post"]', pc
link.href = "/#{board}/res/#{threadID}#p#{postID}"
link.nextSibling.href = "/#{board}/res/#{threadID}#q#{postID}"
@ -2640,7 +2902,7 @@ Get =
innerHTML: "<img #{thumb_src} alt='#{if data.media_status isnt 'available' then "Error: #{data.media_status}, " else ''}#{if spoiler then 'Spoiler Image, ' else ''}#{filesize}' data-md5=#{data.media_hash} style='height: #{data.preview_h}px; width: #{data.preview_w}px;'>"
$.after (if isOP then piM else pi), file
$.replace root.firstChild, pc
$.replace root.firstChild, Get.cleanPost pc
cb() if cb
cleanPost: (root) ->
post = $ '.post', root
@ -2655,7 +2917,9 @@ Get =
# Don't mess with other features
now = Date.now()
for el in $$ '[id]', root
els = $$ '[id]', root
els.push root
for el in els
el.id = "#{now}_#{el.id}"
$.rmClass root, 'forwarded'
@ -2924,7 +3188,7 @@ Quotify =
m[1]
else
# Get the post's board, whether it's inlined or not.
$('.postInfo > .postNum > a[title="Highlight this post"]', post.el).pathname.split('/')[1]
$('.postNum > a[title="Highlight this post"]', post.el).pathname.split('/')[1]
nodes.push a = $.el 'a',
# \u00A0 is nbsp
@ -2957,28 +3221,32 @@ Quotify =
$.replace node, nodes
return
DeleteButton =
DeleteLink =
init: ->
@a = $.el 'a',
className: 'delete_button'
innerHTML: '[&nbsp;&times;&nbsp;]'
a = $.el 'a',
className: 'delete_link'
href: 'javascript:;'
Main.callbacks.push @node
node: (post) ->
unless a = $ '.delete_button', post.el
a = DeleteButton.a.cloneNode true
$.add $('.postInfo', post.el), a
$.on a, 'click', DeleteButton.delete
Menu.addEntry
el: a
open: (post) ->
if post.isArchived
return false
a.textContent = 'Delete this post'
$.on a, 'click', DeleteLink.delete
true
delete: ->
$.off @, 'click', DeleteButton.delete
@innerHTML = '[&nbsp;Deleting...&nbsp;]'
$.off @, 'click', DeleteLink.delete
@textContent = 'Deleting...'
if m = d.cookie.match /4chan_pass=([^;]+)/
pwd = decodeURIComponent m[1]
else
pwd = $.id('delPassword').value
id = $.x('preceding-sibling::input', @).name
board = $.x('preceding-sibling::span[1]/a', @).pathname.match(/\w+/)[0]
pwd =
if m = d.cookie.match /4chan_pass=([^;]+)/
decodeURIComponent m[1]
else
$.id('delPassword').value
id = @parentNode.dataset.id
board = $('.postNum > a[title="Highlight this post"]',
$.id @parentNode.dataset.rootid).pathname.split('/')[1]
self = this
form =
@ -2986,13 +3254,12 @@ DeleteButton =
pwd: pwd
form[id] = 'delete'
$.ajax "https://sys.4chan.org/#{board}/imgboard.php", {
onload: -> DeleteButton.load self, @response
onerror: -> DeleteButton.error self
$.ajax $.id('delform').action.replace("/#{g.BOARD}/", "/#{board}/"), {
onload: -> DeleteLink.load self, @response
onerror: -> DeleteLink.error self
}, {
form: $.formData form
}
load: (self, html) ->
doc = d.implementation.createHTMLDocument ''
doc.documentElement.innerHTML = html
@ -3000,32 +3267,68 @@ DeleteButton =
s = 'Banned!'
else if msg = doc.getElementById 'errmsg' # error!
s = msg.textContent
$.on self, 'click', DeleteButton.delete
$.on self, 'click', DeleteLink.delete
else
s = 'Deleted'
self.innerHTML = "[&nbsp;#{s}&nbsp;]"
self.textContent = s
error: (self) ->
self.innerHTML = '[&nbsp;Connection error, please retry.&nbsp;]'
$.on self, 'click', DeleteButton.delete
self.textContent = 'Connection error, please retry.'
$.on self, 'click', DeleteLink.delete
ReportButton =
ReportLink =
init: ->
@a = $.el 'a',
className: 'report_button'
innerHTML: '[&nbsp;!&nbsp;]'
a = $.el 'a',
className: 'report_link'
href: 'javascript:;'
Main.callbacks.push @node
node: (post) ->
unless a = $ '.report_button', post.el
a = ReportButton.a.cloneNode true
$.add $('.postInfo', post.el), a
$.on a, 'click', ReportButton.report
textContent: 'Report this post'
$.on a, 'click', @report
Menu.addEntry
el: a
open: (post) ->
post.isArchived is false
report: ->
url = "//sys.4chan.org/#{g.BOARD}/imgboard.php?mode=report&no=#{$.x('preceding-sibling::input', @).name}"
a = $ '.postNum > a[title="Highlight this post"]', $.id @parentNode.dataset.rootid
url = "//sys.4chan.org/#{a.pathname.split('/')[1]}/imgboard.php?mode=report&no=#{@parentNode.dataset.id}"
id = Date.now()
set = "toolbar=0,scrollbars=0,location=0,status=1,menubar=0,resizable=1,width=685,height=200"
window.open url, id, set
DownloadLink =
init: ->
# Test for download feature support.
return if $.el('a').download is undefined
a = $.el 'a',
className: 'download_link'
textContent: 'Download file'
Menu.addEntry
el: a
open: (post) ->
unless post.img
return false
a.href = post.img.parentNode.href
fileText = post.fileInfo.firstElementChild
a.download =
if Conf['File Info Formatting']
fileText.dataset.filename
else
$('span', fileText).title
true
ArchiveLink =
init: ->
a = $.el 'a',
className: 'archive_link'
target: '_blank'
textContent: 'Archived post'
Menu.addEntry
el: a
open: (post) ->
path = $('.postNum > a[title="Highlight this post"]', post.el).pathname.split '/'
if (href = Redirect.thread path[1], path[3], post.ID) is "//boards.4chan.org/#{path[1]}/"
return false
a.href = href
true
ThreadStats =
init: ->
dialog = UI.dialog 'stats', 'bottom: 0; left: 0;', '<div class=move><span id=postcount>0</span> / <span id=imagecount>0</span></div>'
@ -3506,11 +3809,23 @@ Main =
if Conf['Image Hover']
ImageHover.init()
if Conf['Report Button']
ReportButton.init()
if Conf['Menu']
Menu.init()
if Conf['Delete Button']
DeleteButton.init()
if Conf['Report Link']
ReportLink.init()
if Conf['Delete Link']
DeleteLink.init()
if Conf['Filter']
Filter.menuInit()
if Conf['Download Link']
DownloadLink.init()
if Conf['Archive Link']
ArchiveLink.init()
if Conf['Resurrect Quotes']
Quotify.init()
@ -3716,6 +4031,60 @@ a[href="javascript:;"] {
display: none !important;
}
.menu_button {
display: inline-block;
}
.menu_button > span {
border-top: .5em solid;
border-right: .3em solid transparent;
border-left: .3em solid transparent;
display: inline-block;
margin: 2px;
vertical-align: middle;
}
#menu {
position: absolute;
outline: none;
}
.entry {
border-bottom: 1px solid rgba(0, 0, 0, .25);
cursor: pointer;
display: block;
outline: none;
padding: 3px 7px;
position: relative;
text-decoration: none;
white-space: nowrap;
}
.entry:last-child {
border: none;
}
.focused.entry {
background: rgba(255, 255, 255, .33);
}
.entry.hasSubMenu {
padding-right: 1.5em;
}
.hasSubMenu::after {
content: "";
border-left: .5em solid;
border-top: .3em solid transparent;
border-bottom: .3em solid transparent;
display: inline-block;
margin: .3em;
position: absolute;
right: 3px;
}
.hasSubMenu:not(.focused) > .subMenu {
display: none;
}
.subMenu {
position: absolute;
left: 100%;
top: 0;
margin-top: -1px;
}
h1 {
text-align: center;
}