Merge pull request #1329 from MayhemYDG/index

Index navigation improvements - EP02
This commit is contained in:
Mayhem 2013-11-16 07:02:03 -08:00
commit aa5bfce32f
16 changed files with 280 additions and 95 deletions

View File

@ -1,3 +1,22 @@
- More index navigation improvements:
- Searching in the index is now possible and will show matched OPs by:
<ul>
<li> comment
<li> subject
<li> filename
<li> name
<li> tripcode
<li> e-mail
</ul>
- The page number on which threads are will now be displayed in OPs, to easily identify where threads are located when:
<ul>
<li> searching through the index.
<li> using different index sorting types.
<li> threads highlighted by the filter are moved to the top and move other threads down.
<ul>
- The elapsed time since the last index refresh is now indicated at the top of the index.
- New setting: `Show replies`, enabled by default. Disable it to only show OPs in the index.
### 3.12.1 - *2013-11-04*
- The index refreshing notification will now only appear on initial page load with slow connections.

View File

@ -32,6 +32,9 @@
background-color: #F2F2F2;
color: #888;
}
.field::-webkit-search-decoration {
display: none;
}
.move {
cursor: move;
}
@ -220,7 +223,7 @@ a[href="javascript:;"] {
color: white;
}
.notification > .close {
padding: 6px;
padding: 7px;
top: 0;
right: 0;
position: absolute;
@ -270,7 +273,7 @@ a[href="javascript:;"] {
}
#fourchanx-settings > nav a.close {
text-decoration: none;
padding: 2px;
padding: 0 2px;
}
.sections-list {
flex: 1;
@ -363,10 +366,37 @@ a[href="javascript:;"] {
}
/* Index */
:root.index-loading .navLinks,
:root.index-loading .board,
:root.index-loading .pagelist {
display: none;
}
#index-search {
padding-right: 1.5em;
width: 100px;
transition: color .25s, border-color .25s, width .25s;
}
#index-search:focus,
#index-search[data-searching] {
width: 200px;
}
#index-search-clear {
color: gray;
margin-left: -1.25em;
}
<% if (type === 'crx') { %>
/* ``::-webkit-*'' selectors break selector lists on Firefox. */
#index-search::-webkit-search-cancel-button,
<% } %>
#index-search:not([data-searching]) + #index-search-clear {
display: none;
}
.page-num {
font-family: inherit;
}
.page-num::before {
font-family: FontAwesome;
}
.summary {
text-decoration: none;
}
@ -720,13 +750,13 @@ a.hide-announcement {
border-style: dashed;
opacity: 1;
}
.remove {
a.remove {
color: #E00 !important;
font-weight: 700;
padding: 3px;
padding: 1px;
}
.remove:hover::after {
content: ' Remove';
font-weight: 700;
}
.qr-preview > label {
background: rgba(0, 0, 0, .5);
@ -741,11 +771,9 @@ a.hide-announcement {
vertical-align: bottom;
}
#add-post {
display: inline-block;
font-size: 30px;
height: 30px;
width: 30px;
line-height: 1;
font-size: 20px;
height: 20px;
width: 20px;
text-align: center;
position: absolute;
right: 0;
@ -818,7 +846,6 @@ a.hide-announcement {
#qr-no-file,
#qr-filename,
#qr-filesize,
#qr-filerm,
#qr-file-spoiler {
margin: 0 2px !important;
}

View File

@ -0,0 +1,4 @@
[<a href="./catalog">Catalog</a>]&nbsp;
[<time id="index-last-refresh" title="Last index refresh" data-init="1">...</time>]&nbsp;
<input type="search" id="index-search" class="field" placeholder="Search">
<a id="index-search-clear" class="fa fa-times-circle" href="javascript:;"></a>

View File

@ -2,10 +2,13 @@
<nav>
<div class="sections-list"></div>
<div class="credits">
<a href="<%= meta.page %>" target="_blank"><%= meta.name %></a> |
<a href="<%= meta.repo %>blob/<%= meta.mainBranch %>/CHANGELOG.md" target="_blank">#{g.VERSION}</a> |
<a href="<%= meta.repo %>blob/<%= meta.mainBranch %>/CONTRIBUTING.md#reporting-bugs-and-suggestions" target="_blank">Issues</a> |
<a href="javascript:;" class="close" title="Close">×</a>
<a href="<%= meta.page %>" target="_blank"><%= meta.name %></a>
&nbsp;|&nbsp;
<a href="<%= meta.repo %>blob/<%= meta.mainBranch %>/CHANGELOG.md" target="_blank">#{g.VERSION}</a>
&nbsp;|&nbsp;
<a href="<%= meta.repo %>blob/<%= meta.mainBranch %>/CONTRIBUTING.md#reporting-bugs-and-suggestions" target="_blank">Issues</a>
&nbsp;|&nbsp;
<a href="javascript:;" class="close fa fa-times" title="Close"></a>
</div>
</nav>
<hr>

View File

@ -4,7 +4,7 @@
<option value="new">New thread</option>
</select>
<span class="move"></span>
<a href="javascript:;" class="close" title="Close">×</a>
<a href="javascript:;" class="close fa fa-times" title="Close"></a>
</div>
<form>
<div class="persona">
@ -15,7 +15,7 @@
</div>
<div id="dump-list-container">
<div id="dump-list"></div>
<a id="add-post" href="javascript:;" title="Add a post">+</a>
<a href="javascript:;" id="add-post" class="fa fa-plus" title="Add a post"></a>
</div>
<div class="textarea">
<textarea data-name="com" placeholder="Comment" class="field"></textarea>
@ -29,7 +29,7 @@
<span id="qr-no-file">No selected file</span>
<input id="qr-filename" data-name="filename" spellcheck="false">
<span id="qr-filesize"></span>
<a id="qr-filerm" href="javascript:;" title="Remove file">×</a>
<a href="javascript:;" id="qr-filerm" class="fa fa-times" title="Remove file"></a>
<input type="checkbox" id="qr-file-spoiler" title="Spoiler image">
</div>
</div>

View File

@ -190,10 +190,12 @@ Build =
else
''
replyLink = if isOP and g.VIEW is 'index'
" &nbsp; <span>[<a href='/#{boardID}/res/#{threadID}' class=replylink>Reply</a>]</span>"
if isOP and g.VIEW is 'index'
pageNum = Math.floor Index.liveThreadIDs.indexOf(postID) / Index.threadsNumPerPage
pageIcon = "<i class='page-num fa fa-file-o' title='This thread is on page #{pageNum} in the original index.'> #{pageNum}</i> "
replyLink = " &nbsp; <span>[<a href='/#{boardID}/res/#{threadID}' class=replylink>Reply</a>]</span>"
else
''
pageIcon = replyLink = ''
container = $.el 'div',
id: "pc#{postID}"
@ -226,6 +228,7 @@ Build =
(if isOP then fileHTML else '') +
"<div class='postInfo desktop' id=pi#{postID}>" +
pageIcon +
"<input type=checkbox name=#{postID} value=delete> " +
"#{subject} " +
"<span class='nameBlock#{capcodeClass}'>" +
@ -279,8 +282,13 @@ Build =
id: "t#{data.no}"
nodes = [if OP then OP.nodes.root else Build.postFromObject data, board.ID]
if data.omitted_posts
nodes.push Build.summary board.ID, data.no, data.omitted_posts, data.omitted_images
if data.omitted_posts or !Conf['Show Replies'] and data.replies
[posts, files] = if Conf['Show Replies']
[data.omitted_posts, data.omitted_images]
else
# XXX data.images is not accurate.
[data.replies, data.omitted_images + data.last_replies.filter((data) -> !!data.ext).length]
nodes.push Build.summary board.ID, data.no, posts, files
$.add root, nodes
root

View File

@ -142,6 +142,7 @@ Config =
Index:
'Index Mode': 'paged'
'Index Sort': 'bump'
'Show Replies': true
Header:
'Header auto-hide': false
'Bottom header': false
@ -162,39 +163,39 @@ Config =
usercss: ''
hotkeys:
# Header, QR & Options
'Toggle board list': ['Ctrl+b', 'Toggle the full board list.']
'Open empty QR': ['q', 'Open QR without post number inserted.']
'Open QR': ['Shift+q', 'Open QR with post number inserted.']
'Open settings': ['Alt+o', 'Open Settings.']
'Close': ['Esc', 'Close Settings, Notifications or QR.']
'Spoiler tags': ['Ctrl+s', 'Insert spoiler tags.']
'Code tags': ['Alt+c', 'Insert code tags.']
'Eqn tags': ['Alt+e', 'Insert eqn tags.']
'Math tags': ['Alt+m', 'Insert math tags.']
'Submit QR': ['Alt+s', 'Submit post.']
'Toggle board list': ['Ctrl+b', 'Toggle the full board list.']
'Open empty QR': ['q', 'Open QR without post number inserted.']
'Open QR': ['Shift+q', 'Open QR with post number inserted.']
'Open settings': ['Alt+o', 'Open Settings.']
'Close': ['Esc', 'Close Settings, Notifications or QR.']
'Spoiler tags': ['Ctrl+s', 'Insert spoiler tags.']
'Code tags': ['Alt+c', 'Insert code tags.']
'Eqn tags': ['Alt+e', 'Insert eqn tags.']
'Math tags': ['Alt+m', 'Insert math tags.']
'Submit QR': ['Alt+s', 'Submit post.']
# Index/Thread related
'Update': ['r', 'Refresh the index/thread.']
'Watch': ['w', 'Watch thread.']
'Update': ['r', 'Refresh the index/thread.']
'Watch': ['w', 'Watch thread.']
# Images
'Expand image': ['Shift+e', 'Expand selected image.']
'Expand images': ['e', 'Expand all images.']
'Expand image': ['Shift+e', 'Expand selected image.']
'Expand images': ['e', 'Expand all images.']
# Board Navigation
'Front page': ['0', 'Jump to page 0.']
'Open front page': ['Shift+0', 'Open page 0 in a new tab.']
'Next page': ['Right', 'Jump to the next page.']
'Previous page': ['Left', 'Jump to the previous page.']
'Search form': ['Ctrl+Alt+s', 'Open the search field on the board index.']
'Front page': ['0', 'Jump to page 0.']
'Open front page': ['Shift+0', 'Open page 0 in a new tab.']
'Next page': ['Right', 'Jump to the next page.']
'Previous page': ['Left', 'Jump to the previous page.']
'Search form': ['Ctrl+Alt+s', 'Focus the search field on the board index.']
# Thread Navigation
'Next thread': ['Down', 'See next thread.']
'Previous thread': ['Up', 'See previous thread.']
'Expand thread': ['Ctrl+e', 'Expand thread.']
'Open thread': ['o', 'Open thread in current tab.']
'Open thread tab': ['Shift+o', 'Open thread in new tab.']
'Next thread': ['Down', 'See next thread.']
'Previous thread': ['Up', 'See previous thread.']
'Expand thread': ['Ctrl+e', 'Expand thread.']
'Open thread': ['o', 'Open thread in current tab.']
'Open thread tab': ['Shift+o', 'Open thread in new tab.']
# Reply Navigation
'Next reply': ['j', 'Select next reply.']
'Previous reply': ['k', 'Select previous reply.']
'Deselect reply': ['Shift+d', 'Deselect reply.']
'Hide': ['x', 'Hide thread.']
'Next reply': ['j', 'Select next reply.']
'Previous reply': ['k', 'Select previous reply.']
'Deselect reply': ['Shift+d', 'Deselect reply.']
'Hide': ['x', 'Hide thread.']
updater:
checkbox:
'Beep': [false, 'Beep on new post to completely read thread.']

View File

@ -36,12 +36,19 @@ Index =
$.on input, 'change', $.cb.value
$.on input, 'change', @cb.sort
repliesEntry =
el: $.el 'label', innerHTML: '<input type=checkbox name="Show Replies"> Show replies'
input = repliesEntry.el.firstChild
input.checked = Conf['Show Replies']
$.on input, 'change', $.cb.checked
$.on input, 'change', @cb.replies
$.event 'AddMenuEntry',
type: 'header'
el: $.el 'span',
textContent: 'Index Navigation'
order: 90
subEntries: [modeEntry, sortEntry]
subEntries: [modeEntry, sortEntry, repliesEntry]
$.addClass doc, 'index-loading'
@update()
@ -50,13 +57,33 @@ Index =
className: 'pagelist'
hidden: true
innerHTML: <%= importHTML('General/Index-pagelist') %>
@navLinks = $.el 'div',
className: 'navLinks'
innerHTML: <%= importHTML('General/Index-navlinks') %>
@searchInput = $ '#index-search', @navLinks
@currentPage = @getCurrentPage()
$.on window, 'popstate', @cb.popstate
$.on @pagelist, 'click', @cb.pageNav
$.asap (-> $('.pagelist', doc) or d.readyState isnt 'loading'), ->
$.replace $('.board'), Index.root
$.replace $('.pagelist'), Index.pagelist
$.on @searchInput, 'input', @onSearchInput
$.on $('#index-search-clear', @navLinks), 'click', @clearSearch
$.asap (-> $('.board', doc) or d.readyState isnt 'loading'), ->
board = $ '.board'
$.replace board, Index.root
# Hacks:
# - When removing an element from the document during page load,
# its ancestors will still be correctly created inside of it.
# - Creating loadable elements inside of an origin-less document
# will not download them.
# - Combine the two and you get a download canceller!
# Does not work on Firefox unfortunately.
d.implementation.createDocument(null, null, null).appendChild board
for navLink in $$ '.navLinks'
$.rm navLink
$.after $.x('child::form/preceding-sibling::hr[1]'), Index.navLinks
$.rmClass doc, 'index-loading'
$.asap (-> $('.pagelist') or d.readyState isnt 'loading'), ->
$.replace $('.pagelist'), Index.pagelist
cb:
mode: ->
@ -65,6 +92,10 @@ Index =
sort: ->
Index.sort()
Index.buildIndex()
replies: ->
Index.buildThreads()
Index.sort()
Index.buildIndex()
popstate: (e) ->
pageNum = Index.getCurrentPage()
Index.pageLoad pageNum if Index.currentPage isnt pageNum
@ -97,31 +128,39 @@ Index =
Index.setPage()
Index.scrollToIndex()
getPagesNum: ->
if Index.isSearching
Math.ceil (Index.sortedNodes.length / 2) / Index.threadsNumPerPage
else
Index.pagesNum
getMaxPageNum: ->
Math.max 0, Index.getPagesNum() - 1
togglePagelist: ->
Index.pagelist.hidden = Conf['Index Mode'] isnt 'paged'
buildPagelist: ->
pagesRoot = $ '.pages', Index.pagelist
if pagesRoot.childElementCount isnt Index.pagesNum
maxPageNum = Index.getMaxPageNum()
if pagesRoot.childElementCount isnt maxPageNum + 1
nodes = []
for i in [0..Index.pagesNum - 1]
for i in [0..maxPageNum] by 1
a = $.el 'a',
textContent: i
href: if i then i else './'
nodes.push $.tn('['), a, $.tn '] '
$.rmAll pagesRoot
$.add pagesRoot, nodes
Index.setPage()
Index.togglePagelist()
setPage: ->
pageNum = Index.getCurrentPage()
pagesRoot = $ '.pages', Index.pagelist
pageNum = Index.getCurrentPage()
maxPageNum = Index.getMaxPageNum()
pagesRoot = $ '.pages', Index.pagelist
# Previous/Next buttons
prev = pagesRoot.previousSibling.firstChild
next = pagesRoot.nextSibling.firstChild
href = Math.max pageNum - 1, 0
prev.href = if href is 0 then './' else href
prev.firstChild.disabled = href is pageNum
href = Math.min pageNum + 1, Index.pagesNum - 1
href = Math.min pageNum + 1, maxPageNum
next.href = if href is 0 then './' else href
next.firstChild.disabled = href is pageNum
# <strong> current page
@ -184,6 +223,18 @@ Index =
notice.el.lastElementChild.textContent = 'Index refreshed!'
setTimeout notice.close, $.SECOND
timeEl = $ '#index-last-refresh', Index.navLinks
timeEl.dataset.utc = e.timeStamp <% if (type === 'userscript') { %>/ 1000<% } %>
if timeEl.dataset.init
RelativeDates.setUpdate el: timeEl
<% if (type === 'userscript') { %>
# XXX https://github.com/greasemonkey/greasemonkey/issues/1571
timeEl.removeAttribute 'data-init'
<% } else { %>
delete timeEl.dataset.init
<% } %>
else
RelativeDates.flush()
Index.scrollToIndex()
parse: (pages) ->
Index.parseThreadList pages
@ -191,6 +242,7 @@ Index =
Index.sort()
Index.buildIndex()
Index.buildPagelist()
Index.setPage()
parseThreadList: (pages) ->
Index.pagesNum = pages.length
Index.threadsNumPerPage = pages[0].threads.length
@ -203,10 +255,11 @@ Index =
Index.nodes = []
threads = []
posts = []
for threadData in Index.liveThreadData
for threadData, i in Index.liveThreadData
threadRoot = Build.thread g.BOARD, threadData
Index.nodes.push threadRoot, $.el 'hr'
if thread = g.BOARD.threads[threadData.no]
thread.setPage Math.floor i / Index.threadsNumPerPage
thread.setStatus 'Sticky', !!threadData.sticky
thread.setStatus 'Closed', !!threadData.closed
else
@ -276,13 +329,14 @@ Index =
offset = 0
for threadRoot, i in Index.sortedNodes by 2 when Get.threadFromRoot(threadRoot).isSticky
Index.sortedNodes.splice offset++ * 2, 0, Index.sortedNodes.splice(i, 2)...
return unless Conf['Filter']
# Put the highlighted thread & <hr> on top of the index
# while keeping the original order they appear in.
offset = 0
for threadRoot, i in Index.sortedNodes by 2 when Get.threadFromRoot(threadRoot).isOnTop
Index.sortedNodes.splice offset++ * 2, 0, Index.sortedNodes.splice(i, 2)...
return
if Conf['Filter']
# Put the highlighted thread & <hr> on top of the index
# while keeping the original order they appear in.
offset = 0
for threadRoot, i in Index.sortedNodes by 2 when Get.threadFromRoot(threadRoot).isOnTop
Index.sortedNodes.splice offset++ * 2, 0, Index.sortedNodes.splice(i, 2)...
if Index.isSearching
Index.sortedNodes = Index.querySearch(Index.searchInput.value) or Index.sortedNodes
buildIndex: ->
if Conf['Index Mode'] is 'paged'
pageNum = Index.getCurrentPage()
@ -291,6 +345,57 @@ Index =
else
nodes = Index.sortedNodes
$.rmAll Index.root
Index.buildReplies nodes
Index.buildReplies nodes if Conf['Show Replies']
$.event 'IndexBuild', nodes
$.add Index.root, nodes
isSearching: false
clearSearch: ->
Index.searchInput.value = null
Index.onSearchInput()
Index.searchInput.focus()
onSearchInput: ->
if Index.isSearching = !!Index.searchInput.value.trim()
unless Index.searchInput.dataset.searching
Index.searchInput.dataset.searching = 1
Index.pageBeforeSearch = Index.getCurrentPage()
pageNum = 0
else
pageNum = Index.getCurrentPage()
else
pageNum = Index.pageBeforeSearch
delete Index.pageBeforeSearch
<% if (type === 'userscript') { %>
# XXX https://github.com/greasemonkey/greasemonkey/issues/1571
Index.searchInput.removeAttribute 'data-searching'
<% } else { %>
delete Index.searchInput.dataset.searching
<% } %>
Index.sort()
# Go to the last available page if we were past the limit.
pageNum = Math.min pageNum, Index.getMaxPageNum() if Conf['Index Mode'] is 'paged'
Index.buildPagelist()
if Index.currentPage is pageNum
Index.buildIndex()
Index.setPage()
else
Index.pageNav pageNum
querySearch: (query) ->
return unless keywords = query.toLowerCase().match /\S+/g
Index.search keywords
search: (keywords) ->
found = []
for threadRoot, i in Index.sortedNodes by 2
if Index.searchMatch Get.threadFromRoot(threadRoot), keywords
found.push Index.sortedNodes[i], Index.sortedNodes[i + 1]
found
searchMatch: (thread, keywords) ->
{info, file} = thread.OP
text = []
for key in ['comment', 'subject', 'name', 'tripcode', 'email']
text.push info[key] if key of info
text.push file.name if file
text = text.join(' ').toLowerCase()
for keyword in keywords
return false if -1 is text.indexOf keyword
return true

View File

@ -1,7 +1,7 @@
class Notice
constructor: (type, content, @timeout) ->
@el = $.el 'div',
innerHTML: '<a href=javascript:; class=close title=Close>×</a><div class=message></div>'
innerHTML: '<a href=javascript:; class="close fa fa-times" title=Close></a><div class=message></div>'
@el.style.opacity = 0
@setType type
$.on @el.firstElementChild, 'click', @close

View File

@ -49,7 +49,7 @@ class Post
@parseComment()
@parseQuotes()
@parseFile(that)
@parseFile that
@clones = []
g.posts[@fullID] = thread.posts[@] = board.posts[@] = @

View File

@ -12,6 +12,11 @@ class Thread
g.threads[@fullID] = board.threads[@] = @
setPage: (pageNum) ->
icon = $ '.page-num', @OP.nodes.post
for key in ['title', 'textContent']
icon[key] = icon[key].replace /\d+/, pageNum
return
setStatus: (type, status) ->
name = "is#{type}"
return if @[name] is status

View File

@ -50,16 +50,19 @@ ExpandThread =
a.textContent = ExpandThread.text '+', a.textContent.match(/\d+/g)... if a
return
num = if thread.isSticky
1
else switch g.BOARD.ID
# XXX boards config
when 'b', 'vg' then 3
when 't' then 1
else 5
replies = $$ '.thread > .replyContainer', threadRoot
if Conf['Show Replies']
num = if thread.isSticky
1
else switch g.BOARD.ID
# XXX boards config
when 'b', 'vg' then 3
when 't' then 1
else 5
replies = replies[...-num]
postsCount = 0
filesCount = 0
for reply in $$('.thread > .replyContainer', threadRoot)[...-num]
for reply in replies
# rm clones
inlined.click() while inlined = $ '.inlined', reply if Conf['Quote Inlining']
postsCount++

View File

@ -87,7 +87,7 @@ Keybinds =
return unless g.VIEW is 'index' and Conf['Index Mode'] is 'paged'
$('.prev button', Index.pagelist).click()
when Conf['Search form']
$.id('search-btn').click()
Index.searchInput.focus()
# Thread Navigation
when Conf['Next thread']
return if g.VIEW isnt 'index'

View File

@ -1,13 +1,17 @@
RelativeDates =
INTERVAL: $.MINUTE / 2
init: ->
return if g.VIEW is 'catalog' or !Conf['Relative Post Dates']
# Flush when page becomes visible again or when the thread updates.
$.on d, 'visibilitychange ThreadUpdate', @flush
# Start the timeout.
@flush()
switch g.VIEW
when 'index'
@flush()
$.on d, 'visibilitychange', @flush
return unless Conf['Relative Post Dates']
when 'thread'
return unless Conf['Relative Post Dates']
@flush()
$.on d, 'visibilitychange ThreadUpdate', @flush if g.VIEW is 'thread'
else
return
Post.callbacks.push
name: 'Relative Post Dates'
@ -21,7 +25,7 @@ RelativeDates =
dateEl = @nodes.date
dateEl.title = dateEl.textContent
RelativeDates.setUpdate @
RelativeDates.setUpdate post: @
# diff is milliseconds from now.
relative: (diff, now, date) ->
@ -81,7 +85,7 @@ RelativeDates =
# Create function `update()`, closed over post, that, when called
# from `flush()`, updates the elements, and re-calls `setOwnTimeout()` to
# re-add `update()` to the stale list later.
setUpdate: (post) ->
setUpdate: ({post, el}) ->
setOwnTimeout = (diff) ->
delay = if diff < $.MINUTE
$.SECOND - (diff + $.SECOND / 2) % $.SECOND
@ -94,11 +98,17 @@ RelativeDates =
setTimeout markStale, delay
update = (now) ->
{date} = post.info
date = if post
post.info.date
else
new Date +el.dataset.utc
diff = now - date
relative = RelativeDates.relative diff, now, date
for singlePost in [post].concat post.clones
singlePost.nodes.date.firstChild.textContent = relative
if post
for singlePost in [post].concat post.clones
singlePost.nodes.date.firstChild.textContent = relative
else
el.firstChild.textContent = RelativeDates.relative diff, now, date
setOwnTimeout diff
markStale = -> RelativeDates.stale.push update

View File

@ -131,7 +131,7 @@ ThreadWatcher =
makeLine: (boardID, threadID, data) ->
x = $.el 'a',
textContent: '×'
className: 'fa fa-times'
href: 'javascript:;'
$.on x, 'click', ThreadWatcher.cb.rm

View File

@ -438,7 +438,7 @@ QR =
className: 'qr-preview'
draggable: true
href: 'javascript:;'
innerHTML: '<a class=remove>×</a><label hidden><input type=checkbox> Spoiler</label><span></span>'
innerHTML: '<a class="remove fa fa-times"></a><label hidden><input type=checkbox> Spoiler</label><span></span>'
@nodes =
el: el