mirror of
https://github.com/imfing/hextra.git
synced 2025-10-24 16:00:28 -04:00
Compare commits
10 Commits
copilot/ad
...
image-zoom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26569738f7 | ||
|
|
1c13e24535 | ||
|
|
6fc6391d06 | ||
|
|
be49fe6f57 | ||
|
|
9e50415b94 | ||
|
|
6e33f17cba | ||
|
|
ba0934b2e1 | ||
|
|
a528d9adc0 | ||
|
|
c2c4cafa13 | ||
|
|
09728a4aa9 |
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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -383,6 +383,16 @@ excludeSearch: true
|
||||
---
|
||||
```
|
||||
|
||||
### Google Analytics
|
||||
|
||||
To enable [Google Analytics](https://marketingplatform.google.com/about/analytics/), set `services.googleAnalytics.ID` flag in `hugo.yaml`:
|
||||
|
||||
```yaml {filename="hugo.yaml"}
|
||||
services:
|
||||
googleAnalytics:
|
||||
ID: G-MEASUREMENT_ID
|
||||
```
|
||||
|
||||
### Google Search Index
|
||||
|
||||
To [block Google Search](https://developers.google.com/search/docs/crawling-indexing/block-indexing) from indexing a page, set `noindex` to true in your page frontmatter:
|
||||
@@ -399,25 +409,7 @@ To exclude an entire directory, use the [`cascade`](https://gohugo.io/configurat
|
||||
> To block search crawlers, you can make a [`robots.txt` template](https://gohugo.io/templates/robots/).
|
||||
> However, `robots.txt` instructions do not necessarily keep a page out of Google search results.
|
||||
|
||||
### Analytics
|
||||
|
||||
Hextra has support for several different analytics solutions. Hextra only supports analytics in production environments. This is to ensure that you do not accidentally send analytic events when working locally. If, however, you do want to test analytics locally, you can run a production server using:
|
||||
|
||||
```
|
||||
hugo server --environment production
|
||||
```
|
||||
|
||||
#### Google Analytics
|
||||
|
||||
To enable [Google Analytics](https://marketingplatform.google.com/about/analytics/), set `services.googleAnalytics.ID` flag in `hugo.yaml`:
|
||||
|
||||
```yaml {filename="hugo.yaml"}
|
||||
services:
|
||||
googleAnalytics:
|
||||
ID: G-MEASUREMENT_ID
|
||||
```
|
||||
|
||||
#### Umami Analytics
|
||||
### Umami Analytics
|
||||
|
||||
To enable [Umami](https://umami.is/docs/), set `params.analytics.umami.serverURL` and `params.analytics.umami.websiteID` flag in `hugo.yaml`:
|
||||
|
||||
@@ -444,7 +436,7 @@ params:
|
||||
# doNotTrack: "true" # optional
|
||||
```
|
||||
|
||||
#### Matomo Analytics
|
||||
### Matomo Analytics
|
||||
|
||||
To enable [Matomo](https://matomo.org/), set `params.analytics.matomo.URL` and `params.analytics.matomo.ID` flag in `hugo.yaml`:
|
||||
|
||||
@@ -456,32 +448,6 @@ params:
|
||||
websiteID: "94db1cb1-74f4-4a40-ad6c-962362670409"
|
||||
```
|
||||
|
||||
#### GoatCounter Analytics
|
||||
|
||||
To enable [GoatCounter](https://www.goatcounter.com/), set `params.analytics.goatCounter.code` in `hugo.yaml`
|
||||
All settings available here are mirrors of the settings described in GoatCounter [settings](https://www.goatcounter.com/help/js#settings-44186)
|
||||
|
||||
```yaml {filename="hugo.yaml"}
|
||||
params:
|
||||
analytics:
|
||||
goatCounter:
|
||||
code: "ABCDE"
|
||||
|
||||
# Optional Settings
|
||||
#------------------
|
||||
# disables automatic collection of data
|
||||
# noOnload: true
|
||||
|
||||
# disables event binding. See more here https://www.goatcounter.com/help/events
|
||||
# noEvents: true
|
||||
|
||||
# allows data collection from local addresses. Use this with a production environment to test locally
|
||||
# allowLocal: true
|
||||
|
||||
# Allow data collection when a page is loaded in a frame or iframe
|
||||
# allowFrame: true
|
||||
```
|
||||
|
||||
### LLMS.txt Support
|
||||
|
||||
To enable [llms.txt](https://llmstxt.org/) output format for your site, which provides a structured text outline for [large language models](https://en.wikipedia.org/wiki/Large_language_model) and AI agents, add the `llms` output format to your site's `hugo.yaml`:
|
||||
|
||||
@@ -8,36 +8,36 @@ title: جزئیات
|
||||
|
||||
## مثال
|
||||
|
||||
{{< details title="جزئیات" >}}
|
||||
{{% details title="جزئیات" %}}
|
||||
|
||||
این محتوای جزئیات است.
|
||||
|
||||
مارکداون **پشتیبانی میشود**.
|
||||
|
||||
{{< /details >}}
|
||||
{{% /details %}}
|
||||
|
||||
{{< details title="برای نمایش کلیک کنید" closed="true" >}}
|
||||
{{% details title="برای نمایش کلیک کنید" closed="true" %}}
|
||||
|
||||
این بهصورت پیشفرض مخفی خواهد بود.
|
||||
|
||||
{{< /details >}}
|
||||
{{% /details %}}
|
||||
|
||||
## نحوه استفاده
|
||||
|
||||
````markdown
|
||||
{{</* details title="جزئیات" */>}}
|
||||
{{%/* details title="جزئیات" */%}}
|
||||
|
||||
این محتوای جزئیات است.
|
||||
|
||||
مارکداون **پشتیبانی میشود**.
|
||||
|
||||
{{</* /details */>}}
|
||||
{{%/* /details */%}}
|
||||
````
|
||||
|
||||
````markdown
|
||||
{{</* details title="برای نمایش کلیک کنید" closed="true" */>}}
|
||||
{{%/* details title="برای نمایش کلیک کنید" closed="true" */%}}
|
||||
|
||||
این بهصورت پیشفرض مخفی خواهد بود.
|
||||
|
||||
{{</* /details */>}}
|
||||
{{%/* /details */%}}
|
||||
````
|
||||
@@ -8,36 +8,36 @@ title: 詳細
|
||||
|
||||
## 例
|
||||
|
||||
{{< details title="詳細" >}}
|
||||
{{% details title="詳細" %}}
|
||||
|
||||
これは詳細のコンテンツです。
|
||||
|
||||
Markdown は **サポートされています**。
|
||||
|
||||
{{< /details >}}
|
||||
{{% /details %}}
|
||||
|
||||
{{< details title="クリックして表示" closed="true" >}}
|
||||
{{% details title="クリックして表示" closed="true" %}}
|
||||
|
||||
これはデフォルトで非表示になります。
|
||||
|
||||
{{< /details >}}
|
||||
{{% /details %}}
|
||||
|
||||
## 使用方法
|
||||
|
||||
````markdown
|
||||
{{</* details title="詳細" */>}}
|
||||
{{%/* details title="詳細" */%}}
|
||||
|
||||
これは詳細のコンテンツです。
|
||||
|
||||
Markdown は **サポートされています**。
|
||||
|
||||
{{</* /details */>}}
|
||||
{{%/* /details */%}}
|
||||
````
|
||||
|
||||
````markdown
|
||||
{{</* details title="クリックして表示" closed="true" */>}}
|
||||
{{%/* details title="クリックして表示" closed="true" */%}}
|
||||
|
||||
これはデフォルトで非表示になります。
|
||||
|
||||
{{</* /details */>}}
|
||||
{{%/* /details */%}}
|
||||
````
|
||||
@@ -8,36 +8,36 @@ A built-in component to display a collapsible content.
|
||||
|
||||
## Example
|
||||
|
||||
{{< details title="Details" >}}
|
||||
{{% details title="Details" %}}
|
||||
|
||||
This is the content of the details.
|
||||
|
||||
Markdown is **supported**.
|
||||
|
||||
{{< /details >}}
|
||||
{{% /details %}}
|
||||
|
||||
{{< details title="Click me to reveal" closed="true" >}}
|
||||
{{% details title="Click me to reveal" closed="true" %}}
|
||||
|
||||
This will be hidden by default.
|
||||
|
||||
{{< /details >}}
|
||||
{{% /details %}}
|
||||
|
||||
## Usage
|
||||
|
||||
````markdown
|
||||
{{</* details title="Details" */>}}
|
||||
{{%/* details title="Details" */%}}
|
||||
|
||||
This is the content of the details.
|
||||
|
||||
Markdown is **supported**.
|
||||
|
||||
{{</* /details */>}}
|
||||
{{%/* /details */%}}
|
||||
````
|
||||
|
||||
````markdown
|
||||
{{</* details title="Click me to reveal" closed="true" */>}}
|
||||
{{%/* details title="Click me to reveal" closed="true" */%}}
|
||||
|
||||
This will be hidden by default.
|
||||
|
||||
{{</* /details */>}}
|
||||
{{%/* /details */%}}
|
||||
````
|
||||
|
||||
@@ -8,36 +8,36 @@ title: 详情
|
||||
|
||||
## 示例
|
||||
|
||||
{{< details title="详情" >}}
|
||||
{{% details title="详情" %}}
|
||||
|
||||
这是详情的内容。
|
||||
|
||||
支持 **Markdown** 格式。
|
||||
|
||||
{{< /details >}}
|
||||
{{% /details %}}
|
||||
|
||||
{{< details title="点击我展开" closed="true" >}}
|
||||
{{% details title="点击我展开" closed="true" %}}
|
||||
|
||||
默认情况下,这部分内容会被隐藏。
|
||||
|
||||
{{< /details >}}
|
||||
{{% /details %}}
|
||||
|
||||
## 使用方法
|
||||
|
||||
````markdown
|
||||
{{</* details title="详情" */>}}
|
||||
{{%/* details title="详情" */%}}
|
||||
|
||||
这是详情的内容。
|
||||
|
||||
支持 **Markdown** 格式。
|
||||
|
||||
{{</* /details */>}}
|
||||
{{%/* /details */%}}
|
||||
````
|
||||
|
||||
````markdown
|
||||
{{</* details title="点击我展开" closed="true" */>}}
|
||||
{{%/* details title="点击我展开" closed="true" */%}}
|
||||
|
||||
默认情况下,这部分内容会被隐藏。
|
||||
|
||||
{{</* /details */>}}
|
||||
{{%/* /details */%}}
|
||||
````
|
||||
@@ -4,8 +4,6 @@ title: Steps
|
||||
|
||||
A built-in component to display a series of steps.
|
||||
|
||||
You can use the Markdown attribute `{class="no-step-marker"}` to prevent a heading from being counted as a step.
|
||||
|
||||
## Example
|
||||
|
||||
{{% steps %}}
|
||||
@@ -18,10 +16,6 @@ This is the first step.
|
||||
|
||||
This is the second step.
|
||||
|
||||
#### Step subheading {class="no-step-marker"}
|
||||
|
||||
This will not be counted as a step.
|
||||
|
||||
### Step 3
|
||||
|
||||
This is the third step.
|
||||
@@ -49,9 +43,5 @@ This is the first step.
|
||||
|
||||
This is the second step.
|
||||
|
||||
#### Step subheading {class="no-step-marker"}
|
||||
|
||||
This will not be counted as a step.
|
||||
|
||||
{{%/* /steps */%}}
|
||||
```
|
||||
|
||||
@@ -237,3 +237,6 @@ params:
|
||||
# inputPosition: top
|
||||
# lang: en
|
||||
# theme: noborder_dark
|
||||
|
||||
imageZoom:
|
||||
enable: true
|
||||
|
||||
@@ -715,7 +715,6 @@
|
||||
"msupsub",
|
||||
"mtable",
|
||||
"mtight",
|
||||
"no-step-marker",
|
||||
"not-prose",
|
||||
"nulldelimiter",
|
||||
"op-symbol",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h{{ .Level }} {{- with .Attributes.class }} class="{{ . }}" {{- end }}>
|
||||
<h{{ .Level }}>
|
||||
{{- .Text | safeHTML -}}
|
||||
{{- if gt .Level 1 -}}
|
||||
<span class="hx:absolute hx:-mt-20" id="{{ .Anchor | safeURL }}"></span>
|
||||
|
||||
@@ -16,9 +16,4 @@
|
||||
{{ partial "components/analytics/matomo.html" . }}
|
||||
{{- end }}
|
||||
|
||||
<!-- GoatCounter -->
|
||||
{{- if .Site.Params.analytics.goatCounter -}}
|
||||
{{ partial "components/analytics/goat-counter.html" . }}
|
||||
{{- end -}}
|
||||
|
||||
{{- end }}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{{- with .Site.Params.analytics.goatCounter -}}
|
||||
{{- if not .code -}}
|
||||
{{- errorf "Missing GoatCounter 'code' configuration. See https://imfing.github.io/hextra/versions/latest/docs/guide/configuration/#goatcounter-analytics" -}}
|
||||
{{- end -}}
|
||||
|
||||
<script
|
||||
data-goatcounter="https://{{ .code }}.goatcounter.com/count"
|
||||
data-goatcounter-settings='
|
||||
{
|
||||
"no_onload":{{ .noOnload | default false }},
|
||||
"no_events":{{ .noEvents | default false }},
|
||||
"allow_local":{{ .allowLocal | default false }},
|
||||
"allow_frame":{{ .allowFrame | default false }}
|
||||
}
|
||||
'
|
||||
async src="//gc.zgo.at/count.js"></script>
|
||||
{{- end -}}
|
||||
@@ -13,3 +13,10 @@
|
||||
{{- if (.Store.Get "hasAsciinema") -}}
|
||||
{{- partial "scripts/asciinema.html" . -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Image zoom */}}
|
||||
{{- with site.Params.imageZoom }}
|
||||
{{- if .enable }}
|
||||
{{- partial "scripts/image-zoom.html" $ -}}
|
||||
{{- 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>
|
||||
Reference in New Issue
Block a user