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 + + 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 ScreenshotDiff 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); } }