- // ==UserScript==
- // @name Paper Clip
- // @description Save selection as clean HTML, Markdown or Text file optimized for printing. This program can also cut and edit text. Hotkey: Command + Shift + S to save as HTML.
- // @author Schimon Jehudah, Adv.
- // @namespace i2p.schimon.paperclip
- // @homepageURL https://greasyfork.org/en/scripts/465960-paper-clip
- // @supportURL https://greasyfork.org/en/scripts/465960-paper-clip/feedback
- // @copyright 2023, Schimon Jehudah (http://schimon.i2p)
- // @license MIT; https://opensource.org/licenses/MIT
- // @require https://unpkg.com/turndown/dist/turndown.js
- // @exclude devtools://*
- // @include *
- // @version 23.05.11
- // @run-at document-end
- // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCB5PSIuOWVtIiBmb250LXNpemU9IjkwIj7wn5OOPC90ZXh0Pjwvc3ZnPgo=
- // ==/UserScript==
-
- /* TODO
-
- 1) Bookmarklet
-
- 2) jsPDF /parallax/jsPDF
-
- 3) Button for:
- icon send (children: Jabber & Email)
- icon paperclip (children: HTML, MD, TXT)
- text send (last format chosen)
-
- */
-
- // Check whether HTML; otherwise, exit.
- //if (!document.contentType == 'text/html')
- if (document.doctype == null) return;
-
- var
- originalBackground, originalColor,
- originalDisplay, originalOutline;
-
- const time = new Date();
- const namespace = 'i2p.schimon.paperclip';
-
- // FIXME set hotkey
- document.onkeyup = function(e) {
- //if (e.ctrlKey && e.shiftKey && e.which == 49) { // Ctrl + Shift + 1
- if (e.metaKey && e.shiftKey && e.which == 83) { // Command + Shift + S
- console.info('Saving selection to HTML.')
- createPage('xhtml');
- }
- };
-
- // event listener
- // event "click" and "mouseup" are the most sensible, albeit not accurate
- // event "mousemove" is the most manipulative (per user), yet (almost) the most accurate
- // event "select" seem to work only inside element input
- window.addEventListener('click',event => {
- //document.addEventListener('click',event => {
- let selection = document.getSelection();
- let btn = document.getElementById(namespace);
- if (!btn && selection.toString().length) {
- btn = createButton(event.pageX, event.pageY);
- btn.append(actionButton('close'));
- btn.append(actionButton('xhtml'));
- btn.append(actionButton('markdown'));
- btn.append(actionButton('text'));
- btn.append(actionButton('xmpp'));
- btn.append(actionButton('email'));
- btn.append(actionButton('edit'));
- btn.append(actionButton('delete'));
- document.body.append(btn);
- } else
- if (btn && !selection.toString().length) {
- btn.remove();
- }
- }, {passive: true});
-
- // TODO declare variables once
- // NOTE consider "mousedown"
- // NOTE consider moving this functionality into function createButton()
- window.addEventListener('mousemove',function(){
- let selection = document.getSelection();
- let btn = document.getElementById(namespace);
- if (btn && !selection.toString().length) {
- btn.remove();
- }
- });
-
- function createButton(x, y) {
- // create element
- let btn = document.createElement(namespace);
- // set content
- btn.id = namespace;
- // btn.textContent = '📎'; // 🖇️ 💾
- // set position
- btn.style.position = 'absolute';
- btn.style.left = x + 5 + 'px';
- btn.style.top = y + 'px';
- // set appearance
- btn.style.fontFamily = 'system-ui'; // cursive sans-serif emoji
- btn.style.background = 'black'; // cornflowerblue, grey, rosybrown
- btn.style.border = 'thin solid white';
- //btn.style.borderWidth = 'thin';
- //btn.style.border = 'solid'; // ridge
- //btn.style.borderColor = 'darkred';
- btn.style.borderRadius = '3px';
- btn.style.padding = '3px';
- //btn.style.marginTop = '100px';
- //btn.style.marginLeft = '10px';
- btn.style.minWidth = '30px';
- btn.style.minHeight = '30px';
- //btn.style.width = '10px';
- //btn.style.height = '10px';
- //btn.style.fontSize = '20px';
- btn.style.zIndex = 10000;
- btn.style.opacity = 0.7;
- // center character
- btn.style.justifyContent = 'center';
- btn.style.alignItems = 'center';
- btn.style.display = 'flex';
- // disable selection marks
- btn.style.outline = 'white'; // none
- btn.style.userSelect = 'none';
- btn.style.cursor = 'default';
- btn.onmouseleave = () => {btn.style.opacity = 0.27;};
- btn.onmouseover = () => {btn.style.opacity = 1;};
- return btn;
- }
-
- function actionButton(type) {
- let content = getSelectedText().outerText; // textContent
- content = content.replace(/%0D%0A%0D%0A/g, " ");
- content = removeMultipleWhiteSpace(content);
- let item = document.createElement('span');
- item.id = `${namespace}-${type}`;
- //item.style.borderRadius = '50%';
- item.style.outline = 'none';
- item.style.padding = '3px';
- item.style.margin = '3px';
- item.style.fontSize = '10px';
- item.style.fontWeight = 'bold';
- item.style.color = 'white';
- item.onmouseleave = () => {resetStyle();};
- switch (type) {
- case 'close':
- item.textContent = 'Close';
- item.title = 'Double-click to close';
- item.ondblclick = () => {item.parentElement.remove();};
- break;
- case 'delete':
- item.textContent = 'Delete';
- item.title = 'Double-click to delete content';
- item.ondblclick = () => {getSelectedText().remove();};
- item.onmouseenter = () => {drawBorder('darkred', 'rgb(255 182 182)', '2px dashed hotpink');};
- break;
- case 'edit':
- item.onmouseenter = () => {drawBorder('darkblue', 'rgb(200 182 255)', '2px solid blue');};
- if (getSelectedText().contentEditable == 'true') {
- item.textContent = 'Stop Edit';
- item.title = 'Turn off edit mode';
- } else {
- item.textContent = 'Edit';
- item.title = 'Turn on edit mode';
- }
- item.onclick = () => {
- let texts = toggleEditeMode();
- item.textContent = texts[0];
- item.title = texts[1];
- }
- break;
- case 'email':
- item.textContent = 'Email';
- item.title = 'Send via Email as reference';
- item.onclick = () => {window.location = `mailto:?subject=Content on ${location.hostname}&body=${document.title}%0D%0A%0D%0A${content}%0D%0A%0D%0A${location.hostname}${location.pathname}`};
- break;
- case 'markdown':
- item.textContent = 'MD';
- item.title = 'Save to Markdown';
- item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
- item.onclick = () => {createPage(type);};
- break;
- case 'text':
- item.textContent = 'Text';
- item.title = 'Save to Plain Text';
- item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
- item.onclick = () => {savePage(getSelectedText().outerText,
- createFilename('txt'),
- "text/plain");};
- break;
- case 'xhtml':
- item.textContent = 'HTML';
- item.title = 'Save to HTML (valid XHTML)';
- item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
- item.onclick = () => {createPage(type);};
- break;
- case 'xmpp':
- item.textContent = 'Jabber';
- item.title = 'Send via XMPP as reference';
- item.onclick = () => {window.location = `xmpp:?subject=Content on ${location.hostname}&body=${document.title}%0D%0A%0D%0A${content}%0D%0A%0D%0A${location.hostname}${location.pathname}`};
- break;
- }
- return item;
- }
-
- function toggleEditeMode() {
- let texts;
- if (getSelectedText().contentEditable == 'true') {
- getSelectedText().contentEditable = 'false';
- texts = ['Start Edit', 'Edit content'];
- } else {
- getSelectedText().contentEditable = 'true';
- texts = ['Stop Edit', 'Turn off edit mode'];
- }
- return texts;
- }
-
- function drawBorder(color, background, outline) {
- let sel = getSelectedText();
- originalColor = sel.style.color;
- originalOutline = sel.style.outline;
- originalBackground = sel.style.background;
- // Draw border around input without affecting style, layout or spacing
- // https://overflow.adminforge.de/questions/29990319/draw-border-around-input-without-affecting-style-layout-or-spacing
- //sel.style.outline = '3px solid';
- //sel.style.background = 'lightgoldenrodyellow';
- //sel.style.outline = '3px dashed';
- //sel.style.background = 'rgba(250,250,210,0.3)';
- //sel.style.outline = '3px double darkblue';
- //sel.style.background = 'rgba(210,250,250,0.8)';
- sel.style.outline = '2px double rosybrown';
- sel.style.outline = outline;
- //sel.style.background = 'rgba(250,250,210,0.7)';
- sel.style.background = 'rgb(250 250 210)';
- sel.style.background = background;
- sel.style.color = 'black'; // DarkRed
- sel.style.color = color;
- }
-
- // TODO remove attribute 'style' of first element after 'body'
- // FIXME
- // http://gothicrichard.synthasite.com/what-i-fond-on-the-net.php
- // https://darknetdiaries.com/episode/65/
- function resetStyle() {
- let sel = getSelectedText();
- sel.style.color = originalColor;
- sel.style.outline = originalOutline;
- sel.style.background = originalBackground;
- }
-
- function createPage(type) {
-
- var template, domParser, data, meta;
- template = '<!DOCTYPE html>';
- domParser = new DOMParser();
- data = domParser.parseFromString(template, 'text/html');
-
- // set title
- if (document.title.length > 0) {
- data.title = document.title;
- }
-
- // set base
- base = data.createElement('base');
- base.href = data.head.baseURI; // location.href;
- data.head.append(base);
-
- const metaTag = [
- 'url',
- 'date',
- 'creator',
- 'user-agent',
- //'connection-type',
- 'content-type-sourced',
- 'charset-sourced'
- //'character-count'
- //'word-count'
- ];
-
- const metaValue = [
- location.href,
- time,
- namespace,
- navigator.userAgent,
- //navigator.connection.effectiveType,
- document.contentType,
- document.charset
- ];
-
- for (let i = 0; i < metaTag.length; i++) {
- meta = document.createElement('meta');
- meta.name = metaTag[i];
- meta.content = metaValue[i];
- data.head.append(meta);
- }
-
- const metaData = [
- //'content-type',
- 'viewport',
- 'description',
- 'keywords',
- 'generator'
- ];
-
- for (let i = 0; i < metaData.length; i++) {
-
- meta = document.createElement('meta');
- meta.name = metaData[i] + '-imported';
-
- try {
- meta.content = document.querySelector('meta[name="' + metaData[i] + '" i]')
- // .querySelector('meta[http-equiv="' + metaData[i] + '" i]')
- .content;
- }
- catch(err) {
- console.warn(metaData[i] + ': Not found.');
- continue;
- }
-
- data.head.append(meta);
- }
-
- data.body.innerHTML = getSelectedText().outerHTML;
- data = listMediaElements(data);
- data = removeAttributes(data);
- data = removeMediaElements(data);
- //data = replaceMediaByLinks(data);
- data = correctLinks(data);
- data = removeEmptyElements(data);
- data = removeCommentNodes(data);
- data = new XMLSerializer().serializeToString(data);
- //data = formatPage(data);
- //data = minify(data);
- //data = removeComments(data);
- data = removeMultipleWhiteSpace(data);
- if (type == 'markdown') {
- let turndownService = new TurndownService();
- data = turndownService.turndown(data);
- savePage(data,
- createFilename('md'),
- "text/plain");
- } else
- if (type == 'xhtml') {
- savePage(data,
- createFilename('html'), // NOTE xhtml is also valid
- "text/html");
- }
-
- }
-
- function replaceMediaByLinks(data) {
- for (const imgElement of data.querySelectorAll('img')) {
- // Create a new <a> element
- const aElement = data.createElement('a');
- aElement.setAttribute.href = imgElement.src;
-
- // Copy the attributes and contents of the <img> element to the new <a> element
- for (let i = 0, l = imgElement.attributes.length; i < l; i++) {
- const name = imgElement.attributes.item(i).name;
- const value = imgElement.attributes.item(i).value;
- aElement.setAttribute(name, value);
- }
- aElement.textContent = imgElement.src;
-
- // Replace the <img> element with the new <a> element
- imgElement.parentNode.replaceChild(aElement, imgElement);
- }
- return data;
- }
-
- function listMediaElements(data) {
-
- const elements = [
- 'audio', 'embed', 'img', 'video',
- 'frame', 'frameset', 'iframe',
- ];
-
- for (let i = 0; i < elements.length; i++) {
- for (const element of data.querySelectorAll(elements[i])) {
- const attributes = ['src', 'data-img-url'];
- for (const attribute of attributes) {
- if (element.getAttribute(attribute)) {
- meta = data.createElement('meta');
- meta.name = `extracted-media-${elements[i]}`;
- meta.content = element.getAttribute(attribute);
- data.head.append(meta);
- }
- }
- }
- }
- return data;
- }
-
- function removeMediaElements(data) {
- // TODO Remove span and preserve its contents
- // Movespan content to its parent element/node
- // https://overflow.lunar.icu/questions/9848465/js-remove-a-tag-without-deleting-content
- // Remove graphics, media and scripts
-
- // TODO Replace "iframe" by "a href"
-
- const elements = [
- 'audio', 'embed', 'img', 'video', 'button',
- 'form', 'frame', 'frameset', 'iframe', 'textarea',
- 'svg', 'input', 'path',
- 'script', 'style',
- 'select',
- ];
-
- for (let i = 0; i < elements.length; i++) {
- for (const element of data.querySelectorAll(elements[i])) {
- element.remove();
- }
- }
-
- return data;
- }
-
- // Remove all attributes
- function removeAttributes(data) {
- // https://stackoverflow.com/questions/1870441/remove-all-attributes
- const removeAttributes = (element) => {
- for (let i = 0; i < element.attributes.length; i++) {
- if (element.attributes[i].name != 'href' &&
- element.attributes[i].name != 'name' &&
- element.attributes[i].name != 'id') {
- element.removeAttribute(element.attributes[i].name);
- }
- }
- };
-
- for (const element of data.querySelectorAll('body *')) {
- removeAttributes(element);
- }
-
- return data;
- }
-
- // Correct links for offline usage
- function correctLinks(data) {
- for (const element of data.querySelectorAll('a')) {
- //if (element.hash) {
- //if (element.hostname + element.pathname == location.hostname + location.pathname) {
- if (element.href.startsWith(element.baseURI + '#')) {
- element.href = element.hash;
- }
- }
- return data;
- }
-
- function removeEmptyElements (data) {
- for (const element of data.body.querySelectorAll('*')) {
- if (/^\s*$/.test(element.outerText)) {
- element.remove();
- }
- }
- return data;
- }
-
- function removeCommentNodes(data) {
- const nodeIterator = data.createNodeIterator(
- data, // Starting node, usually the document body
- NodeFilter.SHOW_ALL, // NodeFilter to show all node types
- null,
- false
- );
-
- let currentNode;
- // Loop through each node in the node iterator
- while (currentNode = nodeIterator.nextNode()) {
- if (currentNode.nodeName == '#comment') {
- currentNode.remove();
- console.log(currentNode.nodeName);
- }
- }
- return data;
- }
-
- function removeComments(str) {
- return str.replace(/<!--[\s\S]*?-->/g, '');
- }
-
- function removeMultipleWhiteSpace(str) {
- //return str.replace(/\s+/g, ' ');
- //return str.replace(/(?<!<code>)\s+(?![^<]*<\/code>)/g, " ");
- return str.replace(/(<(code|pre|code-[^\s]+)[^>]*>.*?<\/\2>)|(\s+)/gs, function(match, p1, p2, p3) {
- if (p1) { // if the match is a code block
- return p1; // return the complete code block as is
- } else { // if the match is whitespace outside of a code block
- return " "; // replace with a single space
- }
- });
- }
-
- // Get parent element of beginning (and end) of selected text
- // https://stackoverflow.com/questions/32515175/get-parent-element-of-beginning-and-end-of-selected-text
- function getSelectedText() {
- var selection = document.getSelection();
- var selectionBegin = selection.anchorNode.parentNode;
- var selectionEnd = selection.focusNode.parentNode;
- var selectionCommon =
- findFirstCommonAncestor
- (
- selectionBegin,
- selectionEnd
- );
- return selectionCommon;
- }
-
- // find common parent
- // https://stackoverflow.com/questions/2453742/whats-the-best-way-to-find-the-first-common-parent-of-two-dom-nodes-in-javascri
- function findFirstCommonAncestor(nodeA, nodeB) {
- let range = new Range();
- range.setStart(nodeA, 0);
- range.setEnd(nodeB, 0);
- // There's a compilication, if nodeA is positioned after
- // nodeB in the document, we created a collapsed range.
- // That means the start and end of the range are at the
- // same position. In that case `range.commonAncestorContainer`
- // would likely just be `nodeB.parentNode`.
- if(range.collapsed) {
- // The old switcheroo does the trick.
- range.setStart(nodeB, 0);
- range.setEnd(nodeA, 0);
- }
- return range.commonAncestorContainer;
- }
-
- // minify html
- // /questions/23284784/javascript-minify-html-regex
- // TODO Don't apply on code/pre
- function minify( s ){
- return s ? s
- .replace(/\>[\r\n ]+\</g, "><") // Removes new lines and irrelevant spaces which might affect layout, and are better gone
- .replace(/(<.*?>)|\s+/g, (m, $1) => $1 ? $1 : ' ')
- .trim()
- : "";
- }
-
- // format html
- // /questions/3913355/how-to-format-tidy-beautify-in-javascript
- // TODO Don't inset span in code/pre
- function formatPage(html) {
- var tab = '\t';
- var result = '';
- var indent= '';
-
- html.split(/>\s*</).forEach(function(element) {
-
- if (element.match( /^\/\w/ )) {
- indent = indent.substring(tab.length);
- }
-
- result += indent + '<' + element + '>\r\n';
-
- if (element.match( /^<?\w[^>]*[^\/]$/ ) && !element.startsWith("input") ) {
- indent += tab;
- }
-
- });
-
- return result.substring(1, result.length-3);
-
- }
-
- function createFilename(extension) {
-
- let day, now, timestamp, title, filename;
-
- day = time
- .toISOString()
- .split('T')[0];
-
- now = [
- time.getHours(),
- time.getMinutes(),
- time.getSeconds()
- ];
-
- for (let i = 0; i < now.length; i++) {
- if (now[i] < 10) {now[i] = '0' + now[i];}
- }
-
- timestamp = [
- day,
- now.join('-')
- ];
-
- /*
- address = [
- location.hostname,
- location.pathname.replace(/\//g,'_')
- ]
-
- filename =
- address.join('') +
- '_' +
- timestamp.join('_') +
- '.html';
- */
-
- if (document.title) {
- title = document.title;
- } else {
- title = location.pathname.split('/');
- title = title[title.length-1];
- }
-
- title = title.replace(/[\/?<>\\:*|'"\.,]/g, '');
- title = title.replace(/ /g, '_');
- title = title.replace(/-/g, '_');
- title = title.replace(/__/g, '_');
-
- filename =
- title + // TODO replace whitespace by underscore
- '_' +
- timestamp.join('_') +
- `.${extension}`;
-
- return filename.toLowerCase();
-
- }
-
- // export file
- // https://stackoverflow.com/questions/4545311/download-a-file-by-jquery-ajax
- // https://stackoverflow.com/questions/43135852/javascript-export-to-text-file
- var savePage = (function () {
- var a = document.createElement("a");
- // document.body.appendChild(a);
- // a.style = "display: none";
- return function (data, fileName, mimetype) {
- var blob = new Blob([data], {type: mimetype}),
- url = window.URL.createObjectURL(blob);
- a.href = url;
- a.download = fileName;
- a.click();
- window.URL.revokeObjectURL(url);
- };
- }());