/**
* SocialShareButton - A lightweight, customizable social sharing component
* @version 1.0.4
* @license GPL-3.0
*/
/** Analytics event schema version. Increment when the payload shape changes. */
const ANALYTICS_SCHEMA_VERSION = "1.0";
class SocialShareButton {
constructor(options = {}) {
// Resolve container element early to prevent duplicate instances
const containerEl = SocialShareButton._resolveContainer(options.container);
if (containerEl && containerEl._socialShareButtonInstance) {
return containerEl._socialShareButtonInstance;
}
this.options = {
url: options.url || (typeof window !== "undefined" ? window.location.href : ""),
title: options.title || (typeof document !== "undefined" ? document.title : ""),
description: options.description || "",
hashtags: options.hashtags || [],
via: options.via || "",
platforms: options.platforms || [
"whatsapp",
"facebook",
"twitter",
"linkedin",
"telegram",
"reddit",
"pinterest",
"discord",
],
theme: options.theme || "dark",
buttonText: options.buttonText || "Share",
customClass: options.customClass || "",
buttonColor: options.buttonColor || "",
buttonHoverColor: options.buttonHoverColor || "",
onShare: options.onShare || null,
onCopy: options.onCopy || null,
container: options.container || null,
showButton: options.showButton !== false,
buttonStyle: options.buttonStyle || "default",
modalPosition: options.modalPosition || "center",
// Analytics — the library emits events but never collects or sends data itself.
// Website owners wire up their own analytics tools via these options.
analytics: options.analytics !== false, // set to false to disable all event emission
onAnalytics: options.onAnalytics || null, // callback: (payload) => void
analyticsPlugins: options.analyticsPlugins || [], // array of { track(payload) } adapters
componentId: options.componentId || null, // optional identifier for this instance
debug: options.debug || false, // log emitted events to console in development
};
this.isModalOpen = false;
this.modal = null;
this.button = null;
this.customColorMouseEnterHandler = null;
this.customColorMouseLeaveHandler = null;
this.handleKeydown = null;
this.listeners = []; // Central registry for all event listeners
this.openTimeout = null; // Track setTimeout for openModal animation
this.closeTimeout = null; // Track setTimeout for closeModal animation
this.feedbackTimeout = null; // Track setTimeout for copy feedback reset
this.ownsBodyLock = false; // Track if this instance owns the body overflow lock
this.eventsAttached = false; // Guard against multiple attachEvents() calls
this.isDestroyed = false; // Track if instance has been destroyed (prevents async callbacks)
this._dynamicUrl = !options.url; // true when url was not provided; updateCurrentPage() will overwrite on SPA route changes
this._dynamicTitle = !options.title; // true when title was not provided; updateCurrentPage() will overwrite on SPA route changes
this._containerEl = containerEl; // Cache resolved element so cleanup still finds it after removal
// If a container was specified but could not be resolved (e.g. SSR or missing DOM node),
// abort registration and initialization to prevent orphan instances and DOM errors.
if (this.options.container && !this._containerEl) {
return;
}
if (containerEl) {
containerEl._socialShareButtonInstance = this;
}
if (typeof window !== "undefined" && SocialShareButton.instances) {
SocialShareButton.instances.add(this);
}
if (this.options.container) {
this.init();
}
}
init() {
if (this.options.showButton) {
this.createButton();
}
this.createModal();
this.attachEvents();
this.applyCustomColors();
}
createButton() {
const button = document.createElement("button");
button.className = `social-share-btn ${this.options.buttonStyle} ${this.options.customClass}`;
button.setAttribute("aria-label", "Share");
button.innerHTML = `
${this.options.buttonText}
`;
this.button = button;
if (this._containerEl) {
this._containerEl.appendChild(button);
}
}
createModal() {
const modal = document.createElement("div");
modal.className = `social-share-modal-overlay ${this.options.theme}`;
modal.style.display = "none";
modal.innerHTML = `
${this.getPlatformsHTML()}
`;
const urlInputContainer = modal.querySelector(".social-share-link-input");
const urlInput = document.createElement("input");
urlInput.type = "text";
urlInput.value = this.options.url;
urlInput.readOnly = true;
urlInput.setAttribute("aria-label", "URL to share");
urlInputContainer.appendChild(urlInput);
this.modal = modal;
document.body.appendChild(modal);
}
getPlatformsHTML() {
const platforms = {
whatsapp: {
name: "WhatsApp",
color: "#25D366",
icon: '',
},
facebook: {
name: "Facebook",
color: "#1877F2",
icon: '',
},
twitter: {
name: "X",
color: "#000000",
icon: '',
},
linkedin: {
name: "LinkedIn",
color: "#0A66C2",
icon: '',
},
telegram: {
name: "Telegram",
color: "#0088cc",
icon: '',
},
reddit: {
name: "Reddit",
color: "#FF4500",
icon: '',
},
email: {
name: "Email",
color: "#7f7f7f",
icon: '',
},
pinterest: {
name: "Pinterest",
color: "#E60023",
icon: '',
},
discord: {
name: "Discord",
color: "#5865F2",
icon: '',
},
};
return this.options.platforms
.filter((platform) => platforms[platform])
.map((platform) => {
const { name, color, icon } = platforms[platform];
return `
`;
})
.join("");
}
getShareURL(platform) {
const { url, title, description, hashtags, via } = this.options;
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
// const encodedDesc = encodeURIComponent(description);
const hashtagString = hashtags.length ? "#" + hashtags.join(" #") : "";
// Build platform-specific messages with customizable parameters
let whatsappMessage,
facebookMessage,
twitterMessage,
telegramMessage,
redditTitle,
emailBody,
pinterestText;
// WhatsApp: Casual with emoji
whatsappMessage = `\u{1F680} ${title}${description ? "\n\n" + description : ""}${hashtagString ? "\n\n" + hashtagString : ""}\n\nLive on the site \u{1F440}\nClean UI, smooth flow \u{2014} worth peeking\n\u{1F447}`;
// Facebook: Title + Description
facebookMessage = `${title}${description ? "\n\n" + description : ""}${hashtagString ? "\n\n" + hashtagString : ""}`;
// Twitter: Title + Description + Hashtags + Via
twitterMessage = `${title}${description ? "\n\n" + description : ""}${hashtagString ? "\n" + hashtagString : ""}`;
// Telegram: Casual with emoji
telegramMessage = `\u{1F517} ${title}${description ? "\n\n" + description : ""}${hashtagString ? "\n\n" + hashtagString : ""}\n\nLive + working\nClean stuff, take a look \u{1F447}`;
// Reddit: Title + Description
redditTitle = `${title}${description ? " - " + description : ""}`;
// Email: Friendly greeting
emailBody = `Hey \u{1F44B}\n\nSharing a clean project I came across:\n${title}${description ? "\n\n" + description : ""}\n\nLive, simple, and usable \u{2014} take a look \u{1F447}`;
// Pinterest: Title + Description
pinterestText = `${title || ""}${description ? " - " + description : ""}`;
const encodedWhatsapp = encodeURIComponent(whatsappMessage);
const encodedFacebook = encodeURIComponent(facebookMessage);
const encodedTwitter = encodeURIComponent(twitterMessage);
const encodedTelegram = encodeURIComponent(telegramMessage);
const encodedReddit = encodeURIComponent(redditTitle);
const encodedEmail = encodeURIComponent(emailBody);
const encodedPinterest = encodeURIComponent(pinterestText);
const urls = {
whatsapp: `https://wa.me/?text=${encodedWhatsapp}%20${encodedUrl}`,
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}"e=${encodedFacebook}`,
twitter: `https://twitter.com/intent/tweet?text=${encodedTwitter}&url=${encodedUrl}${via ? "&via=" + encodeURIComponent(via) : ""}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTelegram}`,
reddit: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedReddit}`,
email: `mailto:?subject=${encodedTitle}&body=${encodedEmail}%20${encodedUrl}`,
pinterest: `https://pinterest.com/pin/create/button/?url=${encodedUrl}&description=${encodedPinterest}`,
discord: "https://discord.com/channels/@me",
};
return urls[platform] || "";
}
addEventListener(element, type, handler, options = false) {
if (!element) return;
element.addEventListener(type, handler, options);
this.listeners.push({ element, type, handler, options });
}
//Remove all tracked event listeners (used in destroy to prevent memory leaks)
removeAllListeners() {
this.listeners.forEach(({ element, type, handler, options }) => {
if (element) {
element.removeEventListener(type, handler, options);
}
});
this.listeners = [];
}
attachEvents() {
// Re-entrancy guard: prevent double-registration if called multiple times
if (this.eventsAttached) return;
// Button click to open modal
if (this.button) {
const openModalHandler = () => this.openModal();
this.addEventListener(this.button, "click", openModalHandler);
}
// Modal overlay click to close
const modalClickHandler = (e) => {
if (e.target === this.modal) {
this.closeModal();
}
};
this.addEventListener(this.modal, "click", modalClickHandler);
// Close button
const closeBtn = this.modal.querySelector(".social-share-modal-close");
const closeBtnHandler = () => this.closeModal();
this.addEventListener(closeBtn, "click", closeBtnHandler);
// Platform buttons
const platformBtns = this.modal.querySelectorAll(".social-share-platform-btn");
platformBtns.forEach((btn) => {
const platformHandler = () => {
const platform = btn.dataset.platform;
this.share(platform);
};
this.addEventListener(btn, "click", platformHandler);
});
// Copy button
const copyBtn = this.modal.querySelector(".social-share-copy-btn");
const copyBtnHandler = () => this.copyLink();
this.addEventListener(copyBtn, "click", copyBtnHandler);
// Input click to select
const input = this.modal.querySelector(".social-share-link-input input");
const inputSelectHandler = (e) => e.target.select();
this.addEventListener(input, "click", inputSelectHandler);
// ESC key to close
this.handleKeydown = (e) => {
if (e.key === "Escape" && this.isModalOpen) {
this.closeModal();
}
};
if (typeof document !== "undefined") {
document.addEventListener("keydown", this.handleKeydown);
}
this.eventsAttached = true; // Mark as attached
}
openModal() {
// Safety check: prevent errors if modal was destroyed
if (!this.modal) return;
this.isModalOpen = true;
this.modal.style.display = "flex";
this._emit("social_share_popup_open", "popup_open");
// Shared body overflow management: only increment counter if this instance doesn't already own the lock
if (typeof document !== "undefined" && document.body) {
if (!this.ownsBodyLock) {
// Only increment if this instance doesn't already own a lock
if (SocialShareButton.openModalCount === 0) {
// Save original overflow before first modal opens
SocialShareButton.originalBodyOverflow = document.body.style.overflow;
}
SocialShareButton.openModalCount++;
this.ownsBodyLock = true; // Mark that this instance owns a lock
}
document.body.style.overflow = "hidden";
}
// Clear any pending animations (both open and close to prevent race conditions)
if (this.openTimeout) {
clearTimeout(this.openTimeout);
this.openTimeout = null;
}
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = null;
}
// Animate in
this.openTimeout = setTimeout(() => {
if (this.modal) {
// Safety check in case destroy() was called
this.modal.classList.add("active");
}
this.openTimeout = null;
}, 10);
}
closeModal() {
if (!this.modal) return; // Safety check
this.modal.classList.remove("active");
this._emit("social_share_popup_close", "popup_close");
// Clear any pending animations (both open and close to prevent race conditions)
if (this.openTimeout) {
clearTimeout(this.openTimeout);
this.openTimeout = null;
}
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = null;
}
this.closeTimeout = setTimeout(() => {
if (this.modal) {
// Safety check in case destroy() was called
this.isModalOpen = false;
this.modal.style.display = "none";
// Shared body overflow management: only decrement if this instance owns the lock
if (this.ownsBodyLock && typeof document !== "undefined" && document.body) {
// Decrement counter (guard against negative)
if (SocialShareButton.openModalCount > 0) {
SocialShareButton.openModalCount--;
}
this.ownsBodyLock = false; // Release the lock
// Restore original overflow only when all modals are closed
if (SocialShareButton.openModalCount === 0) {
document.body.style.overflow = SocialShareButton.originalBodyOverflow || "";
SocialShareButton.originalBodyOverflow = null;
}
}
}
this.closeTimeout = null;
}, 200);
}
share(platform) {
const shareUrl = this.getShareURL(platform);
if (shareUrl) {
this._emit("social_share_click", "share", { platform });
// Platform-specific sharing trigger logic:
// Discord does not provide a native web-share intent; therefore, the most reliable
// fallback is to copy the share URL to the clipboard and then navigate the user
// towards Discord's direct messaging area.
if (platform === "discord") {
this.copyLink();
window.open(shareUrl, "_blank", "noopener,noreferrer");
} else if (platform === "email") {
window.location.href = shareUrl;
} else {
// Default behavior: open platform's share intent in a specialized popup window
window.open(shareUrl, "_blank", "noopener,noreferrer,width=600,height=600");
}
this._emit("social_share_success", "share", { platform });
if (this.options.onShare) {
this.options.onShare(platform, this.options.url);
}
} else {
this._emit("social_share_error", "error", {
platform,
errorMessage: `No share URL configured for platform: ${platform}`,
});
}
}
copyLink() {
const input = this.modal.querySelector(".social-share-link-input input");
const copyBtn = this.modal.querySelector(".social-share-copy-btn");
// Check if clipboard API is available
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(this.options.url)
.then(() => {
// Guard against async callback after destroy
if (this.isDestroyed) return;
copyBtn.textContent = "Copied!";
copyBtn.classList.add("copied");
this._emit("social_share_copy", "copy");
if (this.options.onCopy) {
this.options.onCopy(this.options.url);
}
// Clear any existing feedback timeout
if (this.feedbackTimeout) {
clearTimeout(this.feedbackTimeout);
}
// Track feedback timeout to prevent callback after destroy
this.feedbackTimeout = setTimeout(() => {
if (this.isDestroyed || !copyBtn) return; // Safety check
copyBtn.textContent = "Copy";
copyBtn.classList.remove("copied");
this.feedbackTimeout = null;
}, 2000);
})
.catch(() => {
// Fallback to manual selection
this.fallbackCopy(input, copyBtn);
});
} else {
// Fallback for browsers without clipboard API
this.fallbackCopy(input, copyBtn);
}
}
fallbackCopy(input, copyBtn) {
// Guard against execution after destroy
if (this.isDestroyed) return;
try {
input.select();
input.setSelectionRange(0, 99999); // For mobile devices
document.execCommand("copy");
copyBtn.textContent = "Copied!";
copyBtn.classList.add("copied");
this._emit("social_share_copy", "copy");
if (this.options.onCopy) {
this.options.onCopy(this.options.url);
}
// Clear any existing feedback timeout
if (this.feedbackTimeout) {
clearTimeout(this.feedbackTimeout);
}
// Track feedback timeout to prevent callback after destroy
this.feedbackTimeout = setTimeout(() => {
if (this.isDestroyed || !copyBtn) return; // Safety check
copyBtn.textContent = "Copy";
copyBtn.classList.remove("copied");
this.feedbackTimeout = null;
}, 2000);
} catch (_err) {
copyBtn.textContent = "Failed";
// Clear any existing feedback timeout
if (this.feedbackTimeout) {
clearTimeout(this.feedbackTimeout);
}
// Track feedback timeout to prevent callback after destroy
this.feedbackTimeout = setTimeout(() => {
if (this.isDestroyed || !copyBtn) return; // Safety check
copyBtn.textContent = "Copy";
this.feedbackTimeout = null;
}, 2000);
}
}
destroy() {
if (this.handleKeydown) {
if (typeof document !== "undefined") {
document.removeEventListener("keydown", this.handleKeydown);
}
this.handleKeydown = null;
}
// Mark as destroyed to prevent async callbacks
this.isDestroyed = true;
// Remove all tracked event listeners (prevents memory leaks)
this.removeAllListeners();
// Clear any pending animation timeouts to prevent accessing null references
if (this.openTimeout) {
clearTimeout(this.openTimeout);
this.openTimeout = null;
}
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = null;
}
if (this.feedbackTimeout) {
clearTimeout(this.feedbackTimeout);
this.feedbackTimeout = null;
}
// Remove custom color handlers
if (this.button && this.customColorMouseEnterHandler) {
this.button.removeEventListener("mouseenter", this.customColorMouseEnterHandler);
this.customColorMouseEnterHandler = null;
}
if (this.button && this.customColorMouseLeaveHandler) {
this.button.removeEventListener("mouseleave", this.customColorMouseLeaveHandler);
this.customColorMouseLeaveHandler = null;
}
// Remove DOM elements
if (this.button && this.button.parentNode) {
this.button.parentNode.removeChild(this.button);
}
if (this.modal && this.modal.parentNode) {
this.modal.parentNode.removeChild(this.modal);
}
// Shared body overflow management: only decrement if this instance owns the lock
if (this.ownsBodyLock && typeof document !== "undefined" && document.body) {
// Decrement counter (guard against negative)
if (SocialShareButton.openModalCount > 0) {
SocialShareButton.openModalCount--;
}
this.ownsBodyLock = false; // Release the lock
// Restore original overflow only when all modals are closed
if (SocialShareButton.openModalCount === 0) {
document.body.style.overflow = SocialShareButton.originalBodyOverflow || "";
SocialShareButton.originalBodyOverflow = null;
}
}
// Remove from active instances registry
if (typeof window !== "undefined" && SocialShareButton.instances) {
SocialShareButton.instances.delete(this);
}
const containerEl = this._getContainer();
if (containerEl && containerEl._socialShareButtonInstance === this) {
delete containerEl._socialShareButtonInstance;
}
// Clear references (makes destroy idempotent)
this.button = null;
this.modal = null;
this.isModalOpen = false;
this.eventsAttached = false; // Reset re-entrancy guard
}
updateOptions(options, isInternalRefresh = false) {
// External updates recompute dynamic flags, while internal SPA refreshes
// preserve caller-supplied values to maintain the auto-update contract.
if (!isInternalRefresh) {
if (options.url !== undefined) {
this._dynamicUrl = !options.url;
}
if (options.title !== undefined) {
this._dynamicTitle = !options.title;
}
}
this.options = { ...this.options, ...options };
// Update URL in modal if it exists
if (this.modal) {
const input = this.modal.querySelector(".social-share-link-input input");
if (input) {
input.value = this.options.url;
}
}
// Reapply custom colors if color option changed
if ("buttonColor" in options || "buttonHoverColor" in options) {
this.applyCustomColors();
}
}
applyCustomColors() {
if (!this.button) return;
// Remove legacy global style tag to prevent cross-instance color bleed.
const styleTag = document.getElementById("social-share-custom-colors");
if (styleTag && styleTag.parentNode) {
styleTag.parentNode.removeChild(styleTag);
}
if (this.customColorMouseEnterHandler) {
this.button.removeEventListener("mouseenter", this.customColorMouseEnterHandler);
this.customColorMouseEnterHandler = null;
}
if (this.customColorMouseLeaveHandler) {
this.button.removeEventListener("mouseleave", this.customColorMouseLeaveHandler);
this.customColorMouseLeaveHandler = null;
}
this.button.style.removeProperty("background-color");
this.button.style.removeProperty("background-image");
this.button.style.removeProperty("border-color");
const baseColor = this.options.buttonColor || "";
const hoverColor = this.options.buttonHoverColor || baseColor;
if (!baseColor && !hoverColor) return;
if (baseColor) {
this.button.style.backgroundImage = "none";
this.button.style.backgroundColor = baseColor;
this.button.style.borderColor = baseColor;
}
this.customColorMouseEnterHandler = () => {
if (hoverColor) {
this.button.style.backgroundImage = "none";
this.button.style.backgroundColor = hoverColor;
this.button.style.borderColor = hoverColor;
}
};
this.customColorMouseLeaveHandler = () => {
if (baseColor) {
this.button.style.backgroundImage = "none";
this.button.style.backgroundColor = baseColor;
this.button.style.borderColor = baseColor;
} else {
this.button.style.removeProperty("background-color");
this.button.style.removeProperty("background-image");
this.button.style.removeProperty("border-color");
}
};
// Note: Custom color handlers are managed separately (not in listeners)
// because they need to be removed/reapplied when colors change
this.button.addEventListener("mouseenter", this.customColorMouseEnterHandler);
this.button.addEventListener("mouseleave", this.customColorMouseLeaveHandler);
}
// ---------------------------------------------------------------------------
// Analytics event system
//
// The library is privacy-by-design: it never collects, stores, or transmits
// user data. _emit() only dispatches interaction events locally so that the
// host website can forward them to whichever analytics tool they choose.
//
// Three delivery paths run in parallel for maximum flexibility:
// 1. DOM CustomEvent — works with CDN drops, vanilla JS, and any framework.
// Multiple independent listeners can subscribe with
// document.addEventListener('social-share', handler).
// 2. onAnalytics hook — single direct callback, useful for inline setups.
// 3. analyticsPlugins — adapter registry; each adapter's track() method is
// called in turn, allowing GA4 + Mixpanel + custom
// systems to all receive the same event simultaneously.
// ---------------------------------------------------------------------------
// Resolves a raw container value (string or Element) to a DOM Element, or null if absent/SSR.
static _resolveContainer(raw) {
if (!raw) return null;
if (typeof document === "undefined") return null;
return typeof raw === "string" ? document.querySelector(raw) : raw;
}
// Returns the cached host container element, or null.
_getContainer() {
return this._containerEl || null;
}
/**
* Logs analytics warnings only when debug mode is enabled.
* @param {string} message - Description of the failed analytics path.
* @param {Error} err - The caught error instance.
*/
_debugWarn(message, err) {
// _debugWarn: emit analytics warnings only in debug mode for visibility.
if (!this.options.debug) return;
// eslint-disable-next-line no-console
console.warn("[SocialShareButton Analytics]", message, err);
}
/**
* Emits an analytics event through all configured delivery paths.
*
* Standard payload schema
* ─────────────────────────────────────────────────────────────────────────
* {
* eventName : string — e.g. 'social_share_click'
* interactionType: string — 'share' | 'copy' | 'popup_open' |
* 'popup_close' | 'error'
* platform : string|null — 'twitter', 'facebook', etc.
* url : string — URL being shared
* title : string — page title
* timestamp : number — Unix ms (Date.now())
* componentId : string|null — value of the componentId option
* errorMessage : string — only present on social_share_error events
* }
*
* @param {string} eventName - snake_case event identifier
* @param {string} interactionType - broad interaction category
* @param {Object} [extra] - optional extra fields (platform, errorMessage)
*/
_emit(eventName, interactionType, extra = {}) {
if (this.options.analytics === false) return;
const payload = {
version: ANALYTICS_SCHEMA_VERSION,
source: "social-share-button",
eventName,
interactionType,
platform: extra.platform || null,
url: this.options.url,
title: this.options.title,
timestamp: Date.now(),
componentId: this.options.componentId,
};
if (extra.errorMessage) {
payload.errorMessage = extra.errorMessage;
}
// Optional console output for development / debugging
if (this.options.debug) {
// eslint-disable-next-line no-console
console.log("[SocialShareButton Analytics]", payload);
}
// Path 1 — DOM CustomEvent (framework-agnostic, CDN-friendly)
// Bubbles from the container element so delegated listeners work naturally.
if (typeof window !== "undefined" && typeof CustomEvent !== "undefined") {
try {
const domEvent = new CustomEvent("social-share", {
bubbles: true,
cancelable: false,
composed: true, // crosses shadow-DOM boundaries; safe to set in all envs
detail: payload,
});
const el = this._getContainer();
(el || document).dispatchEvent(domEvent);
} catch (err) {
this._debugWarn("DOM event dispatch failed", err);
}
}
// Path 2 — onAnalytics callback (direct, single-consumer hook)
if (typeof this.options.onAnalytics === "function") {
try {
this.options.onAnalytics(payload);
} catch (err) {
this._debugWarn("onAnalytics callback failed", err);
}
}
// Path 3 — plugin / adapter registry (supports multiple simultaneous consumers)
if (Array.isArray(this.options.analyticsPlugins)) {
for (const plugin of this.options.analyticsPlugins) {
if (plugin && typeof plugin.track === "function") {
try {
plugin.track(payload);
} catch (err) {
this._debugWarn("plugin.track() failed", err);
}
}
}
}
}
static updateCurrentPage() {
if (typeof window === "undefined" || !SocialShareButton.instances) return;
SocialShareButton.instances.forEach((instance) => {
// Only refresh fields that were auto-defaulted from the page;
// caller-supplied url/title values must remain unchanged.
const updates = {};
if (instance._dynamicUrl) {
updates.url = window.location.href;
}
if (instance._dynamicTitle) {
updates.title = document.title;
}
if (Object.keys(updates).length > 0) {
instance.updateOptions(updates, true);
}
});
}
}
// Static properties for shared body overflow management across all instances
SocialShareButton.openModalCount = 0;
SocialShareButton.originalBodyOverflow = null;
SocialShareButton.instances = new Set();
(function () {
// Helper for dynamic route changes in SPAs
function handleRouteChange() {
setTimeout(() => {
SocialShareButton.updateCurrentPage();
}, 50);
}
// Patch pushState and replaceState to track SPA routing dynamically
let isHistoryPatched = false;
function patchHistory() {
if (typeof window === "undefined" || !window.history || isHistoryPatched) return;
isHistoryPatched = true;
const wrap = (type) => {
const orig = window.history[type];
return function (...args) {
const result = orig.apply(this, args);
try {
const event = new CustomEvent("pushstate-or-replacestate", { detail: { type } });
window.dispatchEvent(event);
} catch (_e) {
// Ignore error
}
return result;
};
};
window.history.pushState = wrap("pushState");
window.history.replaceState = wrap("replaceState");
}
// Auto-initialize elements with `data-social-share` attribute
function autoInitElement(element) {
if (element._socialShareButtonInstance) return;
const options = { container: element };
const attrs = {
url: "data-url",
title: "data-title",
description: "data-description",
via: "data-via",
theme: "data-theme",
buttonText: "data-button-text",
customClass: "data-custom-class",
buttonColor: "data-button-color",
buttonHoverColor: "data-button-hover-color",
buttonStyle: "data-button-style",
modalPosition: "data-modal-position",
};
for (const [key, attr] of Object.entries(attrs)) {
const val = element.getAttribute(attr);
if (val !== null) {
options[key] = val;
}
}
if (element.hasAttribute("data-hashtags")) {
options.hashtags = element
.getAttribute("data-hashtags")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
if (element.hasAttribute("data-platforms")) {
options.platforms = element
.getAttribute("data-platforms")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
if (element.hasAttribute("data-show-button")) {
options.showButton = element.getAttribute("data-show-button") !== "false";
}
new SocialShareButton(options);
}
function autoInit(root = document) {
if (typeof document === "undefined") return;
// Fast short-circuit: skip nodes appended by the library itself to avoid redundant scans
if (
root !== document &&
root.classList &&
(root.classList.contains("social-share-btn") ||
root.classList.contains("social-share-modal-overlay"))
) {
return;
}
if (root !== document && root.hasAttribute && root.hasAttribute("data-social-share")) {
autoInitElement(root);
}
const elements = root.querySelectorAll ? root.querySelectorAll("[data-social-share]") : [];
elements.forEach((element) => autoInitElement(element));
}
// Setup MutationObserver to watch for added/removed sharing components
let globalObserver = null;
let initTimeout = null;
function setupMutationObserver() {
if (typeof document === "undefined" || globalObserver) return;
globalObserver = new MutationObserver((mutations) => {
let needsInit = false;
mutations.forEach((mutation) => {
// Auto-cleanup on removal
// Fast-path: Skip costly removal traversal if we have no active tracked instances
if (
mutation.removedNodes.length > 0 &&
SocialShareButton.instances &&
SocialShareButton.instances.size > 0
) {
mutation.removedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
SocialShareButton.instances.forEach((instance) => {
const containerEl = instance._getContainer();
if (containerEl && (node === containerEl || node.contains(containerEl))) {
instance.destroy();
}
});
});
}
// Track if there are any element additions to trigger a batched scan
if (!needsInit && mutation.addedNodes.length > 0) {
for (let i = 0; i < mutation.addedNodes.length; i++) {
if (mutation.addedNodes[i].nodeType === 1) {
needsInit = true;
break;
}
}
}
});
// Batch auto-init to avoid costly scans on every single added node.
// If a timeout is already pending, we let it run (throttle vs debounce)
// so continuous DOM mutations don't indefinitely delay initialization.
if (needsInit && !initTimeout) {
initTimeout = setTimeout(() => {
initTimeout = null;
autoInit(document); // Single global scan is highly optimized natively
}, 10);
}
});
globalObserver.observe(document.body, { childList: true, subtree: true });
}
// Initialize on browser load
if (typeof window !== "undefined" && typeof document !== "undefined") {
patchHistory();
window.addEventListener("popstate", handleRouteChange);
window.addEventListener("hashchange", handleRouteChange);
window.addEventListener("pushstate-or-replacestate", handleRouteChange);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
autoInit();
setupMutationObserver();
});
} else {
autoInit();
setupMutationObserver();
}
}
})();
// Export for different module systems
if (typeof module !== "undefined" && module.exports) {
module.exports = SocialShareButton;
}
if (typeof window !== "undefined") {
window.SocialShareButton = SocialShareButton;
}