Paper Clip

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.

目前为 2023-05-11 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Paper Clip
  3. // @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.
  4. // @author Schimon Jehudah, Adv.
  5. // @namespace i2p.schimon.paperclip
  6. // @homepageURL https://greasyfork.org/en/scripts/465960-paper-clip
  7. // @supportURL https://greasyfork.org/en/scripts/465960-paper-clip/feedback
  8. // @copyright 2023, Schimon Jehudah (http://schimon.i2p)
  9. // @license MIT; https://opensource.org/licenses/MIT
  10. // @require https://unpkg.com/turndown/dist/turndown.js
  11. // @exclude devtools://*
  12. // @include *
  13. // @version 23.05.11
  14. // @run-at document-end
  15. // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCB5PSIuOWVtIiBmb250LXNpemU9IjkwIj7wn5OOPC90ZXh0Pjwvc3ZnPgo=
  16. // ==/UserScript==
  17.  
  18. /* TODO
  19.  
  20. 1) Bookmarklet
  21.  
  22. 2) jsPDF /parallax/jsPDF
  23.  
  24. 3) Button for:
  25. icon send (children: Jabber & Email)
  26. icon paperclip (children: HTML, MD, TXT)
  27. text send (last format chosen)
  28.  
  29. */
  30.  
  31. // Check whether HTML; otherwise, exit.
  32. //if (!document.contentType == 'text/html')
  33. if (document.doctype == null) return;
  34.  
  35. var
  36. originalBackground, originalColor,
  37. originalDisplay, originalOutline;
  38.  
  39. const time = new Date();
  40. const namespace = 'i2p.schimon.paperclip';
  41.  
  42. // FIXME set hotkey
  43. document.onkeyup = function(e) {
  44. //if (e.ctrlKey && e.shiftKey && e.which == 49) { // Ctrl + Shift + 1
  45. if (e.metaKey && e.shiftKey && e.which == 83) { // Command + Shift + S
  46. console.info('Saving selection to HTML.')
  47. createPage('xhtml');
  48. }
  49. };
  50.  
  51. // event listener
  52. // event "click" and "mouseup" are the most sensible, albeit not accurate
  53. // event "mousemove" is the most manipulative (per user), yet (almost) the most accurate
  54. // event "select" seem to work only inside element input
  55. window.addEventListener('click',event => {
  56. //document.addEventListener('click',event => {
  57. let selection = document.getSelection();
  58. let btn = document.getElementById(namespace);
  59. if (!btn && selection.toString().length) {
  60. btn = createButton(event.pageX, event.pageY);
  61. btn.append(actionButton('close'));
  62. btn.append(actionButton('xhtml'));
  63. btn.append(actionButton('markdown'));
  64. btn.append(actionButton('text'));
  65. btn.append(actionButton('xmpp'));
  66. btn.append(actionButton('email'));
  67. btn.append(actionButton('edit'));
  68. btn.append(actionButton('delete'));
  69. document.body.append(btn);
  70. } else
  71. if (btn && !selection.toString().length) {
  72. btn.remove();
  73. }
  74. }, {passive: true});
  75.  
  76. // TODO declare variables once
  77. // NOTE consider "mousedown"
  78. // NOTE consider moving this functionality into function createButton()
  79. window.addEventListener('mousemove',function(){
  80. let selection = document.getSelection();
  81. let btn = document.getElementById(namespace);
  82. if (btn && !selection.toString().length) {
  83. btn.remove();
  84. }
  85. });
  86.  
  87. function createButton(x, y) {
  88. // create element
  89. let btn = document.createElement(namespace);
  90. // set content
  91. btn.id = namespace;
  92. // btn.textContent = '📎'; // 🖇️ 💾
  93. // set position
  94. btn.style.position = 'absolute';
  95. btn.style.left = x + 5 + 'px';
  96. btn.style.top = y + 'px';
  97. // set appearance
  98. btn.style.fontFamily = 'system-ui'; // cursive sans-serif emoji
  99. btn.style.background = 'black'; // cornflowerblue, grey, rosybrown
  100. btn.style.border = 'thin solid white';
  101. //btn.style.borderWidth = 'thin';
  102. //btn.style.border = 'solid'; // ridge
  103. //btn.style.borderColor = 'darkred';
  104. btn.style.borderRadius = '3px';
  105. btn.style.padding = '3px';
  106. //btn.style.marginTop = '100px';
  107. //btn.style.marginLeft = '10px';
  108. btn.style.minWidth = '30px';
  109. btn.style.minHeight = '30px';
  110. //btn.style.width = '10px';
  111. //btn.style.height = '10px';
  112. //btn.style.fontSize = '20px';
  113. btn.style.zIndex = 10000;
  114. btn.style.opacity = 0.7;
  115. // center character
  116. btn.style.justifyContent = 'center';
  117. btn.style.alignItems = 'center';
  118. btn.style.display = 'flex';
  119. // disable selection marks
  120. btn.style.outline = 'white'; // none
  121. btn.style.userSelect = 'none';
  122. btn.style.cursor = 'default';
  123. btn.onmouseleave = () => {btn.style.opacity = 0.27;};
  124. btn.onmouseover = () => {btn.style.opacity = 1;};
  125. return btn;
  126. }
  127.  
  128. function actionButton(type) {
  129. let content = getSelectedText().outerText; // textContent
  130. content = content.replace(/%0D%0A%0D%0A/g, " ");
  131. content = removeMultipleWhiteSpace(content);
  132. let item = document.createElement('span');
  133. item.id = `${namespace}-${type}`;
  134. //item.style.borderRadius = '50%';
  135. item.style.outline = 'none';
  136. item.style.padding = '3px';
  137. item.style.margin = '3px';
  138. item.style.fontSize = '10px';
  139. item.style.fontWeight = 'bold';
  140. item.style.color = 'white';
  141. item.onmouseleave = () => {resetStyle();};
  142. switch (type) {
  143. case 'close':
  144. item.textContent = 'Close';
  145. item.title = 'Double-click to close';
  146. item.ondblclick = () => {item.parentElement.remove();};
  147. break;
  148. case 'delete':
  149. item.textContent = 'Delete';
  150. item.title = 'Double-click to delete content';
  151. item.ondblclick = () => {getSelectedText().remove();};
  152. item.onmouseenter = () => {drawBorder('darkred', 'rgb(255 182 182)', '2px dashed hotpink');};
  153. break;
  154. case 'edit':
  155. item.onmouseenter = () => {drawBorder('darkblue', 'rgb(200 182 255)', '2px solid blue');};
  156. if (getSelectedText().contentEditable == 'true') {
  157. item.textContent = 'Stop Edit';
  158. item.title = 'Turn off edit mode';
  159. } else {
  160. item.textContent = 'Edit';
  161. item.title = 'Turn on edit mode';
  162. }
  163. item.onclick = () => {
  164. let texts = toggleEditeMode();
  165. item.textContent = texts[0];
  166. item.title = texts[1];
  167. }
  168. break;
  169. case 'email':
  170. item.textContent = 'Email';
  171. item.title = 'Send via Email as reference';
  172. 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}`};
  173. break;
  174. case 'markdown':
  175. item.textContent = 'MD';
  176. item.title = 'Save to Markdown';
  177. item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
  178. item.onclick = () => {createPage(type);};
  179. break;
  180. case 'text':
  181. item.textContent = 'Text';
  182. item.title = 'Save to Plain Text';
  183. item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
  184. item.onclick = () => {savePage(getSelectedText().outerText,
  185. createFilename('txt'),
  186. "text/plain");};
  187. break;
  188. case 'xhtml':
  189. item.textContent = 'HTML';
  190. item.title = 'Save to HTML (valid XHTML)';
  191. item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
  192. item.onclick = () => {createPage(type);};
  193. break;
  194. case 'xmpp':
  195. item.textContent = 'Jabber';
  196. item.title = 'Send via XMPP as reference';
  197. 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}`};
  198. break;
  199. }
  200. return item;
  201. }
  202.  
  203. function toggleEditeMode() {
  204. let texts;
  205. if (getSelectedText().contentEditable == 'true') {
  206. getSelectedText().contentEditable = 'false';
  207. texts = ['Start Edit', 'Edit content'];
  208. } else {
  209. getSelectedText().contentEditable = 'true';
  210. texts = ['Stop Edit', 'Turn off edit mode'];
  211. }
  212. return texts;
  213. }
  214.  
  215. function drawBorder(color, background, outline) {
  216. let sel = getSelectedText();
  217. originalColor = sel.style.color;
  218. originalOutline = sel.style.outline;
  219. originalBackground = sel.style.background;
  220. // Draw border around input without affecting style, layout or spacing
  221. // https://overflow.adminforge.de/questions/29990319/draw-border-around-input-without-affecting-style-layout-or-spacing
  222. //sel.style.outline = '3px solid';
  223. //sel.style.background = 'lightgoldenrodyellow';
  224. //sel.style.outline = '3px dashed';
  225. //sel.style.background = 'rgba(250,250,210,0.3)';
  226. //sel.style.outline = '3px double darkblue';
  227. //sel.style.background = 'rgba(210,250,250,0.8)';
  228. sel.style.outline = '2px double rosybrown';
  229. sel.style.outline = outline;
  230. //sel.style.background = 'rgba(250,250,210,0.7)';
  231. sel.style.background = 'rgb(250 250 210)';
  232. sel.style.background = background;
  233. sel.style.color = 'black'; // DarkRed
  234. sel.style.color = color;
  235. }
  236.  
  237. // TODO remove attribute 'style' of first element after 'body'
  238. // FIXME
  239. // http://gothicrichard.synthasite.com/what-i-fond-on-the-net.php
  240. // https://darknetdiaries.com/episode/65/
  241. function resetStyle() {
  242. let sel = getSelectedText();
  243. sel.style.color = originalColor;
  244. sel.style.outline = originalOutline;
  245. sel.style.background = originalBackground;
  246. }
  247.  
  248. function createPage(type) {
  249.  
  250. var template, domParser, data, meta;
  251. template = '<!DOCTYPE html>';
  252. domParser = new DOMParser();
  253. data = domParser.parseFromString(template, 'text/html');
  254.  
  255. // set title
  256. if (document.title.length > 0) {
  257. data.title = document.title;
  258. }
  259.  
  260. // set base
  261. base = data.createElement('base');
  262. base.href = data.head.baseURI; // location.href;
  263. data.head.append(base);
  264.  
  265. const metaTag = [
  266. 'url',
  267. 'date',
  268. 'creator',
  269. 'user-agent',
  270. //'connection-type',
  271. 'content-type-sourced',
  272. 'charset-sourced'
  273. //'character-count'
  274. //'word-count'
  275. ];
  276.  
  277. const metaValue = [
  278. location.href,
  279. time,
  280. namespace,
  281. navigator.userAgent,
  282. //navigator.connection.effectiveType,
  283. document.contentType,
  284. document.charset
  285. ];
  286.  
  287. for (let i = 0; i < metaTag.length; i++) {
  288. meta = document.createElement('meta');
  289. meta.name = metaTag[i];
  290. meta.content = metaValue[i];
  291. data.head.append(meta);
  292. }
  293.  
  294. const metaData = [
  295. //'content-type',
  296. 'viewport',
  297. 'description',
  298. 'keywords',
  299. 'generator'
  300. ];
  301.  
  302. for (let i = 0; i < metaData.length; i++) {
  303.  
  304. meta = document.createElement('meta');
  305. meta.name = metaData[i] + '-imported';
  306.  
  307. try {
  308. meta.content = document.querySelector('meta[name="' + metaData[i] + '" i]')
  309. // .querySelector('meta[http-equiv="' + metaData[i] + '" i]')
  310. .content;
  311. }
  312. catch(err) {
  313. console.warn(metaData[i] + ': Not found.');
  314. continue;
  315. }
  316.  
  317. data.head.append(meta);
  318. }
  319.  
  320. data.body.innerHTML = getSelectedText().outerHTML;
  321. data = listMediaElements(data);
  322. data = removeAttributes(data);
  323. data = removeMediaElements(data);
  324. //data = replaceMediaByLinks(data);
  325. data = correctLinks(data);
  326. data = removeEmptyElements(data);
  327. data = removeCommentNodes(data);
  328. data = new XMLSerializer().serializeToString(data);
  329. //data = formatPage(data);
  330. //data = minify(data);
  331. //data = removeComments(data);
  332. data = removeMultipleWhiteSpace(data);
  333. if (type == 'markdown') {
  334. let turndownService = new TurndownService();
  335. data = turndownService.turndown(data);
  336. savePage(data,
  337. createFilename('md'),
  338. "text/plain");
  339. } else
  340. if (type == 'xhtml') {
  341. savePage(data,
  342. createFilename('html'), // NOTE xhtml is also valid
  343. "text/html");
  344. }
  345.  
  346. }
  347.  
  348. function replaceMediaByLinks(data) {
  349. for (const imgElement of data.querySelectorAll('img')) {
  350. // Create a new <a> element
  351. const aElement = data.createElement('a');
  352. aElement.setAttribute.href = imgElement.src;
  353.  
  354. // Copy the attributes and contents of the <img> element to the new <a> element
  355. for (let i = 0, l = imgElement.attributes.length; i < l; i++) {
  356. const name = imgElement.attributes.item(i).name;
  357. const value = imgElement.attributes.item(i).value;
  358. aElement.setAttribute(name, value);
  359. }
  360. aElement.textContent = imgElement.src;
  361.  
  362. // Replace the <img> element with the new <a> element
  363. imgElement.parentNode.replaceChild(aElement, imgElement);
  364. }
  365. return data;
  366. }
  367.  
  368. function listMediaElements(data) {
  369.  
  370. const elements = [
  371. 'audio', 'embed', 'img', 'video',
  372. 'frame', 'frameset', 'iframe',
  373. ];
  374.  
  375. for (let i = 0; i < elements.length; i++) {
  376. for (const element of data.querySelectorAll(elements[i])) {
  377. const attributes = ['src', 'data-img-url'];
  378. for (const attribute of attributes) {
  379. if (element.getAttribute(attribute)) {
  380. meta = data.createElement('meta');
  381. meta.name = `extracted-media-${elements[i]}`;
  382. meta.content = element.getAttribute(attribute);
  383. data.head.append(meta);
  384. }
  385. }
  386. }
  387. }
  388. return data;
  389. }
  390.  
  391. function removeMediaElements(data) {
  392. // TODO Remove span and preserve its contents
  393. // Movespan content to its parent element/node
  394. // https://overflow.lunar.icu/questions/9848465/js-remove-a-tag-without-deleting-content
  395. // Remove graphics, media and scripts
  396.  
  397. // TODO Replace "iframe" by "a href"
  398.  
  399. const elements = [
  400. 'audio', 'embed', 'img', 'video', 'button',
  401. 'form', 'frame', 'frameset', 'iframe', 'textarea',
  402. 'svg', 'input', 'path',
  403. 'script', 'style',
  404. 'select',
  405. ];
  406.  
  407. for (let i = 0; i < elements.length; i++) {
  408. for (const element of data.querySelectorAll(elements[i])) {
  409. element.remove();
  410. }
  411. }
  412.  
  413. return data;
  414. }
  415.  
  416. // Remove all attributes
  417. function removeAttributes(data) {
  418. // https://stackoverflow.com/questions/1870441/remove-all-attributes
  419. const removeAttributes = (element) => {
  420. for (let i = 0; i < element.attributes.length; i++) {
  421. if (element.attributes[i].name != 'href' &&
  422. element.attributes[i].name != 'name' &&
  423. element.attributes[i].name != 'id') {
  424. element.removeAttribute(element.attributes[i].name);
  425. }
  426. }
  427. };
  428.  
  429. for (const element of data.querySelectorAll('body *')) {
  430. removeAttributes(element);
  431. }
  432.  
  433. return data;
  434. }
  435.  
  436. // Correct links for offline usage
  437. function correctLinks(data) {
  438. for (const element of data.querySelectorAll('a')) {
  439. //if (element.hash) {
  440. //if (element.hostname + element.pathname == location.hostname + location.pathname) {
  441. if (element.href.startsWith(element.baseURI + '#')) {
  442. element.href = element.hash;
  443. }
  444. }
  445. return data;
  446. }
  447.  
  448. function removeEmptyElements (data) {
  449. for (const element of data.body.querySelectorAll('*')) {
  450. if (/^\s*$/.test(element.outerText)) {
  451. element.remove();
  452. }
  453. }
  454. return data;
  455. }
  456.  
  457. function removeCommentNodes(data) {
  458. const nodeIterator = data.createNodeIterator(
  459. data, // Starting node, usually the document body
  460. NodeFilter.SHOW_ALL, // NodeFilter to show all node types
  461. null,
  462. false
  463. );
  464.  
  465. let currentNode;
  466. // Loop through each node in the node iterator
  467. while (currentNode = nodeIterator.nextNode()) {
  468. if (currentNode.nodeName == '#comment') {
  469. currentNode.remove();
  470. console.log(currentNode.nodeName);
  471. }
  472. }
  473. return data;
  474. }
  475.  
  476. function removeComments(str) {
  477. return str.replace(/<!--[\s\S]*?-->/g, '');
  478. }
  479.  
  480. function removeMultipleWhiteSpace(str) {
  481. //return str.replace(/\s+/g, ' ');
  482. //return str.replace(/(?<!<code>)\s+(?![^<]*<\/code>)/g, " ");
  483. return str.replace(/(<(code|pre|code-[^\s]+)[^>]*>.*?<\/\2>)|(\s+)/gs, function(match, p1, p2, p3) {
  484. if (p1) { // if the match is a code block
  485. return p1; // return the complete code block as is
  486. } else { // if the match is whitespace outside of a code block
  487. return " "; // replace with a single space
  488. }
  489. });
  490. }
  491.  
  492. // Get parent element of beginning (and end) of selected text
  493. // https://stackoverflow.com/questions/32515175/get-parent-element-of-beginning-and-end-of-selected-text
  494. function getSelectedText() {
  495. var selection = document.getSelection();
  496. var selectionBegin = selection.anchorNode.parentNode;
  497. var selectionEnd = selection.focusNode.parentNode;
  498. var selectionCommon =
  499. findFirstCommonAncestor
  500. (
  501. selectionBegin,
  502. selectionEnd
  503. );
  504. return selectionCommon;
  505. }
  506.  
  507. // find common parent
  508. // https://stackoverflow.com/questions/2453742/whats-the-best-way-to-find-the-first-common-parent-of-two-dom-nodes-in-javascri
  509. function findFirstCommonAncestor(nodeA, nodeB) {
  510. let range = new Range();
  511. range.setStart(nodeA, 0);
  512. range.setEnd(nodeB, 0);
  513. // There's a compilication, if nodeA is positioned after
  514. // nodeB in the document, we created a collapsed range.
  515. // That means the start and end of the range are at the
  516. // same position. In that case `range.commonAncestorContainer`
  517. // would likely just be `nodeB.parentNode`.
  518. if(range.collapsed) {
  519. // The old switcheroo does the trick.
  520. range.setStart(nodeB, 0);
  521. range.setEnd(nodeA, 0);
  522. }
  523. return range.commonAncestorContainer;
  524. }
  525.  
  526. // minify html
  527. // /questions/23284784/javascript-minify-html-regex
  528. // TODO Don't apply on code/pre
  529. function minify( s ){
  530. return s ? s
  531. .replace(/\>[\r\n ]+\</g, "><") // Removes new lines and irrelevant spaces which might affect layout, and are better gone
  532. .replace(/(<.*?>)|\s+/g, (m, $1) => $1 ? $1 : ' ')
  533. .trim()
  534. : "";
  535. }
  536.  
  537. // format html
  538. // /questions/3913355/how-to-format-tidy-beautify-in-javascript
  539. // TODO Don't inset span in code/pre
  540. function formatPage(html) {
  541. var tab = '\t';
  542. var result = '';
  543. var indent= '';
  544.  
  545. html.split(/>\s*</).forEach(function(element) {
  546.  
  547. if (element.match( /^\/\w/ )) {
  548. indent = indent.substring(tab.length);
  549. }
  550.  
  551. result += indent + '<' + element + '>\r\n';
  552.  
  553. if (element.match( /^<?\w[^>]*[^\/]$/ ) && !element.startsWith("input") ) {
  554. indent += tab;
  555. }
  556.  
  557. });
  558.  
  559. return result.substring(1, result.length-3);
  560.  
  561. }
  562.  
  563. function createFilename(extension) {
  564.  
  565. let day, now, timestamp, title, filename;
  566.  
  567. day = time
  568. .toISOString()
  569. .split('T')[0];
  570.  
  571. now = [
  572. time.getHours(),
  573. time.getMinutes(),
  574. time.getSeconds()
  575. ];
  576.  
  577. for (let i = 0; i < now.length; i++) {
  578. if (now[i] < 10) {now[i] = '0' + now[i];}
  579. }
  580.  
  581. timestamp = [
  582. day,
  583. now.join('-')
  584. ];
  585.  
  586. /*
  587. address = [
  588. location.hostname,
  589. location.pathname.replace(/\//g,'_')
  590. ]
  591.  
  592. filename =
  593. address.join('') +
  594. '_' +
  595. timestamp.join('_') +
  596. '.html';
  597. */
  598.  
  599. if (document.title) {
  600. title = document.title;
  601. } else {
  602. title = location.pathname.split('/');
  603. title = title[title.length-1];
  604. }
  605.  
  606. title = title.replace(/[\/?<>\\:*|'"\.,]/g, '');
  607. title = title.replace(/ /g, '_');
  608. title = title.replace(/-/g, '_');
  609. title = title.replace(/__/g, '_');
  610.  
  611. filename =
  612. title + // TODO replace whitespace by underscore
  613. '_' +
  614. timestamp.join('_') +
  615. `.${extension}`;
  616.  
  617. return filename.toLowerCase();
  618.  
  619. }
  620.  
  621. // export file
  622. // https://stackoverflow.com/questions/4545311/download-a-file-by-jquery-ajax
  623. // https://stackoverflow.com/questions/43135852/javascript-export-to-text-file
  624. var savePage = (function () {
  625. var a = document.createElement("a");
  626. // document.body.appendChild(a);
  627. // a.style = "display: none";
  628. return function (data, fileName, mimetype) {
  629. var blob = new Blob([data], {type: mimetype}),
  630. url = window.URL.createObjectURL(blob);
  631. a.href = url;
  632. a.download = fileName;
  633. a.click();
  634. window.URL.revokeObjectURL(url);
  635. };
  636. }());