markdownr 0.5.17 → 0.5.19

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: 9044a1bee7c704aad77abde049a1e46063496e5e42c3a5f09ec5184bff42bb10
4
- data.tar.gz: bd1168ac32f70f7df78b6a6bc047cdb1a7331dc9813ffc38636ff080116d6c52
3
+ metadata.gz: a3cd57887599183f3350f1dd63f44f636ab03b9d3c3a462a1aaec04793cbbfeb
4
+ data.tar.gz: 8ecb0febc8ba672c28ad2cc9d2cdea0cbe7a1e1c79508bb0ec1b4fcb40c133fa
5
5
  SHA512:
6
- metadata.gz: eaca881e0e2e9fa996381cb944fb9ade2c26dcb09dff96d1d595dfbc79d1150b7024f138086e2b58e255ae79d12d1580ed68298480a9cf209adca8cc77cae80c
7
- data.tar.gz: 93a40be77ea08b3aa335b8c84e6c4741a962a6851c29abd851a99c5698f504fb9bca5d9a1e179a0d859797d08db56c92691f8cec2ea9a293c082a87f80bce8b8
6
+ metadata.gz: 83b163d0b783c29163dfc8d7803e0536dd78a295d7b4d72518dd55015b7e302fae23d83bec507fb1f363eee3af2f5e031f5ca8fd9a42de10c42ff72bce996446
7
+ data.tar.gz: b6a498d1d81abdd6ad981c4c930ba934776895c2606bc0ae9ae42bcfb71890d79be8bcdeec55e190c36199c34ed6b74ce2926b1705161ba862f68752b2d9a838
data/bin/markdownr CHANGED
@@ -43,6 +43,10 @@ OptionParser.new do |opts|
43
43
  options[:hard_wrap] = false
44
44
  end
45
45
 
46
+ opts.on("--verbose", "Print each request (time, IP, method, path) to stdout") do
47
+ options[:verbose] = true
48
+ end
49
+
46
50
  opts.on("-v", "--version", "Show version") do
47
51
  puts "markdownr #{MarkdownServer::VERSION}"
48
52
  exit
@@ -64,6 +68,7 @@ MarkdownServer::App.set :allow_robots, options[:allow_robots] || false
64
68
  MarkdownServer::App.set :index_file, options[:index_file]
65
69
  MarkdownServer::App.set :link_tooltips, options.fetch(:link_tooltips, true)
66
70
  MarkdownServer::App.set :hard_wrap, options.fetch(:hard_wrap, true)
71
+ MarkdownServer::App.set :verbose, options[:verbose] || false
67
72
  MarkdownServer::App.set :port, options[:port]
68
73
  MarkdownServer::App.set :bind, options[:bind]
69
74
  MarkdownServer::App.set :server_settings, { max_threads: options[:threads], min_threads: 1 }
@@ -31,8 +31,10 @@ module MarkdownServer
31
31
  set :protection, false
32
32
  set :host_authorization, { permitted_hosts: [] }
33
33
  set :behind_proxy, false
34
+ set :verbose, false
34
35
  set :session_secret, ENV.fetch("MARKDOWNR_SESSION_SECRET", SecureRandom.hex(64))
35
36
  set :sessions, key: "markdownr_session", same_site: :strict, httponly: true
37
+ set :popup_assets_content, File.read(File.join(settings.views, "popup_assets.erb"))
36
38
  end
37
39
 
38
40
  helpers do
@@ -137,8 +139,7 @@ module MarkdownServer
137
139
  # Auto-link bare Bible verse citations (e.g. "John 3:16" → BibleGateway link).
138
140
  # Skips inline code, fenced code blocks, and citations already inside a link.
139
141
  text = MarkdownServer.link_citations(text) do |canonical, verse, citation|
140
- url = "https://www.biblegateway.com/passage/?search=#{CGI.escape("#{canonical} #{verse}")}&version=CEB"
141
- "[#{citation}](#{url})"
142
+ "[#{citation}](#{MarkdownServer.biblegateway_url(canonical, verse)})"
142
143
  end
143
144
 
144
145
  # Process wiki links BEFORE Kramdown so that | isn't consumed as
@@ -785,6 +786,27 @@ module MarkdownServer
785
786
  end
786
787
  end
787
788
 
789
+ # Returns true when serving an HTML file that should have popup assets injected.
790
+ # Add additional path prefixes here as needed.
791
+ def inject_assets_for_html_path?(relative_path)
792
+ relative_path.start_with?("scripture/resources/books/bible")
793
+ end
794
+
795
+ # Injects popup CSS and JS into an HTML document before </body>.
796
+ # Also auto-links bare Bible verse citations in HTML text nodes.
797
+ # Falls back to appending before </html>, then to end of document.
798
+ def inject_markdownr_assets(html_content)
799
+ html_content = MarkdownServer.link_citations_html(html_content) do |canonical, verse, citation|
800
+ url = MarkdownServer.biblegateway_url(canonical, verse)
801
+ %(<a href="#{h(url)}">#{h(citation)}</a>)
802
+ end
803
+
804
+ assets = settings.popup_assets_content
805
+ inserted = false
806
+ result = html_content.sub(/<\/(body|html)>/i) { inserted = true; "#{assets}</#{$1}>" }
807
+ inserted ? result : html_content + assets
808
+ end
809
+
788
810
  def admin?
789
811
  return true if session[:admin]
790
812
 
@@ -817,6 +839,13 @@ module MarkdownServer
817
839
  end
818
840
  end
819
841
 
842
+ before do
843
+ if settings.verbose
844
+ $stdout.puts "#{Time.now.strftime("%Y-%m-%d %H:%M:%S")} #{client_ip} #{request.request_method} #{request.fullpath}"
845
+ $stdout.flush
846
+ end
847
+ end
848
+
820
849
  get "/" do
821
850
  redirect "/browse/"
822
851
  end
@@ -1167,6 +1196,20 @@ module MarkdownServer
1167
1196
  when ".epub"
1168
1197
  redirect @download_href
1169
1198
 
1199
+ when ".html"
1200
+ if inject_assets_for_html_path?(relative_path)
1201
+ content = File.read(real_path, encoding: "utf-8")
1202
+ halt 200, { "Content-Type" => "text/html; charset=utf-8" }, inject_markdownr_assets(content)
1203
+ else
1204
+ send_file real_path, type: "text/html"
1205
+ end
1206
+
1207
+ when ".css"
1208
+ send_file real_path, type: "text/css"
1209
+
1210
+ when ".js"
1211
+ send_file real_path, type: "application/javascript"
1212
+
1170
1213
  else
1171
1214
  content = File.read(real_path, encoding: "utf-8") rescue nil
1172
1215
  if content.nil? || content.encoding == Encoding::BINARY || !content.valid_encoding?
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require "cgi"
2
3
  #
3
4
  # Bible citation auto-linking for Markdown rendering.
4
5
  #
@@ -102,6 +103,12 @@ module MarkdownServer
102
103
  # Sorted longest-first for runtime lookup (same order as the regex alternation).
103
104
  BIBLE_BOOK_MAP_SORTED = BIBLE_BOOK_MAP.sort_by { |k, _| -k.length }.freeze
104
105
 
106
+ BIBLEGATEWAY_VERSION = "CEB".freeze
107
+
108
+ def self.biblegateway_url(canonical, verse)
109
+ "https://www.biblegateway.com/passage/?search=#{CGI.escape("#{canonical} #{verse}")}&version=#{BIBLEGATEWAY_VERSION}"
110
+ end
111
+
105
112
  # Scans +text+ for bare Bible verse citations and yields each one as
106
113
  # (canonical_book, verse, raw_citation). Returns the transformed string
107
114
  # with each citation replaced by whatever the block returns. Code spans,
@@ -125,6 +132,7 @@ module MarkdownServer
125
132
  _book_alts = BIBLE_BOOK_MAP.keys.sort_by(&:length).reverse
126
133
  .map { |k| Regexp.escape(k) }.join("|")
127
134
  _verse = /\d+[abc]?(?:[–—\-]\d+[abc]?)?(?:,\s*\d+[abc]?(?:[–—\-]\d+[abc]?)?)*(?:ff\.?|f\.)?/
135
+ _citation = "(?:#{_book_alts})\\.?[ \\t]?\\d+(?::#{_verse.source})?"
128
136
 
129
137
  # Combined regex. Four alternatives — only the last (group 4) is replaced:
130
138
  # 1. inline code span → return verbatim
@@ -135,7 +143,32 @@ module MarkdownServer
135
143
  "(`[^`]*?`)" \
136
144
  "|(`{3}[\\s\\S]*?`{3})" \
137
145
  "|(?<!\\!)(\\[[^\\[\\]]*\\]\\([^)]+\\))" \
138
- "|((?:#{_book_alts})\\.?[ \\t]?\\d+(?::#{_verse.source})?)",
146
+ "|(#{_citation})",
139
147
  Regexp::MULTILINE
140
148
  )
149
+
150
+ # HTML-aware version of BIBLE_CITATION_RE. Skips <script>, <style>, existing
151
+ # <a> elements, and all other HTML tags. Group 5 captures bare citations.
152
+ HTML_BIBLE_CITATION_RE = Regexp.new(
153
+ "(<script\\b[^>]*>[\\s\\S]*?</script>)" \
154
+ "|(<style\\b[^>]*>[\\s\\S]*?</style>)" \
155
+ "|(<a\\b[^>]*>[\\s\\S]*?</a>)" \
156
+ "|(<[^>]+>)" \
157
+ "|(#{_citation})",
158
+ Regexp::MULTILINE | Regexp::IGNORECASE
159
+ )
160
+
161
+ # Like link_citations but operates on raw HTML. Skips tag content, script/style
162
+ # blocks, and existing anchor elements; yields bare citations in text nodes.
163
+ def self.link_citations_html(html)
164
+ html.gsub(HTML_BIBLE_CITATION_RE) do |m|
165
+ next m if $1 || $2 || $3 || $4 # skip tags / scripts / styles / anchors
166
+ citation = $5
167
+ entry = BIBLE_BOOK_MAP_SORTED.find { |k, _| citation.start_with?(k) }
168
+ next m unless entry
169
+ abbrev, canonical = entry
170
+ verse = citation[abbrev.length..].sub(/\A\.?[ \t]?/, "")
171
+ yield canonical, verse, citation
172
+ end
173
+ end
141
174
  end
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.5.17"
2
+ VERSION = "0.5.19"
3
3
  end
data/views/layout.erb CHANGED
@@ -777,6 +777,7 @@
777
777
  background: #faf8f4;
778
778
  flex-shrink: 0;
779
779
  cursor: grab;
780
+ touch-action: none;
780
781
  }
781
782
  .link-ctx-popup-header.is-dragging {
782
783
  cursor: grabbing;
@@ -861,8 +862,8 @@
861
862
  position: absolute;
862
863
  bottom: 0;
863
864
  right: 0;
864
- width: 20px;
865
- height: 20px;
865
+ width: 36px;
866
+ height: 36px;
866
867
  cursor: nwse-resize;
867
868
  touch-action: none;
868
869
  border-bottom-right-radius: 5px;
@@ -982,7 +983,9 @@
982
983
  .blb-usage li { margin-bottom: 0.15rem; }
983
984
  .blb-usage p { margin: 0; }
984
985
 
986
+
985
987
  /* Footnote tooltips */
988
+ sup[id^="fnref:"] { position: relative; }
986
989
  .footnote-tooltip {
987
990
  position: absolute;
988
991
  bottom: 100%;
@@ -999,8 +1002,8 @@
999
1002
  z-index: 150;
1000
1003
  box-shadow: 0 2px 8px rgba(0,0,0,0.25);
1001
1004
  margin-bottom: 6px;
1005
+ cursor: pointer;
1002
1006
  }
1003
- .footnote-tooltip { cursor: pointer; }
1004
1007
  .footnote-tooltip::after {
1005
1008
  content: '';
1006
1009
  position: absolute;
@@ -1015,9 +1018,6 @@
1015
1018
  .footnote-tooltip a { color: #d4b96a; }
1016
1019
  .footnote-tooltip p { margin: 0; }
1017
1020
  .footnote-tooltip p + p { margin-top: 0.3rem; }
1018
- sup[id^="fnref:"] {
1019
- position: relative;
1020
- }
1021
1021
 
1022
1022
  /* Responsive */
1023
1023
  @media (max-width: 768px) {
@@ -1426,10 +1426,6 @@
1426
1426
  tooltip.style.left = '50%';
1427
1427
  tooltip.style.transform = 'translateX(-50%)';
1428
1428
  tooltip.style.removeProperty('--arrow-offset');
1429
- // Clamp tooltip to viewport.
1430
- // document.documentElement.clientWidth is more reliable than window.innerWidth
1431
- // on Android browsers (e.g. EinkBro) which may return a pre-scaled layout
1432
- // viewport width for window.innerWidth rather than the CSS viewport width.
1433
1429
  var viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);
1434
1430
  var rect = tooltip.getBoundingClientRect();
1435
1431
  var shift = 0;
@@ -1453,19 +1449,16 @@
1453
1449
  sup.addEventListener('mouseleave', hideTooltip);
1454
1450
 
1455
1451
  // Touch: first tap shows tooltip, second tap navigates
1456
- // Using touchend instead of click so no hover media query check is needed —
1457
- // touchend only fires on real touch, regardless of what (hover:none) reports.
1458
1452
  var linkTouchMoved = false;
1459
1453
  link.addEventListener('touchstart', function() { linkTouchMoved = false; }, { passive: true });
1460
1454
  link.addEventListener('touchmove', function() { linkTouchMoved = true; }, { passive: true });
1461
1455
  link.addEventListener('touchend', function(e) {
1462
- if (linkTouchMoved) return; // scroll, not a tap
1456
+ if (linkTouchMoved) return;
1463
1457
  if (tooltip.parentNode === sup) {
1464
1458
  // Tooltip already showing — let the link navigate
1465
1459
  return;
1466
1460
  }
1467
1461
  e.preventDefault();
1468
- // Dismiss any other open tooltip
1469
1462
  document.querySelectorAll('.footnote-tooltip').forEach(function(t) {
1470
1463
  if (t.parentNode) t.parentNode.removeChild(t);
1471
1464
  });
@@ -1482,7 +1475,7 @@
1482
1475
  window.location.hash = fnHref;
1483
1476
  });
1484
1477
 
1485
- // Dismiss tooltip when tapping elsewhere
1478
+ // Dismiss tooltip when clicking elsewhere
1486
1479
  document.addEventListener('click', function(e) {
1487
1480
  if (tooltip.parentNode === sup && !sup.contains(e.target)) {
1488
1481
  hideTooltip();
@@ -0,0 +1,732 @@
1
+ <style>
2
+ /* Bible Gateway citation links */
3
+ a[href*="biblegateway.com"] {
4
+ color: #7b3fa0;
5
+ font-weight: bold;
6
+ text-decoration: none;
7
+ border-bottom: 1px solid #b07fd4;
8
+ }
9
+ a[href*="biblegateway.com"]:hover { color: #5a2c78; border-bottom-color: #5a2c78; }
10
+
11
+ /* Left-click / tap link context popup */
12
+ .link-ctx-popup {
13
+ position: fixed;
14
+ z-index: 500;
15
+ width: 460px;
16
+ max-width: calc(100vw - 16px);
17
+ max-height: 60vh;
18
+ background: #faf8f4;
19
+ border: 1px solid #d4b96a;
20
+ border-radius: 6px;
21
+ box-shadow: 0 4px 20px rgba(0,0,0,0.22);
22
+ cursor: auto;
23
+ min-width: 280px;
24
+ min-height: 80px;
25
+ display: flex;
26
+ flex-direction: column;
27
+ overflow: hidden;
28
+ }
29
+ .link-ctx-popup-header {
30
+ display: flex;
31
+ justify-content: space-between;
32
+ align-items: center;
33
+ gap: 0.5rem;
34
+ padding: 0.5rem 0.9rem;
35
+ border-bottom: 1px solid #e0d8c8;
36
+ background: #faf8f4;
37
+ flex-shrink: 0;
38
+ cursor: grab;
39
+ touch-action: none;
40
+ }
41
+ .link-ctx-popup-header.is-dragging {
42
+ cursor: grabbing;
43
+ user-select: none;
44
+ }
45
+ .link-ctx-popup-title-wrap {
46
+ flex: 1;
47
+ min-width: 0;
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 4px;
51
+ }
52
+ .link-ctx-popup-title {
53
+ font-family: Georgia, "Times New Roman", serif;
54
+ font-weight: 700;
55
+ font-size: 0.9rem;
56
+ color: #3a3a3a;
57
+ min-width: 0;
58
+ word-break: break-word;
59
+ text-decoration: none;
60
+ }
61
+ a.link-ctx-popup-title:hover { color: #8b6914; }
62
+ .link-ctx-popup-close {
63
+ background: none;
64
+ border: none;
65
+ font-size: 1.2rem;
66
+ line-height: 1;
67
+ color: #888;
68
+ cursor: pointer;
69
+ padding: 0 0.2rem;
70
+ flex-shrink: 0;
71
+ }
72
+ .link-ctx-popup-close:hover { color: #2c2c2c; }
73
+ .link-ctx-popup-newtab {
74
+ color: #666;
75
+ flex-shrink: 0;
76
+ display: flex;
77
+ align-items: center;
78
+ text-decoration: none;
79
+ padding: 0 0.2rem;
80
+ }
81
+ .link-ctx-popup-newtab:hover { color: #8b6914; }
82
+ .link-ctx-popup-back {
83
+ background: none;
84
+ border: none;
85
+ font-size: 1rem;
86
+ line-height: 1;
87
+ color: #888;
88
+ cursor: pointer;
89
+ padding: 0 0.3rem 0 0;
90
+ flex-shrink: 0;
91
+ }
92
+ .link-ctx-popup-back:hover { color: #2c2c2c; }
93
+ .link-ctx-popup-pin {
94
+ background: none;
95
+ border: none;
96
+ color: #bbb;
97
+ cursor: pointer;
98
+ padding: 0 0.2rem;
99
+ flex-shrink: 0;
100
+ display: flex;
101
+ align-items: center;
102
+ line-height: 1;
103
+ }
104
+ .link-ctx-popup-pin:hover { color: #2c2c2c; }
105
+ .link-ctx-popup-pin.pinned { color: #8b6914; }
106
+ .link-ctx-popup--pinned {
107
+ max-height: none;
108
+ min-height: 120px;
109
+ }
110
+ .link-ctx-popup--pinned .link-ctx-popup-header {
111
+ user-select: none;
112
+ }
113
+ .link-ctx-popup-body {
114
+ padding: 0.75rem 1rem;
115
+ flex: 1;
116
+ min-height: 0;
117
+ overflow-y: auto;
118
+ -webkit-overflow-scrolling: touch;
119
+ }
120
+ .link-ctx-popup-resize {
121
+ position: absolute;
122
+ bottom: 0;
123
+ right: 0;
124
+ width: 36px;
125
+ height: 36px;
126
+ cursor: nwse-resize;
127
+ touch-action: none;
128
+ border-bottom-right-radius: 5px;
129
+ background: repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(0,0,0,0.18) 2px, rgba(0,0,0,0.18) 3px);
130
+ z-index: 2;
131
+ }
132
+ .link-ctx-popup-url {
133
+ font-family: "SF Mono", Menlo, Consolas, monospace;
134
+ font-size: 0.75rem;
135
+ color: #555;
136
+ word-break: break-all;
137
+ background: #f0ece3;
138
+ padding: 0.35rem 0.6rem;
139
+ border-radius: 4px;
140
+ margin-bottom: 0.5rem;
141
+ }
142
+
143
+ /* Shared popup content styles */
144
+ .link-ctx-popup-body {
145
+ font-family: Georgia, "Times New Roman", serif;
146
+ font-size: 0.85rem;
147
+ line-height: 1.6;
148
+ color: #2c2c2c;
149
+ }
150
+ .link-ctx-popup-body h1, .link-ctx-popup-body h2, .link-ctx-popup-body h3,
151
+ .link-ctx-popup-body h4, .link-ctx-popup-body h5, .link-ctx-popup-body h6 {
152
+ margin: 0.7rem 0 0.3rem; color: #3a3a3a;
153
+ }
154
+ .link-ctx-popup-body h1 { font-size: 1.2rem; }
155
+ .link-ctx-popup-body h2 { font-size: 1.05rem; border-bottom: none; padding-bottom: 0; }
156
+ .link-ctx-popup-body h3 { font-size: 0.95rem; }
157
+ .link-ctx-popup-body p { margin: 0 0 0.5rem; }
158
+ .link-ctx-popup-body p:last-child { margin-bottom: 0; }
159
+ .link-ctx-popup-body a { color: #8b6914; text-decoration: none; border-bottom: 1px solid #d4b96a; }
160
+ .link-ctx-popup-body a.wiki-link { color: #6a8e3e; border-bottom-color: #6a8e3e; }
161
+ .link-ctx-popup-body code {
162
+ font-family: "SF Mono", Menlo, Consolas, monospace;
163
+ font-size: 0.82em;
164
+ background: #f0ece3;
165
+ padding: 0.1em 0.3em;
166
+ border-radius: 3px;
167
+ }
168
+ .link-ctx-popup-body pre {
169
+ background: #2d2d2d;
170
+ color: #f0f0f0;
171
+ padding: 0.6rem 0.8rem;
172
+ border-radius: 4px;
173
+ font-size: 0.78rem;
174
+ line-height: 1.4;
175
+ overflow-x: auto;
176
+ margin: 0.4rem 0;
177
+ }
178
+ .link-ctx-popup-body pre code { background: none; padding: 0; font-size: 1em; color: inherit; }
179
+ .link-ctx-popup-body ul, .link-ctx-popup-body ol { padding-left: 1.4rem; margin: 0.3rem 0; }
180
+ .link-ctx-popup-body li { margin-bottom: 0.2rem; }
181
+ .link-ctx-popup-body blockquote {
182
+ border-left: 3px solid #d4b96a;
183
+ margin: 0.5rem 0;
184
+ padding: 0.3rem 0.8rem;
185
+ color: #4a4a4a;
186
+ font-style: italic;
187
+ }
188
+ .link-ctx-popup-body table { border-collapse: collapse; font-size: 0.8rem; margin: 0.5rem 0; }
189
+ .link-ctx-popup-body th, .link-ctx-popup-body td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; }
190
+ .link-ctx-popup-body th { background: #f5f0e4; }
191
+
192
+ /* Blue Letter Bible popup tables */
193
+ .blb-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-bottom: 0.6rem; }
194
+ .blb-table th, .blb-table td { padding: 3px 7px; border: 1px solid #ddd; }
195
+ .blb-th { text-align: left; font-weight: normal; background: #f5f0e4; color: #555; width: 38%; }
196
+ .blb-right { text-align: right; }
197
+ .blb-nowrap { white-space: nowrap; vertical-align: top; }
198
+ .blb-match { color: #b33 !important; font-weight: 700 !important; font-style: italic !important; }
199
+ .blb-heading { font-size: 0.82rem; font-weight: 600; margin: 0.7rem 0 0.25rem; color: #555; text-transform: uppercase; letter-spacing: 0.04em; }
200
+ .blb-usage { font-size: 0.85rem; }
201
+ .blb-usage ol { margin: 0.1rem 0 0.1rem 1.3rem; padding: 0; list-style-type: decimal; }
202
+ .blb-usage ol ol { list-style-type: lower-alpha; }
203
+ .blb-usage ol ol ol { list-style-type: lower-roman; }
204
+ .blb-usage ol ol ol ol { list-style-type: lower-alpha; }
205
+ .blb-usage li { margin-bottom: 0.15rem; }
206
+ .blb-usage p { margin: 0; }
207
+ </style>
208
+ <script>
209
+ // Left-click / tap popup for all links (standalone — no .md-content scope restriction)
210
+ (function() {
211
+ var cache = Object.create(null);
212
+ var popup = null;
213
+ var touchMoved = false;
214
+ var historyStack = [];
215
+ var pinnedPopups = [];
216
+ var popupDragging = false;
217
+ var currentPopupPos = { x: 0, y: 0 };
218
+ var mouseLeaveTimer = null;
219
+
220
+ function escHtml(s) {
221
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
222
+ }
223
+
224
+ function findLink(el) {
225
+ while (el && el.tagName !== 'BODY') {
226
+ if (el.tagName === 'A' && el.getAttribute('href')) return el;
227
+ el = el.parentElement;
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function isAnchorOnly(href) { return href.charAt(0) === '#'; }
233
+ function isLocalMd(href) {
234
+ if (/^https?:\/\//i.test(href)) return false;
235
+ return /\.md([?#]|$)/i.test(href);
236
+ }
237
+ function isExternal(href) { return /^https?:\/\//i.test(href); }
238
+
239
+ function previewPath(href) {
240
+ var resolved = new URL(href, location.href);
241
+ return '/preview/' + resolved.pathname.replace(/^\/browse\//, '');
242
+ }
243
+
244
+ 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>';
245
+ 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>';
246
+
247
+ function showPopup(x, y, title, bodyHtml, href, linkRect) {
248
+ if (popup && popup.parentNode) popup.parentNode.removeChild(popup);
249
+ popup = null;
250
+ currentPopupPos = { x: x, y: y, linkRect: linkRect || null };
251
+
252
+ popup = document.createElement('div');
253
+ popup.className = 'link-ctx-popup';
254
+ var thisPopup = popup;
255
+ var backBtn = historyStack.length > 0 ? '<button class="link-ctx-popup-back" aria-label="Back">\u2190</button>' : '';
256
+ var titleHtml = href
257
+ ? '<div class="link-ctx-popup-title-wrap">' +
258
+ '<a class="link-ctx-popup-title" href="' + escHtml(href) + '"><span>' + escHtml(title) + '</span></a>' +
259
+ '<a class="link-ctx-popup-newtab" href="' + escHtml(href) + '" target="_blank" rel="noopener" title="Open in new tab">' + extLinkIcon + '</a>' +
260
+ '</div>'
261
+ : '<span class="link-ctx-popup-title-wrap"><span class="link-ctx-popup-title"><span>' + escHtml(title) + '</span></span></span>';
262
+ popup.innerHTML =
263
+ '<div class="link-ctx-popup-header">' +
264
+ backBtn + titleHtml +
265
+ '<button class="link-ctx-popup-pin" aria-label="Pin" title="Pin popup">' + pinIcon + '</button>' +
266
+ '<button class="link-ctx-popup-close" aria-label="Close">\u00d7</button>' +
267
+ '</div>' +
268
+ '<div class="link-ctx-popup-body">' + bodyHtml + '</div>' +
269
+ '<div class="link-ctx-popup-resize" aria-hidden="true"></div>';
270
+ document.body.appendChild(popup);
271
+
272
+ clearTimeout(mouseLeaveTimer);
273
+ popup.addEventListener('mouseleave', function() {
274
+ if (thisPopup.dataset.pinned || popupDragging) return;
275
+ mouseLeaveTimer = setTimeout(hidePopup, 150);
276
+ });
277
+ popup.addEventListener('mouseenter', function() {
278
+ if (thisPopup.dataset.pinned) return;
279
+ clearTimeout(mouseLeaveTimer);
280
+ });
281
+
282
+ repositionPopup();
283
+ makeDraggable(popup);
284
+ makeResizable(popup);
285
+
286
+ var backBtnEl = popup.querySelector('.link-ctx-popup-back');
287
+ if (backBtnEl) {
288
+ backBtnEl.addEventListener('click', function(e) {
289
+ e.stopPropagation();
290
+ var prev = historyStack.pop();
291
+ if (prev) {
292
+ showPopup(prev.x, prev.y, prev.title, prev.bodyHtml, prev.href, prev.linkRect);
293
+ if (popup && prev.scrollTop) popup.scrollTop = prev.scrollTop;
294
+ }
295
+ });
296
+ }
297
+ popup.querySelector('.link-ctx-popup-close').addEventListener('click', hidePopup);
298
+ popup.querySelector('.link-ctx-popup-pin').addEventListener('click', function(e) {
299
+ e.stopPropagation();
300
+ if (!thisPopup.dataset.pinned) pinPopup(thisPopup);
301
+ else unpinPopup(thisPopup);
302
+ });
303
+ popup.addEventListener('click', function(e) { e.stopPropagation(); });
304
+
305
+ var body = popup.querySelector('.link-ctx-popup-body');
306
+ body.addEventListener('click', function(e) {
307
+ var anchor = findLink(e.target);
308
+ if (!anchor) return;
309
+ var linkHref = anchor.getAttribute('href');
310
+ if (!linkHref || isAnchorOnly(linkHref)) return;
311
+ e.stopPropagation();
312
+ e.preventDefault();
313
+ if (thisPopup.dataset.pinned) { handleLink(anchor, e.clientX, e.clientY, false); return; }
314
+ var savedPos = { x: currentPopupPos.x, y: currentPopupPos.y, linkRect: currentPopupPos.linkRect };
315
+ var titleEl = thisPopup.querySelector('.link-ctx-popup-title');
316
+ historyStack.push({
317
+ x: savedPos.x, y: savedPos.y, linkRect: savedPos.linkRect,
318
+ title: titleEl ? titleEl.querySelector('span').textContent : '',
319
+ bodyHtml: body.innerHTML,
320
+ href: titleEl ? (titleEl.getAttribute('href') || '') : '',
321
+ scrollTop: thisPopup.scrollTop
322
+ });
323
+ handleLink(anchor, savedPos.x, savedPos.y, true);
324
+ });
325
+ body.addEventListener('touchend', function(e) {
326
+ if (touchMoved) return;
327
+ var anchor = findLink(e.target);
328
+ if (!anchor) return;
329
+ var linkHref = anchor.getAttribute('href');
330
+ if (!linkHref || isAnchorOnly(linkHref)) return;
331
+ e.stopPropagation();
332
+ e.preventDefault();
333
+ if (thisPopup.dataset.pinned) { handleLink(anchor, e.clientX, e.clientY, false); return; }
334
+ var savedPos = { x: currentPopupPos.x, y: currentPopupPos.y, linkRect: currentPopupPos.linkRect };
335
+ var titleEl = thisPopup.querySelector('.link-ctx-popup-title');
336
+ historyStack.push({
337
+ x: savedPos.x, y: savedPos.y, linkRect: savedPos.linkRect,
338
+ title: titleEl ? titleEl.querySelector('span').textContent : '',
339
+ bodyHtml: body.innerHTML,
340
+ href: titleEl ? (titleEl.getAttribute('href') || '') : '',
341
+ scrollTop: thisPopup.scrollTop
342
+ });
343
+ handleLink(anchor, savedPos.x, savedPos.y, true);
344
+ }, { passive: false });
345
+
346
+ ['touchstart','touchmove','touchend'].forEach(function(ev) {
347
+ popup.addEventListener(ev, function(e) { e.stopPropagation(); }, { passive: true });
348
+ });
349
+ }
350
+
351
+ function pinPopup(el) {
352
+ clearTimeout(mouseLeaveTimer);
353
+ var ownHistory = historyStack.slice();
354
+ popup = null;
355
+ historyStack = [];
356
+ el.style.width = el.offsetWidth + 'px';
357
+ el.style.height = el.offsetHeight + 'px';
358
+ el.style.maxHeight = 'none';
359
+ el.dataset.pinned = '1';
360
+ el.classList.add('link-ctx-popup--pinned');
361
+ var pinBtn = el.querySelector('.link-ctx-popup-pin');
362
+ if (pinBtn) pinBtn.classList.add('pinned');
363
+ var backBtn = el.querySelector('.link-ctx-popup-back');
364
+ if (backBtn) {
365
+ var newBack = backBtn.cloneNode(true);
366
+ backBtn.parentNode.replaceChild(newBack, backBtn);
367
+ newBack.addEventListener('click', function(e) {
368
+ e.stopPropagation();
369
+ goBackInPinned(el, ownHistory);
370
+ });
371
+ }
372
+ var closeBtn = el.querySelector('.link-ctx-popup-close');
373
+ var newClose = closeBtn.cloneNode(true);
374
+ closeBtn.parentNode.replaceChild(newClose, closeBtn);
375
+ newClose.addEventListener('click', function(e) {
376
+ e.stopPropagation();
377
+ if (el.parentNode) el.parentNode.removeChild(el);
378
+ pinnedPopups = pinnedPopups.filter(function(p) { return p !== el; });
379
+ });
380
+ el._popupHistory = ownHistory;
381
+ pinnedPopups.push(el);
382
+ }
383
+
384
+ function unpinPopup(el) {
385
+ if (popup && popup !== el && popup.parentNode) popup.parentNode.removeChild(popup);
386
+ pinnedPopups = pinnedPopups.filter(function(p) { return p !== el; });
387
+ popup = el;
388
+ historyStack = el._popupHistory || [];
389
+ delete el._popupHistory;
390
+ delete el.dataset.pinned;
391
+ el.classList.remove('link-ctx-popup--pinned');
392
+ var pinBtn = el.querySelector('.link-ctx-popup-pin');
393
+ if (pinBtn) pinBtn.classList.remove('pinned');
394
+ var backBtn = el.querySelector('.link-ctx-popup-back');
395
+ if (backBtn) {
396
+ var newBack = backBtn.cloneNode(true);
397
+ backBtn.parentNode.replaceChild(newBack, backBtn);
398
+ newBack.addEventListener('click', function(e) {
399
+ e.stopPropagation();
400
+ var prev = historyStack.pop();
401
+ if (prev) {
402
+ showPopup(prev.x, prev.y, prev.title, prev.bodyHtml, prev.href, prev.linkRect);
403
+ if (popup && prev.scrollTop) popup.scrollTop = prev.scrollTop;
404
+ }
405
+ });
406
+ }
407
+ var closeBtn = el.querySelector('.link-ctx-popup-close');
408
+ var newClose = closeBtn.cloneNode(true);
409
+ closeBtn.parentNode.replaceChild(newClose, closeBtn);
410
+ newClose.addEventListener('click', hidePopup);
411
+ }
412
+
413
+ function goBackInPinned(el, ownHistory) {
414
+ var prev = ownHistory.pop();
415
+ if (!prev) return;
416
+ var body = el.querySelector('.link-ctx-popup-body');
417
+ if (body) body.innerHTML = prev.bodyHtml;
418
+ var titleSpan = el.querySelector('.link-ctx-popup-title span');
419
+ if (titleSpan) titleSpan.textContent = prev.title;
420
+ var titleLink = el.querySelector('a.link-ctx-popup-title');
421
+ if (titleLink && prev.href) titleLink.href = prev.href;
422
+ var backBtnEl = el.querySelector('.link-ctx-popup-back');
423
+ if (backBtnEl) backBtnEl.style.display = ownHistory.length > 0 ? '' : 'none';
424
+ if (prev.scrollTop) el.scrollTop = prev.scrollTop;
425
+ }
426
+
427
+ function makeDraggable(el) {
428
+ var header = el.querySelector('.link-ctx-popup-header');
429
+ if (!header) return;
430
+ var startX, startY, startLeft, startTop;
431
+
432
+ function onDragStart(clientX, clientY) {
433
+ startX = clientX; startY = clientY;
434
+ startLeft = el.offsetLeft; startTop = el.offsetTop;
435
+ el.style.width = el.offsetWidth + 'px';
436
+ el.style.height = el.offsetHeight + 'px';
437
+ el.style.maxHeight = 'none';
438
+ popupDragging = true;
439
+ header.classList.add('is-dragging');
440
+ }
441
+ function onDragMove(clientX, clientY) {
442
+ el.style.left = (startLeft + clientX - startX) + 'px';
443
+ el.style.top = (startTop + clientY - startY) + 'px';
444
+ }
445
+ function onDragEnd() {
446
+ popupDragging = false;
447
+ header.classList.remove('is-dragging');
448
+ }
449
+
450
+ header.addEventListener('mousedown', function(e) {
451
+ if (e.target.closest('button') || e.target.closest('a')) return;
452
+ e.preventDefault();
453
+ onDragStart(e.clientX, e.clientY);
454
+ document.addEventListener('mousemove', onMouseMove);
455
+ document.addEventListener('mouseup', onMouseUp);
456
+ });
457
+ function onMouseMove(e) { onDragMove(e.clientX, e.clientY); }
458
+ function onMouseUp() {
459
+ onDragEnd();
460
+ document.removeEventListener('mousemove', onMouseMove);
461
+ document.removeEventListener('mouseup', onMouseUp);
462
+ }
463
+
464
+ header.addEventListener('touchstart', function(e) {
465
+ if (e.target.closest('button') || e.target.closest('a')) return;
466
+ e.preventDefault();
467
+ var t = e.touches[0];
468
+ onDragStart(t.clientX, t.clientY);
469
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
470
+ document.addEventListener('touchend', onTouchEnd);
471
+ }, { passive: false });
472
+ function onTouchMove(e) { e.preventDefault(); var t = e.touches[0]; onDragMove(t.clientX, t.clientY); }
473
+ function onTouchEnd() {
474
+ onDragEnd();
475
+ document.removeEventListener('touchmove', onTouchMove);
476
+ document.removeEventListener('touchend', onTouchEnd);
477
+ }
478
+ }
479
+
480
+ function makeResizable(el) {
481
+ var handle = el.querySelector('.link-ctx-popup-resize');
482
+ if (!handle) return;
483
+ var startX, startY, startW, startH;
484
+
485
+ function onResizeStart(clientX, clientY) {
486
+ startX = clientX; startY = clientY;
487
+ startW = el.offsetWidth; startH = el.offsetHeight;
488
+ el.style.width = startW + 'px';
489
+ el.style.height = startH + 'px';
490
+ el.style.maxHeight = 'none';
491
+ }
492
+ function onResizeMove(clientX, clientY) {
493
+ el.style.width = Math.max(280, startW + clientX - startX) + 'px';
494
+ el.style.height = Math.max(80, startH + clientY - startY) + 'px';
495
+ }
496
+
497
+ handle.addEventListener('mousedown', function(e) {
498
+ e.preventDefault(); e.stopPropagation();
499
+ onResizeStart(e.clientX, e.clientY);
500
+ document.addEventListener('mousemove', onMouseMove);
501
+ document.addEventListener('mouseup', onMouseUp);
502
+ });
503
+ function onMouseMove(e) { onResizeMove(e.clientX, e.clientY); }
504
+ function onMouseUp() {
505
+ document.removeEventListener('mousemove', onMouseMove);
506
+ document.removeEventListener('mouseup', onMouseUp);
507
+ }
508
+
509
+ handle.addEventListener('touchstart', function(e) {
510
+ e.preventDefault(); e.stopPropagation();
511
+ var t = e.touches[0];
512
+ onResizeStart(t.clientX, t.clientY);
513
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
514
+ document.addEventListener('touchend', onTouchEnd);
515
+ }, { passive: false });
516
+ function onTouchMove(e) { e.preventDefault(); onResizeMove(e.touches[0].clientX, e.touches[0].clientY); }
517
+ function onTouchEnd() {
518
+ document.removeEventListener('touchmove', onTouchMove);
519
+ document.removeEventListener('touchend', onTouchEnd);
520
+ }
521
+ }
522
+
523
+ function repositionPopup() {
524
+ if (!popup) return;
525
+ var x = currentPopupPos.x, y = currentPopupPos.y;
526
+ var rect = currentPopupPos.linkRect;
527
+ var vw = Math.min(window.innerWidth, document.documentElement.clientWidth);
528
+ var vh = window.innerHeight;
529
+ var pw = popup.offsetWidth;
530
+ var ph = popup.offsetHeight;
531
+
532
+ var left = Math.min(x - 16, vw - pw - 8);
533
+ if (left < 8) left = 8;
534
+
535
+ var top;
536
+ if (rect) {
537
+ var gap = 3;
538
+ var belowTop = rect.bottom + gap;
539
+ var aboveTop = rect.top - ph - gap;
540
+ if (belowTop + ph <= vh - 8) {
541
+ top = belowTop;
542
+ } else if (aboveTop >= 8) {
543
+ top = aboveTop;
544
+ } else {
545
+ top = Math.max(8, vh - ph - 8);
546
+ }
547
+ } else {
548
+ top = y + 12;
549
+ if (top + ph > vh - 8) top = Math.max(8, y - ph - 12);
550
+ if (top < 8) top = 8;
551
+ }
552
+
553
+ popup.style.left = left + 'px';
554
+ popup.style.top = top + 'px';
555
+ }
556
+
557
+ function updatePopup(bodyHtml, title) {
558
+ if (!popup) return;
559
+ var body = popup.querySelector('.link-ctx-popup-body');
560
+ var titleTextEl = popup.querySelector('.link-ctx-popup-title span');
561
+ if (body) body.innerHTML = bodyHtml;
562
+ if (title && titleTextEl) titleTextEl.textContent = title;
563
+ repositionPopup();
564
+ }
565
+
566
+ function hidePopup() {
567
+ clearTimeout(mouseLeaveTimer);
568
+ if (popup && popup.parentNode) popup.parentNode.removeChild(popup);
569
+ popup = null;
570
+ historyStack = [];
571
+ }
572
+
573
+ function applyPopupAnchor(hash) {
574
+ if (!hash || !popup) return;
575
+ var id = hash.replace(/^#/, '');
576
+ try {
577
+ var target = popup.querySelector('[id="' + id.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]');
578
+ if (!target) return;
579
+ var header = popup.querySelector('.link-ctx-popup-header');
580
+ var headerHeight = header ? header.offsetHeight : 0;
581
+ var targetTop = target.getBoundingClientRect().top;
582
+ var popupTop = popup.getBoundingClientRect().top;
583
+ popup.scrollTop += targetTop - popupTop - headerHeight - 8;
584
+ if (/^H[1-6]$/.test(target.tagName)) {
585
+ var titleEl = popup.querySelector('.link-ctx-popup-title span');
586
+ if (titleEl) titleEl.textContent = titleEl.textContent + ' \u203a ' + target.textContent;
587
+ }
588
+ } catch(e) {}
589
+ }
590
+
591
+ function handleLink(anchor, x, y, chained) {
592
+ if (!chained) historyStack = [];
593
+ var href = anchor.getAttribute('href');
594
+ if (!href || isAnchorOnly(href)) return;
595
+ var label = anchor.textContent.trim() || href;
596
+ var linkRect = chained ? currentPopupPos.linkRect : anchor.getBoundingClientRect();
597
+ var linkHash = (function() { try { return new URL(href, location.href).hash; } catch(e) { return ''; } })();
598
+
599
+ if (isLocalMd(href)) {
600
+ var path = previewPath(href);
601
+ var cached = cache[path];
602
+ if (cached && typeof cached === 'object') {
603
+ showPopup(x, y, cached.title || label, cached.html, href, linkRect);
604
+ applyPopupAnchor(linkHash);
605
+ } else if (cached === false) {
606
+ showPopup(x, y, label,
607
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
608
+ '<p style="margin:0;color:#888;font-family:sans-serif;font-size:0.82rem">Preview not available</p>', href, linkRect);
609
+ } else {
610
+ showPopup(x, y, label, '<p style="opacity:0.5;margin:0;font-family:sans-serif">Loading\u2026</p>', href, linkRect);
611
+ if (cached === undefined) {
612
+ cache[path] = null;
613
+ fetch(path)
614
+ .then(function(r) { return r.ok ? r.json() : null; })
615
+ .then(function(data) {
616
+ if (!data) { cache[path] = false; updatePopup('<p style="margin:0;color:#c44;font-family:sans-serif;font-size:0.85rem">Preview unavailable.</p>'); return; }
617
+ var bodyHtml = data.html + (data.frontmatter_html || '');
618
+ cache[path] = { title: data.title, html: bodyHtml };
619
+ updatePopup(bodyHtml, data.title || label);
620
+ applyPopupAnchor(linkHash);
621
+ })
622
+ .catch(function() {
623
+ cache[path] = false;
624
+ updatePopup('<p style="margin:0;color:#c44;font-family:sans-serif;font-size:0.85rem">Preview unavailable.</p>');
625
+ });
626
+ }
627
+ }
628
+ } else if (isExternal(href)) {
629
+ var extKey = 'ext:' + href;
630
+ var extCached = cache[extKey];
631
+ if (extCached && typeof extCached === 'object') {
632
+ showPopup(x, y, extCached.title || label, extCached.html, href, linkRect);
633
+ } else if (extCached === false) {
634
+ showPopup(x, y, label,
635
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
636
+ '<p style="margin:0.5rem 0 0;color:#888;font-family:sans-serif;font-size:0.82rem">Could not fetch page content.</p>', href, linkRect);
637
+ } else {
638
+ showPopup(x, y, label, '<p style="opacity:0.5;margin:0;font-family:sans-serif">Loading\u2026</p>', href, linkRect);
639
+ if (extCached === undefined) {
640
+ cache[extKey] = null;
641
+ fetch('/fetch?url=' + encodeURIComponent(href))
642
+ .then(function(r) { return r.ok ? r.json() : null; })
643
+ .then(function(data) {
644
+ if (!data || data.error) {
645
+ cache[extKey] = false;
646
+ updatePopup(
647
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
648
+ '<p style="margin:0.5rem 0 0;color:#888;font-family:sans-serif;font-size:0.82rem">Could not fetch page content.</p>');
649
+ return;
650
+ }
651
+ cache[extKey] = { title: data.title, html: data.html };
652
+ updatePopup(data.html, data.title || label);
653
+ })
654
+ .catch(function() {
655
+ cache[extKey] = false;
656
+ updatePopup(
657
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
658
+ '<p style="margin:0.5rem 0 0;color:#c44;font-family:sans-serif;font-size:0.82rem">Could not fetch page content.</p>');
659
+ });
660
+ }
661
+ }
662
+ } else {
663
+ showPopup(x, y, label, '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>', href, linkRect);
664
+ }
665
+ }
666
+
667
+ // Left-click on desktop
668
+ document.addEventListener('click', function(e) {
669
+ if (popup && popup.contains(e.target)) return;
670
+ var anchor = findLink(e.target);
671
+ if (!anchor) { hidePopup(); return; }
672
+ var href = anchor.getAttribute('href');
673
+ if (!href || isAnchorOnly(href)) { hidePopup(); return; }
674
+ if (!isLocalMd(href) && !isExternal(href)) { hidePopup(); return; }
675
+ e.preventDefault();
676
+ handleLink(anchor, e.clientX, e.clientY, false);
677
+ });
678
+
679
+ // Touch tracking
680
+ document.addEventListener('touchstart', function(e) {
681
+ if (e.touches.length !== 1) return;
682
+ touchMoved = false;
683
+ }, { passive: true });
684
+
685
+ document.addEventListener('touchmove', function() {
686
+ touchMoved = true;
687
+ }, { passive: true });
688
+
689
+ // Touch tap
690
+ document.addEventListener('touchend', function(e) {
691
+ if (touchMoved) return;
692
+ if (popup && popup.contains(e.target)) return;
693
+ var anchor = findLink(e.target);
694
+ if (!anchor) { hidePopup(); return; }
695
+ var href = anchor.getAttribute('href');
696
+ if (!href || isAnchorOnly(href)) { hidePopup(); return; }
697
+ if (!isLocalMd(href) && !isExternal(href)) { hidePopup(); return; }
698
+ e.preventDefault();
699
+ var touch = e.changedTouches[0];
700
+ handleLink(anchor, touch.clientX, touch.clientY, false);
701
+ }, { passive: false });
702
+
703
+ document.addEventListener('keydown', function(e) {
704
+ if (e.key === 'Escape' && popup) hidePopup();
705
+ });
706
+
707
+ // Hover — per-link, triggered after a short delay when no popup is active
708
+ (function() {
709
+ var hoverTimer = null;
710
+ document.querySelectorAll('a').forEach(function(a) {
711
+ var href = a.getAttribute('href');
712
+ if (!href || isAnchorOnly(href)) return;
713
+ if (!isLocalMd(href) && !isExternal(href)) return;
714
+
715
+ a.addEventListener('mouseenter', function(e) {
716
+ clearTimeout(hoverTimer);
717
+ if (popup) return;
718
+ var x = e.clientX, y = e.clientY;
719
+ hoverTimer = setTimeout(function() {
720
+ if (!popup) handleLink(a, x, y, false);
721
+ }, 300);
722
+ });
723
+
724
+ a.addEventListener('mouseleave', function() {
725
+ clearTimeout(hoverTimer);
726
+ if (popup) mouseLeaveTimer = setTimeout(hidePopup, 150);
727
+ });
728
+ });
729
+ })();
730
+
731
+ })();
732
+ </script>
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.17
4
+ version: 0.5.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn
@@ -111,6 +111,7 @@ files:
111
111
  - views/directory.erb
112
112
  - views/layout.erb
113
113
  - views/markdown.erb
114
+ - views/popup_assets.erb
114
115
  - views/raw.erb
115
116
  - views/search.erb
116
117
  - views/setup_info.erb