From 7bf2e7dd45338536beaea3bdbff06cdd4c7f7f3b Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Fri, 25 Jan 2013 12:11:02 +0100 Subject: [PATCH] Add Menu. Close #627. Add Report/Delete/Download/Archive Link. --- 4chan_x.user.js | 490 +++++++++++++++++++++++++++++++++++++++++++- css/style.css | 53 +++++ src/features.coffee | 383 +++++++++++++++++++++++++++++++++- src/main.coffee | 35 ++++ 4 files changed, 950 insertions(+), 11 deletions(-) diff --git a/4chan_x.user.js b/4chan_x.user.js index 2e0263fe1..0dc70fc38 100644 --- a/4chan_x.user.js +++ b/4chan_x.user.js @@ -20,7 +20,7 @@ // @icon https://github.com/MayhemYDG/4chan-x/raw/stable/img/icon.gif // ==/UserScript== -/* 4chan X Alpha - Version 3.0.0 - 2013-01-24 +/* 4chan X Alpha - Version 3.0.0 - 2013-01-25 * http://mayhemydg.github.com/4chan-x/ * * Copyright (c) 2009-2011 James Campos @@ -43,7 +43,7 @@ */ (function() { - var $, $$, Anonymize, AutoGIF, Board, Build, Clone, Conf, Config, FileInfo, Get, ImageHover, Main, Post, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Recursive, Redirect, ReplyHiding, RevealSpoilers, Sauce, Thread, ThreadHiding, ThreadUpdater, Time, UI, d, g, _base, + var $, $$, Anonymize, ArchiveLink, AutoGIF, Board, Build, Clone, Conf, Config, DeleteLink, DownloadLink, FileInfo, Get, ImageHover, Main, Menu, Post, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Recursive, Redirect, ReplyHiding, ReportLink, RevealSpoilers, Sauce, Thread, ThreadHiding, ThreadUpdater, Time, UI, d, g, _base, __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }, __hasProp = {}.hasOwnProperty, __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; @@ -1014,9 +1014,450 @@ } }; + Menu = { + entries: [], + init: function() { + $.on(d, 'AddMenuEntry', function(e) { + return Menu.addEntry(e.detail); + }); + return Post.prototype.callbacks.push({ + name: 'Menu', + cb: this.node + }); + }, + node: function() { + var a; + if (this.isClone) { + a = $('.menu-button', this.nodes.info); + a.setAttribute('data-clone', true); + $.on(a, 'click', Menu.toggle); + return; + } + a = Menu.makeButton(this); + return $.add(this.nodes.info, [$.tn('\u00A0'), a]); + }, + makeButton: function(post) { + var a; + a = $.el('a', { + className: 'menu-button', + innerHTML: '[]', + href: 'javascript:;' + }); + a.setAttribute('data-postid', post.fullID); + $.on(a, 'click', Menu.toggle); + return a; + }, + makeMenu: function() { + var menu; + menu = $.el('div', { + className: 'reply dialog', + id: 'menu', + tabIndex: 0 + }); + $.on(menu, 'click', function(e) { + return e.stopPropagation(); + }); + $.on(menu, 'keydown', Menu.keybinds); + return menu; + }, + toggle: function(e) { + var lastToggledButton, post; + e.preventDefault(); + e.stopPropagation(); + if (Menu.currentMenu) { + lastToggledButton = Menu.lastToggledButton; + Menu.close(); + if (lastToggledButton === this) { + return; + } + } + Menu.lastToggledButton = this; + post = this.dataset.clone ? Get.postFromRoot($.x('ancestor::div[contains(@class,"postContainer")][1]', this)) : g.posts[this.dataset.postid]; + return Menu.open(this, post); + }, + open: function(button, post) { + var bLeft, bRect, bTop, entry, mRect, menu, _i, _len, _ref; + menu = Menu.makeMenu(); + Menu.currentMenu = menu; + _ref = Menu.entries; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + entry = _ref[_i]; + Menu.insertEntry(entry, menu, post); + } + Menu.focus($('.entry', menu)); + $.on(d, 'click', Menu.close); + $.add(d.body, menu); + mRect = menu.getBoundingClientRect(); + bRect = button.getBoundingClientRect(); + bTop = d.documentElement.scrollTop + d.body.scrollTop + bRect.top; + bLeft = d.documentElement.scrollLeft + d.body.scrollLeft + bRect.left; + menu.style.top = bRect.top + bRect.height + mRect.height < d.documentElement.clientHeight ? bTop + bRect.height + 2 + 'px' : bTop - mRect.height - 2 + 'px'; + menu.style.left = bRect.left + mRect.width < d.documentElement.clientWidth ? bLeft + 'px' : bLeft + bRect.width - mRect.width + 'px'; + return menu.focus(); + }, + insertEntry: function(entry, parent, post) { + var child, submenu, _i, _len, _ref; + if (!entry.open(post)) { + return; + } + $.add(parent, entry.el); + if (!entry.children) { + return; + } + if (submenu = $('.submenu', entry.el)) { + $.rm(submenu); + } + submenu = $.el('div', { + className: 'reply dialog submenu' + }); + $.add(entry.el, submenu); + _ref = entry.children; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + child = _ref[_i]; + Menu.insertEntry(child, submenu, post); + } + }, + close: function() { + $.rm(Menu.currentMenu); + delete Menu.currentMenu; + delete Menu.lastToggledButton; + return $.off(d, 'click', Menu.close); + }, + keybinds: function(e) { + var entry, next, subEntry, submenu; + entry = $('.focused', Menu.currentMenu); + while (subEntry = $('.focused', entry)) { + entry = subEntry; + } + switch (Keybinds.keyCode(e) || e.keyCode) { + case 'Esc': + Menu.lastToggledButton.focus(); + Menu.close(); + break; + case 13: + case 32: + entry.click(); + break; + case 'Up': + if (next = entry.previousElementSibling) { + Menu.focus(next); + } + break; + case 'Down': + if (next = entry.nextElementSibling) { + Menu.focus(next); + } + break; + case 'Right': + if ((submenu = $('.submenu', entry)) && (next = submenu.firstElementChild)) { + Menu.focus(next); + } + break; + case 'Left': + if (next = $.x('parent::*[contains(@class,"submenu")]/parent::*', entry)) { + Menu.focus(next); + } + break; + default: + return; + } + e.preventDefault(); + return e.stopPropagation(); + }, + focus: function(entry) { + var bottom, eRect, focused, left, right, sRect, style, submenu, top, _i, _len, _ref; + if (focused = $.x('parent::*/child::*[contains(@class,"focused")]', entry)) { + $.rmClass(focused, 'focused'); + } + _ref = $$('.focused', entry); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + focused = _ref[_i]; + $.rmClass(focused, 'focused'); + } + $.addClass(entry, 'focused'); + if (!(submenu = $('.submenu', entry))) { + return; + } + sRect = submenu.getBoundingClientRect(); + eRect = entry.getBoundingClientRect(); + if (eRect.top + sRect.height < d.documentElement.clientHeight) { + top = '0px'; + bottom = 'auto'; + } else { + top = 'auto'; + bottom = '0px'; + } + if (eRect.right + sRect.width < d.documentElement.clientWidth) { + left = '100%'; + right = 'auto'; + } else { + left = 'auto'; + right = '100%'; + } + style = submenu.style; + style.top = top; + style.bottom = bottom; + style.left = left; + return style.right = right; + }, + addEntry: function(entry) { + Menu.parseEntry(entry); + return Menu.entries.push(entry); + }, + parseEntry: 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, 'has-submenu'); + for (_i = 0, _len = children.length; _i < _len; _i++) { + child = children[_i]; + Menu.parseEntry(child); + } + } + }; + + ReportLink = { + init: function() { + var a; + a = $.el('a', { + className: 'report-link', + href: 'javascript:;', + textContent: 'Report this post' + }); + $.on(a, 'click', ReportLink.report); + return Menu.addEntry({ + el: a, + open: function(post) { + ReportLink.post = post; + return !post.isDead; + } + }); + }, + report: function() { + var id, post, set, url; + post = ReportLink.post; + url = "//sys.4chan.org/" + post.board + "/imgboard.php?mode=report&no=" + post; + 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); + } + }; + + DeleteLink = { + init: function() { + var div, fileEl, fileEntry, postEl, postEntry; + div = $.el('div', { + className: 'delete-link', + textContent: 'Delete' + }); + postEl = $.el('a', { + className: 'delete-post', + href: 'javascript:;' + }); + fileEl = $.el('a', { + className: 'delete-file', + href: 'javascript:;' + }); + postEntry = { + el: postEl, + open: function() { + postEl.textContent = 'Post'; + $.on(postEl, 'click', DeleteLink["delete"]); + return true; + } + }; + fileEntry = { + el: fileEl, + open: function(post) { + fileEl.textContent = 'File'; + $.on(fileEl, 'click', DeleteLink["delete"]); + return !!post.file; + } + }; + Menu.addEntry({ + el: div, + open: function(post) { + var node, seconds; + if (post.isDead) { + return false; + } + DeleteLink.post = post; + node = div.firstChild; + if (seconds = DeleteLink.cooldown[post.fullID]) { + node.textContent = "Delete (" + seconds + ")"; + DeleteLink.cooldown.el = node; + } else { + node.textContent = 'Delete'; + delete DeleteLink.cooldown.el; + } + return true; + }, + children: [postEntry, fileEntry] + }); + return $.on(d, 'QRPostSuccessful', this.cooldown.start); + }, + "delete": function() { + var form, link, m, post, pwd; + post = DeleteLink.post; + if (DeleteLink.cooldown[post.fullID]) { + return; + } + $.off(this, 'click', DeleteLink["delete"]); + this.textContent = "Deleting " + this.textContent + "..."; + pwd = (m = d.cookie.match(/4chan_pass=([^;]+)/)) ? decodeURIComponent(m[1]) : $.id('delPassword').value; + form = { + mode: 'usrdel', + onlyimgdel: $.hasClass(this, 'delete-file'), + pwd: pwd + }; + form[post.ID] = 'delete'; + link = this; + return $.ajax($.id('delform').action.replace("/" + g.BOARD + "/", "/" + post.board + "/"), { + onload: function() { + return DeleteLink.load(link, this.response); + }, + onerror: function() { + return DeleteLink.error(link); + } + }, { + form: $.formData(form) + }); + }, + load: function(link, html) { + var doc, msg, s; + doc = d.implementation.createHTMLDocument(''); + doc.documentElement.innerHTML = html; + if (doc.title === '4chan - Banned') { + s = 'Banned!'; + } else if (msg = doc.getElementById('errmsg')) { + s = msg.textContent; + $.on(link, 'click', DeleteLink["delete"]); + } else { + s = 'Deleted'; + } + return link.textContent = s; + }, + error: function(link) { + link.textContent = 'Connection error, please retry.'; + return $.on(link, 'click', DeleteLink["delete"]); + }, + cooldown: { + start: function(e) { + var fullID, seconds; + seconds = g.BOARD.ID === 'q' ? 600 : 30; + fullID = "" + g.BOARD + "." + e.detail.postID; + return DeleteLink.cooldown.count(fullID, seconds, seconds); + }, + count: function(fullID, seconds, length) { + var el; + if (!((0 <= seconds && seconds <= length))) { + return; + } + setTimeout(DeleteLink.cooldown.count, 1000, fullID, seconds - 1, length); + el = DeleteLink.cooldown.el; + if (seconds === 0) { + if (el != null) { + el.textContent = 'Delete'; + } + delete DeleteLink.cooldown[fullID]; + delete DeleteLink.cooldown.el; + return; + } + if (el != null) { + el.textContent = "Delete (" + seconds + ")"; + } + return DeleteLink.cooldown[fullID] = seconds; + } + } + }; + + 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) { + if (!post.file) { + return false; + } + a.href = post.file.URL; + a.download = post.file.name; + return true; + } + }); + } + }; + + ArchiveLink = { + init: function() { + var div, entry, type, _i, _len, _ref; + div = $.el('div', { + textContent: 'Archive' + }); + entry = { + el: div, + open: function(post) { + var redirect; + redirect = Redirect.to({ + board: post.board, + threadID: post.thread, + postID: post.ID + }); + return redirect !== ("//boards.4chan.org/" + post.board + "/"); + }, + children: [] + }; + _ref = [['Post', 'post'], ['Name', 'name'], ['Tripcode', 'tripcode'], ['E-mail', 'email'], ['Subject', 'subject'], ['Filename', 'filename'], ['Image MD5', 'md5']]; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + type = _ref[_i]; + entry.children.push(this.createSubEntry(type[0], type[1])); + } + return Menu.addEntry(entry); + }, + createSubEntry: function(text, type) { + var el, open; + el = $.el('a', { + textContent: text, + target: '_blank' + }); + if (type === 'post') { + open = function(post) { + el.href = Redirect.to({ + board: post.board, + threadID: post.thread, + postID: post.ID + }); + return true; + }; + } else { + open = function(post) { + return true; + }; + } + return { + el: el, + open: open + }; + } + }; + Redirect = { image: function(board, filename) { - switch (board.ID) { + switch ("" + board) { case 'a': case 'co': case 'jp': @@ -1057,7 +1498,7 @@ } }, post: function(board, postID) { - switch (board.ID) { + switch ("" + board) { case 'a': case 'co': case 'jp': @@ -1082,7 +1523,7 @@ to: function(data) { var board, url; board = data.board; - switch (board.ID) { + switch ("" + board) { case 'a': case 'co': case 'jp': @@ -1153,7 +1594,7 @@ } } board = data.board, threadID = data.threadID, postID = data.postID; - if (postID) { + if (typeof postID === 'string') { postID = postID.match(/\d+/)[0]; } path = threadID ? "" + board + "/thread/" + threadID : "" + board + "/post/" + postID; @@ -3052,6 +3493,41 @@ } catch (err) { $.log(err, 'Recursive'); } + if (Conf['Menu']) { + try { + Menu.init(); + } catch (err) { + $.log(err, 'Menu'); + } + if (Conf['Report Link']) { + try { + ReportLink.init(); + } catch (err) { + $.log(err, 'Report Link', err.stack); + } + } + if (Conf['Delete Link']) { + try { + DeleteLink.init(); + } catch (err) { + $.log(err, 'Delete Link'); + } + } + if (Conf['Download Link']) { + try { + DownloadLink.init(); + } catch (err) { + $.log(err, 'Download Link'); + } + } + if (Conf['Archive Link']) { + try { + ArchiveLink.init(); + } catch (err) { + $.log(err, 'Archive Link'); + } + } + } if (Conf['Quote Inline']) { try { QuoteInline.init(); @@ -3205,7 +3681,7 @@ settings: function() { return alert('Here be settings'); }, - css: "/* general */\n.dialog.reply {\n display: block;\n border: 1px solid rgba(0, 0, 0, .25);\n padding: 0;\n}\n.move {\n cursor: move;\n}\nlabel {\n cursor: pointer;\n}\na[href=\"javascript:;\"] {\n text-decoration: none;\n}\n.warning {\n color: red;\n}\n\n/* 4chan style fixes */\n.opContainer, .op {\n display: block !important;\n}\n.post {\n overflow: visible !important;\n}\n[hidden] {\n display: none !important;\n}\n\n/* fixed, z-index */\n#qp, #ihover,\n#updater, #stats,\n#boardNavDesktop.reply,\n#qr, #watcher {\n position: fixed;\n}\n#qp, #ihover {\n z-index: 100;\n}\n#updater, #stats {\n z-index: 90;\n}\n#boardNavDesktop.reply:hover {\n z-index: 80;\n}\n#qr {\n z-index: 50;\n}\n#watcher {\n z-index: 30;\n}\n#boardNavDesktop.reply {\n z-index: 10;\n}\n\n\n/* header */\nbody.fourchan_x {\n margin-top: 2.5em;\n}\n#boardNavDesktop.reply {\n border-width: 0 0 1px;\n padding: 4px;\n top: 0;\n right: 0;\n left: 0;\n transition: opacity .1s ease-in-out;\n -o-transition: opacity .1s ease-in-out;\n -moz-transition: opacity .1s ease-in-out;\n -webkit-transition: opacity .1s ease-in-out;\n}\n#boardNavDesktop.reply:not(:hover) {\n opacity: .4;\n transition: opacity 1.5s .5s ease-in-out;\n -o-transition: opacity 1.5s .5s ease-in-out;\n -moz-transition: opacity 1.5s .5s ease-in-out;\n -webkit-transition: opacity 1.5s .5s ease-in-out;\n}\n#boardNavDesktop.reply a {\n margin: -1px;\n}\n#settings {\n float: right;\n}\n\n/* thread updater */\n#updater {\n text-align: right;\n}\n#updater:not(:hover) {\n background: none;\n border: none;\n}\n#updater input[type=number] {\n width: 4em;\n}\n#updater:not(:hover) > div:not(.move) {\n display: none;\n}\n.new {\n color: limegreen;\n}\n\n/* quote */\n.quotelink.deadlink {\n text-decoration: underline !important;\n}\n.deadlink:not(.quotelink) {\n text-decoration: none !important;\n}\n.inlined {\n opacity: .5;\n}\n#qp input, .forwarded {\n display: none;\n}\n.quotelink.forwardlink,\n.backlink.forwardlink {\n text-decoration: none;\n border-bottom: 1px dashed;\n}\n.filtered {\n text-decoration: underline line-through;\n}\n.inline {\n border: 1px solid rgba(128, 128, 128, .5);\n display: table;\n margin: 2px 0;\n}\n.inline .post {\n border: 0 !important;\n display: table !important;\n margin: 0 !important;\n padding: 1px 2px !important;\n}\n#qp {\n padding: 2px 2px 5px;\n}\n#qp .post {\n border: none;\n margin: 0;\n padding: 0;\n}\n#qp img {\n max-height: 300px;\n max-width: 500px;\n}\n.qphl {\n box-shadow: 0 0 0 2px rgba(216, 94, 49, .7);\n}\n\n/* file */\n.fileText:hover .fntrunc,\n.fileText:not(:hover) .fnfull {\n display: none;\n}\n#ihover {\n box-sizing: border-box;\n -moz-box-sizing: border-box;\n max-height: 100%;\n max-width: 75%;\n padding-bottom: 16px;\n}\n\n/* thread & reply hiding */\n.hide-thread-button,\n.hide-reply-button {\n float: left;\n margin-right: 2px;\n}\n.stub ~ .sideArrows,\n.stub ~ .hide-reply-button,\n.stub ~ .post {\n display: none !important;\n}" + css: "/* general */\n.dialog.reply {\n display: block;\n border: 1px solid rgba(0, 0, 0, .25);\n padding: 0;\n}\n.move {\n cursor: move;\n}\nlabel {\n cursor: pointer;\n}\na[href=\"javascript:;\"] {\n text-decoration: none;\n}\n.warning {\n color: red;\n}\n\n/* 4chan style fixes */\n.opContainer, .op {\n display: block !important;\n}\n.post {\n overflow: visible !important;\n}\n[hidden] {\n display: none !important;\n}\n\n/* fixed, z-index */\n#qp, #ihover,\n#updater, #stats,\n#boardNavDesktop.reply,\n#qr, #watcher {\n position: fixed;\n}\n#qp, #ihover {\n z-index: 100;\n}\n#updater, #stats {\n z-index: 90;\n}\n#boardNavDesktop.reply:hover {\n z-index: 80;\n}\n#qr {\n z-index: 50;\n}\n#watcher {\n z-index: 30;\n}\n#boardNavDesktop.reply {\n z-index: 10;\n}\n\n\n/* header */\nbody.fourchan_x {\n margin-top: 2.5em;\n}\n#boardNavDesktop.reply {\n border-width: 0 0 1px;\n padding: 4px;\n top: 0;\n right: 0;\n left: 0;\n transition: opacity .1s ease-in-out;\n -o-transition: opacity .1s ease-in-out;\n -moz-transition: opacity .1s ease-in-out;\n -webkit-transition: opacity .1s ease-in-out;\n}\n#boardNavDesktop.reply:not(:hover) {\n opacity: .4;\n transition: opacity 1.5s .5s ease-in-out;\n -o-transition: opacity 1.5s .5s ease-in-out;\n -moz-transition: opacity 1.5s .5s ease-in-out;\n -webkit-transition: opacity 1.5s .5s ease-in-out;\n}\n#boardNavDesktop.reply a {\n margin: -1px;\n}\n#settings {\n float: right;\n}\n\n/* thread updater */\n#updater {\n text-align: right;\n}\n#updater:not(:hover) {\n background: none;\n border: none;\n}\n#updater input[type=number] {\n width: 4em;\n}\n#updater:not(:hover) > div:not(.move) {\n display: none;\n}\n.new {\n color: limegreen;\n}\n\n/* quote */\n.quotelink.deadlink {\n text-decoration: underline !important;\n}\n.deadlink:not(.quotelink) {\n text-decoration: none !important;\n}\n.inlined {\n opacity: .5;\n}\n#qp input, .forwarded {\n display: none;\n}\n.quotelink.forwardlink,\n.backlink.forwardlink {\n text-decoration: none;\n border-bottom: 1px dashed;\n}\n.filtered {\n text-decoration: underline line-through;\n}\n.inline {\n border: 1px solid rgba(128, 128, 128, .5);\n display: table;\n margin: 2px 0;\n}\n.inline .post {\n border: 0 !important;\n display: table !important;\n margin: 0 !important;\n padding: 1px 2px !important;\n}\n#qp {\n padding: 2px 2px 5px;\n}\n#qp .post {\n border: none;\n margin: 0;\n padding: 0;\n}\n#qp img {\n max-height: 300px;\n max-width: 500px;\n}\n.qphl {\n box-shadow: 0 0 0 2px rgba(216, 94, 49, .7);\n}\n\n/* file */\n.fileText:hover .fntrunc,\n.fileText:not(:hover) .fnfull {\n display: none;\n}\n#ihover {\n box-sizing: border-box;\n -moz-box-sizing: border-box;\n max-height: 100%;\n max-width: 75%;\n padding-bottom: 16px;\n}\n\n/* thread & reply hiding */\n.hide-thread-button,\n.hide-reply-button {\n float: left;\n margin-right: 2px;\n}\n.stub ~ .sideArrows,\n.stub ~ .hide-reply-button,\n.stub ~ .post {\n display: none !important;\n}\n\n/* Menu */\n.menu-button {\n display: inline-block;\n}\n.menu-button > span {\n border-top: 6px solid;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n display: inline-block;\n margin: 2px;\n vertical-align: middle;\n}\n#menu {\n position: absolute;\n outline: none;\n}\n.entry {\n border-bottom: 1px solid rgba(0, 0, 0, .25);\n cursor: pointer;\n display: block;\n outline: none;\n padding: 3px 7px;\n position: relative;\n text-decoration: none;\n white-space: nowrap;\n}\n.entry:last-child {\n border: none;\n}\n.focused.entry {\n background: rgba(255, 255, 255, .33);\n}\n.entry.has-submenu {\n padding-right: 20px;\n}\n.has-submenu::after {\n content: \"\";\n border-left: 6px solid;\n border-top: 4px solid transparent;\n border-bottom: 4px solid transparent;\n display: inline-block;\n margin: 4px;\n position: absolute;\n right: 3px;\n}\n.has-submenu:not(.focused) > .submenu {\n display: none;\n}\n.submenu {\n position: absolute;\n margin: -1px 0;\n}" }; Main.init(); diff --git a/css/style.css b/css/style.css index 794a87845..5bbad357e 100644 --- a/css/style.css +++ b/css/style.css @@ -174,3 +174,56 @@ body.fourchan_x { .stub ~ .post { display: none !important; } + +/* Menu */ +.menu-button { + display: inline-block; +} +.menu-button > span { + border-top: 6px solid; + border-right: 4px solid transparent; + border-left: 4px 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.has-submenu { + padding-right: 20px; +} +.has-submenu::after { + content: ""; + border-left: 6px solid; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + display: inline-block; + margin: 4px; + position: absolute; + right: 3px; +} +.has-submenu:not(.focused) > .submenu { + display: none; +} +.submenu { + position: absolute; + margin: -1px 0; +} diff --git a/src/features.coffee b/src/features.coffee index f76a7bb3e..46ea4c976 100644 --- a/src/features.coffee +++ b/src/features.coffee @@ -244,11 +244,386 @@ Recursive = break return +Menu = + entries: [] + init: -> + # Doc here: https://github.com/MayhemYDG/4chan-x/wiki/Menu-API + $.on d, 'AddMenuEntry', (e) -> Menu.addEntry e.detail + Post::callbacks.push + name: 'Menu' + cb: @node + + node: -> + if @isClone + a = $ '.menu-button', @nodes.info + a.setAttribute 'data-clone', true + $.on a, 'click', Menu.toggle + return + a = Menu.makeButton @ + $.add @nodes.info, [$.tn('\u00A0'), a] + + makeButton: (post) -> + a = $.el 'a', + className: 'menu-button' + innerHTML: '[]' + href: 'javascript:;' + a.setAttribute 'data-postid', post.fullID + $.on a, 'click', Menu.toggle + a + + makeMenu: -> + menu = $.el 'div', + className: 'reply dialog' + id: 'menu' + tabIndex: 0 + $.on menu, 'click', (e) -> e.stopPropagation() + $.on menu, 'keydown', Menu.keybinds + menu + + toggle: (e) -> + e.preventDefault() + e.stopPropagation() + + if Menu.currentMenu + # Close if it's already opened. + # Reopen if we clicked on another button. + {lastToggledButton} = Menu + Menu.close() + return if lastToggledButton is @ + + Menu.lastToggledButton = @ + post = + if @dataset.clone + Get.postFromRoot $.x 'ancestor::div[contains(@class,"postContainer")][1]', @ + else + g.posts[@dataset.postid] + Menu.open @, post + + open: (button, post) -> + menu = Menu.makeMenu() + Menu.currentMenu = menu + + for entry in Menu.entries + Menu.insertEntry entry, menu, post + + Menu.focus $ '.entry', menu + $.on d, 'click', Menu.close + $.add d.body, menu + + # Position + mRect = menu.getBoundingClientRect() + bRect = button.getBoundingClientRect() + bTop = d.documentElement.scrollTop + d.body.scrollTop + bRect.top + bLeft = d.documentElement.scrollLeft + d.body.scrollLeft + bRect.left + menu.style.top = + if bRect.top + bRect.height + mRect.height < d.documentElement.clientHeight + bTop + bRect.height + 2 + 'px' + else + bTop - mRect.height - 2 + 'px' + menu.style.left = + if bRect.left + mRect.width < d.documentElement.clientWidth + bLeft + 'px' + else + bLeft + bRect.width - mRect.width + 'px' + + menu.focus() + + insertEntry: (entry, parent, post) -> + return unless entry.open post + $.add parent, entry.el + + return unless entry.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 entry.children + Menu.insertEntry child, submenu, post + return + + close: -> + $.rm Menu.currentMenu + delete Menu.currentMenu + delete Menu.lastToggledButton + $.off d, 'click', Menu.close + + keybinds: (e) -> + entry = $ '.focused', Menu.currentMenu + while subEntry = $ '.focused', entry + entry = subEntry + + switch Keybinds.keyCode(e) or e.keyCode + when 'Esc' + Menu.lastToggledButton.focus() + Menu.close() + when 13, 32 # 'Enter', 'Space' + entry.click() + when 'Up' + if next = entry.previousElementSibling + Menu.focus next + when 'Down' + if next = entry.nextElementSibling + Menu.focus next + when 'Right' + if (submenu = $ '.submenu', entry) and next = submenu.firstElementChild + Menu.focus next + when 'Left' + if next = $.x 'parent::*[contains(@class,"submenu")]/parent::*', entry + Menu.focus next + else + return + + e.preventDefault() + e.stopPropagation() + + focus: (entry) -> + if focused = $.x 'parent::*/child::*[contains(@class,"focused")]', entry + $.rmClass focused, 'focused' + for focused in $$ '.focused', entry + $.rmClass focused, 'focused' + $.addClass entry, 'focused' + + # Submenu positioning. + return unless submenu = $ '.submenu', entry + sRect = submenu.getBoundingClientRect() + eRect = entry.getBoundingClientRect() + if eRect.top + sRect.height < d.documentElement.clientHeight + top = '0px' + bottom = 'auto' + else + top = 'auto' + bottom = '0px' + if eRect.right + sRect.width < d.documentElement.clientWidth + left = '100%' + right = 'auto' + else + left = 'auto' + right = '100%' + {style} = submenu + style.top = top + style.bottom = bottom + style.left = left + style.right = right + + addEntry: (entry) -> + Menu.parseEntry entry + Menu.entries.push entry + + parseEntry: (entry) -> + {el, children} = entry + $.addClass el, 'entry' + $.on el, 'focus mouseover', (e) -> + e.stopPropagation() + Menu.focus @ + return unless children + $.addClass el, 'has-submenu' + for child in children + Menu.parseEntry child + return + +ReportLink = + init: -> + a = $.el 'a', + className: 'report-link' + href: 'javascript:;' + textContent: 'Report this post' + $.on a, 'click', ReportLink.report + Menu.addEntry + el: a + open: (post) -> + ReportLink.post = post + !post.isDead + report: -> + {post} = ReportLink + url = "//sys.4chan.org/#{post.board}/imgboard.php?mode=report&no=#{post}" + 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 + +DeleteLink = + init: -> + div = $.el 'div', + className: 'delete-link' + textContent: 'Delete' + postEl = $.el 'a', + className: 'delete-post' + href: 'javascript:;' + fileEl = $.el 'a', + className: 'delete-file' + href: 'javascript:;' + + postEntry = + el: postEl + open: -> + postEl.textContent = 'Post' + $.on postEl, 'click', DeleteLink.delete + true + fileEntry = + el: fileEl + open: (post) -> + fileEl.textContent = 'File' + $.on fileEl, 'click', DeleteLink.delete + !!post.file + + Menu.addEntry + el: div + open: (post) -> + return false if post.isDead + DeleteLink.post = post + node = div.firstChild + if seconds = DeleteLink.cooldown[post.fullID] + node.textContent = "Delete (#{seconds})" + DeleteLink.cooldown.el = node + else + node.textContent = 'Delete' + delete DeleteLink.cooldown.el + true + children: [postEntry, fileEntry] + + $.on d, 'QRPostSuccessful', @cooldown.start + + delete: -> + {post} = DeleteLink + return if DeleteLink.cooldown[post.fullID] + + $.off @, 'click', DeleteLink.delete + @textContent = "Deleting #{@textContent}..." + + pwd = + if m = d.cookie.match /4chan_pass=([^;]+)/ + decodeURIComponent m[1] + else + $.id('delPassword').value + + form = + mode: 'usrdel' + onlyimgdel: $.hasClass @, 'delete-file' + pwd: pwd + form[post.ID] = 'delete' + + link = @ + $.ajax $.id('delform').action.replace("/#{g.BOARD}/", "/#{post.board}/"), { + onload: -> DeleteLink.load link, @response + onerror: -> DeleteLink.error link + }, { + form: $.formData form + } + load: (link, html) -> + doc = d.implementation.createHTMLDocument '' + doc.documentElement.innerHTML = html + if doc.title is '4chan - Banned' # Ban/warn check + s = 'Banned!' + else if msg = doc.getElementById 'errmsg' # error! + s = msg.textContent + $.on link, 'click', DeleteLink.delete + else + s = 'Deleted' + link.textContent = s + error: (link) -> + link.textContent = 'Connection error, please retry.' + $.on link, 'click', DeleteLink.delete + + cooldown: + start: (e) -> + seconds = + if g.BOARD.ID is 'q' + 600 + else + 30 + fullID = "#{g.BOARD}.#{e.detail.postID}" + DeleteLink.cooldown.count fullID, seconds, seconds + count: (fullID, seconds, length) -> + return unless 0 <= seconds <= length + setTimeout DeleteLink.cooldown.count, 1000, fullID, seconds-1, length + {el} = DeleteLink.cooldown + if seconds is 0 + el?.textContent = 'Delete' + delete DeleteLink.cooldown[fullID] + delete DeleteLink.cooldown.el + return + el?.textContent = "Delete (#{seconds})" + DeleteLink.cooldown[fullID] = seconds + +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) -> + return false unless post.file + a.href = post.file.URL + a.download = post.file.name + true + +ArchiveLink = + init: -> + div = $.el 'div', + textContent: 'Archive' + + entry = + el: div + open: (post) -> + redirect = Redirect.to + board: post.board + threadID: post.thread + postID: post.ID + redirect isnt "//boards.4chan.org/#{post.board}/" + children: [] + + for type in [ + ['Post', 'post'] + ['Name', 'name'] + ['Tripcode', 'tripcode'] + ['E-mail', 'email'] + ['Subject', 'subject'] + ['Filename', 'filename'] + ['Image MD5', 'md5'] + ] + # Add a sub entry for each type. + entry.children.push @createSubEntry type[0], type[1] + + Menu.addEntry entry + + createSubEntry: (text, type) -> + el = $.el 'a', + textContent: text + target: '_blank' + + if type is 'post' + open = (post) -> + el.href = Redirect.to + board: post.board + threadID: post.thread + postID: post.ID + true + else + open = (post) -> + # value = Filter[type] post + # # We want to parse the exact same stuff as the filter does already. + # return false unless value + # el.href = Redirect.to + # board: post.board + # type: type + # value: value + # isSearch: true + true + + return { + el: el + open: open + } + Redirect = image: (board, filename) -> # XXX need to differentiate between thumbnail only and full_image for img src= # Do not use g.BOARD, the image url can originate from a cross-quote. - switch board.ID + switch "#{board}" when 'a', 'co', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'wsg' "//archive.foolz.us/#{board}/full_image/#{filename}" when 'u' @@ -266,7 +641,7 @@ Redirect = when 'c' "//archive.nyafuu.org/#{board}/full_image/#{filename}" post: (board, postID) -> - switch board.ID + switch "#{board}" when 'a', 'co', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'wsg', 'dev', 'foolz' "//archive.foolz.us/_/api/chan/post/?board=#{board}&num=#{postID}" when 'u', 'kuku' @@ -277,7 +652,7 @@ Redirect = # https://github.com/eksopl/fuuka/issues/27 to: (data) -> {board} = data - switch board.ID + switch "#{board}" when 'a', 'co', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'wsg', 'dev', 'foolz' url = Redirect.path '//archive.foolz.us', 'foolfuuka', data when 'u', 'kuku' @@ -318,7 +693,7 @@ Redirect = {board, threadID, postID} = data # keep the number only if the location.hash was sent f.e. - postID = postID.match(/\d+/)[0] if postID + postID = postID.match(/\d+/)[0] if typeof postID is 'string' path = if threadID "#{board}/thread/#{threadID}" diff --git a/src/main.coffee b/src/main.coffee index e37b60a64..5847b18cb 100644 --- a/src/main.coffee +++ b/src/main.coffee @@ -342,6 +342,41 @@ Main = # XXX handle error $.log err, 'Recursive' + if Conf['Menu'] + try + Menu.init() + catch err + # XXX handle error + $.log err, 'Menu' + + if Conf['Report Link'] + try + ReportLink.init() + catch err + # XXX handle error + $.log err, 'Report Link', err.stack + + if Conf['Delete Link'] + try + DeleteLink.init() + catch err + # XXX handle error + $.log err, 'Delete Link' + + if Conf['Download Link'] + try + DownloadLink.init() + catch err + # XXX handle error + $.log err, 'Download Link' + + if Conf['Archive Link'] + try + ArchiveLink.init() + catch err + # XXX handle error + $.log err, 'Archive Link' + if Conf['Quote Inline'] try QuoteInline.init()