// ==UserScript==
// @name Paper Clip (save, edit & send)
// @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://gf.qytechs.cn/en/scripts/465960-paper-clip
// @supportURL https://gf.qytechs.cn/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.27
// @run-at document-end
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCB5PSIuOWVtIiBmb250LXNpemU9IjkwIj7wn5OOPC90ZXh0Pjwvc3ZnPgo=
// ==/UserScript==
/*
TODO
1) Display preview
2) Bookmarklet
3) jsPDF /parallax/jsPDF
FIXME
1) https://vision.gel.ulaval.ca/~klein/duke3d/
2) Replace element-img enclosed in element-a by href of element-a
*/
// 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.')
generateXHTML();
}
};
// 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);
// TODO Move "append"s to a function
btn.append(actionButton('close'));
btn.append(actionButton('save'));
btn.append(actionButton('edit'));
btn.append(actionButton('send'));
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 'back':
item.textContent = '<';
item.onclick = () => {
item.parentElement.replaceChildren(
actionButton('close'),
actionButton('save'),
actionButton('edit'),
actionButton('send')
)
};
break;
case 'close':
item.textContent = 'x';
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.textContent = 'Edit';
//item.style.cursor = 'context-menu';
item.onclick = () => {
item.parentElement.replaceChildren(
actionButton('back'),
actionButton('delete'),
actionButton('editable')
)
};
break;
case 'editable':
item.onmouseenter = () => {drawBorder('darkblue', 'rgb(200 182 255)', '2px solid blue');};
if (getSelectedText().contentEditable == 'true') {
item.textContent = 'Stop Editing';
item.title = 'Turn off edit mode';
} else {
item.textContent = 'Start Editing';
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 'irc':
item.textContent = 'IRC';
item.title = 'Send via IRC as reference';
item.onclick = () => {alert('This button will be supported in next update')};
break;
case 'markdown':
item.textContent = 'Markdown';
item.title = 'Save to Markdown';
item.onclick = () => {generateMD();}; //TODO URL reference to source URL
break;
case 'matrix':
item.textContent = 'Matrix';
item.title = 'Send via Matrix as reference';
item.onclick = () => {alert('This button will be supported in next update')};
break;
case 'text':
item.textContent = 'Text';
item.title = 'Save to Plain Text';
item.onclick = () => {generateTXT();};
break;
case 'xhtml':
item.textContent = 'HTML';
item.title = 'Save to HTML (valid XHTML)';
item.onclick = () => {generateXHTML();};
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;
case 'save':
item.textContent = 'Save';
//item.style.cursor = 'context-menu';
item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
item.onclick = () => {
item.parentElement.replaceChildren(
actionButton('back'),
actionButton('xhtml'),
actionButton('markdown'),
actionButton('text')
)
};
break;
case 'send':
item.textContent = 'Send';
//item.style.cursor = 'context-menu';
item.onclick = () => {
item.parentElement.replaceChildren(
actionButton('back'),
actionButton('email'),
actionButton('irc'),
actionButton('matrix'),
actionButton('xmpp')
)
};
break;
}
return item;
}
function toggleEditeMode() {
let texts;
if (getSelectedText().contentEditable == 'true') {
getSelectedText().contentEditable = 'false';
texts = ['Continue Editing', 'Edit content'];
} else {
getSelectedText().contentEditable = 'true';
texts = ['Stop Editing', '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 generateTXT() {
let data = getSelectedText().outerText;
data = `${data}
Created: ${time.toDateString()} ${time.toLocaleTimeString()}
Source: ${location.href}
Title: ${document.title}
Document generated using Paper Clip
Save selected content into clean HTML, Markdown or Text
https://gf.qytechs.cn/en/scripts/465960-paper-clip
`;
savePage(
data,
createFilename('txt'),
"text/plain"
);
}
function generateMD() {
let data = getSelectedText().outerHTML;
let turndownService = new TurndownService();
data = turndownService.turndown(data);
data = `${data}
---
This page was saved at ${time.toDateString()} ${time.toLocaleTimeString()} from [${location.hostname}](${location.href}) using [Paper Clip](https://gf.qytechs.cn/en/scripts/465960-paper-clip) and converted into Markdown with [Turndown](https://mixmark-io.github.io/turndown/)
`;
savePage(
data,
createFilename('md'),
"text/plain"
);
}
function generateXHTML() {
let domParser = new DOMParser();
let data = domParser.parseFromString('', 'text/html');
// set title
if (document.title.length > 0) {
data.title = document.title;
}
// set base
// NOTE do not "set base".
// TODO Complete links of ./ and / etc. by fetching complete
// url and replace href with it (it = complete url)
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++) {
let 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++) {
let 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);
}
if (document.dir == 'rtl') {
data.dir = 'rtl';
}
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 = removeWhitespaceFromNodes(data, ['code', 'pre']);
//data = replaceCodeAndPre(data);
//data = setStylesheet(data);
data = new XMLSerializer().serializeToString(data);
//data = formatPage(data);
//data = minify(data);
//data = removeComments(data);
data = removeMultipleWhiteSpace(data);
savePage(
data,
// NOTE xhtml is also valid
createFilename('html'),
"text/html"
);
}
// FIXME
// body::-webkit-scrollbar{width:10.666666666666666px;height:10.666666666666666px;}
function setStylesheet(node) {
let cssStylesheet = document.createElement('style');
document.head.append(cssStylesheet);
cssStylesheet.type = 'text/css';
if (node.querySelector('code') ||
node.querySelector('pre')) {
cssStylesheet.textContent = 'code, pre {overflow: auto; display: grid; max-width: 100vw;}';
}
return node;
}
// TODO Place plain text inside elements <code> <pre> (eliminate <span>, <br> etc.)
// TODO Eliminate all elements without changing original text layout
function replaceCodeAndPre(node) { // correctCodeElements
const codeElements = node.getElementsByTagName('code');
const preElements = node.getElementsByTagName('pre');
// Replace content of all code elements with their own outerText
for (let i = 0; i < codeElements.length; i++) {
const element = codeElements[i];
element.outerText = element.outerText;
}
// Replace content of all pre elements with their own outerText
for (let i = 0; i < preElements.length; i++) {
const element = preElements[i];
element.outerText = element.outerText;
}
return node;
}
function replaceMediaByLinks(node) {
for (const imgElement of node.querySelectorAll('img')) {
// Create a new <a> element
const aElement = node.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 node;
}
// TODO
// Catch all elements with attribute
// contains URL, and
// starts with / (add hostname), and
// contains / (add hostname with first parent path), and
// validate using URL API
function listMediaElements(node) {
const elements = [
'audio', 'embed', 'img', 'svg', 'video',
'frame', 'frameset', 'iframe', '[data-source]',
];
for (let i = 0; i < elements.length; i++) {
for (const element of node.querySelectorAll(elements[i])) {
const attributes = ['src', 'data-img-url', 'data-source'];
for (const attribute of attributes) {
if (element.getAttribute(attribute)) {
let meta = node.createElement('meta');
meta.name = `extracted-media-${element.nodeName.toLowerCase()}`; // Was ${elements[i]}
meta.content = element.getAttribute(attribute);
node.head.append(meta);
}
}
}
}
return node;
}
function removeMediaElements(node) {
// 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 node.querySelectorAll(elements[i])) {
element.remove();
}
}
return node;
}
// Remove all attributes
function removeAttributes(node) {
// 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 node.querySelectorAll('body *')) {
removeAttributes(element);
}
return node;
}
// Correct links for offline usage
function correctLinks(node) {
for (const element of node.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 node;
}
function removeEmptyElements (node) {
for (const element of node.body.querySelectorAll('*')) {
//if (/^\s*$/.test(element.outerText)) {
if (element.tagName.toLowerCase() !== 'br' && /^\s*$/.test(element.textContent)) {
element.remove();
}
}
return node;
}
function removeCommentNodes(node) {
const nodeIterator = node.createNodeIterator(
node, // 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 node;
}
function removeComments(str) {
return str.replace(/<!--[\s\S]*?-->/g, '');
}
function removeWhitespaceFromNodes(node, excludedTags) {
const removeWhitespace = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
node.textContent = node.textContent.trim();
} else if (
node.nodeType === Node.ELEMENT_NODE &&
!excludedTags.includes(node.tagName.toLowerCase())
) {
for (let i = 0; i < node.childNodes.length; i++) {
removeWhitespace(node.childNodes[i]);
}
}
};
removeWhitespace(node);
return node;
}
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
}
});
*/
return str.replace(/(<(code|pre)[^>]*>.*?<\/\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];
}
// TODO ‘ ’ ·
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 (fileData, fileName, fileType) {
var blob = new Blob([fileData], {type: fileType}),
url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
};
}());