From 0edf53f5081e56a6de6d37de14fd41eaead0aef9 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Mon, 14 Jan 2019 14:57:31 -0500 Subject: [PATCH] Add export-to-clipboard feature to logger Related issue: - https://github.com/uBlockOrigin/uBlock-issues/issues/334 Additionally, a number of smallish issues following refactoring of the logger code were addressed. --- src/_locales/en/messages.json | 2 +- src/css/fa-icons.css | 1 + src/css/logger-ui.css | 94 ++++----- src/img/fontawesome/fontawesome-defs.svg | 1 + src/js/logger-ui.js | 240 ++++++++++++++++++++--- src/js/reverselookup-worker.js | 54 ++--- src/js/udom.js | 36 ++-- src/logger-ui.html | 76 ++++--- 8 files changed, 360 insertions(+), 144 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 9d57757eb..ba3132ee6 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -300,7 +300,7 @@ "description": "" }, "settingsNoLargeMediaPrompt":{ - "message":"Block media elements larger than {{input}} kB", + "message":"Block media elements larger than {{input}} KB", "description": "" }, "settingsNoRemoteFontsPrompt":{ diff --git a/src/css/fa-icons.css b/src/css/fa-icons.css index 907dcadd8..67f330342 100644 --- a/src/css/fa-icons.css +++ b/src/css/fa-icons.css @@ -51,6 +51,7 @@ .fa-icon > .fa-icon_exclamation-triangle { width: calc(1em * 1794 / 1792); } +.fa-icon > .fa-icon_clipboard, .fa-icon > .fa-icon_eye-dropper, .fa-icon > .fa-icon_eye-slash, .fa-icon > .fa-icon_files-o, diff --git a/src/css/logger-ui.css b/src/css/logger-ui.css index e9e86f790..ac7a0089c 100644 --- a/src/css/logger-ui.css +++ b/src/css/logger-ui.css @@ -23,8 +23,12 @@ textarea { display: flex; flex-shrink: 0; font-size: 120%; + justify-content: space-between; margin: 0; - padding: 0.25em 0.5em; + padding: 0.25em; + } +.permatoolbar > div { + display: flex; } .permatoolbar .button { cursor: pointer; @@ -59,24 +63,10 @@ body[dir="rtl"] #pageSelector { } #info { fill: #ccc; - padding-left: 0.5em; - padding-right: 0.5em; - position: absolute; } #info:hover { fill: #000; } -body[dir="ltr"] #info { - right: 0; - } -body[dir="rtl"] #info { - left: 0; - } -@media (max-width: 540px) { - #info { - display: none; - } - } /* https://github.com/gorhill/uBlock/issues/3293 @@ -204,17 +194,6 @@ body[dir="rtl"] #netInspector #filterExprPicker { background-color: lightblue; border: 1px solid lightblue; } -#netInspector #settings { - padding-left: 0.5em; - padding-right: 0.5em; - position: absolute; -} -body[dir="ltr"] #netInspector #settings { - right: 0; - } -body[dir="rtl"] #netInspector #settings { - left: 0; - } #netInspector .vscrollable { overflow: hidden; @@ -278,6 +257,9 @@ body.colorBlind #vwRenderer .logEntry > div[data-status="2"], body.colorBlind #netFilteringDialog > .panes > .details > div[data-status="2"] { background-color: rgba(255, 194, 57, 0.1) } +#vwRenderer .logEntry > div[data-tabid="-1"] { + text-shadow: 0 0 0.8em #444; + } #vwRenderer .logEntry > div.cosmetic, #vwRenderer .logEntry > div.redirect { background-color: rgba(255, 255, 0, 0.1); @@ -348,7 +330,7 @@ body[dir="rtl"] #vwRenderer .logEntry > div > span:first-child { #vwRenderer .logEntry > div.canDetails:hover > span:nth-of-type(2), #vwRenderer .logEntry > div.canDetails:hover > span:nth-of-type(3), #vwRenderer .logEntry > div.canDetails:hover > span:nth-of-type(5) { - background: rgba(0, 0, 0, 0.08); + background: rgba(0, 0, 0, 0.1); cursor: zoom-in; } #netInspector:not(.vExpanded) #vwRenderer .logEntry > div > span:nth-of-type(4) { @@ -369,19 +351,6 @@ body[dir="rtl"] #vwRenderer .logEntry > div > span:first-child { #vwRenderer .logEntry > div > span:nth-of-type(5) { position: relative; } -#vwRenderer .logEntry > div[data-tabid="-1"] > span:nth-of-type(5)::before { - border: 5px solid #ccc; - border-bottom: 0; - border-top: 0; - bottom: 0; - content: '\00a0'; - left: 0; - position: absolute; - right: 0; - top: 0; - width: calc(100% - 10px); - z-index: -1; - } #vwRenderer .logEntry > div > span:nth-of-type(6) { } #vwRenderer #vwContent .logEntry > div > span:nth-of-type(6) { @@ -630,19 +599,15 @@ body[dir="rtl"] #netFilteringDialog > .headers > .tools { border: 0; border-bottom: 1px solid white; display: flex; - min-height: 2.2em; } #netFilteringDialog > .panes > .details > div > span { - align-items: center; - display: inline-flex; - flex-wrap: wrap; - padding: 0.25em 0.5em; + padding: 0.5em; } #netFilteringDialog > .panes > .details > div > span:nth-of-type(1) { border: 0; flex-grow: 0; flex-shrink: 0; - justify-content: flex-end; + text-align: right; width: 8em; } body[dir="ltr"] #netFilteringDialog > .panes > .details > div > span:nth-of-type(1) { @@ -875,6 +840,43 @@ body[dir="rtl"] #loggerSettingsDialog ul { max-width: 6em; } +#loggerExportDialog { + display: flex; + flex-direction: column; + } +#loggerExportDialog .options { + display: flex; + justify-content: space-between; + margin-bottom: 1em; + } +#loggerExportDialog .options > div { + white-space: nowrap; + } +#loggerExportDialog .options span[data-i18n] { + border: 1px solid lightblue; + cursor: pointer; + font-size: 90%; + margin: 0 0.25em 0 0; + padding: 0.5em; + white-space: nowrap; + } +#loggerExportDialog .options span[data-i18n]:last-of-type { + margin: 0; + } +#loggerExportDialog .options span[data-i18n]:hover { + background-color: aliceblue; + } +#loggerExportDialog .options span.on[data-i18n], +#loggerExportDialog .options span.pushbutton:active { + background-color: lightblue; + } +#loggerExportDialog .output { + font: x-small mono; + height: 60vh; + padding: 0.5em; + white-space: pre; + } + .hide { display: none !important; } diff --git a/src/img/fontawesome/fontawesome-defs.svg b/src/img/fontawesome/fontawesome-defs.svg index 380545c13..d79a7b2d7 100644 --- a/src/img/fontawesome/fontawesome-defs.svg +++ b/src/img/fontawesome/fontawesome-defs.svg @@ -28,6 +28,7 @@ License - https://github.com/FortAwesome/Font-Awesome/tree/a8386aae19e200ddb0f68 + diff --git a/src/js/logger-ui.js b/src/js/logger-ui.js index 3dbbc5e2f..a7f068fff 100644 --- a/src/js/logger-ui.js +++ b/src/js/logger-ui.js @@ -341,6 +341,9 @@ const parseLogEntry = function(details) { ) { let partyness = ''; if ( entry.tabDomain !== undefined ) { + if ( entry.tabId < 0 ) { + partyness += '0,'; + } partyness += entry.domain === entry.tabDomain ? '1' : '3'; } else { partyness += '?'; @@ -1041,7 +1044,7 @@ const reloadTab = function(ev) { /******************************************************************************/ /******************************************************************************/ -const netFilteringManager = (function() { +(function() { const reRFC3986 = /^([^:\/?#]+:)?(\/\/[^\/?#]*)?([^?#]*)(\?[^#]*)?(#.*)?/; const staticFilterTypes = { 'beacon': 'other', @@ -1400,11 +1403,11 @@ const netFilteringManager = (function() { return urls; }; - const fillSummaryPaneFilterList = function(row) { + const fillSummaryPaneFilterList = function(rows) { + const rawFilter = targetRow.children[1].textContent; + const compiledFilter = targetRow.getAttribute('data-filter'); + const nodeFromFilter = function(filter, lists) { - if ( Array.isArray(lists) === false || lists.length === 0 ) { - return; - } const fragment = document.createDocumentFragment(); const template = document.querySelector( '#filterFinderListEntry > span' @@ -1414,9 +1417,11 @@ const netFilteringManager = (function() { let a = span.querySelector('a:nth-of-type(1)'); a.href += encodeURIComponent(list.assetKey); a.textContent = list.title; + a = span.querySelector('a:nth-of-type(2)'); if ( list.supportURL ) { - a = span.querySelector('a:nth-of-type(2)'); a.setAttribute('href', list.supportURL); + } else { + a.style.display = 'none'; } if ( fragment.childElementCount !== 0 ) { fragment.appendChild(document.createTextNode('\n')); @@ -1430,24 +1435,31 @@ const netFilteringManager = (function() { if ( response instanceof Object === false ) { response = {}; } - const fragment = document.createDocumentFragment(); + let bestMatchFilter = ''; for ( const filter in response ) { - const spans = nodeFromFilter(filter, response[filter]); - if ( spans === undefined ) { continue; } - fragment.appendChild(spans); + if ( filter.length > bestMatchFilter.length ) { + bestMatchFilter = filter; + } + } + if ( + bestMatchFilter !== '' && + Array.isArray(response[bestMatchFilter]) + ) { + rows[0].children[1].textContent = bestMatchFilter; + rows[1].children[1].appendChild(nodeFromFilter( + bestMatchFilter, + response[bestMatchFilter] + )); } - row.children[1].appendChild(fragment); // https://github.com/gorhill/uBlock/issues/2179 - if ( row.children[1].childElementCount === 0 ) { + if ( rows[1].children[1].childElementCount === 0 ) { vAPI.i18n.safeTemplateToDOM( 'loggerStaticFilteringFinderSentence2', { filter: rawFilter }, - row.children[1] + rows[1].children[1] ); } }; - const rawFilter = targetRow.children[1].textContent; - const compiledFilter = targetRow.getAttribute('data-filter'); if ( targetRow.classList.contains('networkRealm') ) { messaging.send( @@ -1499,7 +1511,7 @@ const netFilteringManager = (function() { } // Filter list if ( trcl.contains('canLookup') ) { - fillSummaryPaneFilterList(rows[1]); + fillSummaryPaneFilterList(rows); } else { rows[1].style.display = 'none'; } @@ -1519,7 +1531,7 @@ const netFilteringManager = (function() { // Partyness text = tr.getAttribute('data-parties') || ''; if ( text !== '' ) { - rows[5].children[1].textContent = `${trch[4].textContent}\u2002${text}`; + rows[5].children[1].textContent = `(${trch[4].textContent})\u2002${text}`; } else { rows[5].style.display = 'none'; } @@ -1738,7 +1750,11 @@ const netFilteringManager = (function() { ); }; - return { toggleOn }; + uDom('#netInspector').on( + 'click', + '.canDetails > span:nth-of-type(2),.canDetails > span:nth-of-type(3),.canDetails > span:nth-of-type(5)', + toggleOn + ); })(); // https://www.youtube.com/watch?v=XyNYrmmdUd4 @@ -2255,6 +2271,187 @@ const popupManager = (function() { /******************************************************************************/ +(function() { + const lines = []; + const options = { + format: 'list', + encoding: 'markdown', + time: 'anonymous', + }; + let dialog; + + const collectLines = function() { + lines.length = 0; + let t0 = filteredLoggerEntries.length !== 0 + ? filteredLoggerEntries[filteredLoggerEntries.length - 1].tstamp + : 0; + for ( const entry of filteredLoggerEntries ) { + const text = entry.textContent; + const fields = []; + let i = 0; + let beg = text.indexOf('\t'); + if ( beg === 0 ) { continue; } + let timeField = text.slice(0, beg); + if ( options.time === 'anonymous' ) { + timeField = '+' + Math.round((entry.tstamp - t0) / 1000).toString(); + } + fields.push(timeField); + beg += 1; + while ( beg < text.length ) { + let end = text.indexOf('\t', beg); + if ( end === -1 ) { end = text.length; } + fields.push(text.slice(beg, end)); + beg = end + 1; + i += 1; + } + lines.push(fields); + } + }; + + const formatAsPlainTextTable = function() { + const outputAll = []; + for ( const fields of lines ) { + outputAll.push(fields.join('\t')); + } + outputAll.push(''); + return outputAll.join('\n'); + }; + + const formatAsMarkdownTable = function() { + const outputAll = []; + let fieldCount = 0; + for ( const fields of lines ) { + if ( fields.length <= 2 ) { continue; } + if ( fields.length > fieldCount ) { + fieldCount = fields.length; + } + const outputOne = []; + for ( let i = 0; i < fields.length; i++ ) { + const field = fields[i]; + let code = /\b(?:www\.|https?:\/\/)/.test(field) ? '`' : ''; + outputOne.push(` ${code}${field.replace(/\|/g, '\\|')}${code} `); + } + outputAll.push(outputOne.join('|')); + } + outputAll.unshift( + `${' |'.repeat(fieldCount-1)} `, + `${':--- |'.repeat(fieldCount-1)}:--- ` + ); + return `
Logger output\n\n|${outputAll.join('|\n|')}|\n
\n`; + }; + + const formatAsTable = function() { + if ( options.encoding === 'plain' ) { + return formatAsPlainTextTable(); + } + return formatAsMarkdownTable(); + }; + + const formatAsList = function() { + const outputAll = []; + for ( const fields of lines ) { + const outputOne = []; + for ( let i = 0; i < fields.length; i++ ) { + let str = fields[i]; + if ( str.length === 0 ) { continue; } + outputOne.push(str); + } + outputAll.push(outputOne.join('\n')); + } + let before, between, after; + if ( options.encoding === 'markdown' ) { + const code = '```'; + before = `
Logger output\n\n${code}\n`; + between = `\n${code}\n${code}\n`; + after = `\n${code}\n
\n`; + } else { + before = ''; + between = '\n\n'; + after = '\n'; + } + return `${before}${outputAll.join(between)}${after}`; + }; + + const format = function() { + const output = dialog.querySelector('.output'); + if ( options.format === 'list' ) { + output.textContent = formatAsList(); + } else { + output.textContent = formatAsTable(); + } + }; + + const setRadioButton = function(group, value) { + if ( options.hasOwnProperty(group) === false ) { return; } + const groupEl = dialog.querySelector(`[data-radio="${group}"]`); + const buttonEls = groupEl.querySelectorAll('[data-radio-item]'); + for ( const buttonEl of buttonEls ) { + buttonEl.classList.toggle( + 'on', + buttonEl.getAttribute('data-radio-item') === value + ); + } + options[group] = value; + format(); + }; + + const onOption = function(ev) { + const target = ev.target.closest('span[data-i18n]'); + if ( target === null ) { return; } + + // Copy to clipboard + if ( target.matches('.pushbutton') ) { + const textarea = dialog.querySelector('textarea'); + textarea.focus(); + if ( textarea.selectionEnd === textarea.selectionStart ) { + textarea.select(); + } + document.execCommand('copy'); + ev.stopPropagation(); + return; + } + + // Radio buttons + const group = target.closest('[data-radio]'); + if ( group === null ) { return; } + if ( target.matches('span.on') ) { return; } + const item = target.closest('[data-radio-item]'); + if ( item === null ) { return; } + setRadioButton( + group.getAttribute('data-radio'), + item.getAttribute('data-radio-item') + ); + ev.stopPropagation(); + }; + + const toggleOn = function() { + dialog = modalDialog.create( + '#loggerExportDialog', + ( ) => { + dialog = undefined; + lines.length = 0; + } + ); + setRadioButton('format', options.format); + setRadioButton('encoding', options.encoding); + + dialog.querySelector('.options').addEventListener( + 'click', + onOption, + { capture: true } + ); + + collectLines(); + format(); + + modalDialog.show(); + }; + + uDom.nodeFromId('loggerExport').addEventListener('click', toggleOn); +})(); + +/******************************************************************************/ + // TODO: // - Give some thoughts to: // - an option to discard immediately filtered out new entries @@ -2429,16 +2626,9 @@ window.addEventListener('beforeunload', releaseView); uDom('#pageSelector').on('change', pageSelectorChanged); uDom('#refresh').on('click', reloadTab); - uDom('#netInspector .vCompactToggler').on('click', toggleVCompactView); uDom('#pause').on('click', pauseNetInspector); -uDom('#netInspector').on( - 'click', - 'span:nth-of-type(2),span:nth-of-type(3),span:nth-of-type(5)', - netFilteringManager.toggleOn -); - // https://github.com/gorhill/uBlock/issues/507 // Ensure tab selector is in sync with URL hash pageSelectorFromURLHash(); diff --git a/src/js/reverselookup-worker.js b/src/js/reverselookup-worker.js index a1e1535cd..2ee864037 100644 --- a/src/js/reverselookup-worker.js +++ b/src/js/reverselookup-worker.js @@ -25,18 +25,18 @@ /******************************************************************************/ -var listEntries = Object.create(null), - reBlockStart = /^#block-start-(\d+)\n/gm; +const reBlockStart = /^#block-start-(\d+)\n/gm; +let listEntries = Object.create(null); /******************************************************************************/ -var extractBlocks = function(content, begId, endId) { +const extractBlocks = function(content, begId, endId) { reBlockStart.lastIndex = 0; - var out = []; - var match = reBlockStart.exec(content); + const out = []; + let match = reBlockStart.exec(content); while ( match !== null ) { - var beg = match.index + match[0].length; - var blockId = parseInt(match[1], 10); + const beg = match.index + match[0].length; + const blockId = parseInt(match[1], 10); if ( blockId >= begId && blockId < endId ) { var end = content.indexOf('#block-end-' + match[1], beg); out.push(content.slice(beg, end)); @@ -49,14 +49,14 @@ var extractBlocks = function(content, begId, endId) { /******************************************************************************/ -var fromNetFilter = function(details) { - var lists = [], - compiledFilter = details.compiledFilter; +const fromNetFilter = function(details) { + const lists = []; + const compiledFilter = details.compiledFilter; - for ( let assetKey in listEntries ) { - let entry = listEntries[assetKey]; + for ( const assetKey in listEntries ) { + const entry = listEntries[assetKey]; if ( entry === undefined ) { continue; } - let content = extractBlocks(entry.content, 0, 1000); + const content = extractBlocks(entry.content, 0, 1000); let pos = 0; for (;;) { pos = content.indexOf(compiledFilter, pos); @@ -64,7 +64,7 @@ var fromNetFilter = function(details) { // We need an exact match. // https://github.com/gorhill/uBlock/issues/1392 // https://github.com/gorhill/uBlock/issues/835 - let notFound = pos !== 0 && content.charCodeAt(pos - 1) !== 0x0A; + const notFound = pos !== 0 && content.charCodeAt(pos - 1) !== 0x0A; pos += compiledFilter.length; if ( notFound || @@ -81,7 +81,7 @@ var fromNetFilter = function(details) { } } - let response = {}; + const response = {}; response[details.rawFilter] = lists; postMessage({ @@ -112,18 +112,18 @@ var fromNetFilter = function(details) { // FilterContainer.fromCompiledContent() is our reference code to create // the various compiled versions. -let fromCosmeticFilter = function(details) { - let match = /^#@?#\^?/.exec(details.rawFilter), - prefix = match[0], - exception = prefix.charAt(1) === '@', - selector = details.rawFilter.slice(prefix.length); +const fromCosmeticFilter = function(details) { + const match = /^#@?#\^?/.exec(details.rawFilter); + const prefix = match[0]; + const exception = prefix.charAt(1) === '@'; + const selector = details.rawFilter.slice(prefix.length); // The longer the needle, the lower the number of false positives. - let needle = selector.match(/\w+/g).reduce(function(a, b) { + const needle = selector.match(/\w+/g).reduce(function(a, b) { return a.length > b.length ? a : b; }); - let reHostname = new RegExp( + const reHostname = new RegExp( '^' + details.hostname.split('.').reduce( function(acc, item) { @@ -154,16 +154,16 @@ let fromCosmeticFilter = function(details) { ); } - let hostnameMatches = hn => { + const hostnameMatches = hn => { return hn === '' || reHostname.test(hn) || reEntity !== undefined && reEntity.test(hn); }; - let response = Object.create(null); + const response = Object.create(null); - for ( let assetKey in listEntries ) { - let entry = listEntries[assetKey]; + for ( const assetKey in listEntries ) { + const entry = listEntries[assetKey]; if ( entry === undefined ) { continue; } let content = extractBlocks(entry.content, 1000, 2000), isProcedural, @@ -274,7 +274,7 @@ let fromCosmeticFilter = function(details) { /******************************************************************************/ onmessage = function(e) { // jshint ignore:line - var msg = e.data; + const msg = e.data; switch ( msg.what ) { case 'resetLists': diff --git a/src/js/udom.js b/src/js/udom.js index af8fd2c04..7cb35af08 100644 --- a/src/js/udom.js +++ b/src/js/udom.js @@ -1,7 +1,7 @@ /******************************************************************************* uBlock Origin - a browser extension to block requests. - Copyright (C) 2014-2018 Raymond Hill + Copyright (C) 2014-present Raymond Hill This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -32,11 +32,11 @@ // the code here does *only* what I need, and nothing more, and with a lot // of assumption on passed parameters, etc. I grow it on a per-need-basis only. -var uDom = (function() { +const uDom = (function() { /******************************************************************************/ -var DOMList = function() { +const DOMList = function() { this.nodes = []; }; @@ -54,7 +54,7 @@ Object.defineProperty( /******************************************************************************/ -var DOMListFactory = function(selector, context) { +const DOMListFactory = function(selector, context) { var r = new DOMList(); if ( typeof selector === 'string' ) { selector = selector.trim(); @@ -92,7 +92,7 @@ DOMListFactory.nodeFromSelector = function(selector) { /******************************************************************************/ -var addNodeToList = function(list, node) { +const addNodeToList = function(list, node) { if ( node ) { list.nodes.push(node); } @@ -101,7 +101,7 @@ var addNodeToList = function(list, node) { /******************************************************************************/ -var addNodeListToList = function(list, nodelist) { +const addNodeListToList = function(list, nodelist) { if ( nodelist ) { var n = nodelist.length; for ( var i = 0; i < n; i++ ) { @@ -113,14 +113,14 @@ var addNodeListToList = function(list, nodelist) { /******************************************************************************/ -var addListToList = function(list, other) { +const addListToList = function(list, other) { list.nodes = list.nodes.concat(other.nodes); return list; }; /******************************************************************************/ -var addSelectorToList = function(list, selector, context) { +const addSelectorToList = function(list, selector, context) { var p = context || document; var r = p.querySelectorAll(selector); var n = r.length; @@ -132,15 +132,6 @@ var addSelectorToList = function(list, selector, context) { /******************************************************************************/ -const nodeInNodeList = function(node, nodeList) { - for ( const other of nodeList ) { - if ( other === node ) { return true; } - } - return false; -}; - -/******************************************************************************/ - DOMList.prototype.nodeAt = function(i) { return this.nodes[i] || null; }; @@ -526,7 +517,7 @@ DOMList.prototype.text = function(text) { /******************************************************************************/ -var toggleClass = function(node, className, targetState) { +const toggleClass = function(node, className, targetState) { var tokenList = node.classList; if ( tokenList instanceof DOMTokenList === false ) { return; @@ -628,7 +619,12 @@ const makeEventHandler = function(selector, callback) { return; } const receiver = event.target; - if ( nodeInNodeList(receiver, dispatcher.querySelectorAll(selector)) ) { + const ancestor = receiver.closest(selector); + if ( + ancestor !== null && + ancestor !== dispatcher && + dispatcher.contains(ancestor) + ) { callback.call(receiver, event); } }; @@ -678,7 +674,7 @@ DOMList.prototype.trigger = function(etype) { // Cleanup -var onBeforeUnload = function() { +const onBeforeUnload = function() { var entry; while ( (entry = listenerEntries.pop()) ) { entry.dispose(); diff --git a/src/logger-ui.html b/src/logger-ui.html index c0df2cfed..ee58bb0ea 100644 --- a/src/logger-ui.html +++ b/src/logger-ui.html @@ -14,15 +14,19 @@
- - refresh - code - - info-circle +
+ + refresh + code + +
+
+ info-circle +
@@ -42,23 +46,28 @@
- double-angle-up - times - eraser - pause-circle-oplay-circle-o - - filter - - - angle-up -
-
-
css/fontimagescriptxhrframedom
-
-
+
+ double-angle-up + times + eraser + pause-circle-oplay-circle-o + + filter + + + angle-up +
+
+
css/fontimagescriptxhrframedom
+
+
+
- - cog +
+
+ clipboard + cog +
@@ -162,7 +171,24 @@
+
+ +
+
+
+ List + Table +
+
+ Plain + Markdown +
+
+ Copy to clipboard +
+ +