|
22 | 22 | let isLibLoading = false; |
23 | 23 | const initQueue = []; |
24 | 24 |
|
25 | | - // Session-only storage for anonymous/private browsing mode |
26 | 25 | const memoryStorage = {}; |
27 | 26 |
|
28 | 27 | /** |
|
97 | 96 | return `<span class="icon-svg">${icon.svg}</span><span class="icon-fallback" role="img">${icon.fallback}</span>`; |
98 | 97 | } |
99 | 98 |
|
100 | | - // Configuration constants |
101 | | - const MIN_TOUCH_TARGET_SIZE = 44; // px for accessibility |
102 | | - const RENDER_DEBOUNCE_DELAY = 150; // ms |
| 99 | + const MIN_TOUCH_TARGET_SIZE = 44; |
| 100 | + const RENDER_DEBOUNCE_DELAY = 150; |
103 | 101 |
|
104 | | - // Default theme colors |
105 | 102 | const DEFAULT_THEME = { |
106 | 103 | primary: '#3498db', |
107 | 104 | primaryHover: '#2980b9', |
|
114 | 111 | lightBorder: '#ddd' |
115 | 112 | }; |
116 | 113 |
|
117 | | - // Proportional relationships (calculated from default colors) |
118 | 114 | const COLOR_RATIOS = { |
119 | | - primaryToHover: -18, // Lightness change percentage |
| 115 | + primaryToHover: -18, |
120 | 116 | primaryToActive: -36, |
121 | 117 | sidebarToHover: 8, |
122 | 118 | sidebarToBorder: -8 |
|
236 | 232 | const hsl = hexToHsl(hex); |
237 | 233 | const lightness = hsl.l; |
238 | 234 |
|
239 | | - // Auto-adjust if too dark or too bright |
240 | 235 | let adjustedL = lightness; |
241 | 236 | if (lightness < 15) { |
242 | | - adjustedL = 25; // Minimum usable lightness |
| 237 | + adjustedL = 25; |
243 | 238 | } else if (lightness > 85) { |
244 | | - adjustedL = 75; // Maximum usable lightness |
| 239 | + adjustedL = 75; |
245 | 240 | } |
246 | 241 |
|
247 | 242 | const adjustedHex = hslToHex(hsl.h, hsl.s, adjustedL); |
|
637 | 632 |
|
638 | 633 | if (!prevBtn || !nextBtn || !instance.pdfDoc) return; |
639 | 634 |
|
640 | | - // Check if at beginning (first page of first chapter) |
641 | 635 | const isAtBeginning = instance.currentPage === 1 && |
642 | 636 | instance.currentChapter === 0 && |
643 | 637 | instance.currentModule === 0; |
644 | 638 |
|
645 | | - // Check if at end (last page of last chapter) |
646 | 639 | const isLastChapterOfModule = instance.currentModule === course.modules.length - 1 && |
647 | 640 | instance.currentChapter === course.modules[instance.currentModule].chapters.length - 1; |
648 | 641 | const isAtEnd = instance.currentPage === instance.pdfDoc.numPages && isLastChapterOfModule; |
|
693 | 686 |
|
694 | 687 | const toggleLoading = (show) => loading.style.display = show ? 'flex' : 'none'; |
695 | 688 |
|
696 | | - // Scroll lock to prevent automatic scroll restoration during navigation |
697 | 689 | let isNavigating = false; |
698 | 690 |
|
699 | 691 | /** |
700 | 692 | * Ensures scroll position is reset and stays at top-left (0,0) |
701 | 693 | * Forces scroll to 0 using multiple aggressive methods |
702 | 694 | */ |
703 | 695 | const resetScroll = () => { |
704 | | - // Step 1: Disable scroll-behavior to prevent smooth scroll interference |
705 | 696 | const originalScrollBehavior = canvasContainer.style.scrollBehavior; |
706 | 697 | canvasContainer.style.scrollBehavior = 'auto'; |
707 | 698 |
|
708 | | - // Step 2: Multiple simultaneous scroll reset methods |
709 | 699 | canvasContainer.scrollTop = 0; |
710 | 700 | canvasContainer.scrollLeft = 0; |
711 | 701 | canvasContainer.scroll(0, 0); |
712 | 702 | canvasContainer.scrollTo(0, 0); |
713 | 703 |
|
714 | | - // Step 3: Aggressive continuous locking during rendering |
715 | 704 | let lockCount = 0; |
716 | 705 | const maxLocks = 15; |
717 | 706 |
|
718 | 707 | const forceLock = () => { |
719 | 708 | if (lockCount < maxLocks & isNavigating) { |
720 | | - // Read current position |
721 | 709 | const currentTop = canvasContainer.scrollTop; |
722 | 710 | const currentLeft = canvasContainer.scrollLeft; |
723 | 711 |
|
724 | | - // If not at 0, force it back |
725 | 712 | if (currentTop !== 0 || currentLeft !== 0) { |
726 | 713 | canvasContainer.scrollTop = 0; |
727 | 714 | canvasContainer.scrollLeft = 0; |
|
736 | 723 |
|
737 | 724 | requestAnimationFrame(forceLock); |
738 | 725 |
|
739 | | - // Step 4: Final cleanup and release after 600ms |
740 | 726 | setTimeout(() => { |
741 | 727 | isNavigating = false; |
742 | | - // Restore original scroll-behavior |
743 | 728 | canvasContainer.style.scrollBehavior = originalScrollBehavior; |
744 | 729 | }, 600); |
745 | 730 | }; |
|
780 | 765 | * @param {number} newZoom - Zoom level (100-200) |
781 | 766 | */ |
782 | 767 | function applyZoom(newZoom) { |
783 | | - // Validate zoom (100-200, integer only) |
784 | 768 | newZoom = Math.max(100, Math.min(200, Math.floor(newZoom))); |
785 | 769 |
|
786 | 770 | if (newZoom === instance.zoom) return; |
787 | 771 |
|
788 | | - // Save current scroll position relative to content |
789 | 772 | const scrollContainer = canvasContainer; |
790 | 773 | const scrollRatio = { |
791 | 774 | x: scrollContainer.scrollWidth > 0 ? scrollContainer.scrollLeft / scrollContainer.scrollWidth : 0, |
|
794 | 777 |
|
795 | 778 | instance.zoom = newZoom; |
796 | 779 |
|
797 | | - // Update UI |
798 | 780 | const zoomInput = container.querySelector('.zoom-input'); |
799 | 781 | if (zoomInput) zoomInput.value = newZoom + '%'; |
800 | 782 |
|
|
805 | 787 |
|
806 | 788 | updateZoomUI(); |
807 | 789 |
|
808 | | - // Re-render page with new zoom |
809 | 790 | renderPage(instance.currentPage).then(() => { |
810 | | - // Restore scroll position proportionally |
811 | 791 | setTimeout(() => { |
812 | 792 | if (scrollContainer.scrollWidth > 0) { |
813 | 793 | scrollContainer.scrollLeft = scrollRatio.x * scrollContainer.scrollWidth; |
|
848 | 828 |
|
849 | 829 | return instance.pdfDoc.getPage(num).then(page => { |
850 | 830 | try { |
851 | | - // Get available width from parent container |
852 | 831 | const container = canvas.parentElement; |
853 | 832 | let availableWidth = container.clientWidth; |
854 | 833 |
|
855 | | - // Remove padding from available width for calculation |
856 | 834 | const styles = window.getComputedStyle(container); |
857 | 835 | const paddingLeft = parseFloat(styles.paddingLeft) || 0; |
858 | 836 | const paddingRight = parseFloat(styles.paddingRight) || 0; |
|
861 | 839 | const dpr = window.devicePixelRatio || 1; |
862 | 840 | const viewport = page.getViewport({ scale: 1.0 }); |
863 | 841 |
|
864 | | - // Calculate scale to fit available width with zoom applied |
865 | 842 | let scale = (availableWidth / viewport.width) * (instance.zoom / 100); |
866 | 843 |
|
867 | | - // Ensure scale is at least 1.0 to avoid unnecessary horizontal scroll at 100% zoom |
868 | 844 | if (instance.zoom <= 100) { |
869 | 845 | scale = Math.max(1.0, scale); |
870 | 846 | } |
|
911 | 887 | if (!instance.enableTextSelection) return; |
912 | 888 |
|
913 | 889 | try { |
914 | | - // Get the text layer container |
915 | 890 | const textLayerDiv = container.querySelector('.pdf-viewer-text-layer'); |
916 | 891 | if (!textLayerDiv) return; |
917 | 892 |
|
918 | | - // Clear previous text layer |
919 | 893 | textLayerDiv.innerHTML = ''; |
920 | 894 |
|
921 | | - // Get text content from the page |
922 | 895 | const textContent = await page.getTextContent(); |
923 | 896 |
|
924 | 897 | if (!textContent || !textContent.items || textContent.items.length === 0) return; |
925 | 898 |
|
926 | | - // Set the text layer size and positioning |
927 | 899 | const canvas = container.querySelector('.pdf-viewer-canvas'); |
928 | 900 | if (!canvas) return; |
929 | 901 |
|
930 | | - // Match canvas dimensions exactly |
931 | 902 | textLayerDiv.style.width = canvas.offsetWidth + 'px'; |
932 | 903 | textLayerDiv.style.height = canvas.offsetHeight + 'px'; |
933 | 904 | textLayerDiv.style.top = canvas.offsetTop + 'px'; |
934 | 905 | textLayerDiv.style.left = canvas.offsetLeft + 'px'; |
935 | 906 |
|
936 | | - // Get viewport information for coordinate conversion |
937 | | - const dpr = window.devicePixelRatio || 1; |
938 | 907 | const baseViewport = page.getViewport({ scale: 1.0 }); |
939 | 908 |
|
940 | | - // Process each text item and create spans |
941 | 909 | for (let item of textContent.items) { |
942 | 910 | if (!item.str || item.str.trim() === '') continue; |
943 | 911 |
|
944 | | - // Create span element for text |
945 | 912 | const span = document.createElement('span'); |
946 | 913 | span.textContent = item.str; |
947 | 914 |
|
948 | | - // Get transform matrix [a, b, c, d, e, f] |
949 | | - // where: a, d = scaling; e, f = translation |
950 | 915 | const transform = item.transform; |
951 | 916 |
|
952 | | - // Extract font size from scale coefficients |
953 | 917 | const fontSize = Math.max( |
954 | 918 | Math.abs(transform[0]), |
955 | 919 | Math.abs(transform[3]), |
956 | | - 8 // Minimum font size |
| 920 | + 8 |
957 | 921 | ); |
958 | 922 |
|
959 | | - // Extract position - convert from PDF coordinates to screen coordinates |
960 | | - // PDF coordinates: origin at bottom-left |
961 | | - // Screen coordinates: origin at top-left |
962 | | - const tx = transform[4]; // x translation |
963 | | - const ty = transform[5]; // y translation |
| 923 | + const tx = transform[4]; |
| 924 | + const ty = transform[5]; |
964 | 925 |
|
965 | | - // Convert PDF coordinates to viewport coordinates |
966 | | - // Apply scale to convert to actual viewport size |
967 | 926 | const scaledX = tx * (scaledViewport.width / baseViewport.width); |
968 | 927 | const scaledY = (baseViewport.height - ty) * (scaledViewport.height / baseViewport.height); |
969 | 928 |
|
970 | | - // Convert to percentages for responsive positioning |
971 | 929 | const xPercent = (scaledX / scaledViewport.width) * 100; |
972 | 930 | const yPercent = (scaledY / scaledViewport.height) * 100; |
973 | 931 |
|
974 | | - // Apply styles for text selection |
975 | 932 | span.style.position = 'absolute'; |
976 | 933 | span.style.left = xPercent + '%'; |
977 | 934 | span.style.top = yPercent + '%'; |
|
996 | 953 | textLayerDiv.appendChild(span); |
997 | 954 | } |
998 | 955 |
|
999 | | - // Make text layer visible for selection |
1000 | 956 | textLayerDiv.style.display = 'block'; |
1001 | | - |
1002 | | - // Store reference for cleanup |
1003 | 957 | instance.currentTextLayer = textLayerDiv; |
| 958 | + |
1004 | 959 | } catch (err) { |
1005 | 960 | console.warn('Failed to render text layer:', err); |
1006 | | - // Continue gracefully - text layer is optional |
1007 | 961 | } |
1008 | 962 | } |
1009 | 963 |
|
|
1157 | 1111 | } |
1158 | 1112 |
|
1159 | 1113 |
|
1160 | | - // Debounced resize render to prevent excessive rendering |
1161 | 1114 | const debouncedRender = debounce(() => { |
1162 | 1115 | if (instance.pdfDoc) renderPage(instance.currentPage); |
1163 | 1116 | }, RENDER_DEBOUNCE_DELAY); |
|
1168 | 1121 | const fullscreenBtn = container.querySelector('.fullscreen-btn'); |
1169 | 1122 | const canvasContainer = container.querySelector('.pdf-viewer-canvas-container'); |
1170 | 1123 |
|
1171 | | - // Focus listener - clicking PDF area gives it focus for scroll control |
1172 | 1124 | canvasContainer.addEventListener('click', () => canvasContainer.focus()); |
1173 | | - |
1174 | | - // Scroll lock listener - prevents scroll restoration during navigation |
1175 | 1125 | canvasContainer.addEventListener('scroll', scrollLockHandler, true); |
1176 | 1126 |
|
1177 | | - // Fullscreen state management |
1178 | 1127 | instance.isFullscreen = false; |
1179 | 1128 |
|
1180 | | - // Sidebar collapse state management |
1181 | 1129 | const SIDEBAR_STORAGE_KEY = 'pdfViewer_sidebarCollapsed'; |
1182 | 1130 | instance.sidebarCollapsed = Storage.getItem(SIDEBAR_STORAGE_KEY) === 'true'; |
1183 | 1131 | if (instance.sidebarCollapsed) { |
1184 | 1132 | sidebar.classList.add('collapsed'); |
1185 | 1133 | } |
1186 | 1134 |
|
| 1135 | + /** |
| 1136 | + * Toggles the PDF viewer between normal and full screen modes |
| 1137 | + */ |
1187 | 1138 | function toggleFullscreen() { |
1188 | 1139 | instance.isFullscreen = !instance.isFullscreen; |
1189 | 1140 | const pdfContainer = container.querySelector('.pdf-viewer-main'); |
|
1201 | 1152 | prevBtn.onclick = goToPrev; |
1202 | 1153 | fullscreenBtn.onclick = toggleFullscreen; |
1203 | 1154 | toggleBtn.onclick = () => { |
1204 | | - // On mobile: toggle sidebar visibility (open/close) |
1205 | | - // On desktop: toggle sidebar collapse state |
1206 | 1155 | if (window.innerWidth < 768) { |
1207 | 1156 | const isOpen = sidebar.classList.toggle('open'); |
1208 | 1157 | toggleBtn.setAttribute('aria-expanded', isOpen); |
1209 | 1158 | } else { |
1210 | | - // Desktop: toggle collapse state |
1211 | 1159 | instance.sidebarCollapsed = !instance.sidebarCollapsed; |
1212 | 1160 | sidebar.classList.toggle('collapsed'); |
1213 | 1161 | Storage.setItem(SIDEBAR_STORAGE_KEY, instance.sidebarCollapsed); |
1214 | 1162 | toggleBtn.setAttribute('aria-pressed', instance.sidebarCollapsed); |
1215 | 1163 | } |
1216 | 1164 | }; |
1217 | 1165 |
|
1218 | | - // Zoom controls |
1219 | 1166 | const zoomOutBtn = container.querySelector('.zoom-out-btn'); |
1220 | 1167 | const zoomInBtn = container.querySelector('.zoom-in-btn'); |
1221 | 1168 | const zoomInput = container.querySelector('.zoom-input'); |
|
1255 | 1202 | } |
1256 | 1203 | }; |
1257 | 1204 |
|
1258 | | - // Store event listener for cleanup |
1259 | 1205 | instance.listeners.keyHandler = keyHandler; |
1260 | 1206 |
|
1261 | 1207 | document.addEventListener('keydown', keyHandler); |
1262 | 1208 |
|
1263 | | - // Focus-based scroll handler to prevent overflow scrolling |
| 1209 | + /** |
| 1210 | + * Mouse wheel event handler for navigating PDF pages when the PDF container is in focus. |
| 1211 | + * Prevents default page scrolling when the user is at the top or bottom of the PDF container and tries to scroll outward. |
| 1212 | + * @param {WheelEvent} e - The event object. |
| 1213 | + * @returns {void} |
| 1214 | + */ |
1264 | 1215 | const wheelHandler = (e) => { |
1265 | | - // Only handle scroll when PDF container has focus |
1266 | 1216 | if (canvasContainer === document.activeElement || canvasContainer.contains(e.target)) { |
1267 | | - // PDF container has focus |
1268 | 1217 | const container = canvasContainer; |
1269 | 1218 | const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 1; |
1270 | 1219 | const isAtTop = container.scrollTop === 0; |
1271 | 1220 | const scrollingDown = e.deltaY > 0; |
1272 | 1221 | const scrollingUp = e.deltaY < 0; |
1273 | 1222 |
|
1274 | | - // Prevent default if we're at the boundaries and trying to scroll outward |
1275 | 1223 | if ((isAtBottom && scrollingDown) || (isAtTop && scrollingUp)) { |
1276 | 1224 | e.preventDefault(); |
1277 | 1225 | } |
1278 | 1226 | return; |
1279 | 1227 | } |
1280 | | - // Outside PDF focus - allow page scroll normally |
1281 | 1228 | }; |
1282 | 1229 |
|
1283 | 1230 | instance.listeners.wheelHandler = wheelHandler; |
1284 | | - // Use non-passive listener to allow preventDefault |
1285 | 1231 | canvasContainer.addEventListener('wheel', wheelHandler, { passive: false }); |
1286 | 1232 |
|
1287 | 1233 | const ro = new ResizeObserver(debouncedRender); |
1288 | 1234 | ro.observe(canvas.parentElement); |
1289 | | - |
1290 | | - // Store ResizeObserver for cleanup |
1291 | 1235 | instance.resizeObserver = ro; |
1292 | 1236 |
|
1293 | 1237 | /** |
|
1331 | 1275 |
|
1332 | 1276 | return instance; |
1333 | 1277 | } |
1334 | | - |
| 1278 | + |
1335 | 1279 | global.PDFViewer = { init }; |
| 1280 | + |
1336 | 1281 | })(window); |
0 commit comments