1/** 2 * CM-Lightbox - A lightweight, responsive lightbox library for WordPress 3 * Version: 1.0.0 4 * Author: CM Alpha 5 */ 6 7(function($, window, document) { 8 'use strict'; 9 10 // Module pattern with namespace 11 window.CMLightbox = (function() { 12 13 // Private variables 14 var groups = new Map(); 15 var activeInstance = null; 16 var defaultOptions = { 17 styles: { 18 background: 'rgba(5,5,5,0.67)', 19 backgroundTop: '#000000', 20 backgroundBottom: '#000000', 21 colorTop: '#EFEFEF', 22 colorBottom: '#f8f8f8', 23 border: 'solid thin #000000', 24 closeButton: true 25 }, 26 thumbnails: true, 27 cursor: 'pointer', 28 fadeInDuration: 300, 29 fadeOutDuration: 300, 30 zIndex: 9999 31 }; 32 33 // Debug mode 34 var debugMode = false; 35 36 function log(message, data) { 37 if (debugMode && console && console.log) { 38 console.log('[CM-Lightbox] ' + message, data || ''); 39 } 40 } 41 42 // Core Classes 43 class Group { 44 constructor(name, options) { 45 this.name = name; 46 this.members = []; 47 this.options = $.extend(true, {}, defaultOptions, options); 48 // log('Group created:', { name: name, options: this.options }); 49 } 50 51 addMember(member) { 52 this.members.push(member); 53 // log('Member added to group ' + this.name + ':', member); 54 } 55 } 56 57 class DOMScanner { 58 constructor() { 59 this.attributePrefix = 'data-cm-lb-'; 60 this.groupAttribute = 'data-cm-lb-group'; 61 } 62 63 scan(groupName) { 64 // log('Scanning DOM for group:', groupName); 65 const selector = `[${this.groupAttribute}="${groupName}"]`; 66 const elements = document.querySelectorAll(selector); 67 const members = []; 68 69 elements.forEach((element, index) => { 70 const attributes = this.extractAttributes(element); 71 const member = { 72 element: element, 73 index: index, 74 attributes: attributes, 75 url: attributes.url || element.getAttribute('src') || element.getAttribute('href') 76 }; 77 78 if (attributes.watermarkStatus) { 79 console.log('[CM-Lightbox][Watermark] element', { 80 group: groupName, 81 index: index, 82 status: attributes.watermarkStatus, 83 message: attributes.watermarkMessage || '', 84 url: member.url 85 }); 86 } 87 88 // Apply cursor style if needed 89 if (attributes.cursor !== 'false') { 90 element.style.cursor = 'pointer'; 91 } 92 93 members.push(member); 94 }); 95 96 // log('Scan complete. Found ' + members.length + ' members'); 97 return members; 98 } 99 100 extractAttributes(element) { 101 const attributes = {}; 102 const attrs = element.attributes; 103 104 for (let i = 0; i < attrs.length; i++) { 105 const attr = attrs[i]; 106 if (attr.name.startsWith(this.attributePrefix)) { 107 const key = this.camelCase(attr.name.replace(this.attributePrefix, '')); 108 attributes[key] = attr.value; 109 } 110 } 111 112 return attributes; 113 } 114 115 camelCase(str) { 116 return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); 117 } 118 } 119 120 class ContentTypeDetector { 121 static detect(url, explicitType) { 122 if (!url) { 123 // log('ContentTypeDetector: No URL provided, defaulting to html'); 124 return 'html'; 125 } 126 127 // If explicit type is provided, use it 128 if (explicitType) { 129 // log('ContentTypeDetector: Using explicit type:', explicitType); 130 return explicitType; 131 } 132 133 // log('ContentTypeDetector: Detecting type for URL:', url); 134 135 const cleanUrl = url.toLowerCase(); 136 137 // Remove query parameters and hash for extension checking 138 const urlWithoutParams = cleanUrl.split('?')[0].split('#')[0]; 139 140 // log('ContentTypeDetector: URL without params:', urlWithoutParams); 141 142 // Check for known image services first 143 const imageServices = [ 144 'picsum.photos', 145 'placeholder.com', 146 'placehold.it', 147 'lorempixel.com', 148 'unsplash.com', 149 'pexels.com', 150 'imgur.com', 151 'cloudinary.com', 152 'imgix.net' 153 ]; 154 155 if (imageServices.some(service => cleanUrl.includes(service))) { 156 // 'ContentTypeDetector: Detected as image (known image service)'); 157 return 'image'; 158 } 159 160 // Image detection by file extension 161 if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff|tif)$/i.test(urlWithoutParams)) { 162 // log('ContentTypeDetector: Detected as image (file extension)'); 163 return 'image'; 164 } 165 166 // Check for image in path (common patterns) 167 if (/\/(image|images|img|photo|photos|picture|pictures|media|assets|static)\//i.test(urlWithoutParams)) { 168 // Additional check for numeric endings (common for dynamic images) 169 if (/\/\d+$/.test(urlWithoutParams) || /\/\d+x\d+$/.test(urlWithoutParams)) { 170 // log('ContentTypeDetector: Detected as image (path pattern)'); 171 return 'image'; 172 } 173 } 174 175 // YouTube detection 176 if (cleanUrl.includes('youtube.com') || cleanUrl.includes('youtu.be')) { 177 return 'youtube'; 178 } 179 180 // Audio detection 181 if (/\.mp3$/i.test(urlWithoutParams)) { 182 return 'audio'; 183 } 184 185 // PDF detection 186 if (/\.pdf$/i.test(urlWithoutParams)) { 187 return 'pdf'; 188 } 189 190 // Google Maps detection 191 if (cleanUrl.includes('maps.google.com') || 192 cleanUrl.includes('google.com/maps') || 193 cleanUrl.includes('goo.gl/maps')) { 194 return 'maps'; 195 } 196 197 // Default to HTML 198 // log('ContentTypeDetector: No match found, defaulting to html'); 199 return 'html'; 200 } 201 202 static getYouTubeId(url) { 203 const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/; 204 const match = url.match(regex); 205 return match ? match[1] : null; 206 } 207 } 208 209 class ContentRenderer { 210 static render(type, url, container, options) { 211 // log('Rendering content type:', type); 212 const renderer = this.getRenderer(type); 213 return renderer.call(this, url, container, options); 214 } 215 216 static getRenderer(type) { 217 const renderers = { 218 image: this.renderImage, 219 youtube: this.renderYouTube, 220 audio: this.renderAudio, 221 pdf: this.renderPDF, 222 maps: this.renderMaps, 223 html: this.renderHTML 224 }; 225 226 return renderers[type] || renderers.html; 227 } 228 229 static renderImage(url, container, options) { 230 const img = document.createElement('img'); 231 img.src = url; 232 img.className = 'cm-lightbox-image'; 233 img.dataset.zoomable = 'true'; 234 235 return new Promise((resolve, reject) => { 236 img.onload = () => { 237 if (console && console.log) { 238 console.log('[CM-Lightbox][Watermark] image loaded', { 239 src: img.src,
240 naturalWidth: img.naturalWidth, 241 naturalHeight: img.naturalHeight 242 }); 243 } 244 container.appendChild(img); 245 resolve({ 246 type: 'image', 247 element: img, 248 naturalWidth: img.naturalWidth, 249 naturalHeight: img.naturalHeight 250 }); 251 }; 252 img.onerror = () => { 253 log('Error loading image:', url); 254 reject(new Error('Failed to load image')); 255 }; 256 }); 257 } 258 259 static renderYouTube(url, container, options) { 260 const videoId = ContentTypeDetector.getYouTubeId(url); 261 if (!videoId) { 262 return this.renderHTML('Invalid YouTube URL', container, options); 263 } 264 265 const iframe = document.createElement('iframe'); 266 iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`; 267 iframe.className = 'cm-lightbox-youtube'; 268 iframe.setAttribute('allowfullscreen', ''); 269 iframe.setAttribute('frameborder', '0'); 270 iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'); 271 272 container.appendChild(iframe); 273 274 return Promise.resolve({ 275 type: 'youtube', 276 element: iframe, 277 naturalWidth: 16, // 16:9 aspect ratio 278 naturalHeight: 9 279 }); 280 } 281 282 static renderAudio(url, container, options) { 283 const wrapper = document.createElement('div'); 284 wrapper.className = 'cm-lightbox-audio-wrapper'; 285 286 const audio = document.createElement('audio'); 287 audio.src = url; 288 audio.controls = true; 289 audio.autoplay = true; 290 291 wrapper.appendChild(audio); 292 container.appendChild(wrapper); 293 294 return Promise.resolve({ 295 type: 'audio', 296 element: wrapper, 297 naturalWidth: 400, // Default width for audio player 298 naturalHeight: 100 299 }); 300 } 301 302 static renderPDF(url, container, options) { 303 const iframe = document.createElement('iframe'); 304 iframe.src = url; 305 iframe.className = 'cm-lightbox-pdf'; 306 iframe.setAttribute('frameborder', '0'); 307 308 container.appendChild(iframe); 309 310 return Promise.resolve({ 311 type: 'pdf', 312 element: iframe, 313 naturalWidth: 4, // 4:3 aspect ratio for PDFs 314 naturalHeight: 3 315 }); 316 } 317 318 static renderMaps(url, container, options) { 319 const iframe = document.createElement('iframe'); 320 iframe.src = url; 321 iframe.className = 'cm-lightbox-maps'; 322 iframe.setAttribute('frameborder', '0'); 323 iframe.setAttribute('allowfullscreen', ''); 324 325 container.appendChild(iframe); 326 327 return Promise.resolve({ 328 type: 'maps', 329 element: iframe, 330 naturalWidth: 16, // 16:9 aspect ratio for maps 331 naturalHeight: 9 332 }); 333 } 334 335 static renderHTML(content, container, options) { 336 const wrapper = document.createElement('div'); 337 wrapper.className = 'cm-lightbox-html'; 338 wrapper.innerHTML = content; 339 340 container.appendChild(wrapper); 341 342 return Promise.resolve({ 343 type: 'html', 344 element: wrapper,
345 naturalWidth: 4, // Default 4:3 aspect ratio 346 naturalHeight: 3 347 }); 348 } 349 } 350 351 class ViewportCalculator { 352 constructor() { 353 this.textPadding = 20; 354 this.contentPadding = 40; 355 } 356 357 calculate(content, options) { 358 const viewport = this.getViewportDimensions(); 359 const textHeights = this.calculateTextHeights(options); 360 361 const availableWidth = viewport.width - this.contentPadding; 362 const availableHeight = viewport.height - textHeights.total - this.contentPadding; 363 364 const dimensions = this.calculateOptimalDimensions( 365 content, 366 { width: availableWidth, height: availableHeight } 367 ); 368 369 return { 370 content: dimensions, 371 container: { 372 width: viewport.width, 373 height: viewport.height 374 }, 375 textHeights: textHeights 376 }; 377 } 378 379 calculateOptimalDimensions(content, available) { 380 const contentAspect = content.naturalWidth / content.naturalHeight; 381 const availableAspect = available.width / available.height; 382 383 let width, height; 384 385 if (contentAspect > availableAspect) { 386 // Content is wider - fit to width 387 width = available.width; 388 height = width / contentAspect; 389 } else { 390 // Content is taller - fit to height 391 height = available.height; 392 width = height * contentAspect; 393 } 394 395 return { 396 width: Math.floor(width), 397 height: Math.floor(height) 398 }; 399 } 400 401 calculateTextHeights(options) { 402 const heights = { 403 top: 0, 404 bottom: 0, 405 thumbnails: 0, 406 total: 0 407 }; 408 409 // Measure actual text heights after rendering 410 const topText = document.querySelector('.cm-lightbox-top-text'); 411 const bottomText = document.querySelector('.cm-lightbox-bottom-text'); 412 413 if (options.textTop && topText) { 414 heights.top = topText.offsetHeight || 40; 415 } 416 417 if (options.textBottom && bottomText) { 418 heights.bottom = bottomText.offsetHeight || 40; 419 } 420 421 if (options.thumbnails !== 'false' && options.thumbnails !== false) { 422 heights.thumbnails = 70; // 50px height + padding 423 } 424 425 heights.total = heights.top + heights.bottom + heights.thumbnails + this.textPadding; 426 427 return heights; 428 } 429 430 getViewportDimensions() { 431 return { 432 width: window.innerWidth || document.documentElement.clientWidth, 433 height: window.innerHeight || document.documentElement.clientHeight 434 }; 435 } 436 } 437 438 class StyleParser { 439 parse(styleString) { 440 if (!styleString) return {}; 441 442 const styles = {}; 443 const declarations = styleString.split(';'); 444 445 declarations.forEach(declaration => { 446 const colonIndex = declaration.indexOf(':'); 447 if (colonIndex > 0) { 448 const property = declaration.substring(0, colonIndex).trim(); 449 const value = declaration.substring(colonIndex + 1).trim(); 450 451 if (property && value) { 452 // Convert property names to camelCase 453 const camelProperty = property.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); 454 styles[camelProperty] = value; 455 } 456 } 457 }); 458 459 return styles; 460 } 461 462 apply(overlay, styles) { 463 // Map custom properties to CSS 464 const propertyMap = { 465 'background': (el, value) => { 466 el.style.backgroundColor = value; 467 }, 468 'backgroundTop': (el, value) => { 469 const topText = el.querySelector('.cm-lightbox-top-text'); 470 if (topText) topText.style.backgroundColor = value; 471 }, 472 'backgroundBottom': (el, value) => { 473 const bottomText = el.querySelector('.cm-lightbox-bottom-text'); 474 if (bottomText) bottomText.style.backgroundColor = value; 475 }, 476 'colorTop': (el, value) => { 477 const topText = el.querySelector('.cm-lightbox-top-text'); 478 if (topText) topText.style.color = value; 479 }, 480 'colorBottom': (el, value) => { 481 const bottomText = el.querySelector('.cm-lightbox-bottom-text'); 482 if (bottomText) bottomText.style.color = value; 483 }, 484 'border': (el, value) => { 485 const content = el.querySelector('.cm-lightbox-content'); 486 if (content) content.style.border = value; 487 }, 488 'closeButton': (el, value) => { 489 const closeBtn = el.querySelector('.cm-lightbox-close'); 490 if (closeBtn) closeBtn.style.display = value === 'false' ? 'none' : 'block'; 491 } 492 }; 493
494 Object.keys(styles).forEach(property => { 495 if (propertyMap[property]) { 496 propertyMap[property](overlay, styles[property]); 497 } 498 }); 499 } 500 } 501 502 class LightboxUI { 503 constructor() { 504 this.elements = {}; 505 } 506 507 build() { 508 // Create overlay 509 this.elements.overlay = this.createElement('div', 'cm-lightbox-overlay'); 510 511 // Create container 512 this.elements.container = this.createElement('div', 'cm-lightbox-container'); 513 514 // Create content structure 515 this.elements.topText = this.createElement('div', 'cm-lightbox-top-text'); 516 this.elements.contentWrapper = this.createElement('div', 'cm-lightbox-content-wrapper'); 517 this.elements.content = this.createElement('div', 'cm-lightbox-content'); 518 this.elements.bottomText = this.createElement('div', 'cm-lightbox-bottom-text'); 519 this.elements.thumbnails = this.createElement('div', 'cm-lightbox-thumbnails'); 520 521 // Build content structure 522 this.elements.contentWrapper.appendChild(this.elements.content); 523 this.elements.container.appendChild(this.elements.topText); 524 this.elements.container.appendChild(this.elements.contentWrapper); 525 this.elements.container.appendChild(this.elements.bottomText); 526 this.elements.container.appendChild(this.elements.thumbnails); 527 528 // Create navigation 529 this.elements.prevButton = this.createElement('button', 'cm-lightbox-nav cm-lightbox-prev'); 530 this.elements.prevButton.innerHTML = '‹'; 531 this.elements.prevButton.setAttribute('aria-label', 'Previous'); 532 533 this.elements.nextButton = this.createElement('button', 'cm-lightbox-nav cm-lightbox-next'); 534 this.elements.nextButton.innerHTML = '›'; 535 this.elements.nextButton.setAttribute('aria-label', 'Next'); 536 537 // Create toolbar 538 this.elements.toolbar = this.createElement('div', 'cm-lightbox-toolbar'); 539 540 this.elements.zoomButton = this.createElement('button', 'cm-lightbox-zoom'); 541 this.elements.zoomButton.innerHTML = 'ð'; 542 this.elements.zoomButton.setAttribute('aria-label', 'Zoom'); 543 this.elements.zoomButton.setAttribute('title', 'Zoom'); 544 this.elements.zoomButton.style.display = 'none'; // Hidden by default 545 546 this.elements.fullscreenButton = this.createElement('button', 'cm-lightbox-fullscreen'); 547 this.elements.fullscreenButton.innerHTML = 'â¶'; 548 this.elements.fullscreenButton.setAttribute('aria-label', 'Fullscreen'); 549 this.elements.fullscreenButton.setAttribute('title', 'Full Screen'); 550 551 this.elements.closeButton = this.createElement('button', 'cm-lightbox-close'); 552 this.elements.closeButton.innerHTML = 'â'; 553 this.elements.closeButton.setAttribute('aria-label', 'Close'); 554 this.elements.closeButton.setAttribute('title', 'Close'); 555 556 // Build toolbar 557 this.elements.toolbar.appendChild(this.elements.zoomButton); 558 this.elements.toolbar.appendChild(this.elements.fullscreenButton); 559 this.elements.toolbar.appendChild(this.elements.closeButton); 560 561 // Assemble main structure 562 this.elements.overlay.appendChild(this.elements.container); 563 this.elements.overlay.appendChild(this.elements.prevButton); 564 this.elements.overlay.appendChild(this.elements.nextButton); 565 this.elements.overlay.appendChild(this.elements.toolbar); 566 567 return this.elements.overlay; 568 } 569 570 createElement(tag, className) { 571 const element = document.createElement(tag); 572 if (className) { 573 element.className = className; 574 } 575 return element; 576 } 577 } 578 579 class NavigationController { 580 constructor(lightbox) { 581 this.lightbox = lightbox; 582 this.touchStartX = 0; 583 this.touchEndX = 0; 584 this.bindEvents(); 585 } 586 587 bindEvents() { 588 // Click events 589 this.lightbox.ui.elements.prevButton.addEventListener('click', () => this.navigate(-1)); 590 this.lightbox.ui.elements.nextButton.addEventListener('click', () => this.navigate(1)); 591 592 // Keyboard events 593 this.keyboardHandler = this.handleKeyboard.bind(this); 594 document.addEventListener('keydown', this.keyboardHandler); 595 596 // Touch events for mobile 597 this.setupTouchEvents(); 598 } 599 600 unbindEvents() { 601 document.removeEventListener('keydown', this.keyboardHandler); 602 } 603 604 navigate(direction) { 605 const currentIndex = this.lightbox.currentIndex; 606 const groupSize = this.lightbox.group.members.length; 607 let newIndex = currentIndex + direction; 608 609 // Wrap around 610 if (newIndex < 0) { 611 newIndex = groupSize - 1; 612 } else if (newIndex >= groupSize) { 613 newIndex = 0; 614 } 615 616 this.lightbox.showItem(newIndex); 617 } 618 619 handleKeyboard(e) { 620 if (!this.lightbox.isOpen) return; 621 622 switch(e.key) { 623 case 'ArrowLeft': 624 e.preventDefault(); 625 this.navigate(-1); 626 break; 627 case 'ArrowRight': 628 e.preventDefault(); 629 this.navigate(1); 630 break; 631 case 'Escape': 632 e.preventDefault(); 633 this.lightbox.close(); 634 break; 635 } 636 } 637 638 setupTouchEvents() { 639 const container = this.lightbox.ui.elements.container; 640 641 container.addEventListener('touchstart', (e) => { 642 this.touchStartX = e.changedTouches[0].screenX; 643 }, { passive: true }); 644 645 container.addEventListener('touchend', (e) => { 646 this.touchEndX = e.changedTouches[0].screenX; 647 this.handleSwipe(); 648 }, { passive: true }); 649 } 650 651 handleSwipe() { 652 const swipeThreshold = 50; 653 const diff = this.touchStartX - this.touchEndX; 654 655 if (Math.abs(diff) > swipeThreshold) { 656 if (diff > 0) { 657 // Swipe left - next 658 this.navigate(1); 659 } else { 660 // Swipe right - previous 661 this.navigate(-1); 662 } 663 } 664 } 665 } 666 667 class ThumbnailGallery { 668 constructor(lightbox) { 669 this.lightbox = lightbox; 670 this.thumbnailWidth = 75; 671 this.thumbnailHeight = 50; 672 } 673 674 build() { 675 const container = this.lightbox.ui.elements.thumbnails; 676 container.innerHTML = ''; // Clear existing 677 678 const showThumbnails = this.lightbox.currentMember.attributes.thumbnails !== 'false' && 679 this.lightbox.currentMember.attributes.thumbnails !== false; 680 681 // Always keep thumbnails in DOM, CSS handles visibility 682 if (!showThumbnails) { 683 container.style.display = 'none'; 684 } else { 685 container.style.display = 'block'; 686 } 687 const wrapper = document.createElement('div');
688 wrapper.className = 'cm-lightbox-thumbnails-wrapper'; 689 690 this.lightbox.group.members.forEach((member, index) => { 691 const thumb = this.createThumbnail(member, index); 692 wrapper.appendChild(thumb); 693 }); 694 695 container.appendChild(wrapper); 696 this.updateActive(this.lightbox.currentIndex); 697 } 698 699 createThumbnail(member, index) { 700 const thumb = document.createElement('div'); 701 thumb.className = 'cm-lightbox-thumbnail'; 702 thumb.dataset.index = index; 703 704 // Detect content type for potential video overlay 705 const explicitType = member.attributes.type || member.attributes.contentType; 706 const contentType = ContentTypeDetector.detect(member.url, explicitType); 707 708 // Find the source image within the original DOM element 709 const sourceImg = member.element.querySelector('img'); 710 711 if (sourceImg && sourceImg.src) { 712 // Use the source image for thumbnail 713 const img = document.createElement('img'); 714 img.src = sourceImg.src; 715 img.style.width = this.thumbnailWidth + 'px'; 716 img.style.height = this.thumbnailHeight + 'px'; 717 img.style.objectFit = 'cover'; 718 thumb.appendChild(img); 719 720 // Add video play icon overlay if content is video-related 721 if (contentType === 'youtube' || contentType === 'video') { 722 const playIcon = document.createElement('div'); 723 playIcon.className = 'cm-video-play-icon'; 724 playIcon.innerHTML = ''; 725 thumb.appendChild(playIcon); 726 } 727 } else { 728 // Fallback: if no source image found, detect content type and create thumbnail 729 if (contentType === 'image') { 730 const img = document.createElement('img'); 731 img.src = member.url; 732 img.style.width = this.thumbnailWidth + 'px'; 733 img.style.height = this.thumbnailHeight + 'px'; 734 img.style.objectFit = 'cover'; 735 thumb.appendChild(img); 736 } else { 737 // Placeholder for non-image content 738 thumb.innerHTML = `<div class="cm-lightbox-thumbnail-placeholder">${contentType}</div>`; 739 740 // Add play icon for video placeholders too 741 if (contentType === 'youtube' || contentType === 'video') { 742 const playIcon = document.createElement('div'); 743 playIcon.className = 'cm-lightbox-thumbnail-play-icon'; 744 playIcon.innerHTML = 'â¶'; 745 playIcon.style.cssText = ` 746 position: absolute; 747 top: 50%; 748 left: 50%; 749 transform: translate(-50%, -50%); 750 background: rgba(255, 255, 255, 0.9); 751 color: #333; 752 border-radius: 50%; 753 width: 24px; 754 height: 24px; 755 display: flex; 756 align-items: center; 757 justify-content: center; 758 font-size: 12px; 759 pointer-events: none; 760 z-index: 1; 761 `; 762 thumb.style.position = 'relative'; 763 thumb.appendChild(playIcon); 764 } 765 } 766 } 767 768 thumb.addEventListener('click', () => { 769 this.lightbox.showItem(index); 770 }); 771 772 return thumb; 773 } 774 775 updateActive(index) { 776 const thumbnails = this.lightbox.ui.elements.thumbnails.querySelectorAll('.cm-lightbox-thumbn
776ail'); 777 thumbnails.forEach((thumb, i) => { 778 thumb.classList.toggle('active', i === index); 779 }); 780 781 // Scroll active thumbnail into view 782 if (thumbnails[index]) { 783 thumbnails[index].scrollIntoView({ 784 behavior: 'smooth', 785 block: 'nearest', 786 inline: 'center' 787 }); 788 } 789 } 790 } 791 792 class LightboxInstance { 793 constructor(group) { 794 this.group = group; 795 this.isOpen = false; 796 this.currentIndex = 0; 797 this.currentMember = null; 798 this.currentContent = null; 799 this.isZoomed = false; 800 this.zoomLevel = 1; 801 802 // Create UI 803 this.ui = new LightboxUI(); 804 this.overlay = this.ui.build(); 805 806 // Create components 807 this.viewportCalculator = new ViewportCalculator(); 808 this.styleParser = new StyleParser(); 809 this.navigationController = new NavigationController(this); 810 this.thumbnailGallery = new ThumbnailGallery(this); 811 812 // Bind events 813 this.bindEvents(); 814 815 // log('Lightbox instance created for group:', group.name); 816 } 817 818 bindEvents() { 819 // Close button 820 this.ui.elements.closeButton.addEventListener('click', () => this.close()); 821 822 // Smart click detection for content area 823 this.ui.elements.content.addEventListener('click', (e) => { 824 // Check if the click was on the image itself 825 if (e.target.classList.contains('cm-lightbox-image')) { 826 // Get image dimensions and position 827 const img = e.target; 828 const rect = img.getBoundingClientRect(); 829 830 // Calculate the actual image dimensions within the container 831 const naturalAspect = img.naturalWidth / img.naturalHeight; 832 const containerAspect = rect.width / rect.height; 833 834 let actualWidth, actualHeight, offsetX, offsetY; 835 836 if (naturalAspect > containerAspect) { 837 // Image is wider - fits to width 838 actualWidth = rect.width; 839 actualHeight = rect.width / naturalAspect; 840 offsetX = 0; 841 offsetY = (rect.height - actualHeight) / 2; 842 } else { 843 // Image is taller - fits to height 844 actualHeight = rect.height; 845 actualWidth = rect.height * naturalAspect; 846 offsetX = (rect.width - actualWidth) / 2; 847 offsetY = 0; 848 } 849 850 // Check if click was outside the actual image bounds 851 const clickX = e.clientX - rect.left; 852 const clickY = e.clientY - rect.top; 853 854 855 if (clickX < offsetX || clickX > offsetX + actualWidth || 856 clickY < offsetY || clickY > offsetY + actualHeight) { 857 this.close(); 858 } 859 } else if (!e.target.closest('.cm-lightbox-image')) { 860 // Click was on content area but not on image 861 this.close(); 862 } 863 }); 864 865 // Overlay click to close - close if clicking outside content 866 this.overlay.addEventListener('click', (e) => {
867 // Close if clicking on overlay or container (but not its children) 868 if (e.target === this.overlay || e.target === this.ui.elements.container) { 869 this.close(); 870 } 871 }); 872 873 // Fullscreen button 874 this.ui.elements.fullscreenButton.addEventListener('click', () => this.toggleFullscreen()); 875 876 // Zoom button 877 this.ui.elements.zoomButton.addEventListener('click', () => this.toggleZoom()); 878 879 // Window resize 880 this.resizeHandler = () => this.updateLayout(); 881 window.addEventListener('resize', this.resizeHandler); 882 } 883 884 open(index) { 885 if (this.isOpen) return; 886 887 this.currentIndex = index; 888 this.currentMember = this.group.members[index]; 889 this.isOpen = true; 890 891 // log('Opening lightbox for item:', { index: index, member: this.currentMember }); 892 893 // Add to body 894 document.body.appendChild(this.overlay); 895 896 // Prevent body scroll 897 document.body.style.overflow = 'hidden'; 898 899 // Hide WordPress admin bar if visible (to prevent z-index overlap) 900 const adminBar = document.getElementById('wpadminbar'); 901 if (adminBar && window.getComputedStyle(adminBar).display !== 'none') { 902 this.adminBarWasVisible = true; 903 adminBar.style.display = 'none'; 904 } else { 905 this.adminBarWasVisible = false; 906 } 907 908 // Show overlay with fade in 909 this.overlay.style.display = 'block'; 910 setTimeout(() => { 911 this.overlay.classList.add('cm-lightbox-visible'); 912 }, 10); 913 914 // Load content 915 this.showItem(index); 916 917 // Trigger open event 918 this.triggerEvent('lightbox:open', { group: this.group.name, index: index }); 919 } 920 921 close() { 922 if (!this.isOpen) return; 923 924 // 'Closing lightbox'); 925 926 this.isOpen = false; 927 928 // Fade out 929 this.overlay.classList.remove('cm-lightbox-visible'); 930 931 setTimeout(() => { 932 // Remove from DOM 933 if (this.overlay.parentNode) { 934 this.overlay.parentNode.removeChild(this.overlay); 935 } 936 937 // Restore body scroll 938 document.body.style.overflow = ''; 939 940 // Restore WordPress admin bar if it was visible 941 if (this.adminBarWasVisible) { 942 const adminBar = document.getElementById('wpadminbar'); 943 if (adminBar) { 944 adminBar.style.display = ''; 945 } 946 } 947 948 // Clear content 949 this.ui.elements.content.innerHTML = ''; 950 951 // Reset zoom 952 this.isZoomed = false; 953 this.zoomLevel = 1; 954 955 // Trigger close event 956 this.triggerEvent('lightbox:close', { group: this.group.name }); 957 }, this.group.options.fadeOutDuration); 958 } 959 960 showItem(index) { 961 this.currentIndex = index; 962 this.currentMember = this.group.members[index]; 963 964 // 'Showing item:', { index: index, member: this.currentMember }); 965 966 // Clear previous content 967 this.ui.elements.content.innerHTML = ''; 968 969 // Update text 970 this.updateText(); 971 972 // Apply styles 973 const styles = this.styleParser.parse(this.currentMember.attributes.styles || ''); 974 const mergedStyles = $.extend({}, this.group.options.styles, styles); 975 this.styleParser.apply(this.overlay, mergedStyles); 976 977 // Detect content type 978 // log('showItem: Current member URL:', this.currentMember.url); 979 const explicitType = this.currentMember.attributes.type || this.currentMember.attributes.contentType; 980 const contentType = ContentTypeDetector.detect(this.currentMember.url, explicitType); 981 // log('showItem: Detected content type:', contentType); 982 983 // Show/hide zoom button for images 984 this.ui.elements.zoomButton.style.display = contentType === 'image' ? 'block' : 'none'; 985 986 // Render content 987 ContentRenderer.render(contentType, this.currentMember.url, this.ui.elements.content, this.currentMember.attributes) 988 .then(content => { 989 this.currentContent = content; 990 this.updateLayout(); 991 992 // Build thumbnails 993 this.thumbnailGallery.build(); 994 995 // Update navigation visibility 996 this.updateNavigationVisibility(); 997 998 // Trigger loaded event 999 this.triggerEvent('lightbox:loaded', { 1000 group: this.group.name, 1001 index: index, 1002 type: contentType 1003 }); 1004 }) 1005 .catch(error => { 1006 log('Error loading content:', error); 1007 this.ui.elements.content.innerHTML = '<div class="cm-lightbox-error">Error loading content</div>'; 1008 }); 1009 } 1010 1011 updateText() { 1012 const topText = this.currentMember.attributes.textTop || ''; 1013 const bottomText = this.currentMember.attributes.textBottom || ''; 1014 1015 // Always keep elements in DOM, just update content 1016 this.ui.elements.topText.innerHTML = topText; 1017 this.ui.elements.bottomText.innerHTML = bottomText; 1018 1019 // CSS will handle hiding empty elements with :empty pseudo-class 1020 } 1021 1022 updateLayout() { 1023 if (!this.currentContent) return; 1024 1025 const contentElement = this.currentContent.element; 1026 1027 if (this.isZoomed && this.currentContent.type === 'image') { 1028 // Apply zoom using transform scale 1029 contentElement.style.transform = `scale(${this.zoomLevel})`; 1030 contentElement.style.transformOrigin = 'center center'; 1031 1032 // Enable scrolling for zoomed content 1033 this.ui.elements.contentWrapper.style.overflow = 'auto'; 1034 } else { 1035 // Normal view - let CSS handle the sizing 1036 contentElement.style.transform = ''; 1037 contentElement.style.transformOrigin = ''; 1038 1039 // For images, use percentage sizing 1040 if (this.currentContent.type === 'image') { 1041 contentElement.style.width = '100%'; 1042 contentElement.style.height = '100%'; 1043 contentElement.style.objectFit = 'contain'; 1044 contentElement.style.maxWidth = 'none'; 1045 contentElement.style.maxHeight = 'none'; 1046 } else if (this.currentContent.type === 'youtube' || 1047 this.currentContent.type === 'pdf' || 1048 this.currentContent.type === 'maps') { 1049 // For iframes, use 100% width and height 1050 contentElement.style.width = '100%'; 1051 contentElement.style.height = '100%'; 1052 } else if (this.currentContent.type === 'audio') { 1053 // Audio player stays at fixed size 1054 contentElement.style.width = '400px'; 1055 contentElement.style.height = '100px'; 1056 } else if (this.currentContent.type === 'html') { 1057 // HTML content fills available space 1058 contentElement.style.width = '100%'; 1059 contentElement.style.height = '100%'; 1060 } 1061 1062 // Disable scrolling for normal content 1063 this.ui.elements.contentWrapper.style.overflow = 'hidden'; 1064 } 1065 } 1066 1067 updateNavigationVisibility() { 1068 const hasMultiple = this.group.members.length > 1; 1069 this.ui.elements.prevButton.style.display = hasMultiple ? 'block' : 'none'; 1070 this.ui.elements.nextButton.style.display = hasMultiple ? 'block' : 'none'; 1071 } 1072 1073 toggleFullscreen() { 1074 if (!document.fullscreenElement) { 1075 this.overlay.requestFullscreen().catch(err => { 1076 log('Error entering fullscreen:', err); 1077 }); 1078 } else { 1079 document.exitFullscreen(); 1080 } 1081 } 1082 1083 toggleZoom() {
1084 if (this.currentContent.type !== 'image') return; 1085 1086 this.isZoomed = !this.isZoomed; 1087 1088 if (this.isZoomed) { 1089 this.zoomLevel = 2; // 2x zoom 1090 this.ui.elements.zoomButton.classList.add('active'); 1091 } else { 1092 this.zoomLevel = 1; 1093 this.ui.elements.zoomButton.classList.remove('active'); 1094 } 1095 1096 this.updateLayout(); 1097 1098 // Setup mouse wheel zoom for images 1099 if (this.isZoomed) { 1100 this.setupImageZoom(); 1101 } else { 1102 this.teardownImageZoom(); 1103 } 1104 } 1105 1106 setupImageZoom() { 1107 const contentWrapper = this.ui.elements.contentWrapper; 1108 const image = this.currentContent.element; 1109 1110 // Mouse wheel zoom 1111 this.wheelHandler = (e) => { 1112 e.preventDefault(); 1113 const delta = e.deltaY > 0 ? -0.1 : 0.1; 1114 this.zoomLevel = Math.max(1, Math.min(5, this.zoomLevel + delta)); 1115 this.updateLayout(); 1116 }; 1117 1118 contentWrapper.addEventListener('wheel', this.wheelHandler); 1119 1120 // Drag to pan 1121 let isDragging = false; 1122 let startX, startY, scrollLeft, scrollTop; 1123 1124 this.mouseDownHandler = (e) => { 1125 isDragging = true; 1126 contentWrapper.style.cursor = 'grabbing'; 1127 startX = e.pageX - contentWrapper.offsetLeft; 1128 startY = e.pageY - contentWrapper.offsetTop; 1129 scrollLeft = contentWrapper.scrollLeft; 1130 scrollTop = contentWrapper.scrollTop; 1131 }; 1132 1133 this.mouseLeaveHandler = () => { 1134 isDragging = false; 1135 contentWrapper.style.cursor = 'grab'; 1136 }; 1137 1138 this.mouseUpHandler = () => { 1139 isDragging = false; 1140 contentWrapper.style.cursor = 'grab'; 1141 }; 1142 1143 this.mouseMoveHandler = (e) => { 1144 if (!isDragging) return; 1145 e.preventDefault(); 1146 const x = e.pageX - contentWrapper.offsetLeft; 1147 const y = e.pageY - contentWrapper.offsetTop; 1148 const walkX = (x - startX) * 2; 1149 const walkY = (y - startY) * 2; 1150 contentWrapper.scrollLeft = scrollLeft - walkX; 1151 contentWrapper.scrollTop = scrollTop - walkY; 1152 }; 1153 1154 contentWrapper.style.cursor = 'grab'; 1155 contentWrapper.addEventListener('mousedown', this.mouseDownHandler); 1156 contentWrapper.addEventListener('mouseleave', this.mouseLeaveHandler); 1157 contentWrapper.addEventListener('mouseup', this.mouseUpHandler); 1158 contentWrapper.addEventListener('mousemove', this.mouseMoveHandler); 1159 } 1160 1161 teardownImageZoom() { 1162 const contentWrapper = this.ui.elements.contentWrapper; 1163 1164 if (this.wheelHandler) { 1165 contentWrapper.removeEventListener('wheel', this.wheelHandler); 1166 } 1167 1168 if (this.mouseDownHandler) { 1169 contentWrapper.removeEventListener('mousedown', this.mouseDownHandler); 1170 contentWrapper.removeEventListener('mouseleave', this.mouseLeaveHandler); 1171 contentWrapper.removeEventListener('mouseup', this.mouseUpHandler); 1172 contentWrapper.removeEventListener('mousemove', this.mouseMoveHandler); 1173 } 1174 1175 contentWrapper.style.cursor = ''; 1176 } 1177 1178 triggerEvent(eventName, detail) { 1179 const event = new CustomEvent(eventName, { detail: detail }); 1180 document.dispatchEvent(event); 1181 } 1182 1183 destroy() { 1184 // Remove event listeners 1185 window.removeEventListener('resize', this.resizeHandler); 1186 this.navigationController.unbindEvents(); 1187 1188 // Close if open 1189 if (this.isOpen) { 1190 this.close(); 1191 } 1192 1193 // log('Lightbox instance destroyed'); 1194 } 1195 } 1196 1197 // Scanner instance 1198 const scanner = new DOMScanner(); 1199 1200 // Public API implementation 1201 function init(groupName, options) { 1202 if (!groupName) { 1203 log('Error: Group name is required'); 1204 return; 1205 } 1206 1207 // Check if group already exists 1208 if (groups.has(groupName)) { 1209 // log('Group already initialized:', groupName); 1210 return; 1211 } 1212 1213 // Create group 1214 const group = new Group(groupName, options); 1215 1216 // Scan DOM for members 1217 const members = scanner.scan(groupName);
1218 members.forEach(member => group.addMember(member)); 1219 1220 // Store group 1221 groups.set(groupName, group); 1222 1223 // Bind click events 1224 members.forEach((member, index) => { 1225 member.element.addEventListener('click', function(e) { 1226 e.preventDefault(); 1227 open(groupName, index); 1228 }); 1229 }); 1230 1231 // log('Group initialized:', { name: groupName, memberCount: members.length }); 1232 } 1233 1234 function open(groupName, index) { 1235 const group = groups.get(groupName); 1236 if (!group) { 1237 log('Error: Group not found:', groupName); 1238 return; 1239 } 1240 1241 if (index < 0 || index >= group.members.length) { 1242 log('Error: Invalid index:', index); 1243 return; 1244 } 1245 1246 // Close any existing lightbox 1247 if (activeInstance) { 1248 activeInstance.close(); 1249 } 1250 1251 // Create new instance 1252 activeInstance = new LightboxInstance(group); 1253 activeInstance.open(index); 1254 } 1255 1256 function close() { 1257 if (activeInstance) { 1258 activeInstance.close(); 1259 activeInstance = null; 1260 } 1261 } 1262 1263 function navigate(direction) { 1264 if (activeInstance && activeInstance.navigationController) { 1265 activeInstance.navigationController.navigate(direction); 1266 } 1267 } 1268 1269 function hasGroup(groupName) { 1270 return groups.has(groupName); 1271 } 1272 1273 function destroy(groupName) { 1274 if (groupName) { 1275 // Destroy specific group 1276 const group = groups.get(groupName); 1277 if (group) { 1278 // Remove click events 1279 group.members.forEach(member => { 1280 const newElement = member.element.cloneNode(true); 1281 member.element.parentNode.replaceChild(newElement, member.element); 1282 }); 1283 1284 groups.delete(groupName); 1285 // log('Group destroyed:', groupName); 1286 } 1287 } else { 1288 // Destroy all groups 1289 groups.forEach((group, name) => { 1290 destroy(name); 1291 }); 1292 1293 // Close active instance 1294 if (activeInstance) { 1295 activeInstance.destroy(); 1296 activeInstance = null; 1297 } 1298 } 1299 } 1300 1301 // Public API 1302 return { 1303 init: init, 1304 open: open, 1305 close: close, 1306 navigate: navigate, 1307 hasGroup: hasGroup, 1308 destroy: destroy, 1309 version: '1.0.0' 1310 }; 1311 1312 })(); 1313 1314 // Auto-initialize on DOM ready 1315 $(document).ready(function() { 1316 // Look for groups in the DOM 1317 const foundGroups = new Set(); 1318 1319 $('[data-cm-lb-group]').each(function() { 1320 const groupName = $(this).data('cm-lb-group'); 1321 if (groupName && !foundGroups.has(groupName)) { 1322 foundGroups.add(groupName); 1323 1324 // Get default attributes from first element 1325 const defaultAttrs = {}; 1326 const $this = $(this); 1327 1328 $.each(this.attributes, function() { 1329 if (this.name.startsWith('data-cm-lb-') && 1330 this.name !== 'data-cm-lb-group' && 1331 this.name !== 'data-cm-lb-url') { 1332 const key = this.name.replace('data-cm-lb-', ''); 1333 defaultAttrs[key] = this.value; 1334 } 1335 }); 1336 1337 // Initialize group 1338 CMLightbox.init(groupName, defaultAttrs); 1339 } 1340 }); 1341 1342 if (foundGroups.size > 0) { 1343 // console.log('[CM-Lightbox] Auto-initialized ' + foundGroups.size + ' groups'); 1344 } 1345 }); 1346 1347 // Listen for dynamic content events (pagination, AJAX loads, etc.) 1348 // This allows any plugin to trigger lightbox reinitialization by emitting custom events 1349 $(document).on('cmTilesLoaded', function(event, $newContent) { 1350 // Reinitialize lightbox for newly loaded content 1351 const processedGroups = new Set(); 1352 1353 // Check if specific content was provided, otherwise scan entire document 1354 const $context = $newContent && $newContent.length ? $newContent : $(document); 1355 1356 $context.find('[data-cm-lb-group]').each(function() { 1357 const groupName = $(this).data('cm-lb-group'); 1358 1359 if (groupName && !processedGroups.has(groupName)) { 1360 processedGroups.add(groupName); 1361 1362 // Get default attributes from first element 1363 const defaultAttrs = {}; 1364 $.each(this.attributes, function() { 1365 if (this.name.startsWith('data-cm-lb-') && 1366 this.name !== 'data-cm-lb-group' && 1367 this.name !== 'data-cm-lb-url') {
1368 const key = this.name.replace('data-cm-lb-', ''); 1369 defaultAttrs[key] = this.value; 1370 } 1371 }); 1372 1373 // If group already exists, destroy it first 1374 if (CMLightbox.hasGroup(groupName)) { 1375 CMLightbox.destroy(groupName); 1376 } 1377 1378 // Initialize (or reinitialize) the group 1379 CMLightbox.init(groupName, defaultAttrs); 1380 } 1381 }); 1382 1383 if (processedGroups.size > 0) { 1384 console.log('[CM-Lightbox] Reinitialized ' + processedGroups.size + ' group(s) after dynamic content load'); 1385 } 1386 }); 1387 1388})(jQuery, window, document);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.