Paper Clip (Save & Edit as HTML, Markdown and Plain Text)

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-06-26 提交的版本,查看 最新版本

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