mirror of
				https://github.com/imfing/hextra.git
				synced 2025-10-31 16:34:51 -04:00 
			
		
		
		
	Compare commits
	
		
			17 Commits
		
	
	
		
			v0.11.1
			...
			image-zoom
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 26569738f7 | ||
|   | 1c13e24535 | ||
|   | 708358de80 | ||
|   | 8699deb1dd | ||
|   | a03dbf463f | ||
|   | 6fc6391d06 | ||
|   | be49fe6f57 | ||
|   | 9e50415b94 | ||
|   | 6e33f17cba | ||
|   | 1c06ae5580 | ||
|   | ba0934b2e1 | ||
|   | a528d9adc0 | ||
|   | c2c4cafa13 | ||
|   | 09728a4aa9 | ||
|   | ccb63d60f1 | ||
|   | 3bc454bbf6 | ||
|   | 1b536e27a5 | 
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										84
									
								
								assets/css/components/image-zoom.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								assets/css/components/image-zoom.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | .hextra-zoom-image-overlay { | ||||||
|  |   position: fixed; | ||||||
|  |   inset: 0; | ||||||
|  |   background: var(--hextra-image-zoom-backdrop, rgba(0, 0, 0, 0.9)); | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   z-index: 9999; | ||||||
|  |   opacity: 0; | ||||||
|  |   transition: opacity 260ms cubic-bezier(0.2, 0, 0, 1); | ||||||
|  |   cursor: zoom-out; | ||||||
|  |   overscroll-behavior: contain; | ||||||
|  |   touch-action: none; | ||||||
|  |   backdrop-filter: blur(var(--hextra-image-zoom-blur, 4px)); | ||||||
|  |   -webkit-backdrop-filter: blur(var(--hextra-image-zoom-blur, 4px)); | ||||||
|  |   /* Prevent iOS bounce */ | ||||||
|  |   position: fixed; | ||||||
|  |   overflow: hidden; | ||||||
|  |   will-change: transform; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hextra-zoom-image-overlay.show { | ||||||
|  |   opacity: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hextra-zoom-image-overlay.closing { | ||||||
|  |   opacity: 0; | ||||||
|  |   transition: opacity 360ms cubic-bezier(0.2, 0, 0, 1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hextra-zoom-image { | ||||||
|  |   border-radius: 8px; | ||||||
|  |   box-shadow: 0 18px 80px rgba(0, 0, 0, 0.5); | ||||||
|  |   image-rendering: -webkit-optimize-contrast; | ||||||
|  |   image-rendering: high-quality; | ||||||
|  |   will-change: transform; | ||||||
|  |   /* Prevent image selection on mobile */ | ||||||
|  |   user-select: none; | ||||||
|  |   -webkit-user-select: none; | ||||||
|  |   -webkit-touch-callout: none; | ||||||
|  |   /* Hardware acceleration */ | ||||||
|  |   transform: translateZ(0); | ||||||
|  |   -webkit-transform: translateZ(0); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hextra-zoom-image-overlay.show .hextra-zoom-image { | ||||||
|  |   transition: | ||||||
|  |     transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1), | ||||||
|  |     left 320ms cubic-bezier(0.2, 0.8, 0.2, 1), | ||||||
|  |     top 320ms cubic-bezier(0.2, 0.8, 0.2, 1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hextra-zoom-image-overlay.closing .hextra-zoom-image { | ||||||
|  |   transition: | ||||||
|  |     transform 340ms cubic-bezier(0.3, 0, 0.2, 1), | ||||||
|  |     left 340ms cubic-bezier(0.3, 0, 0.2, 1), | ||||||
|  |     top 340ms cubic-bezier(0.3, 0, 0.2, 1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Disable transitions during interaction */ | ||||||
|  | .hextra-zoom-image-overlay.interacting .hextra-zoom-image { | ||||||
|  |   transition: none !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (prefers-reduced-motion: reduce) { | ||||||
|  |   .hextra-zoom-image-overlay, | ||||||
|  |   .hextra-zoom-image { | ||||||
|  |     transition: none !important; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .content img:not([data-no-zoom]):not(.not-prose img) { | ||||||
|  |   cursor: zoom-in; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Loading indicator */ | ||||||
|  | .hextra-zoom-image.loading { | ||||||
|  |   opacity: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .hextra-zoom-image.loaded { | ||||||
|  |   opacity: 1; | ||||||
|  |   transition: opacity 200ms ease-in-out; | ||||||
|  | } | ||||||
| @@ -33,19 +33,19 @@ | |||||||
|     @apply hx:border-black/4 hx:bg-black/3 hx:break-words hx:rounded-md hx:border hx:py-0.5 hx:px-[.25em] hx:text-[.9em] hx:dark:border-white/10 hx:dark:bg-white/10; |     @apply hx:border-black/4 hx:bg-black/3 hx:break-words hx:rounded-md hx:border hx:py-0.5 hx:px-[.25em] hx:text-[.9em] hx:dark:border-white/10 hx:dark:bg-white/10; | ||||||
|   } |   } | ||||||
|   :where(table):not(:where(.hextra-code-block table, [class~=not-prose],[class~=not-prose] *)) { |   :where(table):not(:where(.hextra-code-block table, [class~=not-prose],[class~=not-prose] *)) { | ||||||
|     @apply hx:block hx:overflow-x-auto hx:my-6 hx:p-0 hx:first:mt-0 hx:w-full hx:text-sm hx:leading-5; |     @apply hx:block hx:overflow-x-auto hx:my-6 hx:p-0 hx:first:mt-0 hx:w-full hx:text-sm hx:leading-5 hx:border-collapse; | ||||||
|  |  | ||||||
|     thead { |     thead { | ||||||
|       @apply hx:border-b hx:border-gray-200 hx:dark:border-neutral-800; |       @apply hx:bg-gray-50 hx:dark:bg-gray-600/20; | ||||||
|     } |     } | ||||||
|     tbody tr { |     tr { | ||||||
|       @apply hx:m-0 hx:border-b hx:border-gray-100 hx:dark:border-neutral-800/50; |       @apply hx:m-0 hx:border-t hx:border-gray-300 hx:p-0 hx:dark:border-gray-600; | ||||||
|     } |     } | ||||||
|     th { |     th { | ||||||
|       @apply hx:m-0 hx:p-2 hx:font-semibold hx:first:pl-0 hx:last:pr-0; |       @apply hx:m-0 hx:border hx:border-gray-300 hx:p-2 hx:font-semibold hx:dark:border-gray-600; | ||||||
|     } |     } | ||||||
|     td { |     td { | ||||||
|       @apply hx:m-0 hx:p-2 hx:first:pl-0 hx:last:pr-0; |       @apply hx:m-0 hx:border hx:border-gray-300 hx:p-2 hx:dark:border-gray-600; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)) { |   :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)) { | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								assets/js/head/banner.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								assets/js/head/banner.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | // The section must not be in the banner.js (body) file because it can create a quick flash. | ||||||
|  |  | ||||||
|  | if (localStorage.getItem('{{ site.Params.banner.key | default `banner-closed` }}')) { | ||||||
|  |   document.documentElement.style.setProperty("--hextra-banner-height", "0px"); | ||||||
|  |   document.documentElement.classList.add("hextra-banner-hidden"); | ||||||
|  | } | ||||||
							
								
								
									
										14
									
								
								assets/js/head/theme.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								assets/js/head/theme.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | // The section must not be in the theme.js (body) file because it can create a quick flash (switch between light and dark). | ||||||
|  |  | ||||||
|  | function setTheme(theme) { | ||||||
|  |   document.documentElement.classList.remove("light", "dark"); | ||||||
|  |  | ||||||
|  |   if (theme !== "light" && theme !== "dark") { | ||||||
|  |     theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   document.documentElement.classList.add(theme); | ||||||
|  |   document.documentElement.style.colorScheme = theme; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | setTheme("color-theme" in localStorage ? localStorage.getItem("color-theme") : '{{ site.Params.theme.default | default `system`}}') | ||||||
							
								
								
									
										508
									
								
								assets/js/image-zoom.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										508
									
								
								assets/js/image-zoom.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,508 @@ | |||||||
|  | (function () { | ||||||
|  |   'use strict'; | ||||||
|  |  | ||||||
|  |   function createOverlayFromTarget(targetImg) { | ||||||
|  |     const src = targetImg.currentSrc || targetImg.src; | ||||||
|  |     const alt = targetImg.alt || ""; | ||||||
|  |     if (!src) return; | ||||||
|  |  | ||||||
|  |     const rect = targetImg.getBoundingClientRect(); | ||||||
|  |  | ||||||
|  |     const overlay = document.createElement("div"); | ||||||
|  |     overlay.className = "hextra-zoom-image-overlay"; | ||||||
|  |     overlay.setAttribute("role", "dialog"); | ||||||
|  |     overlay.setAttribute("aria-modal", "true"); | ||||||
|  |     overlay.setAttribute("aria-label", alt || "Zoomed image"); | ||||||
|  |  | ||||||
|  |     const img = document.createElement("img"); | ||||||
|  |     img.className = "hextra-zoom-image loading"; | ||||||
|  |     img.src = src; | ||||||
|  |     if (alt) img.alt = alt; | ||||||
|  |  | ||||||
|  |     // Center-origin positioning for cleaner transforms | ||||||
|  |     const startCX = rect.left + rect.width / 2; | ||||||
|  |     const startCY = rect.top + rect.height / 2; | ||||||
|  |     img.style.position = "fixed"; | ||||||
|  |     img.style.left = startCX + "px"; | ||||||
|  |     img.style.top = startCY + "px"; | ||||||
|  |     img.style.width = rect.width + "px"; | ||||||
|  |     img.style.height = rect.height + "px"; | ||||||
|  |     img.style.transformOrigin = "center center"; | ||||||
|  |     img.style.transform = "translate3d(-50%, -50%, 0) scale(1)"; | ||||||
|  |  | ||||||
|  |     // Ensure overlay scales from center when zooming the whole overlay | ||||||
|  |     overlay.style.transformOrigin = "center center"; | ||||||
|  |     overlay.appendChild(img); | ||||||
|  |  | ||||||
|  |     // Image loaded handler | ||||||
|  |     img.addEventListener('load', function () { | ||||||
|  |       img.classList.remove('loading'); | ||||||
|  |       img.classList.add('loaded'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Gesture state management | ||||||
|  |     let isPinching = false; | ||||||
|  |     let isDragging = false; | ||||||
|  |     let isInteracting = false; | ||||||
|  |     let pinchEndTimer = null; | ||||||
|  |  | ||||||
|  |     const pointers = new Map(); // pointerId -> {x, y, startX, startY} | ||||||
|  |     const SCALE_MIN = 1; | ||||||
|  |     const SCALE_MAX = 5; | ||||||
|  |  | ||||||
|  |     let gestureState = { | ||||||
|  |       scale: 1, | ||||||
|  |       panX: 0, | ||||||
|  |       panY: 0, | ||||||
|  |       startScale: 1, | ||||||
|  |       startPanX: 0, | ||||||
|  |       startPanY: 0, | ||||||
|  |       initialDistance: 0, | ||||||
|  |       initialMidpointX: 0, | ||||||
|  |       initialMidpointY: 0, | ||||||
|  |       dragStartX: 0, | ||||||
|  |       dragStartY: 0, | ||||||
|  |       dragPanX: 0, | ||||||
|  |       dragPanY: 0 | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // Utility functions | ||||||
|  |     function getDistance(p1, p2) { | ||||||
|  |       const dx = p2.x - p1.x; | ||||||
|  |       const dy = p2.y - p1.y; | ||||||
|  |       return Math.sqrt(dx * dx + dy * dy); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function getMidpoint(p1, p2) { | ||||||
|  |       return { | ||||||
|  |         x: (p1.x + p2.x) / 2, | ||||||
|  |         y: (p1.y + p2.y) / 2 | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function setInteracting(value) { | ||||||
|  |       isInteracting = value; | ||||||
|  |       if (value) { | ||||||
|  |         overlay.classList.add('interacting'); | ||||||
|  |       } else { | ||||||
|  |         overlay.classList.remove('interacting'); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Pointer event handlers | ||||||
|  |     let tapCandidate = false; | ||||||
|  |     let tapStartX = 0; | ||||||
|  |     let tapStartY = 0; | ||||||
|  |     let tapStartTime = 0; | ||||||
|  |  | ||||||
|  |     function onPointerDown(e) { | ||||||
|  |       e.preventDefault(); | ||||||
|  |  | ||||||
|  |       if (typeof overlay.setPointerCapture === 'function') { | ||||||
|  |         try { | ||||||
|  |           overlay.setPointerCapture(e.pointerId); | ||||||
|  |         } catch (err) { | ||||||
|  |           // ignore pointer capture failures (e.g. Safari) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const pointerData = { | ||||||
|  |         x: e.clientX, | ||||||
|  |         y: e.clientY, | ||||||
|  |         startX: e.clientX, | ||||||
|  |         startY: e.clientY | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       pointers.set(e.pointerId, pointerData); | ||||||
|  |  | ||||||
|  |       if (pointers.size === 1) { | ||||||
|  |         isDragging = false; | ||||||
|  |         if (gestureState.scale > SCALE_MIN) { | ||||||
|  |           setInteracting(true); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Set drag baseline so a single finger can pan when zoomed | ||||||
|  |         gestureState.dragStartX = e.clientX; | ||||||
|  |         gestureState.dragStartY = e.clientY; | ||||||
|  |         gestureState.dragPanX = gestureState.panX; | ||||||
|  |         gestureState.dragPanY = gestureState.panY; | ||||||
|  |  | ||||||
|  |         tapCandidate = true; | ||||||
|  |         tapStartX = e.clientX; | ||||||
|  |         tapStartY = e.clientY; | ||||||
|  |         tapStartTime = (typeof performance !== 'undefined' ? performance.now() : Date.now()); | ||||||
|  |       } else if (pointers.size === 2) { | ||||||
|  |         isPinching = true; | ||||||
|  |         isDragging = false; | ||||||
|  |         setInteracting(true); | ||||||
|  |         tapCandidate = false; | ||||||
|  |  | ||||||
|  |         const pts = Array.from(pointers.values()); | ||||||
|  |         gestureState.initialDistance = getDistance(pts[0], pts[1]) || 1; | ||||||
|  |         gestureState.startScale = gestureState.scale; | ||||||
|  |         gestureState.startPanX = gestureState.panX; | ||||||
|  |         gestureState.startPanY = gestureState.panY; | ||||||
|  |  | ||||||
|  |         const midpoint = getMidpoint(pts[0], pts[1]); | ||||||
|  |         gestureState.initialMidpointX = midpoint.x; | ||||||
|  |         gestureState.initialMidpointY = midpoint.y; | ||||||
|  |  | ||||||
|  |         if (pinchEndTimer) { | ||||||
|  |           clearTimeout(pinchEndTimer); | ||||||
|  |           pinchEndTimer = null; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function onPointerMove(e) { | ||||||
|  |       const pointer = pointers.get(e.pointerId); | ||||||
|  |       if (!pointer) return; | ||||||
|  |  | ||||||
|  |       e.preventDefault(); | ||||||
|  |  | ||||||
|  |       pointer.x = e.clientX; | ||||||
|  |       pointer.y = e.clientY; | ||||||
|  |  | ||||||
|  |       if (pointers.size === 2) { | ||||||
|  |         const pts = Array.from(pointers.values()); | ||||||
|  |         const currentDistance = getDistance(pts[0], pts[1]); | ||||||
|  |         if (!currentDistance) return; | ||||||
|  |  | ||||||
|  |         const currentMidpoint = getMidpoint(pts[0], pts[1]); | ||||||
|  |  | ||||||
|  |         const distanceRatio = currentDistance / (gestureState.initialDistance || currentDistance); | ||||||
|  |         let nextScale = gestureState.startScale * distanceRatio; | ||||||
|  |         nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, nextScale)); | ||||||
|  |  | ||||||
|  |         const totalStart = baseScale * gestureState.startScale; | ||||||
|  |         const totalNext = baseScale * nextScale; | ||||||
|  |  | ||||||
|  |         let nextPanX = gestureState.panX; | ||||||
|  |         let nextPanY = gestureState.panY; | ||||||
|  |  | ||||||
|  |         if (totalStart > 0) { | ||||||
|  |           const startOffsetX = gestureState.initialMidpointX - final.cx - gestureState.startPanX; | ||||||
|  |           const startOffsetY = gestureState.initialMidpointY - final.cy - gestureState.startPanY; | ||||||
|  |           const currentOffsetX = currentMidpoint.x - final.cx; | ||||||
|  |           const currentOffsetY = currentMidpoint.y - final.cy; | ||||||
|  |           const ratio = totalNext / totalStart; | ||||||
|  |  | ||||||
|  |           nextPanX = currentOffsetX - ratio * startOffsetX; | ||||||
|  |           nextPanY = currentOffsetY - ratio * startOffsetY; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         gestureState.scale = nextScale; | ||||||
|  |         gestureState.panX = nextPanX; | ||||||
|  |         gestureState.panY = nextPanY; | ||||||
|  |  | ||||||
|  |         tapCandidate = false; | ||||||
|  |         applyTransform(); | ||||||
|  |       } else if (pointers.size === 1) { | ||||||
|  |         const moveX = pointer.x - gestureState.dragStartX; | ||||||
|  |         const moveY = pointer.y - gestureState.dragStartY; | ||||||
|  |         const dragThreshold = 6; | ||||||
|  |  | ||||||
|  |         if (!isDragging) { | ||||||
|  |           const distanceSq = moveX * moveX + moveY * moveY; | ||||||
|  |           if (gestureState.scale > SCALE_MIN && distanceSq > dragThreshold * dragThreshold) { | ||||||
|  |             isDragging = true; | ||||||
|  |             tapCandidate = false; | ||||||
|  |             setInteracting(true); | ||||||
|  |             gestureState.dragPanX = gestureState.panX; | ||||||
|  |             gestureState.dragPanY = gestureState.panY; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (isDragging) { | ||||||
|  |           gestureState.panX = gestureState.dragPanX + moveX; | ||||||
|  |           gestureState.panY = gestureState.dragPanY + moveY; | ||||||
|  |           applyTransform(); | ||||||
|  |         } else { | ||||||
|  |           const cancelTapThreshold = 10; | ||||||
|  |           if ( | ||||||
|  |             Math.abs(pointer.x - tapStartX) > cancelTapThreshold || | ||||||
|  |             Math.abs(pointer.y - tapStartY) > cancelTapThreshold | ||||||
|  |           ) { | ||||||
|  |             tapCandidate = false; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function onPointerUp(e) { | ||||||
|  |       if (typeof overlay.releasePointerCapture === 'function') { | ||||||
|  |         try { | ||||||
|  |           overlay.releasePointerCapture(e.pointerId); | ||||||
|  |         } catch (err) { | ||||||
|  |           // ignore release failures | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       pointers.delete(e.pointerId); | ||||||
|  |  | ||||||
|  |       if (pointers.size === 0) { | ||||||
|  |         const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()); | ||||||
|  |         const duration = now - tapStartTime; | ||||||
|  |         const shouldClose = tapCandidate && !isPinching && !isDragging && duration < 300; | ||||||
|  |  | ||||||
|  |         if (shouldClose) { | ||||||
|  |           close(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         tapCandidate = false; | ||||||
|  |         isDragging = false; | ||||||
|  |  | ||||||
|  |         if (isPinching) { | ||||||
|  |           pinchEndTimer = setTimeout(() => { | ||||||
|  |             isPinching = false; | ||||||
|  |           }, 180); | ||||||
|  |         } else { | ||||||
|  |           isPinching = false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         gestureState.startScale = gestureState.scale; | ||||||
|  |         gestureState.startPanX = gestureState.panX; | ||||||
|  |         gestureState.startPanY = gestureState.panY; | ||||||
|  |  | ||||||
|  |         setTimeout(() => setInteracting(false), 120); | ||||||
|  |       } else if (pointers.size === 1) { | ||||||
|  |         isPinching = false; | ||||||
|  |         isDragging = false; | ||||||
|  |  | ||||||
|  |         const remaining = Array.from(pointers.values())[0]; | ||||||
|  |         remaining.startX = remaining.x; | ||||||
|  |         remaining.startY = remaining.y; | ||||||
|  |  | ||||||
|  |         gestureState.dragStartX = remaining.x; | ||||||
|  |         gestureState.dragStartY = remaining.y; | ||||||
|  |         gestureState.dragPanX = gestureState.panX; | ||||||
|  |         gestureState.dragPanY = gestureState.panY; | ||||||
|  |  | ||||||
|  |         if (gestureState.scale <= SCALE_MIN) { | ||||||
|  |           setTimeout(() => setInteracting(false), 120); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function onPointerCancel(e) { | ||||||
|  |       onPointerUp(e); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Mouse wheel zoom and scroll handling | ||||||
|  |     function onWheel(e) { | ||||||
|  |       e.preventDefault(); | ||||||
|  |  | ||||||
|  |       // If it's a regular scroll (not pinch), dismiss the overlay | ||||||
|  |       if (!e.ctrlKey && !e.metaKey) { | ||||||
|  |         // Regular scroll - dismiss overlay gracefully | ||||||
|  |         close(); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Handle trackpad pinch (ctrl+wheel or cmd+wheel on Mac) | ||||||
|  |       const delta = e.deltaY; | ||||||
|  |       const scaleFactor = 0.01; | ||||||
|  |       const zoomSpeed = Math.exp(-delta * scaleFactor); | ||||||
|  |  | ||||||
|  |       const prevScale = gestureState.scale; | ||||||
|  |       const unclamped = prevScale * zoomSpeed; | ||||||
|  |       const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, unclamped)); | ||||||
|  |  | ||||||
|  |       if (Math.abs(nextScale - prevScale) > 0.0001) { | ||||||
|  |         const totalPrev = baseScale * prevScale; | ||||||
|  |         const totalNext = baseScale * nextScale; | ||||||
|  |  | ||||||
|  |         if (totalPrev > 0) { | ||||||
|  |           const anchorOffsetX = e.clientX - final.cx; | ||||||
|  |           const anchorOffsetY = e.clientY - final.cy; | ||||||
|  |           const ratio = totalNext / totalPrev; | ||||||
|  |  | ||||||
|  |           gestureState.panX = anchorOffsetX + (gestureState.panX - anchorOffsetX) * ratio; | ||||||
|  |           gestureState.panY = anchorOffsetY + (gestureState.panY - anchorOffsetY) * ratio; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       gestureState.scale = nextScale; | ||||||
|  |       gestureState.startScale = nextScale; | ||||||
|  |       gestureState.startPanX = gestureState.panX; | ||||||
|  |       gestureState.startPanY = gestureState.panY; | ||||||
|  |  | ||||||
|  |       setInteracting(true); | ||||||
|  |       applyTransform(); | ||||||
|  |       setTimeout(() => setInteracting(false), 150); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Keyboard navigation | ||||||
|  |     function onKeyDown(e) { | ||||||
|  |       if (e.key === "Escape") { | ||||||
|  |         close(); | ||||||
|  |       } else if (e.key === "+" || e.key === "=") { | ||||||
|  |         gestureState.scale = Math.min(SCALE_MAX, gestureState.scale * 1.2); | ||||||
|  |         gestureState.startScale = gestureState.scale; | ||||||
|  |         gestureState.startPanX = gestureState.panX; | ||||||
|  |         gestureState.startPanY = gestureState.panY; | ||||||
|  |         applyTransform(); | ||||||
|  |       } else if (e.key === "-") { | ||||||
|  |         gestureState.scale = Math.max(SCALE_MIN, gestureState.scale / 1.2); | ||||||
|  |         gestureState.startScale = gestureState.scale; | ||||||
|  |         gestureState.startPanX = gestureState.panX; | ||||||
|  |         gestureState.startPanY = gestureState.panY; | ||||||
|  |         applyTransform(); | ||||||
|  |       } else if (e.key === "0") { | ||||||
|  |         gestureState.scale = 1; | ||||||
|  |         gestureState.panX = 0; | ||||||
|  |         gestureState.panY = 0; | ||||||
|  |         gestureState.startScale = gestureState.scale; | ||||||
|  |         gestureState.startPanX = 0; | ||||||
|  |         gestureState.startPanY = 0; | ||||||
|  |         applyTransform(); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Apply transforms | ||||||
|  |     let final = computeFinal(); | ||||||
|  |     let baseScale = final.scale; | ||||||
|  |  | ||||||
|  |     function computeFinal() { | ||||||
|  |       const vv = window.visualViewport; | ||||||
|  |       const vw = vv ? vv.width : window.innerWidth; | ||||||
|  |       const vh = vv ? vv.height : window.innerHeight; | ||||||
|  |       const vx = vv ? vv.offsetLeft : 0; | ||||||
|  |       const vy = vv ? vv.offsetTop : 0; | ||||||
|  |  | ||||||
|  |       const margin = 20; | ||||||
|  |       const maxW = Math.min(vw - margin * 2, 1200); | ||||||
|  |       const maxH = vh - margin * 2; | ||||||
|  |  | ||||||
|  |       const scale = Math.min(maxW / rect.width, maxH / rect.height, 2); | ||||||
|  |       const centerX = vx + vw / 2; | ||||||
|  |       const centerY = vy + vh / 2; | ||||||
|  |  | ||||||
|  |       return { cx: centerX, cy: centerY, scale }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function applyTransform() { | ||||||
|  |       img.style.left = final.cx + "px"; | ||||||
|  |       img.style.top = final.cy + "px"; | ||||||
|  |  | ||||||
|  |       overlay.style.transform = "none"; | ||||||
|  |  | ||||||
|  |       const totalScale = baseScale * gestureState.scale; | ||||||
|  |       const transform = `translate3d(-50%, -50%, 0) translate3d(${gestureState.panX}px, ${gestureState.panY}px, 0) scale(${totalScale})`; | ||||||
|  |       img.style.transform = transform; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Close function | ||||||
|  |     function close(immediate = false) { | ||||||
|  |       overlay.classList.add("closing"); | ||||||
|  |  | ||||||
|  |       // Animate back to original position | ||||||
|  |       img.style.left = startCX + "px"; | ||||||
|  |       img.style.top = startCY + "px"; | ||||||
|  |       img.style.transform = `translate3d(-50%, -50%, 0) scale(1)`; | ||||||
|  |  | ||||||
|  |       // Cleanup event listeners | ||||||
|  |       cleanup(); | ||||||
|  |  | ||||||
|  |       if (immediate) { | ||||||
|  |         overlay.remove(); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Remove after animation | ||||||
|  |       const done = () => overlay.remove(); | ||||||
|  |       overlay.addEventListener("transitionend", done, { once: true }); | ||||||
|  |       img.addEventListener("transitionend", done, { once: true }); | ||||||
|  |  | ||||||
|  |       // Fallback removal | ||||||
|  |       setTimeout(() => { | ||||||
|  |         if (overlay.parentNode) { | ||||||
|  |           overlay.remove(); | ||||||
|  |         } | ||||||
|  |       }, 400); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function cleanup() { | ||||||
|  |       window.removeEventListener("keydown", onKeyDown, true); | ||||||
|  |       overlay.removeEventListener("wheel", onWheel); | ||||||
|  |       overlay.removeEventListener("pointermove", onPointerMove); | ||||||
|  |       overlay.removeEventListener("pointerdown", onPointerDown); | ||||||
|  |       overlay.removeEventListener("pointerup", onPointerUp); | ||||||
|  |       overlay.removeEventListener("pointercancel", onPointerCancel); | ||||||
|  |       window.removeEventListener("resize", onResize); | ||||||
|  |       if (window.visualViewport) { | ||||||
|  |         window.visualViewport.removeEventListener("resize", onResize); | ||||||
|  |         window.visualViewport.removeEventListener("scroll", onResize); | ||||||
|  |       } | ||||||
|  |       if (pinchEndTimer) { | ||||||
|  |         clearTimeout(pinchEndTimer); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Handle viewport changes | ||||||
|  |     function onResize() { | ||||||
|  |       final = computeFinal(); | ||||||
|  |       baseScale = final.scale; | ||||||
|  |       gestureState.startScale = gestureState.scale; | ||||||
|  |       gestureState.startPanX = gestureState.panX; | ||||||
|  |       gestureState.startPanY = gestureState.panY; | ||||||
|  |       applyTransform(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Setup event listeners | ||||||
|  |     overlay.addEventListener("wheel", onWheel, { passive: false }); | ||||||
|  |     overlay.addEventListener("pointermove", onPointerMove); | ||||||
|  |     overlay.addEventListener("pointerdown", onPointerDown); | ||||||
|  |     overlay.addEventListener("pointerup", onPointerUp); | ||||||
|  |     overlay.addEventListener("pointercancel", onPointerCancel); | ||||||
|  |     window.addEventListener("keydown", onKeyDown, true); | ||||||
|  |     window.addEventListener("resize", onResize, { passive: true }); | ||||||
|  |  | ||||||
|  |     if (window.visualViewport) { | ||||||
|  |       window.visualViewport.addEventListener("resize", onResize, { passive: true }); | ||||||
|  |       window.visualViewport.addEventListener("scroll", onResize, { passive: true }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Add to DOM | ||||||
|  |     document.body.appendChild(overlay); | ||||||
|  |  | ||||||
|  |     // Trigger opening animation | ||||||
|  |     requestAnimationFrame(() => { | ||||||
|  |       overlay.classList.add("show"); | ||||||
|  |       applyTransform(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Initialize after DOM ready | ||||||
|  |   function init() { | ||||||
|  |     const container = document.querySelector(".content"); | ||||||
|  |     if (!container) return; | ||||||
|  |  | ||||||
|  |     container.addEventListener("click", function (e) { | ||||||
|  |       const target = e.target; | ||||||
|  |       if (!(target instanceof HTMLImageElement)) return; | ||||||
|  |       // Only allow images inside `.content` that are NOT within a `.not-prose` block | ||||||
|  |       if (target.closest('.not-prose')) return; | ||||||
|  |       if (target.hasAttribute('data-no-zoom')) return; | ||||||
|  |  | ||||||
|  |       if (e.defaultPrevented) return; | ||||||
|  |       if (typeof e.button === 'number' && e.button !== 0) return; | ||||||
|  |       if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; | ||||||
|  |  | ||||||
|  |       const interactiveParent = target.closest('a[href], button, [role="button"], summary, label'); | ||||||
|  |       if (interactiveParent && interactiveParent !== target) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       e.preventDefault(); | ||||||
|  |  | ||||||
|  |       createOverlayFromTarget(target); | ||||||
|  |     }, true); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (document.readyState === 'loading') { | ||||||
|  |     document.addEventListener('DOMContentLoaded', init, { once: true }); | ||||||
|  |   } else { | ||||||
|  |     init(); | ||||||
|  |   } | ||||||
|  | })(); | ||||||
							
								
								
									
										2
									
								
								build.sh
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								build.sh
									
									
									
									
									
								
							| @@ -9,7 +9,7 @@ echo "Using base URL: $BASE_URL" | |||||||
| # Version configuration - modify these arrays to specify versions to build | # Version configuration - modify these arrays to specify versions to build | ||||||
| # MAIN_VERSION format: "ref:display_name:source_dir" | # MAIN_VERSION format: "ref:display_name:source_dir" | ||||||
| # VERSIONS format: "ref:display_name:source_dir" where source_dir is either "docs" or "exampleSite" | # VERSIONS format: "ref:display_name:source_dir" where source_dir is either "docs" or "exampleSite" | ||||||
| MAIN_VERSION="v0.11.0:latest:exampleSite" | MAIN_VERSION="v0.11.1:latest:docs" | ||||||
| VERSIONS=( | VERSIONS=( | ||||||
|   "main:latest:docs" # latest version always builds from main |   "main:latest:docs" # latest version always builds from main | ||||||
|   "v0.10.2:v0.10:exampleSite" |   "v0.10.2:v0.10:exampleSite" | ||||||
|   | |||||||
| @@ -164,6 +164,21 @@ menu: | |||||||
|       weight: 3 |       weight: 3 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | ### Hiding | ||||||
|  |  | ||||||
|  | Hiding the sidebar can be done using front matter: | ||||||
|  |  | ||||||
|  | ```yaml {filename="content/docs/guide/configuration.md"} | ||||||
|  | --- | ||||||
|  | title: Configuration | ||||||
|  | sidebar: | ||||||
|  |   hide: true | ||||||
|  | --- | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | This will hide the main sidebar from the page, freeing up space for the main content of the page. | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Right Sidebar | ## Right Sidebar | ||||||
|  |  | ||||||
| ### Table of Contents | ### Table of Contents | ||||||
| @@ -404,7 +419,7 @@ params: | |||||||
|     umami: |     umami: | ||||||
|       serverURL: "https://example.com" |       serverURL: "https://example.com" | ||||||
|       websiteID: "94db1cb1-74f4-4a40-ad6c-962362670409" |       websiteID: "94db1cb1-74f4-4a40-ad6c-962362670409" | ||||||
|       # scriptName: "umami.js" # optional (default: umami.js) |       # scriptName: "script.js" # optional (default: script.js) | ||||||
|       # https://umami.is/docs/tracker-configuration#data-host-url |       # https://umami.is/docs/tracker-configuration#data-host-url | ||||||
|       # hostURL: "http://stats.example.org" # optional |       # hostURL: "http://stats.example.org" # optional | ||||||
|       # https://umami.is/docs/tracker-configuration#data-auto-track |       # https://umami.is/docs/tracker-configuration#data-auto-track | ||||||
|   | |||||||
| @@ -5,12 +5,10 @@ next: /docs/guide/deploy-site | |||||||
|  |  | ||||||
| ## Example | ## Example | ||||||
|  |  | ||||||
| {{< tabs items="macOS,Linux,Windows" >}} | {{< tabs >}} | ||||||
|  |   {{< tab name="JSON" >}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{< /tab >}} | ||||||
|   {{< tab >}}**macOS**: A desktop operating system by Apple.{{< /tab >}} |   {{< tab name="YAML" >}}**YAML**: YAML is a human-readable data serialization language.{{< /tab >}} | ||||||
|   {{< tab >}}**Linux**: An open-source operating system.{{< /tab >}} |   {{< tab name="TOML" >}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{< /tab >}} | ||||||
|   {{< tab >}}**Windows**: A desktop operating system by Microsoft.{{< /tab >}} |  | ||||||
|  |  | ||||||
| {{< /tabs >}} | {{< /tabs >}} | ||||||
|  |  | ||||||
| ## Usage | ## Usage | ||||||
| @@ -18,37 +16,35 @@ next: /docs/guide/deploy-site | |||||||
| ### Default | ### Default | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
| {{</* tabs items="JSON,YAML,TOML" */>}} | {{</* tabs */>}} | ||||||
|  |  | ||||||
|   {{</* tab */>}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{</* /tab */>}} |   {{</* tab name="JSON" */>}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{</* /tab */>}} | ||||||
|   {{</* tab */>}}**YAML**: YAML is a human-readable data serialization language.{{</* /tab */>}} |   {{</* tab name="YAML" */>}}**YAML**: YAML is a human-readable data serialization language.{{</* /tab */>}} | ||||||
|   {{</* tab */>}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{</* /tab */>}} |   {{</* tab name="TOML" */>}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{</* /tab */>}} | ||||||
|  |  | ||||||
| {{</* /tabs */>}} | {{</* /tabs */>}} | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ### Specify Selected Index | ### Specify Selected Tab | ||||||
|  |  | ||||||
| Use `defaultIndex` property to specify the selected tab. The index starts from 0. | Use `selected` property to specify the selected tab. | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
| {{</* tabs items="JSON,YAML,TOML" defaultIndex="1" */>}} | {{</* tabs */>}} | ||||||
|  |  | ||||||
|   {{</* tab */>}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{</* /tab */>}} |   {{</* tab name="JSON" */>}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{</* /tab */>}} | ||||||
|   {{</* tab */>}}**YAML**: YAML is a human-readable data serialization language.{{</* /tab */>}} |   {{</* tab name="YAML" selected=true */>}}**YAML**: YAML is a human-readable data serialization language.{{</* /tab */>}} | ||||||
|   {{</* tab */>}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{</* /tab */>}} |   {{</* tab name="TOML" */>}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{</* /tab */>}} | ||||||
|  |  | ||||||
| {{</* /tabs */>}} | {{</* /tabs */>}} | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| The `YAML` tab will be selected by default. | The `YAML` tab will be selected by default. | ||||||
|  |  | ||||||
| {{< tabs items="JSON,YAML,TOML" defaultIndex="1" >}} | {{< tabs >}} | ||||||
|  |   {{< tab name="JSON" >}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{< /tab >}} | ||||||
| {{< tab >}}**JSON**: JavaScript Object Notation (JSON) is a standard text-based format for representing structured data based on JavaScript object syntax.{{< /tab >}} |   {{< tab name="YAML" selected=true >}}**YAML**: YAML is a human-readable data serialization language.{{< /tab >}} | ||||||
| {{< tab >}}**YAML**: YAML is a human-readable data serialization language.{{< /tab >}} |   {{< tab name="TOML" >}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{< /tab >}} | ||||||
| {{< tab >}}**TOML**: TOML aims to be a minimal configuration file format that's easy to read due to obvious semantics.{{< /tab >}} |  | ||||||
|  |  | ||||||
| {{< /tabs >}} | {{< /tabs >}} | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -57,9 +53,9 @@ The `YAML` tab will be selected by default. | |||||||
| Markdown syntax including code block is also supported: | Markdown syntax including code block is also supported: | ||||||
|  |  | ||||||
| ```` | ```` | ||||||
| {{</* tabs items="JSON,YAML,TOML" */>}} | {{</* tabs */>}} | ||||||
|  |  | ||||||
|   {{</* tab */>}} |   {{</* tab name="JSON" */>}} | ||||||
|   ```json |   ```json | ||||||
|   { "hello": "world" } |   { "hello": "world" } | ||||||
|   ``` |   ``` | ||||||
| @@ -70,21 +66,21 @@ Markdown syntax including code block is also supported: | |||||||
| {{</* /tabs */>}} | {{</* /tabs */>}} | ||||||
| ```` | ```` | ||||||
|  |  | ||||||
| {{< tabs items="JSON,YAML,TOML" >}} | {{< tabs >}} | ||||||
|  |  | ||||||
|   {{< tab >}} |   {{< tab name="JSON" >}} | ||||||
|   ```json |   ```json | ||||||
|   { "hello": "world" } |   { "hello": "world" } | ||||||
|   ``` |   ``` | ||||||
|   {{< /tab >}} |   {{< /tab >}} | ||||||
|  |  | ||||||
|   {{< tab >}} |   {{< tab name="YAML" >}} | ||||||
|   ```yaml |   ```yaml | ||||||
|   hello: world |   hello: world | ||||||
|   ``` |   ``` | ||||||
|   {{< /tab >}} |   {{< /tab >}} | ||||||
|  |  | ||||||
|   {{< tab >}} |   {{< tab name="TOML" >}} | ||||||
|   ```toml |   ```toml | ||||||
|   hello = "world" |   hello = "world" | ||||||
|   ``` |   ``` | ||||||
| @@ -97,7 +93,7 @@ Markdown syntax including code block is also supported: | |||||||
|  |  | ||||||
| Tabs with the same list of `items` can be synchronized. When enabled, selecting a tab updates all other tabs with the same `items` and remembers the selection across pages. | Tabs with the same list of `items` can be synchronized. When enabled, selecting a tab updates all other tabs with the same `items` and remembers the selection across pages. | ||||||
|  |  | ||||||
| Enable globally in your `hugo.yaml` under the `page` section: | Enable/disable globally in your `hugo.yaml` under the `page` section: | ||||||
|  |  | ||||||
| ```yaml {filename="hugo.yaml"} | ```yaml {filename="hugo.yaml"} | ||||||
| params: | params: | ||||||
| @@ -106,20 +102,33 @@ params: | |||||||
|       sync: true |       sync: true | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| With this enabled the following two tab blocks will always display the same selected item: | Enable/disable per page inside the front matter: | ||||||
|  |  | ||||||
|  | ```yaml {filename="my_page.md"} | ||||||
|  | --- | ||||||
|  | title: My page | ||||||
|  | params: | ||||||
|  |   tabs: | ||||||
|  |     sync: true | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | Example content. | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | With this enabled, the following two tab blocks will always display the same selected item: | ||||||
|  |  | ||||||
| ```markdown | ```markdown | ||||||
| {{</* tabs items="A,B" */>}} | {{</* tabs */>}} | ||||||
|  |  | ||||||
|   {{</* tab */>}}A content{{</* /tab */>}} |   {{</* tab name="A" */>}}A content{{</* /tab */>}} | ||||||
|   {{</* tab */>}}B content{{</* /tab */>}} |   {{</* tab name="B" */>}}B content{{</* /tab */>}} | ||||||
|  |  | ||||||
| {{</* /tabs */>}} | {{</* /tabs */>}} | ||||||
|  |  | ||||||
| {{</* tabs items="A,B" */>}} | {{</* tabs */>}} | ||||||
|  |  | ||||||
|   {{</* tab */>}}Second A content{{</* /tab */>}} |   {{</* tab name="A" */>}}Second A content{{</* /tab */>}} | ||||||
|   {{</* tab */>}}Second B content{{</* /tab */>}} |   {{</* tab name="B" */>}}Second B content{{</* /tab */>}} | ||||||
|  |  | ||||||
| {{</* /tabs */>}} | {{</* /tabs */>}} | ||||||
| ``` | ``` | ||||||
|   | |||||||
| @@ -116,7 +116,6 @@ menu: | |||||||
|       params: |       params: | ||||||
|         type: link |         type: link | ||||||
|         icon: beaker |         icon: beaker | ||||||
|       parent: versions |  | ||||||
|     - identifier: v0.10 |     - identifier: v0.10 | ||||||
|       name: v0.10 ↗ |       name: v0.10 ↗ | ||||||
|       url: https://imfing.github.io/hextra/versions/v0.10/ |       url: https://imfing.github.io/hextra/versions/v0.10/ | ||||||
| @@ -238,3 +237,6 @@ params: | |||||||
|       # inputPosition: top |       # inputPosition: top | ||||||
|       # lang: en |       # lang: en | ||||||
|       # theme: noborder_dark |       # theme: noborder_dark | ||||||
|  |  | ||||||
|  |   imageZoom: | ||||||
|  |     enable: true | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ https://umami.is/docs/tracker-configuration | |||||||
|  |  | ||||||
| {{- $attributes := newScratch -}} | {{- $attributes := newScratch -}} | ||||||
|  |  | ||||||
| {{- $attributes.SetInMap "umami" "src" (printf "%s/%s" .serverURL (.scriptName | default "umami.js")) -}} | {{- $attributes.SetInMap "umami" "src" (printf "%s/%s" .serverURL (.scriptName | default "script.js")) -}} | ||||||
| {{- $attributes.SetInMap "umami" "data-website-id" .websiteID -}} | {{- $attributes.SetInMap "umami" "data-website-id" .websiteID -}} | ||||||
|  |  | ||||||
| {{- if .hostURL -}} | {{- if .hostURL -}} | ||||||
|   | |||||||
| @@ -55,32 +55,17 @@ | |||||||
|  |  | ||||||
|   {{ partial "components/analytics/analytics.html" . }} |   {{ partial "components/analytics/analytics.html" . }} | ||||||
|  |  | ||||||
|   <script> |   {{- $scriptsHead := slice -}} | ||||||
|     // The section must not be in the theme.js file because it can create a quick flash (switch between light and dark). |   {{- range resources.Match "js/head/*.js" -}} | ||||||
|  |     {{ $scriptsHead = $scriptsHead | append (resources.ExecuteAsTemplate .Name $ .) }} | ||||||
|  |   {{- end -}} | ||||||
|  |  | ||||||
|     function setTheme(theme) { |   {{- $scripts := $scriptsHead | resources.Concat "js/main-head.js" -}} | ||||||
|       document.documentElement.classList.remove("light", "dark"); |  | ||||||
|  |  | ||||||
|       if (theme !== "light" && theme !== "dark") { |   {{- if hugo.IsProduction -}} | ||||||
|        theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; |   {{- $scripts = $scripts | minify | fingerprint -}} | ||||||
|       } |   {{- end -}} | ||||||
|  |   <script src="{{ $scripts.RelPermalink }}" integrity="{{ $scripts.Data.Integrity }}"></script> | ||||||
|       document.documentElement.classList.add(theme); |  | ||||||
|       document.documentElement.style.colorScheme = theme; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     setTheme("color-theme" in localStorage ? localStorage.getItem("color-theme") : '{{ site.Params.theme.default | default `system`}}') |  | ||||||
|  |  | ||||||
|   </script> |  | ||||||
|  |  | ||||||
|   <script> |  | ||||||
|     // The section must not be in the banner.js file because it can create a quick flash. |  | ||||||
|  |  | ||||||
|     if (localStorage.getItem('{{ site.Params.banner.key | default `banner-closed` }}')) { |  | ||||||
|       document.documentElement.style.setProperty("--hextra-banner-height", "0px"); |  | ||||||
|       document.documentElement.classList.add("hextra-banner-hidden"); |  | ||||||
|     } |  | ||||||
|   </script> |  | ||||||
|  |  | ||||||
|   <!-- Math engine --> |   <!-- Math engine --> | ||||||
|   {{ $noop := .WordCount -}} |   {{ $noop := .WordCount -}} | ||||||
|   | |||||||
| @@ -13,3 +13,10 @@ | |||||||
| {{- if (.Store.Get "hasAsciinema") -}} | {{- if (.Store.Get "hasAsciinema") -}} | ||||||
|   {{- partial "scripts/asciinema.html" . -}} |   {{- partial "scripts/asciinema.html" . -}} | ||||||
| {{- end -}} | {{- end -}} | ||||||
|  |  | ||||||
|  | {{/* Image zoom */}} | ||||||
|  | {{- with site.Params.imageZoom }} | ||||||
|  |   {{- if .enable }} | ||||||
|  |     {{- partial "scripts/image-zoom.html" $ -}} | ||||||
|  |   {{- end -}} | ||||||
|  | {{- end -}} | ||||||
|   | |||||||
| @@ -1,18 +1,9 @@ | |||||||
| {{- $jsSwitcherMenu := resources.Get "js/switcher-menu.js" -}} | {{- $scriptsBody := slice }} | ||||||
| {{- $jsTheme := resources.Get "js/theme.js" | resources.ExecuteAsTemplate "theme.js" . -}} | {{- range resources.Match "js/core/*.js" -}} | ||||||
| {{- $jsBanner := resources.Get "js/banner.js" | resources.ExecuteAsTemplate "banner.js" . -}} |   {{ $scriptsBody = $scriptsBody | append (resources.ExecuteAsTemplate .Name $ .) }} | ||||||
| {{- $jsMenu := resources.Get "js/menu.js" -}} | {{- end -}} | ||||||
| {{- $jsTabs := resources.Get "js/tabs.js" -}} |  | ||||||
| {{- $jsLang := resources.Get "js/lang.js" -}} |  | ||||||
| {{- $jsNavMenu := resources.Get "js/nav-menu.js" -}} |  | ||||||
| {{- $jsCodeCopy := resources.Get "js/code-copy.js" -}} |  | ||||||
| {{- $jsFileTree := resources.Get "js/filetree.js" -}} |  | ||||||
| {{- $jsSidebar := resources.Get "js/sidebar.js" -}} |  | ||||||
| {{- $jsBackToTop := resources.Get "js/back-to-top.js" -}} |  | ||||||
| {{- $jsTocScroll := resources.Get "js/toc-scroll.js" -}} |  | ||||||
| {{- $jsFavicon := resources.Get "js/favicon.js" | resources.ExecuteAsTemplate "favicon.js" . -}} |  | ||||||
|  |  | ||||||
| {{- $scripts := slice $jsSwitcherMenu $jsTheme $jsBanner $jsMenu $jsCodeCopy $jsTabs $jsLang $jsNavMenu $jsFileTree $jsSidebar $jsBackToTop $jsTocScroll $jsFavicon | resources.Concat "js/main.js" -}} | {{- $scripts := $scriptsBody | resources.Concat "js/main.js" -}} | ||||||
| {{- if hugo.IsProduction -}} | {{- if hugo.IsProduction -}} | ||||||
|   {{- $scripts = $scripts | minify | fingerprint -}} |   {{- $scripts = $scripts | minify | fingerprint -}} | ||||||
| {{- end -}} | {{- end -}} | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								layouts/_partials/scripts/image-zoom.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								layouts/_partials/scripts/image-zoom.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | {{/* Optional minimal image zoom assets */}} | ||||||
|  | {{- $js := resources.Get "js/image-zoom.js" -}} | ||||||
|  | {{- $css := resources.Get "css/components/image-zoom.css" -}} | ||||||
|  |  | ||||||
|  | {{- if hugo.IsProduction -}} | ||||||
|  |   {{- $js = $js | minify | fingerprint -}} | ||||||
|  |   {{- $css = $css | minify | fingerprint -}} | ||||||
|  | {{- end -}} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <link rel="preload" href="{{ $css.RelPermalink }}" as="style" integrity="{{ $css.Data.Integrity }}" /> | ||||||
|  | <link href="{{ $css.RelPermalink }}" rel="stylesheet" integrity="{{ $css.Data.Integrity }}" /> | ||||||
|  | <script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script> | ||||||
							
								
								
									
										69
									
								
								layouts/_partials/shortcodes/tabs.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								layouts/_partials/shortcodes/tabs.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | {{- $tabsID := .id }} | ||||||
|  |  | ||||||
|  | {{- /* | ||||||
|  | The `tabs` parameter is a list of dict with the following keys: | ||||||
|  |   - `id`: (int) the ID of the tab (the Ordinal of the tab shortcode). | ||||||
|  |   - `name`: (string) the name of the tab (the title). | ||||||
|  |   - `content`: (string) the content of the tab. | ||||||
|  |   - `selected`: (bool) whether the tab is selected. | ||||||
|  | */ -}} | ||||||
|  | {{- $tabs := .tabs }} | ||||||
|  |  | ||||||
|  | {{- if eq (len $tabs) 0 -}} | ||||||
|  |   {{ errorf "tabs must have at least one tab" }} | ||||||
|  | {{- end -}} | ||||||
|  |  | ||||||
|  | {{- $enableSync := .enableSync }} | ||||||
|  |  | ||||||
|  | {{- /* Create group data for syncing and select the first tab if none is selected. */ -}} | ||||||
|  | {{- $selectedIndex := 0 -}} | ||||||
|  | {{  $dataTabGroup := slice -}} | ||||||
|  |  | ||||||
|  | {{- range $i, $item := $tabs -}} | ||||||
|  |   {{- $dataTabGroup = $dataTabGroup | append ($item.name) -}} | ||||||
|  |  | ||||||
|  |   {{- if $item.selected -}} | ||||||
|  |     {{- $selectedIndex = $i -}} | ||||||
|  |   {{- end -}} | ||||||
|  | {{- end -}} | ||||||
|  |  | ||||||
|  | {{- /* Generate a unique ID for each tab group. */ -}} | ||||||
|  | {{- $globalID := printf "tabs-%02v" $tabsID -}} | ||||||
|  |  | ||||||
|  | <div class="hextra-scrollbar hx:overflow-x-auto hx:overflow-y-hidden hx:overscroll-x-contain"> | ||||||
|  |   <div | ||||||
|  |     class="hx:mt-4 hx:flex hx:w-max hx:min-w-full hx:border-b hx:border-gray-200 hx:pb-px hx:dark:border-neutral-800" | ||||||
|  |     {{ if $enableSync }} data-tab-group="{{ delimit $dataTabGroup `,` }}"{{ end }} | ||||||
|  |   > | ||||||
|  |     {{- range $i, $item := $tabs -}} | ||||||
|  |       <button | ||||||
|  |         class="hextra-tabs-toggle hx:cursor-pointer hx:data-[state=selected]:border-primary-500 hx:data-[state=selected]:text-primary-600 hx:data-[state=selected]:dark:border-primary-500 hx:data-[state=selected]:dark:text-primary-600 hx:mr-2 hx:rounded-t hx:p-2 hx:font-medium hx:leading-5 hx:transition-colors hx:-mb-0.5 hx:select-none hx:border-b-2 hx:border-transparent hx:text-gray-600 hx:hover:border-gray-200 hx:hover:text-black hx:dark:text-gray-200 hx:dark:hover:border-neutral-800 hx:dark:hover:text-white" | ||||||
|  |         role="tab" | ||||||
|  |         type="button" | ||||||
|  |         aria-controls="tabs-panel-{{ $globalID }}-{{ $item.id }}" | ||||||
|  |         {{- if eq $i $selectedIndex -}} | ||||||
|  |         aria-selected="true" | ||||||
|  |         tabindex="0" | ||||||
|  |         data-state="selected" | ||||||
|  |         {{- end }} | ||||||
|  |       > | ||||||
|  |         {{- $item.name -}} | ||||||
|  |       </button> | ||||||
|  |     {{- end -}} | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | <div> | ||||||
|  |     {{- range $i, $item := $tabs -}} | ||||||
|  |       <div | ||||||
|  |         class="hextra-tabs-panel hx:rounded-sm hx:pt-6 hx:hidden hx:data-[state=selected]:block" | ||||||
|  |         id="tabs-panel-{{ $globalID }}-{{ $item.id }}" | ||||||
|  |         role="tabpanel" | ||||||
|  |         {{- if eq $i $selectedIndex -}} | ||||||
|  |         tabindex="0" | ||||||
|  |         data-state="selected" | ||||||
|  |         {{ end -}} | ||||||
|  |       > | ||||||
|  |         {{- $item.content  | markdownify -}} | ||||||
|  |       </div> | ||||||
|  |     {{- end -}} | ||||||
|  | </div> | ||||||
| @@ -3,17 +3,22 @@ | |||||||
| {{- $disableSidebar := .disableSidebar | default false -}} | {{- $disableSidebar := .disableSidebar | default false -}} | ||||||
| {{- $displayPlaceholder := .displayPlaceholder | default false -}} | {{- $displayPlaceholder := .displayPlaceholder | default false -}} | ||||||
|  |  | ||||||
| {{- $sidebarClass := cond $disableSidebar (cond $displayPlaceholder "hx:md:hidden hx:xl:block" "hx:md:hidden") "hx:md:sticky" -}} |  | ||||||
|  |  | ||||||
| {{- $navRoot := cond (eq site.Home.Type "docs") site.Home $context.FirstSection -}} | {{- $navRoot := cond (eq site.Home.Type "docs") site.Home $context.FirstSection -}} | ||||||
| {{- $pageURL := $context.RelPermalink -}} | {{- $pageURL := $context.RelPermalink -}} | ||||||
|  |  | ||||||
| {{/* EXPERIMENTAL */}} |  | ||||||
| {{- if .context.Params.sidebar.hide -}} | {{- if .context.Params.sidebar.hide -}} | ||||||
|   {{- $disableSidebar = true -}} |   {{- $disableSidebar = true -}} | ||||||
|   {{- $displayPlaceholder = true -}} |   {{- $displayPlaceholder = false -}} | ||||||
| {{- end -}} | {{- end -}} | ||||||
|  |  | ||||||
|  | {{- $sidebarClass := "hx:md:sticky" -}} | ||||||
|  | {{- if $disableSidebar -}} | ||||||
|  |   {{- if $displayPlaceholder -}} | ||||||
|  |     {{- $sidebarClass = "hx:md:hidden hx:xl:block" -}} | ||||||
|  |   {{- else -}} | ||||||
|  |     {{- $sidebarClass = "hx:md:hidden" -}} | ||||||
|  |   {{- end -}} | ||||||
|  | {{- end -}} | ||||||
|  |  | ||||||
| <aside class="hextra-sidebar-container hx:flex hx:flex-col hx:print:hidden hx:md:top-16 hx:md:shrink-0 hx:md:w-64 hx:md:self-start hx:max-md:[transform:translate3d(0,-100%,0)] {{ $sidebarClass }}"> | <aside class="hextra-sidebar-container hx:flex hx:flex-col hx:print:hidden hx:md:top-16 hx:md:shrink-0 hx:md:w-64 hx:md:self-start hx:max-md:[transform:translate3d(0,-100%,0)] {{ $sidebarClass }}"> | ||||||
|   <!-- Search bar on small screen --> |   <!-- Search bar on small screen --> | ||||||
|   | |||||||
| @@ -1,18 +1,24 @@ | |||||||
| {{- /* | {{- /* | ||||||
| Create a tab. | Create a tab. | ||||||
|  |  | ||||||
| @example {{< tab >}}content{{< /tab >}} | @param {string} name The name of the tab. | ||||||
|  | @param {string} selected Whether the tab is selected. | ||||||
|  |  | ||||||
|  | @example {{< tab name="Foo" selected=true >}}content{{< /tab >}} | ||||||
| */ -}} | */ -}} | ||||||
|  |  | ||||||
| {{- $defaultIndex := int ((.Parent.Get "defaultIndex") | default "0") -}} | {{- $name := .Get "name" | default (printf "Tab %d" .Ordinal) -}} | ||||||
|  |  | ||||||
| <div | {{- $selected := .Get "selected" -}} | ||||||
|   class="hextra-tabs-panel hx:rounded-sm hx:pt-6 hx:hidden hx:data-[state=selected]:block" | {{- if .Parent.Get "defaultIndex" -}} | ||||||
|   id="tabs-panel-{{ .Ordinal }}" |   {{- $selected = eq .Ordinal (int (.Parent.Get "defaultIndex")) -}} | ||||||
|   role="tabpanel" | {{- end -}} | ||||||
|   {{- if eq .Ordinal $defaultIndex }} tabindex="0" {{ end -}} |  | ||||||
|   {{- if eq .Ordinal $defaultIndex }} data-state="selected" {{ end -}} | {{- $tabs := .Parent.Store.Get "tabs" | default slice -}} | ||||||
| > | {{ .Parent.Store.Set "tabs" ($tabs | append (dict | ||||||
|   {{- .InnerDeindent | markdownify -}} |     "id" .Ordinal | ||||||
| </div> |     "name" $name | ||||||
| {{- /* Drop trailing newlines */ -}} |     "content" .InnerDeindent | ||||||
|  |     "selected" $selected | ||||||
|  |   )) | ||||||
|  | -}} | ||||||
|   | |||||||
| @@ -1,49 +1,39 @@ | |||||||
| {{- /* | {{- /* | ||||||
| Create a tabbed interface with the given items. | Create a tabbed interface with the given items. | ||||||
|  |  | ||||||
| @param {string} items The items to display in the tabs. | @example {{< tabs >}}...{{< /tabs >}} | ||||||
| @param {string} defaultIndex The index of the default tab. |  | ||||||
|  |  | ||||||
| @example {{< tabs items="JSON,YAML,TOML" >}}{{< /tabs >}} |  | ||||||
| */ -}} | */ -}} | ||||||
|  |  | ||||||
| {{- $items := split (.Get "items") "," -}} | {{- /* Unused, but required for the shortcode to work. */ -}} | ||||||
| {{- $defaultIndex := int ((.Get "defaultIndex") | default "0") -}} | {{- .Inner -}} | ||||||
|  |  | ||||||
| {{- $enableSync := site.Params.page.tabs.sync | default false -}} | {{- /* Enable syncing of tabs across the page. */ -}} | ||||||
|  | {{- $enableSync := false -}} | ||||||
| {{- if not (.Get "items") -}} | {{- if or (eq .Page.Params.tabs.sync false) (eq .Page.Params.tabs.sync true) -}} | ||||||
|   {{ errorf "tabs shortcode: 'items' parameter is required" }} |   {{- $enableSync = .Page.Params.tabs.sync -}} | ||||||
|  | {{- else -}} | ||||||
|  |   {{- $enableSync = site.Params.page.tabs.sync | default false -}} | ||||||
| {{- end -}} | {{- end -}} | ||||||
|  |  | ||||||
| {{- if not $items -}} | {{- $tabs := ($.Store.Get "tabs") | default slice -}} | ||||||
|   {{ errorf "tabs shortcode: 'items' parameter cannot be empty" }} |  | ||||||
|  | {{- /* Compatibility with previous parameter "items". */ -}} | ||||||
|  | {{- if .Get "defaultIndex" -}} | ||||||
|  |   {{- warnf "The 'defaultIndex' parameter of the 'tabs' shortcode is deprecated. Please use 'selected' on 'tab' instead." -}} | ||||||
| {{- end -}} | {{- end -}} | ||||||
|  |  | ||||||
| {{- range $items -}} | {{- if .Get "items" -}} | ||||||
|   {{- if eq (trim . " ") "" -}} |   {{- warnf "The 'items' parameter of the 'tabs' shortcode is deprecated. Please use 'name' on 'tab' instead." -}} | ||||||
|     {{ errorf "tabs shortcode: empty item found in 'items' parameter" }} |  | ||||||
|   {{- end -}} |  | ||||||
| {{- end -}} |  | ||||||
|  |  | ||||||
| <div class="hextra-scrollbar hx:overflow-x-auto hx:overflow-y-hidden hx:overscroll-x-contain"> |   {{- $items := split (.Get "items") "," -}} | ||||||
|   <div class="hx:mt-4 hx:flex hx:w-max hx:min-w-full hx:border-b hx:border-gray-200 hx:pb-px hx:dark:border-neutral-800"{{ if $enableSync }} data-tab-group="{{ delimit $items `,` }}"{{ end }}> |  | ||||||
|  |   {{- $temp := slice -}} | ||||||
|   {{- range $i, $item := $items -}} |   {{- range $i, $item := $items -}} | ||||||
|       <button |     {{- $tab := index $tabs $i -}} | ||||||
|         class="hextra-tabs-toggle hx:cursor-pointer hx:data-[state=selected]:border-primary-500 hx:data-[state=selected]:text-primary-600 hx:data-[state=selected]:dark:border-primary-500 hx:data-[state=selected]:dark:text-primary-600 hx:mr-2 hx:rounded-t hx:p-2 hx:font-medium hx:leading-5 hx:transition-colors hx:-mb-0.5 hx:select-none hx:border-b-2 hx:border-transparent hx:text-gray-600 hx:hover:border-gray-200 hx:hover:text-black hx:dark:text-gray-200 hx:dark:hover:border-neutral-800 hx:dark:hover:text-white" |     {{- $temp = $temp | append (merge $tab (dict "name" $item)) -}} | ||||||
|         role="tab" |  | ||||||
|         type="button" |  | ||||||
|         aria-controls="tabs-panel-{{ $i }}" |  | ||||||
|         {{- if eq $i $defaultIndex }} aria-selected="true" {{ end -}} |  | ||||||
|         {{- if eq $i $defaultIndex }} tabindex="0" {{ end -}} |  | ||||||
|         {{- if eq $i $defaultIndex }} data-state="selected"{{ end -}} |  | ||||||
|       > |  | ||||||
|         {{- $item -}} |  | ||||||
|       </button> |  | ||||||
|   {{- end -}} |   {{- end -}} | ||||||
|   </div> |  | ||||||
| </div> |   {{- $tabs = $temp -}} | ||||||
| <div> | {{- end -}} | ||||||
|   {{- .Inner -}} |  | ||||||
| </div> | {{- partial "shortcodes/tabs" (dict "tabs" $tabs "enableSync" $enableSync "id" .Ordinal) -}} | ||||||
| {{- /* Drop trailing newlines */ -}} |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user