markdownr 0.5.11 → 0.5.13

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: 49c6809e18e2fe51a4e65446e185529edd529e61bb77e6eea79696b734ebdb8b
4
- data.tar.gz: 9d533023489082e69413b4e718c27efa05076f1da863f25d9d68de26bd3ec090
3
+ metadata.gz: 8825ac583ace67b9cd50c7aef4d09a9de175bd6f3c81d4fcd2d8d5159476b76f
4
+ data.tar.gz: 55301d8c94bea36eef2b30571a4971e4d2661689ed4faab4d85a2632dda61af3
5
5
  SHA512:
6
- metadata.gz: a2602173ee41f8d6034e410a8deba4fb11f5f3149e39c410dbc6366349dc93ba0bec519c6860e4970a7f14ed20dd5f8eeadf03d6452e821993e0d0450bebf19e
7
- data.tar.gz: a5dc9a0bb156371fddcbdcdab626ee36b82079a175c56d967f7d4ed1f746d5f191bf709efbb7c0d4cb4f8419d072f51b1d92c8e39e90c8e643c220ebe57d4cbf
6
+ metadata.gz: 4fb68ab533ae36e9509d85c29bddb4c23fc36747a4eadfd1e1b78fe73d1af2d872f14089557db3dbe43ed1fec08df44b60cabd07471664f3792e20ff60857c04
7
+ data.tar.gz: abe3b62dc09675cc99d670915cae25690270ce83a9274cf7954020c8b883b94c22ed9d984e12cf27858fff227346196137cae291b0d944cce3290a7e8672364b
@@ -10,11 +10,14 @@ require "pathname"
10
10
  require "set"
11
11
  require "net/http"
12
12
  require "base64"
13
+ require_relative "bible_citations"
13
14
 
14
15
  module MarkdownServer
15
16
  class App < Sinatra::Base
16
17
  EXCLUDED = %w[.git .obsidian __pycache__ .DS_Store node_modules .claude].freeze
17
18
 
19
+ # Bible citation auto-linking — see lib/markdown_server/bible_citations.rb
20
+
18
21
  set :views, File.expand_path("../../views", __dir__)
19
22
 
20
23
  configure do
@@ -125,6 +128,25 @@ module MarkdownServer
125
128
  end
126
129
 
127
130
  def render_markdown(text)
131
+ # Convert mermaid code fences to raw HTML divs before Kramdown so Rouge
132
+ # never touches them and the content is preserved exactly for Mermaid.js.
133
+ text = text.gsub(/^```mermaid[ \t]*\r?\n([\s\S]*?)^```[ \t]*(\r?\n|\z)/m) do
134
+ "<div class=\"mermaid\">\n#{h($1.rstrip)}\n</div>\n\n"
135
+ end
136
+
137
+ # Auto-link bare Bible verse citations (e.g. "John 3:16" → BibleGateway link).
138
+ # Skips inline code, fenced code blocks, and citations already inside a link.
139
+ text = text.gsub(BIBLE_CITATION_RE) do |m|
140
+ next m if $1 || $2 || $3 # code span / code fence / existing link
141
+ citation = $4
142
+ entry = BIBLE_BOOK_MAP_SORTED.find { |k, _| citation.start_with?(k) }
143
+ next m unless entry
144
+ abbrev, canonical = entry
145
+ rest = citation[abbrev.length..].sub(/\A\.?[ \t]?/, "")
146
+ url = "https://www.biblegateway.com/passage/?search=#{CGI.escape("#{canonical} #{rest}")}&version=CEB"
147
+ "[#{citation}](#{url})"
148
+ end
149
+
128
150
  # Process wiki links BEFORE Kramdown so that | isn't consumed as
129
151
  # a GFM table delimiter.
130
152
  text = text.gsub(/\[\[([^\]]+)\]\]/) do
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # Bible citation auto-linking for Markdown rendering.
4
+ #
5
+ # Provides two constants used in render_markdown:
6
+ #
7
+ # BIBLE_BOOK_MAP – hash of abbreviation → BibleGateway canonical name
8
+ # BIBLE_CITATION_RE – combined regex that matches inline code, fenced code
9
+ # blocks, existing markdown links (to skip), and bare
10
+ # citations (to replace with a BibleGateway link).
11
+ #
12
+ # Usage in render_markdown:
13
+ #
14
+ # text = text.gsub(BIBLE_CITATION_RE) do |m|
15
+ # next m if $1 || $2 || $3 # code span / code fence / existing link
16
+ # citation = $4
17
+ # canonical = BIBLE_BOOK_MAP.find { |k, _| citation.start_with?(k) }&.last
18
+ # next m unless canonical
19
+ # rest = citation[canonical.length..].sub(/\A\.?[ \t]?/, "")
20
+ # url = "https://www.biblegateway.com/passage/?search=#{CGI.escape("#{canonical} #{rest}")}&version=CEB"
21
+ # "[#{citation}](#{url})"
22
+ # end
23
+ #
24
+ # To copy to another project: copy this file and add the gsub block above to
25
+ # your markdown rendering method.
26
+
27
+ module MarkdownServer
28
+ # Maps every recognised abbreviation to its BibleGateway canonical book name.
29
+ # Keys are sorted longest-first when building the regex so longer forms win.
30
+ BIBLE_BOOK_MAP = {
31
+ # ── Old Testament ────────────────────────────────────────────────────────
32
+ "Genesis"=>"Genesis","Gen."=>"Genesis","Gen"=>"Genesis","Gn"=>"Genesis",
33
+ "Exodus"=>"Exodus","Ex."=>"Exodus","Exo"=>"Exodus","Ex"=>"Exodus",
34
+ "Leviticus"=>"Leviticus","Lev."=>"Leviticus","Lev"=>"Leviticus",
35
+ "Numbers"=>"Numbers","Num."=>"Numbers","Nu."=>"Numbers","Nu"=>"Numbers",
36
+ "Deuteronomy"=>"Deuteronomy","Deut."=>"Deuteronomy","Deut"=>"Deuteronomy",
37
+ "Dt."=>"Deuteronomy","Dt"=>"Deuteronomy",
38
+ "Joshua"=>"Joshua","Josh."=>"Joshua",
39
+ "Judges"=>"Judges","Judg."=>"Judges",
40
+ "Ruth"=>"Ruth",
41
+ "1 Samuel"=>"1 Samuel","1Samuel"=>"1 Samuel","1 Sam."=>"1 Samuel","1 Sam"=>"1 Samuel","1Sam."=>"1 Samuel",
42
+ "2 Samuel"=>"2 Samuel","2Samuel"=>"2 Samuel","2 Sam."=>"2 Samuel","2 Sam"=>"2 Samuel","2Sam."=>"2 Samuel","2Sa."=>"2 Samuel",
43
+ "1 Kings"=>"1 Kings","1Kings"=>"1 Kings","1 Ki."=>"1 Kings","1Ki."=>"1 Kings",
44
+ "2 Kings"=>"2 Kings","2Kings"=>"2 Kings","2 Ki."=>"2 Kings","2Ki."=>"2 Kings",
45
+ "1 Chronicles"=>"1 Chronicles","1 Chr."=>"1 Chronicles","1Chr."=>"1 Chronicles","1Ch"=>"1 Chronicles",
46
+ "2 Chronicles"=>"2 Chronicles","2 Chron."=>"2 Chronicles","2Chron."=>"2 Chronicles","2 Chr."=>"2 Chronicles","2Chr."=>"2 Chronicles","2 Ch"=>"2 Chronicles",
47
+ "Ezra"=>"Ezra","Nehemiah"=>"Nehemiah","Neh."=>"Nehemiah",
48
+ "Esther"=>"Esther","Esth."=>"Esther",
49
+ "Job"=>"Job",
50
+ "Psalms"=>"Psalms","Psalm"=>"Psalms","Psa."=>"Psalms","Psa"=>"Psalms","Ps."=>"Psalms","Ps"=>"Psalms",
51
+ "Proverbs"=>"Proverbs","Prov."=>"Proverbs","Prov"=>"Proverbs","Pr"=>"Proverbs",
52
+ "Ecclesiastes"=>"Ecclesiastes","Eccl."=>"Ecclesiastes","Ecc."=>"Ecclesiastes",
53
+ "Song of Songs"=>"Song of Songs","Song"=>"Song of Songs",
54
+ "Isaiah"=>"Isaiah","Isa."=>"Isaiah","Isa"=>"Isaiah","Is"=>"Isaiah",
55
+ "Jeremiah"=>"Jeremiah","Jer."=>"Jeremiah","Je"=>"Jeremiah",
56
+ "Lamentations"=>"Lamentations","Lam."=>"Lamentations","Lam"=>"Lamentations","La"=>"Lamentations",
57
+ "Ezekiel"=>"Ezekiel","Ezek."=>"Ezekiel","Ezek"=>"Ezekiel",
58
+ "Daniel"=>"Daniel","Dan."=>"Daniel","Dan"=>"Daniel",
59
+ "Hosea"=>"Hosea","Hos."=>"Hosea","Hos"=>"Hosea",
60
+ "Joel"=>"Joel","Amos"=>"Amos",
61
+ "Obadiah"=>"Obadiah","Jonah"=>"Jonah","Jon."=>"Jonah",
62
+ "Micah"=>"Micah","Mic."=>"Micah",
63
+ "Nahum"=>"Nahum","Nah."=>"Nahum",
64
+ "Haggai"=>"Haggai",
65
+ "Zechariah"=>"Zechariah","Zech."=>"Zechariah","Zec"=>"Zechariah","Zc"=>"Zechariah",
66
+ "Zephaniah"=>"Zephaniah","Zep."=>"Zephaniah",
67
+ "Malachi"=>"Malachi","Mal."=>"Malachi",
68
+ # ── New Testament ────────────────────────────────────────────────────────
69
+ "Matthew"=>"Matthew","Matt."=>"Matthew","Mt."=>"Matthew","Mt"=>"Matthew",
70
+ "Mark"=>"Mark","Mk."=>"Mark","Mk"=>"Mark",
71
+ "Luke"=>"Luke","Lk."=>"Luke","Lk"=>"Luke",
72
+ "John"=>"John","Jn."=>"John","Jn"=>"John","Jo"=>"John",
73
+ "Acts"=>"Acts","Ac."=>"Acts",
74
+ "Romans"=>"Romans","Rom."=>"Romans","Ro"=>"Romans",
75
+ "1 Corinthians"=>"1 Corinthians","1Corinthians"=>"1 Corinthians","1 Cor."=>"1 Corinthians","1Cor."=>"1 Corinthians","1 Cor"=>"1 Corinthians","1Cor"=>"1 Corinthians","1Co."=>"1 Corinthians","1Co"=>"1 Corinthians",
76
+ "2 Corinthians"=>"2 Corinthians","2Corinthians"=>"2 Corinthians","2 Cor."=>"2 Corinthians","2Cor."=>"2 Corinthians","2 Cor"=>"2 Corinthians","2Co."=>"2 Corinthians","2Co"=>"2 Corinthians",
77
+ "Galatians"=>"Galatians","Gal."=>"Galatians","Gal"=>"Galatians",
78
+ "Ephesians"=>"Ephesians","Eph."=>"Ephesians","Eph"=>"Ephesians",
79
+ "Philippians"=>"Philippians","Phil."=>"Philippians","Phil"=>"Philippians",
80
+ "Colossians"=>"Colossians","Col."=>"Colossians","Col"=>"Colossians",
81
+ "1 Thessalonians"=>"1 Thessalonians","1 Thess."=>"1 Thessalonians","1Thess."=>"1 Thessalonians","1 Th"=>"1 Thessalonians","1Th"=>"1 Thessalonians",
82
+ "2 Thessalonians"=>"2 Thessalonians","2Thessalonians"=>"2 Thessalonians","2 Thess."=>"2 Thessalonians","2Thess."=>"2 Thessalonians","2 Th"=>"2 Thessalonians","2Th"=>"2 Thessalonians",
83
+ "1 Timothy"=>"1 Timothy","1Timothy"=>"1 Timothy","1 Tim."=>"1 Timothy","1Tim."=>"1 Timothy","1 Ti."=>"1 Timothy","1Ti."=>"1 Timothy",
84
+ "2 Timothy"=>"2 Timothy","2Timothy"=>"2 Timothy","2 Tim."=>"2 Timothy","2Tim."=>"2 Timothy","2 Ti."=>"2 Timothy","2Ti."=>"2 Timothy",
85
+ "Titus"=>"Titus","Tit."=>"Titus","Tit"=>"Titus","Ti."=>"Titus",
86
+ "Philemon"=>"Philemon","Philem."=>"Philemon",
87
+ "Hebrews"=>"Hebrews","Hebrew"=>"Hebrews","Heb."=>"Hebrews","Heb"=>"Hebrews","Hb"=>"Hebrews",
88
+ "James"=>"James","Jas."=>"James","Jas"=>"James",
89
+ "1 Peter"=>"1 Peter","1Peter"=>"1 Peter","1 Pet."=>"1 Peter","1Pet."=>"1 Peter","1 Pt."=>"1 Peter","1Pt."=>"1 Peter",
90
+ "2 Peter"=>"2 Peter","2Peter"=>"2 Peter","2 Pet."=>"2 Peter","2Pet."=>"2 Peter","2 Pt."=>"2 Peter","2Pt."=>"2 Peter","2Pe"=>"2 Peter",
91
+ "1 John"=>"1 John","1John"=>"1 John","1 Jn."=>"1 John","1Jn."=>"1 John","1 Jn"=>"1 John","1Jn"=>"1 John",
92
+ "2 John"=>"2 John","2 Jn."=>"2 John","2Jn."=>"2 John",
93
+ "3 John"=>"3 John","3 Jn."=>"3 John","3Jn."=>"3 John",
94
+ "Jude"=>"Jude",
95
+ "Revelation"=>"Revelation","Rev."=>"Revelation","Rev"=>"Revelation",
96
+ # ── Deuterocanonical (present in CEB) ───────────────────────────────────
97
+ "Wisdom of Solomon"=>"Wisdom of Solomon","Wisdom"=>"Wisdom of Solomon","Wis."=>"Wisdom of Solomon",
98
+ "1 Maccabees"=>"1 Maccabees","1Macc."=>"1 Maccabees",
99
+ }.freeze
100
+
101
+ # Sorted longest-first for runtime lookup (same order as the regex alternation).
102
+ BIBLE_BOOK_MAP_SORTED = BIBLE_BOOK_MAP.sort_by { |k, _| -k.length }.freeze
103
+
104
+ _book_alts = BIBLE_BOOK_MAP.keys.sort_by(&:length).reverse
105
+ .map { |k| Regexp.escape(k) }.join("|")
106
+ _verse = /\d+[abc]?(?:[–—\-]\d+[abc]?)?(?:,\s*\d+[abc]?(?:[–—\-]\d+[abc]?)?)*(?:ff\.?|f\.)?/
107
+
108
+ # Combined regex. Four alternatives — only the last (group 4) is replaced:
109
+ # 1. inline code span → return verbatim
110
+ # 2. fenced code block → return verbatim
111
+ # 3. existing md link → return verbatim
112
+ # 4. bare citation → replace with BibleGateway markdown link
113
+ BIBLE_CITATION_RE = Regexp.new(
114
+ "(`[^`]*?`)" \
115
+ "|(`{3}[\\s\\S]*?`{3})" \
116
+ "|(?<!\\!)(\\[[^\\[\\]]*\\]\\([^)]+\\))" \
117
+ "|((?:#{_book_alts})\\.?[ \\t]?\\d+:#{_verse.source})",
118
+ Regexp::MULTILINE
119
+ )
120
+ end
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.5.11"
2
+ VERSION = "0.5.13"
3
3
  end
data/views/layout.erb CHANGED
@@ -315,6 +315,15 @@
315
315
  }
316
316
  .md-content tr:nth-child(even) { background: #fdfcf9; }
317
317
 
318
+ /* Bible Gateway citation links */
319
+ a[href*="biblegateway.com"] {
320
+ color: #7b3fa0;
321
+ font-weight: bold;
322
+ text-decoration: none;
323
+ border-bottom: 1px solid #b07fd4;
324
+ }
325
+ a[href*="biblegateway.com"]:hover { color: #5a2c78; border-bottom-color: #5a2c78; }
326
+
318
327
  /* Wiki links */
319
328
  a.wiki-link {
320
329
  color: #6a8e3e;
@@ -754,6 +763,9 @@
754
763
  box-shadow: 0 4px 20px rgba(0,0,0,0.22);
755
764
  -webkit-overflow-scrolling: touch;
756
765
  cursor: auto;
766
+ resize: both;
767
+ min-width: 280px;
768
+ min-height: 80px;
757
769
  }
758
770
  .link-ctx-popup-header {
759
771
  display: flex;
@@ -766,6 +778,11 @@
766
778
  top: 0;
767
779
  background: #faf8f4;
768
780
  z-index: 1;
781
+ cursor: grab;
782
+ }
783
+ .link-ctx-popup-header.is-dragging {
784
+ cursor: grabbing;
785
+ user-select: none;
769
786
  }
770
787
  .link-ctx-popup-title-wrap {
771
788
  flex: 1;
@@ -815,6 +832,26 @@
815
832
  flex-shrink: 0;
816
833
  }
817
834
  .link-ctx-popup-back:hover { color: #2c2c2c; }
835
+ .link-ctx-popup-pin {
836
+ background: none;
837
+ border: none;
838
+ color: #bbb;
839
+ cursor: pointer;
840
+ padding: 0 0.2rem;
841
+ flex-shrink: 0;
842
+ display: flex;
843
+ align-items: center;
844
+ line-height: 1;
845
+ }
846
+ .link-ctx-popup-pin:hover { color: #2c2c2c; }
847
+ .link-ctx-popup-pin.pinned { color: #8b6914; }
848
+ .link-ctx-popup--pinned {
849
+ max-height: none;
850
+ min-height: 120px;
851
+ }
852
+ .link-ctx-popup--pinned .link-ctx-popup-header {
853
+ user-select: none;
854
+ }
818
855
  .link-ctx-popup-body {
819
856
  padding: 0.75rem 1rem;
820
857
  }
@@ -829,6 +866,10 @@
829
866
  margin-bottom: 0.5rem;
830
867
  }
831
868
 
869
+ /* Mermaid diagrams */
870
+ .mermaid { text-align: center; margin: 1.2rem 0; overflow-x: auto; }
871
+ .mermaid svg { max-width: 100%; height: auto; }
872
+
832
873
  /* Link preview popup (hover) */
833
874
  .link-tooltip-anchor { position: relative; }
834
875
  .link-preview-popup {
@@ -1550,12 +1591,35 @@
1550
1591
  }, { passive: true });
1551
1592
  })();
1552
1593
 
1594
+ // Mermaid diagram rendering — lazily loads Mermaid.js on first use
1595
+ var mermaidReady = false;
1596
+ var mermaidQueue = [];
1597
+ function runMermaidIn(container) {
1598
+ var nodes = Array.prototype.slice.call(container.querySelectorAll('.mermaid:not([data-processed="true"])'));
1599
+ if (!nodes.length) return;
1600
+ var doRender = function() { mermaid.run({ nodes: nodes }); };
1601
+ if (mermaidReady) { doRender(); return; }
1602
+ mermaidQueue.push(doRender);
1603
+ if (mermaidQueue.length > 1) return;
1604
+ var s = document.createElement('script');
1605
+ s.src = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
1606
+ s.onload = function() {
1607
+ mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
1608
+ mermaidReady = true;
1609
+ mermaidQueue.splice(0).forEach(function(fn) { fn(); });
1610
+ };
1611
+ document.head.appendChild(s);
1612
+ }
1613
+ runMermaidIn(document.body);
1614
+
1553
1615
  // Left-click / tap popup for all links
1554
1616
  (function() {
1555
1617
  var cache = Object.create(null);
1556
1618
  var popup = null;
1557
1619
  var touchMoved = false;
1558
1620
  var historyStack = [];
1621
+ var pinnedPopups = [];
1622
+ var popupDragging = false;
1559
1623
  var currentPopupPos = { x: 0, y: 0 };
1560
1624
  var mouseLeaveTimer = null;
1561
1625
 
@@ -1584,6 +1648,7 @@
1584
1648
  }
1585
1649
 
1586
1650
  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>';
1651
+ var pinIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>';
1587
1652
 
1588
1653
  function showPopup(x, y, title, bodyHtml, href, linkRect) {
1589
1654
  // Remove old popup without clearing historyStack
@@ -1593,6 +1658,7 @@
1593
1658
 
1594
1659
  popup = document.createElement('div');
1595
1660
  popup.className = 'link-ctx-popup';
1661
+ var thisPopup = popup;
1596
1662
  var backBtn = historyStack.length > 0 ? '<button class="link-ctx-popup-back" aria-label="Back">\u2190</button>' : '';
1597
1663
  var titleHtml = href
1598
1664
  ? '<div class="link-ctx-popup-title-wrap">' +
@@ -1603,6 +1669,7 @@
1603
1669
  popup.innerHTML =
1604
1670
  '<div class="link-ctx-popup-header">' +
1605
1671
  backBtn + titleHtml +
1672
+ '<button class="link-ctx-popup-pin" aria-label="Pin" title="Pin popup">' + pinIcon + '</button>' +
1606
1673
  '<button class="link-ctx-popup-close" aria-label="Close">\u00d7</button>' +
1607
1674
  '</div>' +
1608
1675
  '<div class="link-ctx-popup-body">' + bodyHtml + '</div>';
@@ -1610,27 +1677,47 @@
1610
1677
 
1611
1678
  clearTimeout(mouseLeaveTimer);
1612
1679
  popup.addEventListener('mouseleave', function() {
1680
+ if (thisPopup.dataset.pinned || popupDragging) return;
1613
1681
  mouseLeaveTimer = setTimeout(hidePopup, 150);
1614
1682
  });
1615
1683
  popup.addEventListener('mouseenter', function() {
1684
+ if (thisPopup.dataset.pinned) return;
1616
1685
  clearTimeout(mouseLeaveTimer);
1617
1686
  });
1618
1687
 
1619
1688
  repositionPopup();
1689
+ makeDraggable(popup);
1690
+ // Lock explicit dimensions on first interaction so resize can grow past max-height
1691
+ thisPopup.addEventListener('mousedown', function lockSize() {
1692
+ thisPopup.style.width = thisPopup.offsetWidth + 'px';
1693
+ thisPopup.style.height = thisPopup.offsetHeight + 'px';
1694
+ thisPopup.style.maxHeight = 'none';
1695
+ thisPopup.removeEventListener('mousedown', lockSize);
1696
+ }, true);
1697
+ runMermaidIn(popup);
1620
1698
 
1621
1699
  var backBtnEl = popup.querySelector('.link-ctx-popup-back');
1622
1700
  if (backBtnEl) {
1623
1701
  backBtnEl.addEventListener('click', function(e) {
1624
1702
  e.stopPropagation();
1625
1703
  var prev = historyStack.pop();
1626
- if (prev) showPopup(prev.x, prev.y, prev.title, prev.bodyHtml, prev.href, prev.linkRect);
1704
+ if (prev) {
1705
+ showPopup(prev.x, prev.y, prev.title, prev.bodyHtml, prev.href, prev.linkRect);
1706
+ if (popup && prev.scrollTop) popup.scrollTop = prev.scrollTop;
1707
+ }
1627
1708
  });
1628
1709
  }
1629
1710
  popup.querySelector('.link-ctx-popup-close').addEventListener('click', hidePopup);
1711
+ popup.querySelector('.link-ctx-popup-pin').addEventListener('click', function(e) {
1712
+ e.stopPropagation();
1713
+ if (!thisPopup.dataset.pinned) pinPopup(thisPopup);
1714
+ else unpinPopup(thisPopup);
1715
+ });
1630
1716
  popup.addEventListener('click', function(e) { e.stopPropagation(); });
1631
1717
 
1632
1718
  // Links in the popup body push current state to history, then open a new popup
1633
- // at the same screen position as the current popup
1719
+ // at the same screen position as the current popup.
1720
+ // When the popup is pinned, links open a new floating popup at the click position instead.
1634
1721
  var body = popup.querySelector('.link-ctx-popup-body');
1635
1722
  body.addEventListener('click', function(e) {
1636
1723
  var anchor = findLink(e.target);
@@ -1639,13 +1726,15 @@
1639
1726
  if (!linkHref || isAnchorOnly(linkHref)) return;
1640
1727
  e.stopPropagation();
1641
1728
  e.preventDefault();
1729
+ if (thisPopup.dataset.pinned) { handleLink(anchor, e.clientX, e.clientY, false); return; }
1642
1730
  var savedPos = { x: currentPopupPos.x, y: currentPopupPos.y, linkRect: currentPopupPos.linkRect };
1643
- var titleEl = popup.querySelector('.link-ctx-popup-title');
1731
+ var titleEl = thisPopup.querySelector('.link-ctx-popup-title');
1644
1732
  historyStack.push({
1645
1733
  x: savedPos.x, y: savedPos.y, linkRect: savedPos.linkRect,
1646
1734
  title: titleEl ? titleEl.querySelector('span').textContent : '',
1647
1735
  bodyHtml: body.innerHTML,
1648
- href: titleEl ? (titleEl.getAttribute('href') || '') : ''
1736
+ href: titleEl ? (titleEl.getAttribute('href') || '') : '',
1737
+ scrollTop: thisPopup.scrollTop
1649
1738
  });
1650
1739
  handleLink(anchor, savedPos.x, savedPos.y, true);
1651
1740
  });
@@ -1657,13 +1746,15 @@
1657
1746
  if (!linkHref || isAnchorOnly(linkHref)) return;
1658
1747
  e.stopPropagation();
1659
1748
  e.preventDefault();
1749
+ if (thisPopup.dataset.pinned) { handleLink(anchor, e.clientX, e.clientY, false); return; }
1660
1750
  var savedPos = { x: currentPopupPos.x, y: currentPopupPos.y, linkRect: currentPopupPos.linkRect };
1661
- var titleEl = popup.querySelector('.link-ctx-popup-title');
1751
+ var titleEl = thisPopup.querySelector('.link-ctx-popup-title');
1662
1752
  historyStack.push({
1663
1753
  x: savedPos.x, y: savedPos.y, linkRect: savedPos.linkRect,
1664
1754
  title: titleEl ? titleEl.querySelector('span').textContent : '',
1665
1755
  bodyHtml: body.innerHTML,
1666
- href: titleEl ? (titleEl.getAttribute('href') || '') : ''
1756
+ href: titleEl ? (titleEl.getAttribute('href') || '') : '',
1757
+ scrollTop: thisPopup.scrollTop
1667
1758
  });
1668
1759
  handleLink(anchor, savedPos.x, savedPos.y, true);
1669
1760
  }, { passive: false });
@@ -1673,6 +1764,117 @@
1673
1764
  });
1674
1765
  }
1675
1766
 
1767
+ function pinPopup(el) {
1768
+ clearTimeout(mouseLeaveTimer);
1769
+ var ownHistory = historyStack.slice();
1770
+ popup = null;
1771
+ historyStack = [];
1772
+ el.style.width = el.offsetWidth + 'px';
1773
+ el.style.height = el.offsetHeight + 'px';
1774
+ el.style.maxHeight = 'none';
1775
+ el.dataset.pinned = '1';
1776
+ el.classList.add('link-ctx-popup--pinned');
1777
+ var pinBtn = el.querySelector('.link-ctx-popup-pin');
1778
+ if (pinBtn) pinBtn.classList.add('pinned');
1779
+ // Re-wire back button to operate on this element's own history
1780
+ var backBtn = el.querySelector('.link-ctx-popup-back');
1781
+ if (backBtn) {
1782
+ var newBack = backBtn.cloneNode(true);
1783
+ backBtn.parentNode.replaceChild(newBack, backBtn);
1784
+ newBack.addEventListener('click', function(e) {
1785
+ e.stopPropagation();
1786
+ goBackInPinned(el, ownHistory);
1787
+ });
1788
+ }
1789
+ // Re-wire close button to remove this element
1790
+ var closeBtn = el.querySelector('.link-ctx-popup-close');
1791
+ var newClose = closeBtn.cloneNode(true);
1792
+ closeBtn.parentNode.replaceChild(newClose, closeBtn);
1793
+ newClose.addEventListener('click', function(e) {
1794
+ e.stopPropagation();
1795
+ if (el.parentNode) el.parentNode.removeChild(el);
1796
+ pinnedPopups = pinnedPopups.filter(function(p) { return p !== el; });
1797
+ });
1798
+ el._popupHistory = ownHistory;
1799
+ pinnedPopups.push(el);
1800
+ }
1801
+
1802
+ function unpinPopup(el) {
1803
+ // Close any existing floating popup
1804
+ if (popup && popup !== el && popup.parentNode) popup.parentNode.removeChild(popup);
1805
+ pinnedPopups = pinnedPopups.filter(function(p) { return p !== el; });
1806
+ // Restore as active floating popup
1807
+ popup = el;
1808
+ historyStack = el._popupHistory || [];
1809
+ delete el._popupHistory;
1810
+ delete el.dataset.pinned;
1811
+ el.classList.remove('link-ctx-popup--pinned');
1812
+ // Update pin button
1813
+ var pinBtn = el.querySelector('.link-ctx-popup-pin');
1814
+ if (pinBtn) pinBtn.classList.remove('pinned');
1815
+ // Re-wire back button to use restored global historyStack
1816
+ var backBtn = el.querySelector('.link-ctx-popup-back');
1817
+ if (backBtn) {
1818
+ var newBack = backBtn.cloneNode(true);
1819
+ backBtn.parentNode.replaceChild(newBack, backBtn);
1820
+ newBack.addEventListener('click', function(e) {
1821
+ e.stopPropagation();
1822
+ var prev = historyStack.pop();
1823
+ if (prev) {
1824
+ showPopup(prev.x, prev.y, prev.title, prev.bodyHtml, prev.href, prev.linkRect);
1825
+ if (popup && prev.scrollTop) popup.scrollTop = prev.scrollTop;
1826
+ }
1827
+ });
1828
+ }
1829
+ // Re-wire close button
1830
+ var closeBtn = el.querySelector('.link-ctx-popup-close');
1831
+ var newClose = closeBtn.cloneNode(true);
1832
+ closeBtn.parentNode.replaceChild(newClose, closeBtn);
1833
+ newClose.addEventListener('click', hidePopup);
1834
+ }
1835
+
1836
+ function goBackInPinned(el, ownHistory) {
1837
+ var prev = ownHistory.pop();
1838
+ if (!prev) return;
1839
+ var body = el.querySelector('.link-ctx-popup-body');
1840
+ if (body) body.innerHTML = prev.bodyHtml;
1841
+ var titleSpan = el.querySelector('.link-ctx-popup-title span');
1842
+ if (titleSpan) titleSpan.textContent = prev.title;
1843
+ var titleLink = el.querySelector('a.link-ctx-popup-title');
1844
+ if (titleLink && prev.href) titleLink.href = prev.href;
1845
+ var backBtnEl = el.querySelector('.link-ctx-popup-back');
1846
+ if (backBtnEl) backBtnEl.style.display = ownHistory.length > 0 ? '' : 'none';
1847
+ if (prev.scrollTop) el.scrollTop = prev.scrollTop;
1848
+ }
1849
+
1850
+ function makeDraggable(el) {
1851
+ var header = el.querySelector('.link-ctx-popup-header');
1852
+ if (!header) return;
1853
+ var startX, startY, startLeft, startTop;
1854
+ header.addEventListener('mousedown', function(e) {
1855
+ if (e.target.closest('button') || e.target.closest('a')) return;
1856
+ e.preventDefault();
1857
+ startX = e.clientX;
1858
+ startY = e.clientY;
1859
+ startLeft = el.offsetLeft;
1860
+ startTop = el.offsetTop;
1861
+ popupDragging = true;
1862
+ header.classList.add('is-dragging');
1863
+ document.addEventListener('mousemove', onMove);
1864
+ document.addEventListener('mouseup', onUp);
1865
+ });
1866
+ function onMove(e) {
1867
+ el.style.left = (startLeft + e.clientX - startX) + 'px';
1868
+ el.style.top = (startTop + e.clientY - startY) + 'px';
1869
+ }
1870
+ function onUp() {
1871
+ popupDragging = false;
1872
+ header.classList.remove('is-dragging');
1873
+ document.removeEventListener('mousemove', onMove);
1874
+ document.removeEventListener('mouseup', onUp);
1875
+ }
1876
+ }
1877
+
1676
1878
  function repositionPopup() {
1677
1879
  if (!popup) return;
1678
1880
  var x = currentPopupPos.x, y = currentPopupPos.y;
@@ -1716,6 +1918,7 @@
1716
1918
  if (body) body.innerHTML = bodyHtml;
1717
1919
  if (title && titleTextEl) titleTextEl.textContent = title;
1718
1920
  repositionPopup();
1921
+ runMermaidIn(popup);
1719
1922
  }
1720
1923
 
1721
1924
  function hidePopup() {
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.5.11
4
+ version: 0.5.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn
@@ -105,6 +105,7 @@ files:
105
105
  - bin/markdownr
106
106
  - lib/markdown_server.rb
107
107
  - lib/markdown_server/app.rb
108
+ - lib/markdown_server/bible_citations.rb
108
109
  - lib/markdown_server/version.rb
109
110
  - views/admin_login.erb
110
111
  - views/directory.erb