Add a magnifier to zoom images. The resolution of the magnifier depends on the original image's resolution.
- Mouse wheel (no modifier): Fine zoom adjustment (±0.2x increments)
- Ctrl + Mouse wheel: Integer zoom steps (±1x increments)
- Shift + Mouse wheel: Lens size adjustment (±10px increments)
- Alt + Mouse wheel: Lens roundness adjustment (±5% increments)
- Ctrl + Shift + Mouse wheel: Toggle auto-hide lens behavior
- Double-click lens: Export settings dialog
html
<img id="pcb "class="zoomable" src="60e80de9886443b1b4b61f7325a1e3a9.jpg" style="height: 160px;"/>
JavaScript
(()=>{
class Magnifier {
static lensCounter = 0;
constructor(img) {
this.img = img;
this.img.style.cursor = 'crosshair';
this.zoom = 2;
this.lensSize = 120;
this.lensRoundness = 50; // percent, 0=square, 50=circle
this.dragging = false;
this.dragOffset = {x:0, y:0};
this.lastMouse = null;
this.autoHideLens = true;
this.tooltipTimeout = null;
// Create container
this.container = document.createElement('div');
this.container.className = 'magnifier-container';
img.parentNode.insertBefore(this.container, img);
this.container.appendChild(img);
// Create lens with unique id - use image id if available, otherwise generate one
if (this.img.id) {
this.lensId = `magnifier-lens-${this.img.id}`;
} else {
Magnifier.lensCounter += 1;
this.lensId = `magnifier-lens-${Magnifier.lensCounter}`;
}
this.storageKey = `magnifier-${this.lensId}`; // Set storage key AFTER lensId is created
this.lens = document.createElement('div');
this.lens.className = 'magnifier-lens';
this.lens.id = this.lensId;
this.lens.style.width = this.lens.style.height = this.lensSize + 'px';
this.lens.style.borderRadius = this.lensRoundness + '%';
this.lens.style.opacity = 1;
this.lens.style.pointerEvents = 'auto';
this.lens.style.overflow = 'hidden';
// Append lens to Spotfire visualization area instead of container
const spotfireElement = document.querySelector(".sf-element.sf-element-visualization-area")?.firstElementChild;
if (spotfireElement) {
spotfireElement.appendChild(this.lens);
} else {
// Fallback to original container if Spotfire element not found
this.container.appendChild(this.lens);
}
// Create canvas for lens
this.lensCanvas = document.createElement('canvas');
this.lensCanvas.width = this.lensSize;
this.lensCanvas.height = this.lensSize;
this.lensCanvas.style.width = this.lensSize + 'px';
this.lensCanvas.style.height = this.lensSize + 'px';
this.lensCanvas.style.borderRadius = this.lensRoundness + '%';
this.lens.appendChild(this.lensCanvas);
this.lensCtx = this.lensCanvas.getContext('2d');
// Create tooltip
this.tooltip = document.createElement('div');
this.tooltip.className = 'zoom-tooltip';
this.tooltip.textContent = '2x';
this.lens.appendChild(this.tooltip);
this.loadSettings(); // Load saved settings from localStorage BEFORE centering
this.centerLens();
this.updateZoomTooltip();
this.showLens();
// Set initial mouse position to image center for lens background
this.lastMouse = {
x: this.img.naturalWidth ? this.img.naturalWidth / 2 : this.img.width / 2,
y: this.img.naturalHeight ? this.img.naturalHeight / 2 : this.img.height / 2
};
// Wait for image to load before updating lens background
if (!this.img.complete) {
this.img.addEventListener('load', () => {
this.lastMouse = {
x: this.img.naturalWidth / 2,
y: this.img.naturalHeight / 2
};
this.centerLens();
this.updateLensBackground();
});
} else {
this.updateLensBackground();
}
this.addEvents();
}
centerLens() {
// Only center if no position was loaded from localStorage
if (!this.positionLoaded) {
this.lens.style.left = (this.img.width / 2 - this.lensSize / 2) + 'px';
this.lens.style.top = (this.img.height / 2 - this.lensSize / 2) + 'px';
}
}
showLens() {
this.lens.style.opacity = 1;
this.lens.style.pointerEvents = 'auto';
this.lens.style.display = 'block';
}
hideLens() {
if (!this.autoHideLens) return;
this.lens.style.opacity = 0;
this.lens.style.pointerEvents = 'auto';
}
addEvents() {
this.img.addEventListener('mouseenter', () => this.showLens());
this.img.addEventListener('mouseleave', () => {
if (!this.lens.matches(':hover')) this.hideLens();
});
this.lens.addEventListener('mouseenter', () => {
this.showLens();
this.tooltip.style.opacity = 1;
// Clear tooltip timeout since mouse is over lens
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout);
this.tooltipTimeout = null;
}
});
this.lens.addEventListener('mouseleave', () => {
this.tooltip.style.opacity = 0;
if (!this.img.matches(':hover')) this.hideLens();
});
// Drag logic scoped to this instance
this.lens.addEventListener('mousedown', (e) => {
this.dragging = true;
this.lens.style.cursor = 'grabbing';
const lensRect = this.lens.getBoundingClientRect();
this.dragOffset.x = e.clientX - lensRect.left;
this.dragOffset.y = e.clientY - lensRect.top;
document.body.style.userSelect = 'none';
e.preventDefault();
// Add mousemove and mouseup listeners for drag only while dragging
const onMove = (ev) => {
if (this.dragging) {
const containerRect = this.container.getBoundingClientRect();
let newLeft = ev.clientX - containerRect.left - this.dragOffset.x;
let newTop = ev.clientY - containerRect.top - this.dragOffset.y;
this.lens.style.left = newLeft + 'px';
this.lens.style.top = newTop + 'px';
}
};
const onUp = () => {
this.dragging = false;
this.lens.style.cursor = 'crosshair';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
this.saveSettings(); // Save position after dragging
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
});
this.lens.addEventListener('wheel', (e) => this.handleZoomWheel(e));
this.img.addEventListener('wheel', (e) => this.handleZoomWheel(e));
this.img.addEventListener('mousemove', (e) => this.handleMouseMove(e));
// Double-click to show settings export dialog
this.lens.addEventListener('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
showSettingsDialog();
});
}
updateZoomTooltip(mode) {
if (mode === 'size') {
this.tooltip.textContent = `Size: ${this.lensSize}px`;
} else if (mode === 'round') {
this.tooltip.textContent = `Round: ${this.lensRoundness}%`;
} else if (mode === 'autoHide') {
this.tooltip.textContent = `Auto-hide: ${this.autoHideLens ? 'ON' : 'OFF'}`;
} else {
if (Number.isInteger(this.zoom)) {
this.tooltip.textContent = `${this.zoom}x`;
} else {
this.tooltip.textContent = `${this.zoom.toFixed(1)}x`;
}
}
// Toast the tooltip when called from zoom/size/roundness/autoHide changes
if (mode === 'size' || mode === 'round' || mode === 'autoHide' || mode === undefined) {
this.toastTooltip();
}
}
toastTooltip() {
// Clear any existing timeout
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout);
}
// Show tooltip immediately
this.tooltip.style.opacity = 1;
// Hide after 1.5 seconds unless mouse is over lens
this.tooltipTimeout = setTimeout(() => {
if (!this.lens.matches(':hover')) {
this.tooltip.style.opacity = 0;
}
this.tooltipTimeout = null;
}, 1500);
}
loadSettings() {
try {
// First check for settings in the span tag
const spanSettings = readSettingsFromSpan();
let settings = null;
// Get image ID for lookup
const imageId = this.img.id || this.lensId.replace('magnifier-lens-', '');
if (spanSettings && spanSettings[imageId]) {
settings = spanSettings[imageId];
console.log(`Loading settings for ${imageId} from span:`, settings);
} else {
// Fall back to localStorage
const saved = localStorage.getItem(this.storageKey);
if (saved) {
settings = JSON.parse(saved);
console.log(`Loading settings for ${imageId} from localStorage:`, settings);
}
}
if (settings) {
// Restore zoom level
if (settings.zoom !== undefined) {
this.zoom = Math.max(0.2, Math.min(20, settings.zoom));
}
// Restore lens size
if (settings.lensSize !== undefined) {
this.lensSize = Math.max(40, Math.min(400, settings.lensSize));
this.lens.style.width = this.lens.style.height = this.lensSize + 'px';
this.lensCanvas.width = this.lensCanvas.height = this.lensSize;
this.lensCanvas.style.width = this.lensCanvas.style.height = this.lensSize + 'px';
}
// Restore roundness
if (settings.lensRoundness !== undefined) {
this.lensRoundness = Math.max(0, Math.min(50, settings.lensRoundness));
this.lens.style.borderRadius = this.lensRoundness + '%';
this.lensCanvas.style.borderRadius = this.lensRoundness + '%';
}
// Restore autoHideLens setting
if (settings.autoHideLens !== undefined) {
this.autoHideLens = Boolean(settings.autoHideLens);
}
// Restore position - flag that position was loaded to prevent centering
if (settings.position && settings.position.x !== undefined && settings.position.y !== undefined) {
this.lens.style.left = settings.position.x + 'px';
this.lens.style.top = settings.position.y + 'px';
this.positionLoaded = true; // Flag to prevent centering
}
// Update tooltip with loaded zoom
this.updateZoomTooltip();
}
} catch (e) {
console.warn('Failed to load magnifier settings:', e);
}
}
saveSettings() {
try {
const settings = {
zoom: this.zoom,
lensSize: this.lensSize,
lensRoundness: this.lensRoundness,
autoHideLens: this.autoHideLens,
position: {
x: parseInt(this.lens.style.left) || 0,
y: parseInt(this.lens.style.top) || 0
}
};
localStorage.setItem(this.storageKey, JSON.stringify(settings));
} catch (e) {
console.warn('Failed to save magnifier settings:', e);
}
}
handleZoomWheel(e) {
e.preventDefault();
e.stopPropagation();
if (!this.dragging) {
let updateLens = false;
// Shift+wheel: lens size only
if (e.shiftKey && !e.ctrlKey && !e.altKey) {
if (e.deltaY < 0) {
this.lensSize = Math.min(this.lensSize + 10, 400);
} else {
this.lensSize = Math.max(this.lensSize - 10, 40);
}
this.lens.style.width = this.lens.style.height = this.lensSize + 'px';
this.lensCanvas.width = this.lensCanvas.height = this.lensSize;
this.lensCanvas.style.width = this.lensCanvas.style.height = this.lensSize + 'px';
// this.centerLens();
this.updateZoomTooltip('size');
updateLens = true;
this.saveSettings(); // Save lens size change
}
// Alt+wheel: border-radius only
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
this.lensRoundness = Math.max(0, Math.min(50, this.lensRoundness + (e.deltaY < 0 ? 5 : -5)));
this.lens.style.borderRadius = this.lensRoundness + '%';
this.lensCanvas.style.borderRadius = this.lensRoundness + '%';
this.updateZoomTooltip('round');
this.saveSettings(); // Save roundness change
}
// Ctrl+Shift+wheel: toggle autoHideLens
if (e.ctrlKey && e.shiftKey && !e.altKey) {
this.autoHideLens = !this.autoHideLens;
this.updateZoomTooltip('autoHide');
this.saveSettings(); // Save autoHideLens change
}
// Ctrl+wheel: zoom (integer)
if (e.ctrlKey && !e.shiftKey && !e.altKey) {
if (e.deltaY < 0) {
this.zoom = Math.min(Math.round(this.zoom + 1), 20);
} else {
this.zoom = Math.max(Math.round(this.zoom - 1), 0);
}
this.updateZoomTooltip();
updateLens = true;
this.saveSettings(); // Save zoom change
}
// No modifier: zoom (fine)
if (!e.ctrlKey && !e.shiftKey && !e.altKey) {
if (e.deltaY < 0) {
this.zoom = Math.min(this.zoom + 0.2, 10);
} else {
this.zoom = Math.max(this.zoom - 0.2, 0);
}
this.updateZoomTooltip();
updateLens = true;
this.saveSettings(); // Save zoom change
}
// Always update lens background if lens size or zoom changed
if (updateLens && this.lastMouse) {
this.updateLensBackground();
}
}
}
handleMouseMove(e) {
if (!this.dragging) {
const rect = this.img.getBoundingClientRect();
// Mouse position relative to image (CSS pixels)
const x_css = e.clientX - rect.left;
const y_css = e.clientY - rect.top;
// Map CSS pixel to natural image pixel
const scaleX = this.img.naturalWidth / rect.width;
const scaleY = this.img.naturalHeight / rect.height;
const x = x_css * scaleX;
const y = y_css * scaleY;
this.lastMouse = { x, y };
// Do NOT move lens position here; only update magnified region
this.updateLensBackground();
}
}
updateLensBackground() {
if (this.lastMouse && !this.dragging) {
// Clear lens canvas
this.lensCtx.clearRect(0, 0, this.lensSize, this.lensSize);
// Calculate source region in image so mouse is at center of lens
const sw = this.lensSize / this.zoom;
const sh = this.lensSize / this.zoom;
let sx = this.lastMouse.x - sw / 2;
let sy = this.lastMouse.y - sh / 2;
// Clamp to image bounds
sx = Math.max(0, Math.min(this.img.naturalWidth - sw, sx));
sy = Math.max(0, Math.min(this.img.naturalHeight - sh, sy));
// Draw zoomed region to lens canvas
this.lensCtx.drawImage(
this.img,
sx, sy, sw, sh,
0, 0, this.lensSize, this.lensSize
);
}
}
}
// Function to read settings from the span or pre tag
function readSettingsFromSpan() {
const settingsElement = document.getElementById('zoomable-settings');
if (settingsElement && settingsElement.innerHTML.trim()) {
try {
const content = settingsElement.innerHTML.trim();
// Expected format: "zoomable-settings:{json}"
if (content.startsWith('zoomable-settings:')) {
const jsonStr = content.substring('zoomable-settings:'.length);
return JSON.parse(jsonStr);
}
} catch (e) {
console.warn('Failed to parse settings from element:', e);
}
}
return null;
}
// Function to collect all current settings from all magnifiers
function collectAllSettings() {
const allSettings = {};
document.querySelectorAll('.magnifier-lens').forEach(lens => {
const lensId = lens.id;
if (lensId.startsWith('magnifier-lens-')) {
const imageId = lensId.replace('magnifier-lens-', '');
const storageKey = `magnifier-${lensId}`;
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
allSettings[imageId] = JSON.parse(saved);
}
} catch (e) {
console.warn(`Failed to read settings for ${imageId}:`, e);
}
}
});
return allSettings;
}
// Function to show settings dialog
function showSettingsDialog() {
// Check if dialog is already open
if (settingsDialog.style.display === 'block') {
return; // Don't open multiple instances
}
const allSettings = collectAllSettings();
const settingsJson = JSON.stringify(allSettings, null, 2);
const htmlCode = `<pre id="zoomable-settings" hidden>\nzoomable-settings:${settingsJson}\n</pre>`;
const textarea = settingsDialog.querySelector('#settings-output');
textarea.value = htmlCode;
// Show overlay and dialog
modalOverlay.style.display = 'block';
settingsDialog.style.display = 'block';
}
// Create modal overlay
const modalOverlay = document.createElement('div');
modalOverlay.className = 'modal-overlay';
document.body.appendChild(modalOverlay);
// Create global settings dialog (shared by all magnifiers)
const settingsDialog = document.createElement('div');
settingsDialog.className = 'settings-dialog';
settingsDialog.innerHTML = `
<button class="close-btn">×</button>
<h3>Export Magnifier Settings</h3>
<div class="message">Copy the code below and paste it into your HTML to preserve these magnifier settings:</div>
<textarea id="settings-output" readonly></textarea>
<button class="copy-btn">Copy to Clipboard</button>
`;
document.body.appendChild(settingsDialog);
// Settings dialog event handlers
function closeDialog() {
settingsDialog.style.display = 'none';
modalOverlay.style.display = 'none';
}
settingsDialog.querySelector('.close-btn').addEventListener('click', closeDialog);
modalOverlay.addEventListener('click', closeDialog);
// Prevent dialog from closing when clicking inside the dialog
settingsDialog.addEventListener('click', (e) => {
e.stopPropagation();
});
settingsDialog.querySelector('.copy-btn').addEventListener('click', () => {
const textarea = settingsDialog.querySelector('#settings-output');
textarea.select();
document.execCommand('copy');
const btn = settingsDialog.querySelector('.copy-btn');
const originalText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = originalText, 1000);
});
// Enhance all images with class 'zoomable'
document.querySelectorAll('img.zoomable').forEach(img => {
new Magnifier(img);
});
// Embed CSS as a string and inject next to the first zoomable image
const magnifierCSS = `
<style>
.magnifier-container {
position: relative;
display: inline-block;
}
.magnifier-lens {
position: absolute;
border: 2px solid #333;
border-radius: 50%;
width: 120px;
height: 120px;
pointer-events: auto;
display: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
background-repeat: no-repeat;
background-size: 1200px 1200px;
cursor: grab;
overflow: hidden;
opacity: 1;
transition: opacity 2s;
}
.zoom-tooltip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.7);
color: #fff;
padding: 2px 8px;
border-radius: 8px;
font-size: 16px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 20;
white-space: nowrap;
}
.settings-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border: 2px solid #333;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 1000;
max-width: 600px;
width: 90%;
padding: 20px;
font-family: Arial, sans-serif;
display: none;
}
.settings-dialog h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 18px;
}
.settings-dialog .close-btn {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.settings-dialog .close-btn:hover {
color: #333;
}
.settings-dialog .message {
margin-bottom: 15px;
color: #666;
font-size: 14px;
}
.settings-dialog textarea {
width: 100%;
height: 150px;
font-family: 'Courier New', monospace;
font-size: 12px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
resize: vertical;
background: #f9f9f9;
}
.settings-dialog .copy-btn {
margin: 10px;
padding: 8px 16px;
background: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.settings-dialog .copy-btn:hover {
background: #005a9e;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: none;
}
</style>
`;
const firstZoomable = document.querySelector('img.zoomable');
if (firstZoomable) {
firstZoomable.insertAdjacentHTML('beforebegin', magnifierCSS);
}
})()
No comments:
Post a Comment