markdownr 0.4.9 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 498dc869909593a83b885a1cc30de710e07b6e686434da94a49c2d2e1f473d41
4
- data.tar.gz: 83c31394846d27ec2b474465dafa548494b42683e800348e752b080caeb3df42
3
+ metadata.gz: cf955ffcbe5dfc1e1f5070b3fa36a6d2d15a092e7be6ca7f8ac5dcd4b863162a
4
+ data.tar.gz: 3ea514cfed258bc71a8a3e82f6ef883e1ed619fc63b46e9217eb4745c455fdd5
5
5
  SHA512:
6
- metadata.gz: bf3f02fae7411669eb42765b7a65280a55d6e4dca36a77601017fd52c42e2a75adee172d173cf470033d897d75c6a18bb84277094348a3f9b1f578b3d962586e
7
- data.tar.gz: 8388865e12e977a8f6ec1be0047dd4dd14981d2671bbcb3182fc77e87ccb20ee5617f6b2e8f944b2355187bdffb4a2107ac2266f171aacb848a9d010d4fca137
6
+ metadata.gz: 19f44c77aab4f8a5a2978c85f8fd900f6a0cc45d9399897a26acae80d5809ce57669716a1a5e488c276a4b53e0b2e5d5e3bc13d8b6463a5c8d9c76342eb39e4f
7
+ data.tar.gz: b8adb9e49b1def08d6d3f4061de5048d3cf14232d4ce0c3b9fde51f605f52fb9338a9b39ad49f569d9663fdf9e718e1396f37674e55c7c3f025ffa7a271d3548
@@ -491,7 +491,7 @@ module MarkdownServer
491
491
  } || ""
492
492
  end
493
493
 
494
- def page_html(raw)
494
+ def page_html(raw, base_url = nil)
495
495
  w = raw.dup
496
496
  # Remove inert elements and their entire contents
497
497
  STRIP_FULL.each { |t| w.gsub!(/<#{t}[^>]*>.*?<\/#{t}>/im, " ") }
@@ -508,12 +508,30 @@ module MarkdownServer
508
508
  w.match(/<body[^>]*>(.*?)<\/body>/im)&.[](1) ||
509
509
  w
510
510
 
511
- # Rewrite tags: keep allowed (strip attrs), block→newline, rest→empty
512
- out = content.gsub(/<(\/?)(\w+)[^>]*>/) do
513
- slash, tag = $1, $2.downcase
514
- if ALLOWED_HTML.include?(tag) then "<#{slash}#{tag}>"
515
- elsif BLOCK_HTML.include?(tag) then "\n"
516
- else ""
511
+ # Rewrite tags: keep allowed (strip attrs), preserve <a href>, block→newline, rest→empty
512
+ out = content.gsub(/<(\/?)(\w+)([^>]*)>/) do
513
+ slash, tag, attrs = $1, $2.downcase, $3
514
+ if ALLOWED_HTML.include?(tag)
515
+ "<#{slash}#{tag}>"
516
+ elsif tag == "a"
517
+ if slash.empty?
518
+ m = attrs.match(/href\s*=\s*["']([^"']*)["']/i)
519
+ if m && !m[1].match?(/\Ajavascript:/i)
520
+ href = m[1]
521
+ if base_url && href !~ /\Ahttps?:\/\//i && !href.start_with?("#")
522
+ href = (URI.join(base_url, href).to_s rescue href)
523
+ end
524
+ %(<a href="#{h(href)}" target="_blank" rel="noopener">)
525
+ else
526
+ ""
527
+ end
528
+ else
529
+ "</a>"
530
+ end
531
+ elsif BLOCK_HTML.include?(tag)
532
+ "\n"
533
+ else
534
+ ""
517
535
  end
518
536
  end
519
537
 
@@ -533,6 +551,9 @@ module MarkdownServer
533
551
  .gsub(/<(\w+)>\s*<\/\1>/, "") # drop empty tags
534
552
  .strip
535
553
 
554
+ # Strip all footer navigation after "Read full chapter" up to (but not including) copyright
555
+ out.gsub!(/(<a[^>]*>Read\s+full\s+chapter<\/a>)[\s\S]*?(?=©|Copyright\b)/i, "\\1\n")
556
+
536
557
  out.length > 10_000 ? out[0, 10_000] : out
537
558
  end
538
559
 
@@ -627,7 +648,8 @@ module MarkdownServer
627
648
  html = fetch_external_page(url)
628
649
  halt 502, '{"error":"fetch failed"}' unless html
629
650
 
630
- JSON.dump({ title: page_title(html), html: page_html(html) })
651
+ title = page_title(html).sub(/ [-–] .*/, "").strip
652
+ JSON.dump({ title: title, html: page_html(html, url) })
631
653
  end
632
654
 
633
655
  get "/search/?*" do
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.4.9"
2
+ VERSION = "0.5.1"
3
3
  end
data/views/layout.erb CHANGED
@@ -678,7 +678,7 @@
678
678
  /* Footnote list — hide numeric markers; labels are inline via <strong> */
679
679
  .footnotes ol { list-style: none; padding-left: 1.2em; }
680
680
 
681
- /* Right-click / long-press link context popup */
681
+ /* Left-click / tap link context popup */
682
682
  .link-ctx-popup {
683
683
  position: fixed;
684
684
  z-index: 500;
@@ -705,6 +705,13 @@
705
705
  background: #faf8f4;
706
706
  z-index: 1;
707
707
  }
708
+ .link-ctx-popup-title-wrap {
709
+ flex: 1;
710
+ min-width: 0;
711
+ display: flex;
712
+ align-items: center;
713
+ gap: 4px;
714
+ }
708
715
  .link-ctx-popup-title {
709
716
  font-family: Georgia, "Times New Roman", serif;
710
717
  font-weight: 700;
@@ -712,7 +719,9 @@
712
719
  color: #3a3a3a;
713
720
  min-width: 0;
714
721
  word-break: break-word;
722
+ text-decoration: none;
715
723
  }
724
+ a.link-ctx-popup-title:hover { color: #8b6914; }
716
725
  .link-ctx-popup-close {
717
726
  background: none;
718
727
  border: none;
@@ -724,12 +733,28 @@
724
733
  flex-shrink: 0;
725
734
  }
726
735
  .link-ctx-popup-close:hover { color: #2c2c2c; }
736
+ .link-ctx-popup-newtab {
737
+ color: #666;
738
+ flex-shrink: 0;
739
+ display: flex;
740
+ align-items: center;
741
+ text-decoration: none;
742
+ padding: 0 0.2rem;
743
+ }
744
+ .link-ctx-popup-newtab:hover { color: #8b6914; }
745
+ .link-ctx-popup-back {
746
+ background: none;
747
+ border: none;
748
+ font-size: 1rem;
749
+ line-height: 1;
750
+ color: #888;
751
+ cursor: pointer;
752
+ padding: 0 0.3rem 0 0;
753
+ flex-shrink: 0;
754
+ }
755
+ .link-ctx-popup-back:hover { color: #2c2c2c; }
727
756
  .link-ctx-popup-body {
728
757
  padding: 0.75rem 1rem;
729
- font-family: Georgia, "Times New Roman", serif;
730
- font-size: 0.85rem;
731
- line-height: 1.6;
732
- color: #2c2c2c;
733
758
  }
734
759
  .link-ctx-popup-url {
735
760
  font-family: "SF Mono", Menlo, Consolas, monospace;
@@ -741,49 +766,8 @@
741
766
  border-radius: 4px;
742
767
  margin-bottom: 0.5rem;
743
768
  }
744
- .link-ctx-popup-body h1, .link-ctx-popup-body h2, .link-ctx-popup-body h3,
745
- .link-ctx-popup-body h4, .link-ctx-popup-body h5, .link-ctx-popup-body h6 {
746
- margin: 0.7rem 0 0.3rem; color: #3a3a3a;
747
- }
748
- .link-ctx-popup-body h1 { font-size: 1.2rem; }
749
- .link-ctx-popup-body h2 { font-size: 1.05rem; border-bottom: none; padding-bottom: 0; }
750
- .link-ctx-popup-body h3 { font-size: 0.95rem; }
751
- .link-ctx-popup-body p { margin: 0 0 0.5rem; }
752
- .link-ctx-popup-body p:last-child { margin-bottom: 0; }
753
- .link-ctx-popup-body a { color: #8b6914; text-decoration: none; border-bottom: 1px solid #d4b96a; }
754
- .link-ctx-popup-body a.wiki-link { color: #6a8e3e; border-bottom-color: #6a8e3e; }
755
- .link-ctx-popup-body code {
756
- font-family: "SF Mono", Menlo, Consolas, monospace;
757
- font-size: 0.82em;
758
- background: #f0ece3;
759
- padding: 0.1em 0.3em;
760
- border-radius: 3px;
761
- }
762
- .link-ctx-popup-body pre {
763
- background: #2d2d2d;
764
- color: #f0f0f0;
765
- padding: 0.6rem 0.8rem;
766
- border-radius: 4px;
767
- font-size: 0.78rem;
768
- line-height: 1.4;
769
- overflow-x: auto;
770
- margin: 0.4rem 0;
771
- }
772
- .link-ctx-popup-body pre code { background: none; padding: 0; font-size: 1em; color: inherit; }
773
- .link-ctx-popup-body ul, .link-ctx-popup-body ol { padding-left: 1.4rem; margin: 0.3rem 0; }
774
- .link-ctx-popup-body li { margin-bottom: 0.2rem; }
775
- .link-ctx-popup-body blockquote {
776
- border-left: 3px solid #d4b96a;
777
- margin: 0.5rem 0;
778
- padding: 0.3rem 0.8rem;
779
- color: #4a4a4a;
780
- font-style: italic;
781
- }
782
- .link-ctx-popup-body table { border-collapse: collapse; font-size: 0.8rem; margin: 0.5rem 0; }
783
- .link-ctx-popup-body th, .link-ctx-popup-body td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; }
784
- .link-ctx-popup-body th { background: #f5f0e4; }
785
769
 
786
- /* Link preview popup */
770
+ /* Link preview popup (hover) */
787
771
  .link-tooltip-anchor { position: relative; }
788
772
  .link-preview-popup {
789
773
  position: absolute;
@@ -799,10 +783,6 @@
799
783
  border-radius: 6px;
800
784
  box-shadow: 0 4px 20px rgba(0,0,0,0.18);
801
785
  padding: 0.8rem 1rem;
802
- font-family: Georgia, "Times New Roman", serif;
803
- font-size: 0.85rem;
804
- line-height: 1.6;
805
- color: #2c2c2c;
806
786
  cursor: auto;
807
787
  -webkit-overflow-scrolling: touch;
808
788
  }
@@ -814,26 +794,36 @@
814
794
  border-bottom: 1px solid #e0d8c8;
815
795
  color: #3a3a3a;
816
796
  }
797
+
798
+ /* Shared popup content styles */
799
+ .link-ctx-popup-body,
800
+ .link-preview-popup {
801
+ font-family: Georgia, "Times New Roman", serif;
802
+ font-size: 0.85rem;
803
+ line-height: 1.6;
804
+ color: #2c2c2c;
805
+ }
806
+ .link-ctx-popup-body h1, .link-ctx-popup-body h2, .link-ctx-popup-body h3,
807
+ .link-ctx-popup-body h4, .link-ctx-popup-body h5, .link-ctx-popup-body h6,
817
808
  .link-preview-popup h1, .link-preview-popup h2, .link-preview-popup h3,
818
809
  .link-preview-popup h4, .link-preview-popup h5, .link-preview-popup h6 {
819
- margin: 0.7rem 0 0.3rem;
820
- color: #3a3a3a;
810
+ margin: 0.7rem 0 0.3rem; color: #3a3a3a;
821
811
  }
822
- .link-preview-popup h1 { font-size: 1.2rem; }
823
- .link-preview-popup h2 { font-size: 1.05rem; border-bottom: none; padding-bottom: 0; }
824
- .link-preview-popup h3 { font-size: 0.95rem; }
825
- .link-preview-popup p { margin: 0 0 0.5rem; }
826
- .link-preview-popup p:last-child { margin-bottom: 0; }
827
- .link-preview-popup a { color: #8b6914; text-decoration: none; border-bottom: 1px solid #d4b96a; }
828
- .link-preview-popup a.wiki-link { color: #6a8e3e; border-bottom-color: #6a8e3e; }
829
- .link-preview-popup code {
812
+ .link-ctx-popup-body h1, .link-preview-popup h1 { font-size: 1.2rem; }
813
+ .link-ctx-popup-body h2, .link-preview-popup h2 { font-size: 1.05rem; border-bottom: none; padding-bottom: 0; }
814
+ .link-ctx-popup-body h3, .link-preview-popup h3 { font-size: 0.95rem; }
815
+ .link-ctx-popup-body p, .link-preview-popup p { margin: 0 0 0.5rem; }
816
+ .link-ctx-popup-body p:last-child, .link-preview-popup p:last-child { margin-bottom: 0; }
817
+ .link-ctx-popup-body a, .link-preview-popup a { color: #8b6914; text-decoration: none; border-bottom: 1px solid #d4b96a; }
818
+ .link-ctx-popup-body a.wiki-link, .link-preview-popup a.wiki-link { color: #6a8e3e; border-bottom-color: #6a8e3e; }
819
+ .link-ctx-popup-body code, .link-preview-popup code {
830
820
  font-family: "SF Mono", Menlo, Consolas, monospace;
831
821
  font-size: 0.82em;
832
822
  background: #f0ece3;
833
823
  padding: 0.1em 0.3em;
834
824
  border-radius: 3px;
835
825
  }
836
- .link-preview-popup pre {
826
+ .link-ctx-popup-body pre, .link-preview-popup pre {
837
827
  background: #2d2d2d;
838
828
  color: #f0f0f0;
839
829
  padding: 0.6rem 0.8rem;
@@ -843,26 +833,21 @@
843
833
  overflow-x: auto;
844
834
  margin: 0.4rem 0;
845
835
  }
846
- .link-preview-popup pre code { background: none; padding: 0; font-size: 1em; }
836
+ .link-ctx-popup-body pre code, .link-preview-popup pre code { background: none; padding: 0; font-size: 1em; color: inherit; }
837
+ .link-ctx-popup-body ul, .link-ctx-popup-body ol,
847
838
  .link-preview-popup ul, .link-preview-popup ol { padding-left: 1.4rem; margin: 0.3rem 0; }
848
- .link-preview-popup li { margin-bottom: 0.2rem; }
849
- .link-preview-popup blockquote {
839
+ .link-ctx-popup-body li, .link-preview-popup li { margin-bottom: 0.2rem; }
840
+ .link-ctx-popup-body blockquote, .link-preview-popup blockquote {
850
841
  border-left: 3px solid #d4b96a;
851
842
  margin: 0.5rem 0;
852
843
  padding: 0.3rem 0.8rem;
853
844
  color: #4a4a4a;
854
845
  font-style: italic;
855
846
  }
856
- .link-preview-popup table {
857
- border-collapse: collapse;
858
- font-size: 0.8rem;
859
- margin: 0.5rem 0;
860
- }
861
- .link-preview-popup th, .link-preview-popup td {
862
- border: 1px solid #ddd;
863
- padding: 0.3rem 0.5rem;
864
- }
865
- .link-preview-popup th { background: #f5f0e4; }
847
+ .link-ctx-popup-body table, .link-preview-popup table { border-collapse: collapse; font-size: 0.8rem; margin: 0.5rem 0; }
848
+ .link-ctx-popup-body th, .link-ctx-popup-body td,
849
+ .link-preview-popup th, .link-preview-popup td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; }
850
+ .link-ctx-popup-body th, .link-preview-popup th { background: #f5f0e4; }
866
851
 
867
852
  /* Footnote tooltips */
868
853
  .footnote-tooltip {
@@ -1427,14 +1412,13 @@
1427
1412
  }, { passive: true });
1428
1413
  })();
1429
1414
 
1430
- // Right-click / long-press popup for all links
1415
+ // Left-click / tap popup for all links
1431
1416
  (function() {
1432
1417
  var cache = Object.create(null);
1433
1418
  var popup = null;
1434
- var longPressTimer = null;
1435
1419
  var touchMoved = false;
1436
- var pendingTouch = null;
1437
- var longPressHandled = false;
1420
+ var historyStack = [];
1421
+ var currentPopupPos = { x: 0, y: 0 };
1438
1422
 
1439
1423
  function escHtml(s) {
1440
1424
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
@@ -1460,19 +1444,91 @@
1460
1444
  return '/preview/' + resolved.pathname.replace(/^\/browse\//, '');
1461
1445
  }
1462
1446
 
1463
- function showPopup(x, y, title, bodyHtml) {
1464
- hidePopup();
1447
+ var extLinkIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;flex-shrink:0"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
1448
+
1449
+ function showPopup(x, y, title, bodyHtml, href) {
1450
+ // Remove old popup without clearing historyStack
1451
+ if (popup && popup.parentNode) popup.parentNode.removeChild(popup);
1452
+ popup = null;
1453
+ currentPopupPos = { x: x, y: y };
1454
+
1465
1455
  popup = document.createElement('div');
1466
1456
  popup.className = 'link-ctx-popup';
1457
+ var backBtn = historyStack.length > 0 ? '<button class="link-ctx-popup-back" aria-label="Back">\u2190</button>' : '';
1458
+ var titleHtml = href
1459
+ ? '<div class="link-ctx-popup-title-wrap">' +
1460
+ '<a class="link-ctx-popup-title" href="' + escHtml(href) + '"><span>' + escHtml(title) + '</span></a>' +
1461
+ '<a class="link-ctx-popup-newtab" href="' + escHtml(href) + '" target="_blank" rel="noopener" title="Open in new tab">' + extLinkIcon + '</a>' +
1462
+ '</div>'
1463
+ : '<span class="link-ctx-popup-title-wrap"><span class="link-ctx-popup-title"><span>' + escHtml(title) + '</span></span></span>';
1467
1464
  popup.innerHTML =
1468
1465
  '<div class="link-ctx-popup-header">' +
1469
- '<span class="link-ctx-popup-title">' + escHtml(title) + '</span>' +
1466
+ backBtn + titleHtml +
1470
1467
  '<button class="link-ctx-popup-close" aria-label="Close">\u00d7</button>' +
1471
1468
  '</div>' +
1472
1469
  '<div class="link-ctx-popup-body">' + bodyHtml + '</div>';
1473
1470
  document.body.appendChild(popup);
1474
1471
 
1475
- // Position near pointer, clamped to viewport
1472
+ repositionPopup();
1473
+
1474
+ var backBtnEl = popup.querySelector('.link-ctx-popup-back');
1475
+ if (backBtnEl) {
1476
+ backBtnEl.addEventListener('click', function(e) {
1477
+ e.stopPropagation();
1478
+ var prev = historyStack.pop();
1479
+ if (prev) showPopup(prev.x, prev.y, prev.title, prev.bodyHtml, prev.href);
1480
+ });
1481
+ }
1482
+ popup.querySelector('.link-ctx-popup-close').addEventListener('click', hidePopup);
1483
+ popup.addEventListener('click', function(e) { e.stopPropagation(); });
1484
+
1485
+ // Links in the popup body push current state to history, then open a new popup
1486
+ // at the same screen position as the current popup
1487
+ var body = popup.querySelector('.link-ctx-popup-body');
1488
+ body.addEventListener('click', function(e) {
1489
+ var anchor = findLink(e.target);
1490
+ if (!anchor) return;
1491
+ var linkHref = anchor.getAttribute('href');
1492
+ if (!linkHref || isAnchorOnly(linkHref)) return;
1493
+ e.stopPropagation();
1494
+ e.preventDefault();
1495
+ var savedPos = { x: currentPopupPos.x, y: currentPopupPos.y };
1496
+ var titleEl = popup.querySelector('.link-ctx-popup-title');
1497
+ historyStack.push({
1498
+ x: savedPos.x, y: savedPos.y,
1499
+ title: titleEl ? titleEl.querySelector('span').textContent : '',
1500
+ bodyHtml: body.innerHTML,
1501
+ href: titleEl ? (titleEl.getAttribute('href') || '') : ''
1502
+ });
1503
+ handleLink(anchor, savedPos.x, savedPos.y, true);
1504
+ });
1505
+ body.addEventListener('touchend', function(e) {
1506
+ if (touchMoved) return;
1507
+ var anchor = findLink(e.target);
1508
+ if (!anchor) return;
1509
+ var linkHref = anchor.getAttribute('href');
1510
+ if (!linkHref || isAnchorOnly(linkHref)) return;
1511
+ e.stopPropagation();
1512
+ e.preventDefault();
1513
+ var savedPos = { x: currentPopupPos.x, y: currentPopupPos.y };
1514
+ var titleEl = popup.querySelector('.link-ctx-popup-title');
1515
+ historyStack.push({
1516
+ x: savedPos.x, y: savedPos.y,
1517
+ title: titleEl ? titleEl.querySelector('span').textContent : '',
1518
+ bodyHtml: body.innerHTML,
1519
+ href: titleEl ? (titleEl.getAttribute('href') || '') : ''
1520
+ });
1521
+ handleLink(anchor, savedPos.x, savedPos.y, true);
1522
+ }, { passive: false });
1523
+
1524
+ ['touchstart','touchmove','touchend'].forEach(function(ev) {
1525
+ popup.addEventListener(ev, function(e) { e.stopPropagation(); }, { passive: true });
1526
+ });
1527
+ }
1528
+
1529
+ function repositionPopup() {
1530
+ if (!popup) return;
1531
+ var x = currentPopupPos.x, y = currentPopupPos.y;
1476
1532
  var vw = Math.min(window.innerWidth, document.documentElement.clientWidth);
1477
1533
  var vh = window.innerHeight;
1478
1534
  var left = x + 12;
@@ -1482,29 +1538,25 @@
1482
1538
  if (top < 8) top = 8;
1483
1539
  popup.style.left = left + 'px';
1484
1540
  popup.style.top = top + 'px';
1485
-
1486
- popup.querySelector('.link-ctx-popup-close').addEventListener('click', hidePopup);
1487
- popup.addEventListener('click', function(e) { e.stopPropagation(); });
1488
- popup.addEventListener('contextmenu', function(e) { e.preventDefault(); e.stopPropagation(); });
1489
- ['touchstart','touchmove','touchend'].forEach(function(ev) {
1490
- popup.addEventListener(ev, function(e) { e.stopPropagation(); }, { passive: true });
1491
- });
1492
1541
  }
1493
1542
 
1494
1543
  function updatePopup(bodyHtml, title) {
1495
1544
  if (!popup) return;
1496
1545
  var body = popup.querySelector('.link-ctx-popup-body');
1497
- var titleEl = popup.querySelector('.link-ctx-popup-title');
1546
+ var titleTextEl = popup.querySelector('.link-ctx-popup-title span');
1498
1547
  if (body) body.innerHTML = bodyHtml;
1499
- if (title && titleEl) titleEl.textContent = title;
1548
+ if (title && titleTextEl) titleTextEl.textContent = title;
1549
+ repositionPopup();
1500
1550
  }
1501
1551
 
1502
1552
  function hidePopup() {
1503
1553
  if (popup && popup.parentNode) popup.parentNode.removeChild(popup);
1504
1554
  popup = null;
1555
+ historyStack = [];
1505
1556
  }
1506
1557
 
1507
- function handleLink(anchor, x, y) {
1558
+ function handleLink(anchor, x, y, chained) {
1559
+ if (!chained) historyStack = [];
1508
1560
  var href = anchor.getAttribute('href');
1509
1561
  if (!href || isAnchorOnly(href)) return;
1510
1562
  var label = anchor.textContent.trim() || href;
@@ -1513,13 +1565,13 @@
1513
1565
  var path = previewPath(href);
1514
1566
  var cached = cache[path];
1515
1567
  if (cached && typeof cached === 'object') {
1516
- showPopup(x, y, cached.title || label, cached.html);
1568
+ showPopup(x, y, cached.title || label, cached.html, href);
1517
1569
  } else if (cached === false) {
1518
1570
  showPopup(x, y, label,
1519
1571
  '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
1520
- '<p style="margin:0;color:#888;font-family:sans-serif;font-size:0.82rem">Preview not available</p>');
1572
+ '<p style="margin:0;color:#888;font-family:sans-serif;font-size:0.82rem">Preview not available</p>', href);
1521
1573
  } else {
1522
- showPopup(x, y, label, '<p style="opacity:0.5;margin:0;font-family:sans-serif">Loading\u2026</p>');
1574
+ showPopup(x, y, label, '<p style="opacity:0.5;margin:0;font-family:sans-serif">Loading\u2026</p>', href);
1523
1575
  if (cached === undefined) {
1524
1576
  cache[path] = null;
1525
1577
  fetch(path)
@@ -1539,13 +1591,13 @@
1539
1591
  var extKey = 'ext:' + href;
1540
1592
  var extCached = cache[extKey];
1541
1593
  if (extCached && typeof extCached === 'object') {
1542
- showPopup(x, y, extCached.title || label, extCached.html);
1594
+ showPopup(x, y, extCached.title || label, extCached.html, href);
1543
1595
  } else if (extCached === false) {
1544
1596
  showPopup(x, y, label,
1545
1597
  '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
1546
- '<p style="margin:0.5rem 0 0;color:#888;font-family:sans-serif;font-size:0.82rem">Could not fetch page content.</p>');
1598
+ '<p style="margin:0.5rem 0 0;color:#888;font-family:sans-serif;font-size:0.82rem">Could not fetch page content.</p>', href);
1547
1599
  } else {
1548
- showPopup(x, y, label, '<p style="opacity:0.5;margin:0;font-family:sans-serif">Loading\u2026</p>');
1600
+ showPopup(x, y, label, '<p style="opacity:0.5;margin:0;font-family:sans-serif">Loading\u2026</p>', href);
1549
1601
  if (extCached === undefined) {
1550
1602
  cache[extKey] = null;
1551
1603
  fetch('/fetch?url=' + encodeURIComponent(href))
@@ -1570,201 +1622,76 @@
1570
1622
  }
1571
1623
  }
1572
1624
  } else {
1573
- showPopup(x, y, label, '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>');
1625
+ showPopup(x, y, label, '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>', href);
1574
1626
  }
1575
1627
  }
1576
1628
 
1577
- // Right-click (desktop + most mobile browsers fire contextmenu on long-press)
1578
- document.addEventListener('contextmenu', function(e) {
1579
- if (popup && popup.contains(e.target)) { e.preventDefault(); return; }
1629
+ // Left-click on desktop
1630
+ document.addEventListener('click', function(e) {
1631
+ if (popup && popup.contains(e.target)) return;
1580
1632
  var anchor = findLink(e.target);
1581
- if (!anchor) return;
1633
+ if (!anchor) { hidePopup(); return; }
1582
1634
  var href = anchor.getAttribute('href');
1583
- if (!href || isAnchorOnly(href)) return;
1635
+ if (!href || isAnchorOnly(href)) { hidePopup(); return; }
1636
+ if (!anchor.closest('.md-content')) { hidePopup(); return; }
1584
1637
  e.preventDefault();
1585
- clearTimeout(longPressTimer);
1586
- pendingTouch = null;
1587
- if (!longPressHandled) handleLink(anchor, e.clientX, e.clientY);
1588
- longPressHandled = false;
1638
+ handleLink(anchor, e.clientX, e.clientY);
1589
1639
  });
1590
1640
 
1591
- // Long-press fallback for touch devices that don't fire contextmenu
1641
+ // Touch tap
1592
1642
  document.addEventListener('touchstart', function(e) {
1593
1643
  if (e.touches.length !== 1) return;
1594
- if (popup && popup.contains(e.target)) return;
1595
- var anchor = findLink(e.target);
1596
- if (!anchor) return;
1597
- var href = anchor.getAttribute('href');
1598
- if (!href || isAnchorOnly(href)) return;
1599
1644
  touchMoved = false;
1600
- longPressHandled = false;
1601
- var t = e.touches[0];
1602
- pendingTouch = { anchor: anchor, x: t.clientX, y: t.clientY };
1603
- clearTimeout(longPressTimer);
1604
- longPressTimer = setTimeout(function() {
1605
- if (!touchMoved && pendingTouch) {
1606
- longPressHandled = true;
1607
- handleLink(pendingTouch.anchor, pendingTouch.x, pendingTouch.y);
1608
- pendingTouch = null;
1609
- setTimeout(function() { longPressHandled = false; }, 400);
1610
- }
1611
- }, 600);
1612
1645
  }, { passive: true });
1613
1646
 
1614
1647
  document.addEventListener('touchmove', function() {
1615
1648
  touchMoved = true;
1616
- clearTimeout(longPressTimer);
1617
- pendingTouch = null;
1618
1649
  }, { passive: true });
1619
1650
 
1620
- document.addEventListener('touchend', function() {
1621
- clearTimeout(longPressTimer);
1622
- pendingTouch = null;
1623
- }, { passive: true });
1624
-
1625
- document.addEventListener('touchcancel', function() {
1626
- clearTimeout(longPressTimer);
1627
- pendingTouch = null;
1628
- longPressHandled = false;
1629
- }, { passive: true });
1651
+ document.addEventListener('touchend', function(e) {
1652
+ if (touchMoved) return;
1653
+ if (popup && popup.contains(e.target)) return;
1654
+ var anchor = findLink(e.target);
1655
+ if (!anchor) { hidePopup(); return; }
1656
+ var href = anchor.getAttribute('href');
1657
+ if (!href || isAnchorOnly(href)) { hidePopup(); return; }
1658
+ if (!anchor.closest('.md-content')) { hidePopup(); return; }
1659
+ e.preventDefault();
1660
+ var touch = e.changedTouches[0];
1661
+ handleLink(anchor, touch.clientX, touch.clientY);
1662
+ }, { passive: false });
1630
1663
 
1631
- document.addEventListener('click', function(e) {
1632
- if (popup && !popup.contains(e.target)) hidePopup();
1633
- });
1634
1664
  document.addEventListener('keydown', function(e) {
1635
1665
  if (e.key === 'Escape' && popup) hidePopup();
1636
1666
  });
1637
- })();
1638
1667
 
1639
1668
  <% if settings.link_tooltips %>
1640
- // Link preview popup for local markdown links
1641
- (function() {
1642
- var content = document.querySelector('.md-content');
1643
- if (!content) return;
1644
-
1645
- var cache = Object.create(null);
1646
- var activePopup = null;
1647
- var activeAnchor = null;
1648
- var hideTimer = null;
1649
-
1650
- function escHtml(s) {
1651
- return String(s)
1652
- .replace(/&/g, '&amp;').replace(/</g, '&lt;')
1653
- .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1654
- }
1655
-
1656
- function isLocalMdLink(a) {
1657
- if (a.classList.contains('broken')) return false;
1658
- var href = a.getAttribute('href');
1659
- if (!href) return false;
1660
- if (/^https?:\/\//i.test(href)) return false;
1661
- var path = href.split('?')[0].split('#')[0];
1662
- return /\.md$/i.test(path);
1663
- }
1664
-
1665
- function previewUrl(a) {
1666
- var resolved = new URL(a.getAttribute('href'), location.href);
1667
- return '/preview/' + resolved.pathname.replace(/^\/browse\//, '');
1668
- }
1669
-
1670
- function showPopup(anchor, html) {
1671
- hidePopup();
1672
- var pop = document.createElement('div');
1673
- pop.className = 'link-preview-popup';
1674
- pop.innerHTML = html;
1675
- anchor.appendChild(pop);
1676
- activePopup = pop;
1677
- activeAnchor = anchor;
1678
-
1679
- // Clamp right edge to viewport
1680
- var vw = Math.min(window.innerWidth, document.documentElement.clientWidth);
1681
- var rect = pop.getBoundingClientRect();
1682
- if (rect.right > vw - 8) {
1683
- pop.style.left = Math.max(0, parseFloat(pop.style.left || 0) - (rect.right - (vw - 8))) + 'px';
1684
- }
1685
- // If top goes off screen, flip below the link instead
1686
- rect = pop.getBoundingClientRect();
1687
- if (rect.top < 8) {
1688
- pop.style.bottom = 'auto';
1689
- pop.style.top = '100%';
1690
- }
1691
-
1692
- // Keep popup open while hovering over it
1693
- pop.addEventListener('mouseenter', function() { clearTimeout(hideTimer); });
1694
- pop.addEventListener('mouseleave', function() { hideTimer = setTimeout(hidePopup, 10); });
1695
-
1696
- // Stop clicks/touches inside the popup from triggering the outer link
1697
- pop.addEventListener('click', function(e) {
1698
- e.stopPropagation();
1699
- if (!e.target.closest('a')) e.preventDefault();
1700
- });
1701
- pop.addEventListener('touchstart', function(e) { e.stopPropagation(); }, { passive: true });
1702
- pop.addEventListener('touchmove', function(e) { e.stopPropagation(); }, { passive: true });
1703
- pop.addEventListener('touchend', function(e) {
1704
- e.stopPropagation();
1705
- if (!e.target.closest('a')) e.preventDefault();
1706
- });
1707
- }
1708
-
1709
- function hidePopup() {
1710
- if (activePopup && activePopup.parentNode) activePopup.parentNode.removeChild(activePopup);
1711
- activePopup = null;
1712
- activeAnchor = null;
1713
- }
1714
-
1715
- function fetchAndShow(anchor) {
1716
- var url = previewUrl(anchor);
1717
- var cached = cache[url];
1718
- if (cached === undefined) {
1719
- cache[url] = null; // in-flight
1720
- showPopup(anchor, '<p style="opacity:0.5;margin:0">Loading\u2026</p>');
1721
- fetch(url)
1722
- .then(function(r) { return r.ok ? r.json() : null; })
1723
- .then(function(data) {
1724
- if (!data) { cache[url] = false; if (activeAnchor === anchor) hidePopup(); return; }
1725
- var html = '<div class="link-preview-popup-title">' + escHtml(data.title) + '</div>' + data.html;
1726
- cache[url] = html;
1727
- if (activeAnchor === anchor) showPopup(anchor, html);
1728
- })
1729
- .catch(function() { cache[url] = false; if (activeAnchor === anchor) hidePopup(); });
1730
- } else if (typeof cached === 'string') {
1731
- showPopup(anchor, cached);
1732
- }
1733
- }
1734
-
1735
- content.querySelectorAll('a').forEach(function(a) {
1736
- if (!isLocalMdLink(a)) return;
1737
- a.classList.add('link-tooltip-anchor');
1738
-
1669
+ // Hover same popup as click, triggered after a short delay when no popup is active
1670
+ (function() {
1671
+ var content = document.querySelector('.md-content');
1672
+ if (!content) return;
1739
1673
  var hoverTimer = null;
1740
- a.addEventListener('click', hidePopup);
1741
- a.addEventListener('mouseenter', function() {
1742
- hoverTimer = setTimeout(function() { fetchAndShow(a); }, 300);
1743
- });
1744
- a.addEventListener('mouseleave', function() {
1745
- clearTimeout(hoverTimer);
1746
- hideTimer = setTimeout(hidePopup, 10);
1747
- });
1748
-
1749
- var touchMoved = false;
1750
- a.addEventListener('touchstart', function() { touchMoved = false; }, { passive: true });
1751
- a.addEventListener('touchmove', function() { touchMoved = true; }, { passive: true });
1752
- a.addEventListener('touchend', function(e) {
1753
- if (touchMoved) return;
1754
- if (activeAnchor === a && activePopup) return; // second tap → navigate
1755
- e.preventDefault();
1756
- document.querySelectorAll('.link-preview-popup').forEach(function(p) {
1757
- if (p.parentNode) p.parentNode.removeChild(p);
1674
+ content.querySelectorAll('a').forEach(function(a) {
1675
+ var href = a.getAttribute('href');
1676
+ if (!href || isAnchorOnly(href)) return;
1677
+ if (!isLocalMd(href) && !isExternal(href)) return;
1678
+ a.addEventListener('mouseenter', function(e) {
1679
+ clearTimeout(hoverTimer);
1680
+ if (popup) return;
1681
+ var x = e.clientX, y = e.clientY;
1682
+ hoverTimer = setTimeout(function() {
1683
+ if (!popup) handleLink(a, x, y, false);
1684
+ }, 300);
1685
+ });
1686
+ a.addEventListener('mouseleave', function() {
1687
+ clearTimeout(hoverTimer);
1758
1688
  });
1759
- fetchAndShow(a);
1760
1689
  });
1761
- });
1690
+ })();
1691
+ <% end %>
1762
1692
 
1763
- document.addEventListener('click', function(e) {
1764
- if (activePopup && activeAnchor && !activeAnchor.contains(e.target)) hidePopup();
1765
- });
1766
1693
  })();
1767
- <% end %>
1694
+
1768
1695
 
1769
1696
  </script>
1770
1697
  </body>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: markdownr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.9
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn