diff --git a/changedetectionio/blueprint/ui/templates/diff.html b/changedetectionio/blueprint/ui/templates/diff.html
index f1efed8ed..e25f5a2b5 100644
--- a/changedetectionio/blueprint/ui/templates/diff.html
+++ b/changedetectionio/blueprint/ui/templates/diff.html
@@ -8,9 +8,10 @@
{% endif %}
const highlight_submit_ignore_url="{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}";
-
+ const watch_url= {{watch_a.link|tojson}};
+
diff --git a/changedetectionio/static/js/snippet-to-image.js b/changedetectionio/static/js/snippet-to-image.js
index 196a7dc6b..2c53b970e 100644
--- a/changedetectionio/static/js/snippet-to-image.js
+++ b/changedetectionio/static/js/snippet-to-image.js
@@ -1,72 +1,354 @@
/**
* snippet-to-image.js
- * Converts the selected diff content to a JPEG image for sharing/downloading
+ * Converts selected diff content to a shareable JPEG image with metadata
*/
+// Constants
+const IMAGE_PADDING = 5;
+const JPEG_QUALITY = 0.95;
+const CANVAS_SCALE = 1;
+const RENDER_DELAY_MS = 50;
+
+/**
+ * Utility: Get the target URL from global watch_url or fallback to current URL
+ */
+function getTargetUrl() {
+ return (typeof watch_url !== 'undefined' && watch_url) ? watch_url : window.location.href;
+}
+
+/**
+ * Utility: Get formatted current date with timezone
+ */
+function getFormattedDate() {
+ return new Date().toLocaleString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ timeZoneName: 'short'
+ });
+}
+
+/**
+ * Utility: Get version comparison info from the diff selectors
+ */
+function getVersionInfo() {
+ const fromSelect = document.getElementById('diff-version');
+ const toSelect = document.getElementById('current-version');
+
+ if (!fromSelect || !toSelect) {
+ return '';
+ }
+
+ const fromOption = fromSelect.options[fromSelect.selectedIndex];
+ const toOption = toSelect.options[toSelect.selectedIndex];
+ const fromLabel = fromOption ? (fromOption.getAttribute('label') || fromOption.text) : 'Unknown';
+ const toLabel = toOption ? (toOption.getAttribute('label') || toOption.text) : 'Unknown';
+
+ return `
Change comparison from ${fromLabel} to ${toLabel}`;
+}
+
+/**
+ * Helper: Find text node containing newline in a given direction
+ */
+function findTextNodeWithNewline(node, searchBackwards = false) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const text = node.textContent;
+ const idx = searchBackwards ? text.lastIndexOf('\n') : text.indexOf('\n');
+ if (idx !== -1) {
+ return { node, offset: searchBackwards ? idx + 1 : idx };
+ }
+ } else {
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
+ let textNode;
+ while (textNode = walker.nextNode()) {
+ const text = textNode.textContent;
+ const idx = searchBackwards ? text.lastIndexOf('\n') : text.indexOf('\n');
+ if (idx !== -1) {
+ return { node: textNode, offset: searchBackwards ? idx + 1 : idx };
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * Helper: Walk through siblings in a given direction to find line boundary
+ */
+function findLineBoundary(node, container, searchBackwards = false) {
+ let currentNode = node;
+
+ while (currentNode && currentNode !== container) {
+ const sibling = searchBackwards ? currentNode.previousSibling : currentNode.nextSibling;
+ let currentSibling = sibling;
+
+ while (currentSibling) {
+ const result = findTextNodeWithNewline(currentSibling, searchBackwards);
+ if (result) {
+ return result;
+ }
+ currentSibling = searchBackwards ? currentSibling.previousSibling : currentSibling.nextSibling;
+ }
+
+ currentNode = currentNode.parentNode;
+ }
+
+ return null;
+}
+
+/**
+ * Helper: Get the last text node in a container
+ */
+function getLastTextNode(container) {
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
+ let lastNode = null;
+ let textNode;
+ while (textNode = walker.nextNode()) {
+ lastNode = textNode;
+ }
+ return lastNode;
+}
+
+/**
+ * Expands a selection range to include complete lines
+ * If a user selects partial text, this ensures full lines are captured
+ */
+function expandRangeToFullLines(range, container) {
+ const newRange = range.cloneRange();
+
+ // Expand start to line beginning
+ if (newRange.startContainer.nodeType === Node.TEXT_NODE) {
+ const text = newRange.startContainer.textContent;
+ const lastNewline = text.lastIndexOf('\n', newRange.startOffset - 1);
+ if (lastNewline !== -1) {
+ newRange.setStart(newRange.startContainer, lastNewline + 1);
+ } else {
+ const lineStart = findLineBoundary(newRange.startContainer, container, true);
+ if (lineStart) {
+ newRange.setStart(lineStart.node, lineStart.offset);
+ } else {
+ newRange.setStart(container, 0);
+ }
+ }
+ }
+
+ // Expand end to line end
+ if (newRange.endContainer.nodeType === Node.TEXT_NODE) {
+ const text = newRange.endContainer.textContent;
+ const nextNewline = text.indexOf('\n', newRange.endOffset);
+ if (nextNewline !== -1) {
+ newRange.setEnd(newRange.endContainer, nextNewline);
+ } else {
+ const lineEnd = findLineBoundary(newRange.endContainer, container, false);
+ if (lineEnd) {
+ newRange.setEnd(lineEnd.node, lineEnd.offset);
+ } else {
+ const lastNode = getLastTextNode(container);
+ newRange.setEnd(
+ lastNode || container,
+ lastNode ? lastNode.textContent.length : container.childNodes.length
+ );
+ }
+ }
+ }
+
+ return newRange;
+}
+
+/**
+ * Create a temporary element with the selected content styled for capture
+ */
+function createCaptureElement(selectedFragment, originalElement) {
+ const originalStyles = window.getComputedStyle(originalElement);
+
+ // Create wrapper container
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = `
+
+ `;
+
+ const tempElement = wrapper.firstElementChild;
+ tempElement.appendChild(selectedFragment);
+
+ return tempElement;
+}
+
+/**
+ * Create footer with metadata (URL, date, version info)
+ */
+function createFooter() {
+ const url = getTargetUrl();
+ const date = getFormattedDate();
+ const versionInfo = getVersionInfo();
+
+ const footer = document.createElement("div");
+ footer.innerHTML = `
+
+ ${url}
+ Generated by changedetection.io at ${date}
+ ${versionInfo}
+
+ `;
+
+ return footer.firstElementChild;
+}
+
+/**
+ * Add EXIF metadata to JPEG image
+ */
+function addExifMetadata(jpegDataUrl) {
+ if (typeof piexif === 'undefined') {
+ return jpegDataUrl;
+ }
+
+ try {
+ const url = getTargetUrl();
+ const timestamp = new Date().toISOString();
+
+ const exifObj = {
+ "0th": {
+ [piexif.ImageIFD.Software]: "changedetection.io",
+ [piexif.ImageIFD.ImageDescription]: `Diff snapshot from ${url}`,
+ [piexif.ImageIFD.Copyright]: "Generated by changedetection.io"
+ },
+ "Exif": {
+ [piexif.ExifIFD.DateTimeOriginal]: timestamp,
+ [piexif.ExifIFD.UserComment]: `URL: ${url} | Captured: ${timestamp} | Source: changedetection.io`
+ }
+ };
+
+ const exifBytes = piexif.dump(exifObj);
+ return piexif.insert(exifBytes, jpegDataUrl);
+ } catch (error) {
+ console.warn("Failed to add EXIF metadata:", error);
+ return jpegDataUrl;
+ }
+}
+
+/**
+ * Display or download the generated image
+ */
+function displayImage(jpegDataUrl) {
+ const win = window.open();
+ if (win) {
+ win.document.write(`
+
+ Diff Screenshot
+
+
+
+
+ `);
+ } else {
+ // Fallback: trigger download if popup is blocked
+ const a = document.createElement("a");
+ a.href = jpegDataUrl;
+ a.download = "diff-snapshot-" + Date.now() + ".jpg";
+ a.click();
+ }
+}
+
+/**
+ * Update button UI state
+ */
+function setButtonState(button, isLoading, originalHtml = '') {
+ if (!button) return;
+
+ if (isLoading) {
+ button.innerHTML = 'Generating...';
+ button.style.opacity = "0.5";
+ button.style.pointerEvents = "none";
+ } else {
+ button.innerHTML = originalHtml;
+ button.style.opacity = "1";
+ button.style.pointerEvents = "auto";
+ }
+}
+
+/**
+ * Main function: Convert selected diff text to a shareable JPEG image
+ *
+ * Features:
+ * - Expands partial selections to full lines
+ * - Preserves all diff highlighting and formatting
+ * - Adds metadata footer with URL and version info
+ * - Embeds EXIF metadata in the JPEG
+ * - Opens in new window or downloads if popup blocked
+ */
async function diffToJpeg() {
- // Check if html2canvas is loaded
+ // Validate dependencies
if (typeof html2canvas === 'undefined') {
alert("html2canvas library is not loaded yet. Please wait a moment and try again.");
return;
}
- // Get the user's text selection
+ // Validate selection
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
alert("Please select the text/lines you want to capture first by highlighting with your mouse.");
return;
}
- // Show a loading indicator
- const btn = document.getElementById("share-as-image-btn");
- const originalText = btn ? btn.innerHTML : '';
- if (btn) {
- btn.innerHTML = 'Generating...';
- btn.style.opacity = "0.5";
- btn.style.pointerEvents = "none";
+ const originalRange = selection.getRangeAt(0);
+ const differenceElement = document.getElementById("difference");
+
+ if (!differenceElement || !differenceElement.contains(originalRange.commonAncestorContainer)) {
+ alert("Please select text within the diff content.");
+ return;
}
+ // Setup UI state
+ const btn = document.getElementById("share-as-image-btn");
+ const originalBtnHtml = btn ? btn.innerHTML : '';
+ setButtonState(btn, true);
+
let tempElement = null;
try {
- // Get the selected content with HTML (preserves spans and styling)
- const range = selection.getRangeAt(0);
- const selectedFragment = range.cloneContents();
+ // Expand selection to full lines and clone content
+ const expandedRange = expandRangeToFullLines(originalRange, differenceElement);
+ const selectedFragment = expandedRange.cloneContents();
- // Create a temporary element to hold the selected content
- tempElement = document.createElement("pre");
- tempElement.id = "temp-capture-element";
- tempElement.appendChild(selectedFragment);
+ // Create temporary element with proper styling
+ tempElement = createCaptureElement(selectedFragment, differenceElement);
+ tempElement.appendChild(createFooter());
- // Copy styles from the original #difference element
- const originalPre = document.getElementById("difference");
- const originalStyles = window.getComputedStyle(originalPre);
-
- tempElement.style.position = "absolute";
- tempElement.style.left = "-9999px";
- tempElement.style.top = "0";
- tempElement.style.whiteSpace = "pre-wrap";
- tempElement.style.fontFamily = originalStyles.fontFamily;
- tempElement.style.fontSize = originalStyles.fontSize;
- tempElement.style.lineHeight = originalStyles.lineHeight;
- tempElement.style.color = originalStyles.color;
- tempElement.style.backgroundColor = "#ffffff";
- tempElement.style.padding = "10px";
- tempElement.style.border = originalStyles.border;
- tempElement.style.width = "auto";
- tempElement.style.maxWidth = originalPre.offsetWidth + "px";
-
- // Add to document so it can be measured and captured
+ // Add to DOM for rendering
document.body.appendChild(tempElement);
- console.log("Capturing selection...");
- console.log("Temp element dimensions:", tempElement.offsetWidth, "x", tempElement.offsetHeight);
-
- // Give browser time to render
- await new Promise(resolve => setTimeout(resolve, 50));
+ // Wait for rendering
+ await new Promise(resolve => setTimeout(resolve, RENDER_DELAY_MS));
+ // Capture to canvas
const canvas = await html2canvas(tempElement, {
- scale: 2, // higher quality
+ scale: CANVAS_SCALE,
useCORS: true,
allowTaint: true,
logging: false,
@@ -75,48 +357,35 @@ async function diffToJpeg() {
scrollY: 0
});
- console.log("Canvas created:", canvas.width, "x", canvas.height);
-
+ // Validate canvas
if (canvas.width === 0 || canvas.height === 0) {
throw new Error("Canvas is empty - no content captured");
}
// Convert to JPEG
- const jpeg = canvas.toDataURL("image/jpeg", 0.95);
+ let jpeg = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
if (jpeg === "data:," || jpeg.length < 100) {
throw new Error("Failed to generate image data");
}
- // Open in new window
- const win = window.open();
- if (win) {
- win.document.write('Diff Screenshot
');
- } else {
- // Fallback to download if popup blocked
- const a = document.createElement("a");
- a.href = jpeg;
- a.download = "diff-snapshot-" + Date.now() + ".jpg";
- a.click();
- }
+ // Add EXIF metadata
+ jpeg = addExifMetadata(jpeg);
- // Clear the selection
+ // Display the image
+ displayImage(jpeg);
+
+ // Clear selection
selection.removeAllRanges();
} catch (error) {
console.error("Error generating image:", error);
alert("Failed to generate image: " + error.message);
} finally {
- // Clean up temporary element
+ // Cleanup
if (tempElement && tempElement.parentNode) {
tempElement.parentNode.removeChild(tempElement);
}
-
- // Reset button state
- if (btn) {
- btn.innerHTML = originalText;
- btn.style.opacity = "1";
- btn.style.pointerEvents = "auto";
- }
+ setButtonState(btn, false, originalBtnHtml);
}
}