PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, Embed PDF viewer, YouTube Videos, 3D FlipBook, Social feeds & more / trunk
EmbedPress – PDF Embedder, Embed PDF viewer, YouTube Videos, 3D FlipBook, Social feeds & more vtrunk
4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / assets / js / ep-view-count.js
embedpress / assets / js Last commit date
chunks 1 week ago vendor 8 months ago admin.build.js 1 week ago admin.js 3 months ago analytics-tracker.js 4 weeks ago analytics.build.js 1 week ago blocks.build.js 1 week ago carousel.js 1 year ago custom-player.build.js 1 week ago documents-viewer-script.js 3 months ago ep-pdf-lightbox.js 3 months ago ep-view-count.js 1 week ago ep-yt-queue.js 4 weeks ago feature-notices.js 7 months ago front.js 4 weeks ago frontend.build.js 3 months ago gallery-justify.js 1 week ago gutneberg-script.js 1 month ago index.html 7 years ago initCarousel.js 2 years ago initplyr.js 1 month ago instafeed.js 4 weeks ago instagram-shortcode-generator.js 1 month ago lazy-load.js 6 months ago license.js 3 months ago meetup-timezone.js 3 months ago onboarding.build.js 1 week ago pdf-gallery-elementor-editor.js 3 months ago pdf-gallery.js 3 months ago preview.js 8 months ago settings.js 3 months ago sponsored.js 9 months ago
ep-view-count.js
473 lines
1 /**
2 * EmbedPress — public-facing view + download count badge for PDF/Document.
3 *
4 * Discovers wrappers carrying [data-embed-type="PDF"|"Document"] across
5 * Gutenberg blocks, [embedpress] shortcode, and Elementor widgets. Renders
6 * a single inline badge that reads
7 *
8 * 👁 N views ⤓ M downloads
9 *
10 * Views are self-recorded on first paint (session-deduped server-side).
11 * Downloads are recorded when the bundled PDF.js viewer posts a
12 * {source:'embedpress-pdf-viewer', type:'download'} message from inside its
13 * iframe — i.e., when the visitor clicks Save/Download/Open from the PDF.js
14 * toolbar. There is intentionally NO external download button.
15 */
16 (function () {
17 'use strict';
18
19 var cfg = window.embedpressViewCount || {};
20 if (!cfg.restUrl || !cfg.types || !cfg.types.length) {
21 return;
22 }
23
24 var processed = new WeakSet();
25 var SESSION_COOKIE = 'ep_vc_session';
26 // contentId -> wrapper element, so postMessage receivers can find the
27 // badge they need to update.
28 var contentIdIndex = Object.create(null);
29
30 function ensureSessionId() {
31 var m = document.cookie.match(/(?:^|;\s*)ep_vc_session=([^;]+)/);
32 if (m) return decodeURIComponent(m[1]);
33 var tm = document.cookie.match(/(?:^|;\s*)ep_session_id=([^;]+)/);
34 var sid = tm ? decodeURIComponent(tm[1]) : ('ep-vc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10));
35 document.cookie = SESSION_COOKIE + '=' + encodeURIComponent(sid) + '; path=/; max-age=86400; samesite=lax';
36 return sid;
37 }
38 var sessionId = ensureSessionId();
39
40 function getEmbedUrl(el) {
41 var iframe = el.tagName === 'IFRAME' ? el : el.querySelector('iframe');
42 var src = iframe && iframe.getAttribute('src');
43 if (src) {
44 try {
45 var u = new URL(src, window.location.href);
46 var fileParam = u.searchParams.get('file');
47 if (fileParam) return decodeURIComponent(fileParam);
48 } catch (e) { /* fall through */ }
49 return src;
50 }
51 return el.getAttribute('data-embed-url')
52 || el.getAttribute('data-url')
53 || el.getAttribute('data-src')
54 || el.getAttribute('href')
55 || '';
56 }
57
58 // A source-id is only usable if it actually identifies the embed. Blocks
59 // render data-source-id={`source-${clientId}`}; when clientId was undefined
60 // at save time that becomes the literal "source-undefined", which is shared
61 // by EVERY such embed — collapsing their counts into one ever-growing tally.
62 // Reject that (and any blank) so we fall back to a per-instance id instead.
63 function isUsableId(v) {
64 return !!v && v !== 'source-undefined' && v !== 'undefined' && v !== 'source-' && v !== 'source-null';
65 }
66
67 // Deterministic index of `el` among all embeds that resolve to the SAME
68 // url-hash, in DOCUMENT order. This must NOT depend on processing order:
69 // the flipbook and modern viewers load asynchronously, so a process-order
70 // counter would assign a different suffix to the same embed across reloads
71 // — splitting its count and making it look like counting "stopped". DOM
72 // order is fixed by the saved post content, so this is stable per instance.
73 function sameHashIndex(el, hash) {
74 var nodes = document.querySelectorAll('[data-embed-type]');
75 var idx = 0;
76 for (var i = 0; i < nodes.length; i++) {
77 // Skip nested embeds (handled by their own outer wrapper).
78 if (nodes[i].parentElement && nodes[i].parentElement.closest('[data-embed-type]')) continue;
79 if (urlHash(getEmbedUrl(nodes[i])) !== hash) continue;
80 if (nodes[i] === el) return idx;
81 idx++;
82 }
83 return idx;
84 }
85
86 // Distinctive hash over the FULL url. A previous version base64-encoded the
87 // url and kept the first 10 chars — but every "https://…" base64 starts with
88 // the same "aHR0cHM6Ly" prefix, so different files collapsed to one hash and
89 // then fought over a per-instance suffix. A djb2-style hash of the whole
90 // string keeps distinct files distinct.
91 function urlHash(url) {
92 url = String(url || '');
93 var h = 5381;
94 for (var i = 0; i < url.length; i++) {
95 h = ((h << 5) + h + url.charCodeAt(i)) >>> 0; // h * 33 + c, unsigned
96 }
97 return h.toString(36);
98 }
99
100 function deriveContentId(el, embedType) {
101 var stored = el.getAttribute('data-embedpress-content');
102 if (isUsableId(stored)) return stored;
103
104 var existing = el.getAttribute('data-source-id') || el.getAttribute('data-emid');
105 if (isUsableId(existing)) {
106 el.setAttribute('data-embedpress-content', existing);
107 return existing;
108 }
109
110 // No usable saved id (e.g. "source-undefined"): build a stable id from
111 // the file URL + this embed's DOCUMENT-ORDER index among same-file
112 // embeds, so multiple embeds of the same file are counted separately
113 // AND each keeps the same id across reloads.
114 var url = getEmbedUrl(el);
115 var hash = urlHash(url);
116 var contentId = 'ep-' + embedType + '-' + hash + '-' + sameHashIndex(el, hash);
117 el.setAttribute('data-embedpress-content', contentId);
118 return contentId;
119 }
120
121 function fmt(n, key, fallback) {
122 var template = (cfg.labels && cfg.labels[key]) || fallback;
123 return template.replace('%s', n.toLocaleString());
124 }
125 function formatViews(n) { return fmt(n, n === 1 ? 'singular' : 'plural', '%s views'); }
126 function formatDownloads(n) { return fmt(n, n === 1 ? 'downloadSingular' : 'downloadPlural', '%s downloads'); }
127
128 function buildBadge() {
129 var wrapper = document.createElement('div');
130 wrapper.className = 'ep-view-count';
131 wrapper.innerHTML =
132 '<span class="ep-view-count__item ep-view-count__item--views" hidden>' +
133 '<svg class="ep-view-count__icon" width="14" height="14" viewBox="0 0 24 24" ' +
134 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
135 'stroke-linejoin="round" aria-hidden="true">' +
136 '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z"/>' +
137 '<circle cx="12" cy="12" r="3"/>' +
138 '</svg>' +
139 '<span class="ep-view-count__label" data-views></span>' +
140 '</span>' +
141 '<span class="ep-view-count__sep" hidden></span>' +
142 '<span class="ep-view-count__item ep-view-count__item--downloads" hidden>' +
143 '<svg class="ep-view-count__icon" width="14" height="14" viewBox="0 0 24 24" ' +
144 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
145 'stroke-linejoin="round" aria-hidden="true">' +
146 '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>' +
147 '<polyline points="7 10 12 15 17 10"/>' +
148 '<line x1="12" y1="15" x2="12" y2="3"/>' +
149 '</svg>' +
150 '<span class="ep-view-count__label" data-downloads></span>' +
151 '</span>';
152 return wrapper;
153 }
154
155 // Resolve the effective badge position. Positioning is a Pro feature —
156 // only the default 'below' is free. When Pro is inactive, any non-default
157 // position falls back to 'below' (and 'above' insert reverts to after).
158 function resolvePosition(el) {
159 var pos = el.getAttribute('data-ep-count-position');
160 if (!pos || pos === 'below') return 'below';
161 if (!cfg.isPro) return 'below';
162 return pos;
163 }
164
165 // The badge always sits OUTSIDE the embed (never overlaying it). Position
166 // only decides which side — 'above-*' inserts before the embed, otherwise
167 // after — and the CSS modifier class handles horizontal alignment.
168 function isAbove(el) {
169 return resolvePosition(el).indexOf('above') === 0;
170 }
171
172 function placeBadge(el, node) {
173 var above = isAbove(el);
174 if (el.tagName === 'IFRAME') {
175 if (!el.parentNode) return;
176 // 'above' goes before the iframe; 'below' goes after it, but still
177 // before a trailing "Powered By EmbedPress" line so the count
178 // always sits above the branding.
179 var ref = above ? el : el.nextSibling;
180 if (!above) {
181 var sib = el.nextElementSibling;
182 while (sib) {
183 if (sib.classList && sib.classList.contains('embedpress-el-powered')) { ref = sib; break; }
184 sib = sib.nextElementSibling;
185 }
186 }
187 el.parentNode.insertBefore(node, ref);
188 } else if (above) {
189 el.insertBefore(node, el.firstChild);
190 } else {
191 // Keep the badge above any "Powered By EmbedPress" branding.
192 var powered = el.querySelector(':scope > .embedpress-el-powered')
193 || el.querySelector('.embedpress-el-powered');
194 if (powered && powered.parentNode) {
195 powered.parentNode.insertBefore(node, powered);
196 } else {
197 el.appendChild(node);
198 }
199 }
200 }
201
202 // Apply the per-embed alignment/side class. 'below' keeps the original
203 // placement, so no class is added.
204 function applyPosition(el, badge) {
205 var pos = resolvePosition(el);
206 if (pos === 'below') return;
207 badge.classList.add('ep-view-count--pos-' + pos);
208 }
209
210 function findExistingBadge(el) {
211 if (el.tagName === 'IFRAME') {
212 var sibs = [el.previousElementSibling, el.nextElementSibling];
213 for (var i = 0; i < sibs.length; i++) {
214 var s = sibs[i];
215 if (s && s.classList && s.classList.contains('ep-view-count')) return s;
216 }
217 return null;
218 }
219 // The badge may be nested (e.g. inserted before a deeper
220 // ".embedpress-el-powered" branding line), so search descendants — but
221 // ignore any badge that belongs to a NESTED embed wrapper so we don't
222 // mistake a child embed's badge for this one's.
223 var candidates = el.querySelectorAll('.ep-view-count');
224 for (var j = 0; j < candidates.length; j++) {
225 var c = candidates[j];
226 var owner = c.closest('[data-embed-type]');
227 if (owner === el) return c;
228 }
229 return null;
230 }
231
232 function ensureBadgeFor(el) {
233 var existing = findExistingBadge(el);
234 if (existing) return existing;
235 var badge = buildBadge();
236 placeBadge(el, badge);
237 applyPosition(el, badge);
238 return badge;
239 }
240
241 function setViewCount(el, count) {
242 var badge = ensureBadgeFor(el);
243 var item = badge.querySelector('.ep-view-count__item--views');
244 item.hidden = false;
245 item.querySelector('[data-views]').textContent = formatViews(count);
246 el.setAttribute('data-ep-view-count', String(count));
247 syncSeparator(badge);
248 }
249
250 function setDownloadCount(el, count) {
251 var badge = ensureBadgeFor(el);
252 var item = badge.querySelector('.ep-view-count__item--downloads');
253 item.hidden = false;
254 item.querySelector('[data-downloads]').textContent = formatDownloads(count);
255 el.setAttribute('data-ep-download-count', String(count));
256 syncSeparator(badge);
257 }
258
259 function syncSeparator(badge) {
260 var v = badge.querySelector('.ep-view-count__item--views');
261 var d = badge.querySelector('.ep-view-count__item--downloads');
262 var sep = badge.querySelector('.ep-view-count__sep');
263 sep.hidden = v.hidden || d.hidden;
264 if (!sep.hidden) sep.textContent = '·';
265 }
266
267 function postForm(url, params) {
268 var body = new URLSearchParams();
269 Object.keys(params).forEach(function (k) {
270 if (params[k] != null && params[k] !== '') body.set(k, params[k]);
271 });
272 return fetch(url, {
273 method: 'POST',
274 credentials: 'same-origin',
275 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
276 body: body.toString()
277 })
278 .then(function (r) { return r.ok ? r.json() : null; })
279 .then(function (j) { return j && typeof j.count === 'number' ? j.count : null; })
280 .catch(function () { return null; });
281 }
282 function getJson(url, query) {
283 var sep = url.indexOf('?') === -1 ? '?' : '&';
284 var full = url + sep + 'content_id=' + encodeURIComponent(query);
285 return fetch(full, { credentials: 'same-origin' })
286 .then(function (r) { return r.ok ? r.json() : null; })
287 .then(function (j) { return j && typeof j.count === 'number' ? j.count : null; })
288 .catch(function () { return null; });
289 }
290
291 // The per-embed block/widget toggle is the ONLY thing that shows a counter.
292 // The global option never overrides it. Editors emit an explicit marker on
293 // the wrapper:
294 // data-ep-views="on" -> show this counter for this embed
295 // data-ep-views="off" -> hide this counter for this embed
296 // absent -> off (e.g. embeds saved before this feature)
297 function resolve(el, attr) {
298 return el.getAttribute(attr) === 'on';
299 }
300 function viewAllowed(el) {
301 return resolve(el, 'data-ep-views');
302 }
303 function downloadAllowed(el) {
304 return resolve(el, 'data-ep-downloads');
305 }
306
307 function processElement(el) {
308 if (processed.has(el)) return;
309 var embedType = el.getAttribute('data-embed-type');
310 if (!embedType || cfg.types.indexOf(embedType) === -1) return;
311 processed.add(el);
312
313 var contentId = deriveContentId(el, embedType);
314 var embedUrl = getEmbedUrl(el);
315 contentIdIndex[contentId] = el;
316
317 if (viewAllowed(el) && cfg.trackUrl) {
318 postForm(cfg.trackUrl, {
319 content_id: contentId,
320 session_id: sessionId,
321 embed_type: embedType,
322 embed_url: embedUrl
323 }).then(function (count) {
324 if (count === null) return getJson(cfg.restUrl, contentId);
325 return count;
326 }).then(function (count) {
327 if (count === null) return;
328 setViewCount(el, count);
329 });
330 }
331
332 if (downloadAllowed(el) && cfg.downloadUrl) {
333 getJson(cfg.downloadUrl, contentId).then(function (count) {
334 setDownloadCount(el, count === null ? 0 : count);
335 });
336 }
337 }
338
339 function scan(root) {
340 var nodes = (root || document).querySelectorAll('[data-embed-type]');
341 for (var i = 0; i < nodes.length; i++) {
342 if (nodes[i].parentElement && nodes[i].parentElement.closest('[data-embed-type]')) continue;
343 processElement(nodes[i]);
344 }
345 }
346
347 if (document.readyState === 'loading') {
348 document.addEventListener('DOMContentLoaded', function () { scan(); });
349 } else {
350 scan();
351 }
352
353 if ('MutationObserver' in window) {
354 new MutationObserver(function (mutations) {
355 mutations.forEach(function (m) {
356 m.addedNodes.forEach(function (n) {
357 if (n.nodeType !== 1) return;
358 if (n.matches && n.matches('[data-embed-type]')) processElement(n);
359 if (n.querySelectorAll) n.querySelectorAll('[data-embed-type]').forEach(processElement);
360 });
361 });
362 }).observe(document.body, { childList: true, subtree: true });
363 }
364
365 // Refresh view badge after analytics tracker fires its own view event.
366 document.addEventListener('embedpress:view', function (ev) {
367 var detail = ev && ev.detail;
368 if (!detail || !detail.contentId) return;
369 var el = contentIdIndex[detail.contentId];
370 if (!el || !cfg.restUrl) return;
371 getJson(cfg.restUrl, detail.contentId).then(function (c) {
372 if (c !== null) setViewCount(el, c);
373 });
374 });
375
376 /**
377 * Receive download notifications from the bundled PDF.js viewer
378 * (static/pdf/web/ep-scripts.js posts these when the toolbar
379 * Download / Save buttons are clicked). Resolves the wrapper from
380 * event.source.frameElement so we credit the right embed when the
381 * page contains multiple PDFs.
382 */
383 window.addEventListener('message', function (ev) {
384 var data = ev && ev.data;
385 if (!data || data.source !== 'embedpress-pdf-viewer' || data.type !== 'download') return;
386 // Need an endpoint to POST to; the actual per-embed gate is
387 // downloadAllowed(el) below (after we resolve which embed sent this),
388 // so a per-embed opt-in still tracks when the global default is off.
389 if (!cfg.downloadTrackUrl) return;
390
391 // Resolve which embed sent the download. The PDF.js viewer runs at
392 // admin-ajax.php (same origin), so `iframe.contentWindow === ev.source`
393 // is the primary match. Firefox, however, does not always keep that
394 // identity stable across the viewer's internal reloads/bfcache, so we
395 // fall back to matching the viewer iframe by its own `file=` src — the
396 // most reliable cross-browser signal — before the host-URL fallback.
397 var sourceFrame = null;
398 try {
399 var frames = document.getElementsByTagName('iframe');
400 for (var i = 0; i < frames.length; i++) {
401 if (frames[i].contentWindow === ev.source) { sourceFrame = frames[i]; break; }
402 }
403 } catch (e) { /* cross-origin — try fallbacks below */ }
404
405 var el = sourceFrame ? sourceFrame.closest('[data-embed-type]') : null;
406
407 // Firefox fallback: the message carries the viewer's own href, whose
408 // `file=` param identifies the PDF. Match the iframe whose src points
409 // at the same file and resolve ITS wrapper — this credits a correct
410 // instance even when contentWindow identity didn't hold.
411 if (!el) {
412 try {
413 // Prefer the explicit `file` the viewer sent (flipbook runs in a
414 // blank frame, so its href has no file= to parse). Fall back to
415 // parsing href's file= query for the classic PDF.js viewer.
416 var target = '';
417 if (data.file) {
418 target = data.file;
419 } else {
420 var u = new URL(data.href || '', window.location.href);
421 var fileParam = u.searchParams.get('file');
422 if (fileParam) target = decodeURIComponent(fileParam);
423 }
424 if (target) {
425 var iframes = document.getElementsByTagName('iframe');
426 for (var k = 0; k < iframes.length && !el; k++) {
427 var isrc = iframes[k].getAttribute('src') || '';
428 if (isrc.indexOf('file=') === -1) continue;
429 try {
430 var iu = new URL(isrc, window.location.href);
431 var ifile = iu.searchParams.get('file');
432 if (ifile && decodeURIComponent(ifile) === target) {
433 el = iframes[k].closest('[data-embed-type]');
434 }
435 } catch (e2) { /* skip this iframe */ }
436 }
437 // Last resort: match an embed wrapper by its resolved URL.
438 if (!el) {
439 var nodes = document.querySelectorAll('[data-embed-type]');
440 for (var n = 0; n < nodes.length && !el; n++) {
441 if (getEmbedUrl(nodes[n]) === target) el = nodes[n];
442 }
443 }
444 }
445 } catch (e) { /* ignore */ }
446 }
447 if (!el) return;
448 if (!downloadAllowed(el)) return;
449
450 var embedType = el.getAttribute('data-embed-type');
451 var contentId = el.getAttribute('data-embedpress-content') || deriveContentId(el, embedType);
452 var embedUrl = getEmbedUrl(el);
453
454 // Optimistically bump the visible count NOW so it feels instant — the
455 // flipbook's watermark/blob download and the REST round-trip can take a
456 // moment, and waiting for the server response made the badge look like
457 // it wasn't reacting. Reconcile with the authoritative count below.
458 var current = parseInt(el.getAttribute('data-ep-download-count') || '0', 10);
459 if (isNaN(current)) current = 0;
460 setDownloadCount(el, current + 1);
461
462 postForm(cfg.downloadTrackUrl, {
463 content_id: contentId,
464 session_id: sessionId,
465 embed_type: embedType,
466 embed_url: embedUrl
467 }).then(function (count) {
468 if (count === null) return;
469 setDownloadCount(el, count);
470 });
471 });
472 })();
473