diff --git a/4chan_x.user.js b/4chan_x.user.js index a0674ced7..6dd950b45 100644 --- a/4chan_x.user.js +++ b/4chan_x.user.js @@ -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: '[ + ]', - href: 'javascript:;' + innerHTML: '[ + ] ' }); - $.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 = 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: '[]' + }); + 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: '[ × ]', + 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 = '[ Deleting... ]'; - 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 = "[ " + s + " ]"; + return self.textContent = s; }, error: function(self) { - self.innerHTML = '[ Connection error, please retry. ]'; - 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: '[ ! ]', - 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;\ }\ diff --git a/changelog b/changelog index 00bcd8b04..f070f1fab 100644 --- a/changelog +++ b/changelog @@ -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. diff --git a/script.coffee b/script.coffee index 06b6fa052..2d2be6cee 100644 --- a/script.coffee +++ b/script.coffee @@ -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: '[ + ]' - href: 'javascript:;' - $.add a, $.tn " #{opInfo} (#{text})" - $.on a, 'click', ThreadHiding.cb - $.prepend thread, a + innerHTML: '[ + ] ' + 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 = 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: '[]' + @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: "#{if data.media_status isnt " $.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: '[ × ]' + 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 = '[ Deleting... ]' + $.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 = "[ #{s} ]" + self.textContent = s error: (self) -> - self.innerHTML = '[ Connection error, please retry. ]' - $.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: '[ ! ]' + 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;', '
0 / 0
' @@ -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; }