QR = mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/vnd.adobe.flash.movie', 'application/x-shockwave-flash', 'video/webm'] validExtension: /\.(jpe?g|png|gif|pdf|swf|webm)$/i typeFromExtension: 'jpg': 'image/jpeg' 'jpeg': 'image/jpeg' 'png': 'image/png' 'gif': 'image/gif' 'pdf': 'application/pdf' 'swf': 'application/vnd.adobe.flash.movie' 'webm': 'video/webm' extensionFromType: 'image/jpeg': 'jpg' 'image/png': 'png' 'image/gif': 'gif' 'application/pdf': 'pdf' 'application/vnd.adobe.flash.movie': 'swf' 'application/x-shockwave-flash': 'swf' 'video/webm': 'webm' init: -> return unless Conf['Quick Reply'] @posts = [] @captcha = Captcha.v2 $.on d, '4chanXInitFinished', -> BoardConfig.ready QR.initReady Callbacks.Post.push name: 'Quick Reply' cb: @node @shortcut = sc = $.el 'a', className: 'fa fa-comment-o disabled' textContent: 'QR' title: 'Quick Reply' href: 'javascript:;' $.on sc, 'click', -> return unless QR.postingIsEnabled if Conf['Persistent QR'] or !QR.nodes or QR.nodes.el.hidden QR.open() QR.nodes.com.focus() else QR.close() Header.addShortcut 'qr', sc, 540 initReady: -> QR.postingIsEnabled = true {config} = g.BOARD prop = (key, def) -> +(config[key] ? def) QR.min_width = prop 'min_image_width', 1 QR.min_height = prop 'min_image_height', 1 QR.max_width = QR.max_height = 10000 QR.max_size = prop 'max_filesize', 4194304 QR.max_size_video = prop 'max_webm_filesize', QR.max_size QR.max_comment = prop 'max_comment_chars', 2000 QR.max_width_video = QR.max_height_video = 2048 QR.max_duration_video = prop 'max_webm_duration', 120 QR.forcedAnon = !!config.forced_anon QR.spoiler = !!config.spoilers if (origToggle = $.id 'togglePostFormLink') link = $.el 'h1', className: "qr-link-container" $.extend link, `<%= html('?{g.VIEW === "thread"}{Reply to Thread}{Start a Thread}') %>` QR.link = link.firstElementChild $.on link.firstChild, 'click', -> QR.open() QR.nodes.com.focus() $.before origToggle, link origToggle.firstElementChild.textContent = 'Original Form' if g.VIEW is 'thread' linkBot = $.el 'div', className: "brackets-wrap qr-link-container-bottom" $.extend linkBot, `<%= html('Reply to Thread') %>` $.on linkBot.firstElementChild, 'click', -> QR.open() QR.nodes.com.focus() $.prepend navLinksBot, linkBot if (navLinksBot = $ '.navLinksBot') $.on d, 'QRGetFile', QR.getFile $.on d, 'QRDrawFile', QR.drawFile $.on d, 'QRSetFile', QR.setFile $.on d, 'paste', QR.paste $.on d, 'dragover', QR.dragOver $.on d, 'drop', QR.dropFile $.on d, 'dragstart dragend', QR.drag $.on d, 'IndexRefreshInternal', QR.generatePostableThreadsList $.on d, 'ThreadUpdate', QR.statusCheck return if !Conf['Persistent QR'] QR.open() QR.hide() if Conf['Auto Hide QR'] statusCheck: -> return unless QR.nodes {thread} = QR.posts[0] if thread isnt 'new' and g.threads.get("#{g.BOARD}.#{thread}").isDead QR.abort() else QR.status() node: -> $.on @nodes.quote, 'click', QR.quote QR.generatePostableThreadsList() if @isFetchedQuote open: -> if QR.nodes QR.captcha.setup() if QR.nodes.el.hidden QR.nodes.el.hidden = false QR.unhide() else try QR.dialog() catch err delete QR.nodes Main.handleErrors message: 'Quick Reply dialog creation crashed.' error: err return $.rmClass QR.shortcut, 'disabled' close: -> if QR.req QR.abort() return QR.nodes.el.hidden = true QR.cleanNotifications() QR.blur() $.rmClass QR.nodes.el, 'dump' $.addClass QR.shortcut, 'disabled' new QR.post true for post in QR.posts.splice 0, QR.posts.length - 1 post.delete() QR.cooldown.auto = false QR.status() QR.captcha.destroy() focus: -> $.queueTask -> unless QR.inBubble() QR.hasFocus = d.activeElement and QR.nodes.el.contains(d.activeElement) QR.nodes.el.classList.toggle 'focus', QR.hasFocus inBubble: -> bubbles = $$ 'iframe[src^="https://www.google.com/recaptcha/api2/frame"]' d.activeElement in bubbles or bubbles.some (el) -> getComputedStyle(el).visibility isnt 'hidden' and el.getBoundingClientRect().bottom > 0 hide: -> QR.blur() $.addClass QR.nodes.el, 'autohide' QR.nodes.autohide.checked = true unhide: -> $.rmClass QR.nodes.el, 'autohide' QR.nodes.autohide.checked = false toggleHide: -> if @checked QR.hide() else QR.unhide() blur: -> d.activeElement.blur() if QR.nodes.el.contains(d.activeElement) toggleSJIS: (e) -> e.preventDefault() Conf['sjisPreview'] = !Conf['sjisPreview'] $.set 'sjisPreview', Conf['sjisPreview'] QR.nodes.el.classList.toggle 'sjis-preview', Conf['sjisPreview'] texPreviewShow: -> return QR.texPreviewHide() if $.hasClass QR.nodes.el, 'tex-preview' $.addClass QR.nodes.el, 'tex-preview' QR.nodes.texPreview.textContent = QR.nodes.com.value $.event 'mathjax', null, QR.nodes.texPreview texPreviewHide: -> $.rmClass QR.nodes.el, 'tex-preview' addPost: -> wasOpen = (QR.nodes and !QR.nodes.el.hidden) QR.open() if wasOpen $.addClass QR.nodes.el, 'dump' new QR.post true QR.nodes.com.focus() setCustomCooldown: (enabled) -> Conf['customCooldownEnabled'] = enabled QR.cooldown.customCooldown = enabled QR.nodes.customCooldown.classList.toggle 'disabled', !enabled toggleCustomCooldown: -> enabled = $.hasClass QR.nodes.customCooldown, 'disabled' QR.setCustomCooldown enabled $.set 'customCooldownEnabled', enabled error: (err, focusOverride) -> QR.open() if typeof err is 'string' el = $.tn err else el = err el.removeAttribute 'style' notice = new Notice 'warning', el QR.notifications.push notice unless Header.areNotificationsEnabled alert el.textContent if d.hidden and not QR.cooldown.auto else if d.hidden or not (focusOverride or d.hasFocus()) notif = new Notification el.textContent, body: el.textContent icon: Favicon.logo notif.onclick = -> window.focus() if $.engine isnt 'gecko' # Firefox automatically closes notifications # so we can't control the onclose properly. notif.onclose = -> notice.close() notif.onshow = -> setTimeout -> notif.onclose = null notif.close() , 7 * $.SECOND connectionError: -> $.el 'span', `<%= html( 'Connection error while posting. ' + '[More info]' ) %>` notifications: [] cleanNotifications: -> for notification in QR.notifications notification.close() QR.notifications = [] status: -> return unless QR.nodes {thread} = QR.posts[0] if thread isnt 'new' and g.threads.get("#{g.BOARD}.#{thread}").isDead value = 'Dead' disabled = true QR.cooldown.auto = false value = if QR.req QR.req.progress else QR.cooldown.seconds or value {status} = QR.nodes status.value = unless value 'Submit' else if QR.cooldown.auto "Auto #{value}" else value status.disabled = disabled or false openPost: -> QR.open() if QR.selected.isLocked index = QR.posts.indexOf QR.selected (QR.posts[index+1] or new QR.post()).select() $.addClass QR.nodes.el, 'dump' QR.cooldown.auto = true quote: (e) -> e?.preventDefault() return unless QR.postingIsEnabled sel = d.getSelection() post = Get.postFromNode @ {root} = post.nodes postRange = new Range() postRange.selectNode root text = if post.board.ID is g.BOARD.ID then ">>#{post}\n" else ">>>/#{post.board}/#{post}\n" for i in [0...sel.rangeCount] range = sel.getRangeAt i # Trim range to be fully inside post if range.compareBoundaryPoints(Range.START_TO_START, postRange) < 0 range.setStartBefore root if range.compareBoundaryPoints(Range.END_TO_END, postRange) > 0 range.setEndAfter root continue unless range.toString().trim() frag = range.cloneContents() ancestor = range.commonAncestorContainer # Quoting the insides of a spoiler/code tag. if $.x 'ancestor-or-self::*[self::s or contains(@class,"removed-spoiler")]', ancestor $.prepend frag, $.tn '[spoiler]' $.add frag, $.tn '[/spoiler]' if insideCode = $.x 'ancestor-or-self::pre[contains(@class,"prettyprint")]', ancestor $.prepend frag, $.tn '[code]' $.add frag, $.tn '[/code]' for node in $$ (if insideCode then 'br' else '.prettyprint br'), frag $.replace node, $.tn '\n' for node in $$ 'br', frag $.replace node, $.tn '\n>' unless node is frag.lastChild g.SITE.insertTags?(frag) for node in $$ '.linkify[data-original]', frag $.replace node, $.tn node.dataset.original for node in $$ '.embedder', frag $.rm node.previousSibling if node.previousSibling?.nodeValue is ' ' $.rm node text += ">#{frag.textContent.trim()}\n" QR.openPost() {com, thread} = QR.nodes thread.value = Get.threadFromNode @ unless com.value caretPos = com.selectionStart # Replace selection for text. com.value = com.value[...caretPos] + text + com.value[com.selectionEnd..] # Move the caret to the end of the new quote. range = caretPos + text.length com.setSelectionRange range, range com.focus() QR.selected.save com QR.selected.save thread characterCount: -> counter = QR.nodes.charCount count = QR.nodes.com.value.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length counter.textContent = count counter.hidden = count < QR.max_comment/2 (if count > QR.max_comment then $.addClass else $.rmClass) counter, 'warning' getFile: -> $.event 'QRFile', QR.selected?.file drawFile: (e) -> file = QR.selected?.file return unless file and /^(image|video)\//.test(file.type) isVideo = /^video\//.test file el = $.el (if isVideo then 'video' else 'img') $.on el, 'error', -> QR.openError() $.on el, (if isVideo then 'loadeddata' else 'load'), -> e.target.getContext('2d').drawImage el, 0, 0 URL.revokeObjectURL el.src el.src = URL.createObjectURL file openError: -> div = $.el 'div' $.extend div, `<%= html('Could not open file. [More info]') %>` QR.error div setFile: (e) -> {file, name, source} = e.detail file.name = name if name? file.source = source if source? QR.open() QR.handleFiles [file] drag: (e) -> # Let it drag anything from the page. toggle = if e.type is 'dragstart' then $.off else $.on toggle d, 'dragover', QR.dragOver toggle d, 'drop', QR.dropFile dragOver: (e) -> e.preventDefault() e.dataTransfer.dropEffect = 'copy' # cursor feedback dropFile: (e) -> # Let it only handle files from the desktop. return unless e.dataTransfer.files.length e.preventDefault() QR.open() QR.handleFiles e.dataTransfer.files paste: (e) -> return unless e.clipboardData.items file = null score = -1 for item in e.clipboardData.items when item.kind is 'file' and (file2 = item.getAsFile()) score2 = 2*(file2.size <= QR.max_size) + (file2.type is 'image/png') if score2 > score file = file2 score = score2 if file {type} = file blob = new Blob [file], {type} blob.name = "#{Conf['pastedname']}.#{$.getOwn(QR.extensionFromType, type) or 'jpg'}" QR.open() QR.handleFiles [blob] $.addClass QR.nodes.el, 'dump' return pasteFF: -> {pasteArea} = QR.nodes return unless pasteArea.childNodes.length images = $$ 'img', pasteArea $.rmAll pasteArea for img in images {src} = img if m = src.match /data:(image\/(\w+));base64,(.+)/ bstr = atob m[3] arr = new Uint8Array(bstr.length) for i in [0...bstr.length] arr[i] = bstr.charCodeAt(i) blob = new Blob [arr], {type: m[1]} blob.name = "#{Conf['pastedname']}.#{m[2]}" QR.handleFiles [blob] else if /^https?:\/\//.test src QR.handleUrl src return handleUrl: (urlDefault) -> QR.open() QR.selected.preventAutoPost() CrossOrigin.permission -> url = prompt 'Enter a URL:', urlDefault return if url is null QR.nodes.fileButton.focus() CrossOrigin.file url, (blob) -> if blob and not /^text\//.test blob.type QR.handleFiles [blob] else QR.error "Can't load file." handleFiles: (files) -> if @ isnt QR # file input files = [@files...] @value = null return unless files.length QR.cleanNotifications() for file in files QR.handleFile file, files.length $.addClass QR.nodes.el, 'dump' unless files.length is 1 if d.activeElement is QR.nodes.fileButton and $.hasClass QR.nodes.fileSubmit, 'has-file' QR.nodes.filename.focus() handleFile: (file, nfiles) -> isText = /^text\//.test file.type if nfiles is 1 post = QR.selected else post = QR.posts[QR.posts.length - 1] if (if isText then post.com or post.pasting else post.file) post = new QR.post() post[if isText then 'pasteText' else 'setFile'] file openFileInput: -> return if QR.nodes.fileButton.disabled QR.nodes.fileInput.click() QR.nodes.fileButton.focus() generatePostableThreadsList: -> return unless QR.nodes list = QR.nodes.thread options = [list.firstElementChild] for thread in g.BOARD.threads.keys options.push $.el 'option', value: thread textContent: "Thread #{thread}" val = list.value $.rmAll list $.add list, options list.value = val return if list.value is val # Fix the value if the option disappeared. list.value = if g.VIEW is 'thread' g.THREADID else 'new' (if g.VIEW is 'thread' then $.addClass else $.rmClass) QR.nodes.el, 'reply-to-thread' dialog: -> QR.nodes = nodes = el: dialog = UI.dialog 'qr', `<%= readHTML('QuickReply.html') %>` setNode = (name, query) -> nodes[name] = $ query, dialog setNode 'move', '.move' setNode 'autohide', '#autohide' setNode 'close', '.close' setNode 'thread', 'select' setNode 'form', 'form' setNode 'sjisToggle', '#sjis-toggle' setNode 'texButton', '#tex-preview-button' setNode 'name', '[data-name=name]' setNode 'email', '[data-name=email]' setNode 'sub', '[data-name=sub]' setNode 'com', '[data-name=com]' setNode 'charCount', '#char-count' setNode 'texPreview', '#tex-preview' setNode 'dumpList', '#dump-list' setNode 'addPost', '#add-post' setNode 'oekaki', '.oekaki' setNode 'drawButton', '#qr-draw-button' setNode 'fileSubmit', '#file-n-submit' setNode 'fileButton', '#qr-file-button' setNode 'noFile', '#qr-no-file' setNode 'filename', '#qr-filename' setNode 'spoiler', '#qr-file-spoiler' setNode 'oekakiButton', '#qr-oekaki-button' setNode 'fileRM', '#qr-filerm' setNode 'urlButton', '#url-button' setNode 'pasteArea', '#paste-area' setNode 'customCooldown', '#custom-cooldown-button' setNode 'dumpButton', '#dump-button' setNode 'status', '[type=submit]' setNode 'flashTag', '[name=filetag]' setNode 'fileInput', '[type=file]' {config} = g.BOARD {classList} = QR.nodes.el classList.toggle 'forced-anon', QR.forcedAnon classList.toggle 'has-spoiler', QR.spoiler classList.toggle 'has-sjis', !!config.sjis_tags classList.toggle 'has-math', !!config.math_tags classList.toggle 'sjis-preview', !!config.sjis_tags and Conf['sjisPreview'] classList.toggle 'show-new-thread-option', Conf['Show New Thread Option in Threads'] if parseInt(Conf['customCooldown'], 10) > 0 $.addClass QR.nodes.fileSubmit, 'custom-cooldown' $.get 'customCooldownEnabled', Conf['customCooldownEnabled'], ({customCooldownEnabled}) -> QR.setCustomCooldown customCooldownEnabled $.sync 'customCooldownEnabled', QR.setCustomCooldown QR.flagsInput() $.on nodes.autohide, 'change', QR.toggleHide $.on nodes.close, 'click', QR.close $.on nodes.form, 'submit', QR.submit $.on nodes.sjisToggle, 'click', QR.toggleSJIS $.on nodes.texButton, 'mousedown', QR.texPreviewShow $.on nodes.texButton, 'mouseup', QR.texPreviewHide $.on nodes.addPost, 'click', -> new QR.post true $.on nodes.drawButton, 'click', QR.oekaki.draw $.on nodes.fileButton, 'click', QR.openFileInput $.on nodes.noFile, 'click', QR.openFileInput $.on nodes.filename, 'focus', -> $.addClass @parentNode, 'focus' $.on nodes.filename, 'blur', -> $.rmClass @parentNode, 'focus' $.on nodes.spoiler, 'change', -> QR.selected.nodes.spoiler.click() $.on nodes.oekakiButton, 'click', QR.oekaki.button $.on nodes.fileRM, 'click', -> QR.selected.rmFile() $.on nodes.urlButton, 'click', -> QR.handleUrl '' $.on nodes.customCooldown, 'click', QR.toggleCustomCooldown $.on nodes.dumpButton, 'click', -> nodes.el.classList.toggle 'dump' $.on nodes.fileInput, 'change', QR.handleFiles window.addEventListener 'focus', QR.focus, true window.addEventListener 'blur', QR.focus, true # We don't receive blur events from captcha iframe. $.on d, 'click', QR.focus # XXX Workaround for image pasting in Firefox, obsolete as of v50. # https://bugzilla.mozilla.org/show_bug.cgi?id=906420 if $.engine is 'gecko' and not window.DataTransferItemList nodes.pasteArea.hidden = false new MutationObserver(QR.pasteFF).observe nodes.pasteArea, {childList: true} # save selected post's data items = ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag'] i = 0 save = -> QR.selected.save @ while name = items[i++] continue if not (node = nodes[name]) event = if node.nodeName is 'SELECT' then 'change' else 'input' $.on nodes[name], event, save # XXX Blink and WebKit treat width and height of