From 45b97f65986a28df57a6f2b0aeabe158046ffe46 Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Sun, 24 May 2026 20:45:01 -0500 Subject: [PATCH 1/5] Adding Image Comparison --- src/Pages/_Generate/GenerateTab.cshtml | 18 + src/wwwroot/css/genpage.css | 155 ++++ .../js/genpage/gentab/currentimagehandler.js | 672 ++++++++++++++++++ .../js/genpage/gentab/outputhistory.js | 28 + 4 files changed, 873 insertions(+) diff --git a/src/Pages/_Generate/GenerateTab.cshtml b/src/Pages/_Generate/GenerateTab.cshtml index f5fe592a1..80c781b5a 100644 --- a/src/Pages/_Generate/GenerateTab.cshtml +++ b/src/Pages/_Generate/GenerateTab.cshtml @@ -197,6 +197,24 @@ [Close]
+
diff --git a/src/wwwroot/css/genpage.css b/src/wwwroot/css/genpage.css index ba3fef876..8325e8d90 100644 --- a/src/wwwroot/css/genpage.css +++ b/src/wwwroot/css/genpage.css @@ -809,6 +809,161 @@ body { overflow: hidden; margin: auto; } +#image_compare_modal .modal-dialog { + display: none; +} +#image_compare_modal .imageview_modal_imagewrap { + height: calc(100vh - 2rem); +} +#image_compare_modal .imageview_popup_modal_undertext { + height: 2rem; + display: flex; + justify-content: center; +} +#image_compare_modal .image_fullview_extra_buttons { + display: flex; + align-items: center; +} +#image_compare_modal .image_fullview_extra_buttons [aria-pressed="true"] { + background-color: var(--button-background-hover); + border-color: var(--emphasis); +} +#image_compare_stage { + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + padding: 0; + background-color: transparent; +} +#image_compare_stage.image_compare_stage_side { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + gap: 0; +} +#image_compare_stage.image_compare_stage_single { + display: block; +} +#image_compare_stage.image_compare_stage_side .image_compare_slot, +#image_compare_stage.image_compare_stage_overlay .image_compare_slot, +#image_compare_stage.image_compare_stage_single .image_compare_slot { + width: 100%; + height: 100%; +} +#image_compare_stage .image_compare_slot { + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + text-align: left; + cursor: grab; +} +#image_compare_stage .image_compare_media { + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + background-color: transparent; + display: block; + position: relative; + margin: auto; +} +#image_compare_stage.image_compare_stage_overlay { + display: flex; +} +#image_compare_stage .image_compare_overlay { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + --image-compare-split: 50%; + cursor: grab; +} +#image_compare_stage .image_compare_overlay_layer { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} +#image_compare_stage .image_compare_overlay_layer_left { + clip-path: inset(0 calc(100% - var(--image-compare-split)) 0 0); +} +#image_compare_stage .image_compare_overlay_layer_right { + clip-path: inset(0 0 0 var(--image-compare-split)); +} +#image_compare_stage .image_compare_overlay_slide_vertical .image_compare_overlay_layer_left { + clip-path: inset(0 0 calc(100% - var(--image-compare-split)) 0); +} +#image_compare_stage .image_compare_overlay_slide_vertical .image_compare_overlay_layer_right { + clip-path: inset(var(--image-compare-split) 0 0 0); +} +#image_compare_stage .image_compare_overlay_transparency .image_compare_overlay_layer_left, +#image_compare_stage .image_compare_overlay_transparency .image_compare_overlay_layer_right { + clip-path: none; +} +#image_compare_stage .image_compare_overlay_transparency .image_compare_overlay_layer_right { + opacity: 0.5; +} +#image_compare_stage .image_compare_overlay_divider { + position: absolute; + top: 0; + bottom: 0; + left: var(--image-compare-split); + width: 1.5rem; + transform: translateX(-50%); + background-color: transparent; + pointer-events: auto; + cursor: ew-resize; + z-index: 2; +} +#image_compare_stage .image_compare_overlay_slide_vertical .image_compare_overlay_divider { + top: var(--image-compare-split); + left: 0; + right: 0; + width: 100%; + height: 1.5rem; + transform: translateY(-50%); + cursor: ns-resize; +} +#image_compare_stage .image_compare_overlay_divider::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: calc(50% - 1px); + width: 2px; + background-color: var(--emphasis); + box-shadow: 0 0 0.75rem var(--emphasis); +} +#image_compare_stage .image_compare_overlay_slide_vertical .image_compare_overlay_divider::before { + top: calc(50% - 1px); + left: 0; + right: 0; + width: auto; + height: 2px; +} +#image_compare_stage .image_compare_overlay_divider::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 1rem; + height: 1rem; + border-radius: 50%; + transform: translate(-50%, -50%); + background-color: var(--emphasis); + pointer-events: none; +} +@media (max-width: 900px) { + #image_compare_stage.image_compare_stage_side { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} .browser-folder-tree-container { width: 15rem; display: inline-block; diff --git a/src/wwwroot/js/genpage/gentab/currentimagehandler.js b/src/wwwroot/js/genpage/gentab/currentimagehandler.js index 460e7091c..a5f4feaf0 100644 --- a/src/wwwroot/js/genpage/gentab/currentimagehandler.js +++ b/src/wwwroot/js/genpage/gentab/currentimagehandler.js @@ -1390,3 +1390,675 @@ function imageInputHandler() { }); } imageInputHandler(); + +class ImageCompareHelper { + static modeDefinitions = { + side: { layout: 'side' }, + slide_horizontal: { layout: 'slide', axis: 'x' }, + slide_vertical: { layout: 'slide', axis: 'y' }, + transparency: { layout: 'transparency' }, + single: { layout: 'single' } + }; + + constructor() { + this.zoomRate = 1.1; + this.modal = getRequiredElementById('image_compare_modal'); + this.modalJq = $('#image_compare_modal'); + this.stage = getRequiredElementById('image_compare_stage'); + document.addEventListener('click', (e) => { + if (e.target.tagName == 'BODY') { + return; + } + if (!this.noClose && this.isOpen() && !findParentOfClass(e.target, 'imageview_popup_modal_undertext')) { + this.close(); + e.preventDefault(); + e.stopPropagation(); + return false; + } + this.noClose = false; + }, true); + this.modalJq.on('hidden.bs.modal', () => { + this.close(); + }); + this.modalJq.on('shown.bs.modal', () => { + if (this.hasSelection()) { + this.applyView(); + } + }); + this.stage.addEventListener('wheel', this.onWheel.bind(this), { passive: false }); + this.stage.addEventListener('mousedown', this.onMouseDown.bind(this)); + document.addEventListener('mouseup', this.onGlobalMouseUp.bind(this)); + document.addEventListener('mousemove', this.onGlobalMouseMove.bind(this)); + window.addEventListener('resize', this.onWindowResize.bind(this)); + this.mode = 'side'; + this.left = null; + this.right = null; + this.resetViewportState(); + this.modeButtonMap = {}; + for (let button of this.modal.querySelectorAll('[data-compare-mode]')) { + let mode = button.dataset.compareMode; + button.addEventListener('click', () => this.setMode(mode)); + this.modeButtonMap[mode] = button; + } + this.swapButton = getRequiredElementById('image_compare_swap_button'); + this.swapButton.addEventListener('click', () => this.swapImages()); + this.updateModeControls(); + } + + getImgOrContainer() { + if (this.isOverlayMode()) { + let overlay = this.getOverlay(); + return overlay ? [overlay] : []; + } + return [...this.stage.querySelectorAll('.image_compare_slot')]; + } + + getImg() { + return [...this.stage.querySelectorAll('.image_compare_media')]; + } + + getContainerAlignment(container) { + if (ImageCompareHelper.modeDefinitions[this.mode].layout != 'side' || window.matchMedia('(max-width: 900px)').matches) { + return 'center'; + } + if (this.getImgOrContainer()[0] == container) { + return 'right'; + } + return 'left'; + } + + getHeightPercent() { + let img = this.getImg()[0]; + if (img && img.style.height) { + return parseFloat((img.style.height || '100%').replaceAll('%', '')); + } + let layout = this.getStateLayout(); + if (!layout || !layout.rect.height) { + return this.zoom * 100; + } + return (layout.mediaHeight * this.zoom / layout.rect.height) * 100; + } + + getImgLeft() { + let img = this.getImg()[0]; + let layout = this.getStateLayout(); + if (!img || !layout) { + return this.panX; + } + let left = parseFloat((img.style.left || `${layout.baseLeft}px`).replaceAll('px', '')); + if (isNaN(left)) { + return this.panX; + } + return left - layout.baseLeft; + } + + getImgTop() { + let img = this.getImg()[0]; + let layout = this.getStateLayout(); + if (!img || !layout) { + return this.panY; + } + let top = parseFloat((img.style.top || `${layout.baseTop}px`).replaceAll('px', '')); + if (isNaN(top)) { + return this.panY; + } + return top - layout.baseTop; + } + + onMouseDown(e) { + if (!this.hasSelection()) { + return; + } + if (e.button == 2) { // right-click + return; + } + let viewport = this.getViewportFromTarget(e.target); + if (!viewport || e.ctrlKey || e.shiftKey) { + return; + } + let divider = this.getOverlayDividerFromTarget(e.target); + if (divider) { + this.updateOverlaySplitFromClientPosition(viewport, e.clientX, e.clientY); + this.lastMouseX = e.clientX; + this.lastMouseY = e.clientY; + this.isAdjustingOverlaySplit = true; + this.setViewportCursor(this.getSlideAxis() == 'y' ? 'ns-resize' : 'ew-resize'); + e.preventDefault(); + e.stopPropagation(); + return; + } + this.lastMouseX = e.clientX; + this.lastMouseY = e.clientY; + this.isDragging = true; + this.setViewportCursor('grabbing'); + e.preventDefault(); + e.stopPropagation(); + } + + onGlobalMouseUp(e) { + if (!this.isDragging && !this.isAdjustingOverlaySplit) { + return; + } + this.setViewportCursor('grab'); + this.isDragging = false; + this.isAdjustingOverlaySplit = false; + this.noClose = this.didDrag; + this.didDrag = false; + this.lastMouseX = 0; + this.lastMouseY = 0; + } + + moveImg(xDiff, yDiff) { + if (this.getImgOrContainer().length == 0) { + return; + } + let newLeft = this.getImgLeft() + xDiff; + let newTop = this.getImgTop() + yDiff; + this.clampPan(newLeft, newTop); + } + + onGlobalMouseMove(e) { + if (this.isAdjustingOverlaySplit) { + let xDiff = e.clientX - this.lastMouseX; + let yDiff = e.clientY - this.lastMouseY; + this.lastMouseX = e.clientX; + this.lastMouseY = e.clientY; + let overlay = this.getOverlay(); + if (overlay) { + this.updateOverlaySplitFromClientPosition(overlay, e.clientX, e.clientY); + } + if (Math.abs(xDiff) > 1 || Math.abs(yDiff) > 1) { + this.didDrag = true; + } + e.preventDefault(); + return; + } + if (!this.isDragging) { + return; + } + let xDiff = e.clientX - this.lastMouseX; + let yDiff = e.clientY - this.lastMouseY; + this.lastMouseX = e.clientX; + this.lastMouseY = e.clientY; + this.moveImg(xDiff, yDiff); + if (Math.abs(xDiff) > 1 || Math.abs(yDiff) > 1) { + this.didDrag = true; + } + this.applyView(); + e.preventDefault(); + } + + onWheel(e) { + if (!this.hasSelection() || e.ctrlKey || e.shiftKey) { + return; + } + let viewport = this.getViewportFromTarget(e.target); + let layout = this.getViewportLayout(viewport); + if (!viewport || !e.deltaY) { + return; + } + if (!layout) { + return; + } + let rect = layout.rect; + if (!rect.width || !rect.height) { + return; + } + let origHeight = this.getHeightPercent(); + let zoom = Math.pow(this.zoomRate, -e.deltaY / 100); + let minHeight = 10; + let maxHeight = this.getMaxHeight(); + if (maxHeight <= 0) { + maxHeight = Math.max(minHeight, origHeight * 4); + } + let newHeight = Math.max(minHeight, Math.min(origHeight * zoom, maxHeight)); + if (Math.abs(newHeight - origHeight) < 0.0001) { + e.preventDefault(); + return; + } + this.updateImageRendering(newHeight); + this.setViewportCursor('grab'); + let localX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); + let localY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); + let zoomRatio = newHeight / origHeight; + let imgLeft = this.getImgLeft(); + let imgTop = this.getImgTop(); + let newPanX = localX - layout.baseLeft - (localX - layout.baseLeft - imgLeft) * zoomRatio; + let newPanY = localY - layout.baseTop - (localY - layout.baseTop - imgTop) * zoomRatio; + this.panX = newPanX; + this.panY = newPanY; + this.setHeightPercent(newHeight); + this.clampPan(newPanX, newPanY); + this.applyView(); + e.preventDefault(); + } + + onImgLoad() { + this.applyView(); + } + + renderMediaElement(src, mediaClass, imageAttrs = '', videoAttrs = '', audioAttrs = '', allowAudio = true) { + let encodedSrc = escapeHtmlForUrl(src); + let videoType = isVideoExt(src); + if (videoType) { + return ``; + } + if (allowAudio && isAudioExt(src)) { + return ``; + } + return ``; + } + + showComparison(left, right) { + this.left = left; + this.right = right; + let wasAlreadyOpen = this.isOpen(); + this.render(); + if (wasAlreadyOpen) { + this.applyView(); + } + else { + this.modalJq.modal('show'); + } + } + + close() { + if (this.isOpen()) { + if (this.modal.contains(document.activeElement)) { + document.activeElement.blur(); + } + this.modalJq.modal('hide'); + } + this.reset(); + } + + isOpen() { + return this.modalJq.is(':visible'); + } + + isShowingPair(a, b) { + return this.isOpen() && this.left?.src == a?.src && this.right?.src == b?.src; + } + + getMediaLayout(container, media) { + if (!container || !media) { + return null; + } + let rect = container.getBoundingClientRect(); + if (!rect.width || !rect.height) { + return null; + } + let width = media.naturalWidth ?? media.videoWidth; + let height = media.naturalHeight ?? media.videoHeight; + if (!width || !height) { + return null; + } + let imgAspectRatio = width / height; + let targetWidth = rect.height * imgAspectRatio; + let mediaWidth = targetWidth; + let mediaHeight = rect.height; + if (targetWidth > rect.width) { + mediaWidth = rect.width; + mediaHeight = rect.width / imgAspectRatio; + } + let baseLeft = 0; + let alignment = this.getContainerAlignment(container); + if (alignment == 'center') { + baseLeft = (rect.width - mediaWidth) / 2; + } + else if (alignment == 'right') { + baseLeft = rect.width - mediaWidth; + } + return { + viewport: container, + media: media, + rect: rect, + mediaWidth: mediaWidth, + mediaHeight: mediaHeight, + baseLeft: baseLeft, + baseTop: (rect.height - mediaHeight) / 2 + }; + } + + getStateLayout() { + return this.getViewportLayout(this.getImgOrContainer()[0]); + } + + getMediaMaxHeight(img) { + if (!img) { + return 0; + } + let width = img.naturalWidth ?? img.videoWidth; + let height = img.naturalHeight ?? img.videoHeight; + if (!width || !height) { + return 0; + } + return Math.sqrt(width * height) * 2; + } + + getMaxHeight() { + let maxHeight = 0; + for (let img of this.getImg()) { + maxHeight = Math.max(maxHeight, this.getMediaMaxHeight(img)); + } + return maxHeight; + } + + updateImageRendering(heightPercent = this.getHeightPercent()) { + for (let img of this.getImg()) { + let maxHeight = this.getMediaMaxHeight(img); + if (maxHeight > 0 && heightPercent > maxHeight / 5) { + img.style.imageRendering = 'pixelated'; + } + else { + img.style.imageRendering = ''; + } + } + } + + setHeightPercent(heightPercent) { + let layout = this.getStateLayout(); + if (!layout || !layout.rect.height || !layout.mediaHeight) { + this.zoom = Math.max(0.1, heightPercent / 100); + return; + } + let baseHeightPercent = (layout.mediaHeight / layout.rect.height) * 100; + if (baseHeightPercent <= 0) { + return; + } + this.zoom = Math.max(0.1, heightPercent / baseHeightPercent); + } + + resetViewportState() { + this.overlaySplitPercent = 50; + this.zoom = 1; + this.panX = 0; + this.panY = 0; + this.lastMouseX = 0; + this.lastMouseY = 0; + this.isDragging = false; + this.isAdjustingOverlaySplit = false; + this.didDrag = false; + this.noClose = false; + } + + reset() { + this.stopPanning(true); + this.left = null; + this.right = null; + this.mode = 'side'; + this.resetViewportState(); + this.setStageContent('side', ''); + this.updateModeControls(); + } + + isOverlayMode() { + return this.isSlideMode() || ImageCompareHelper.modeDefinitions[this.mode].layout == 'transparency'; + } + + isSlideMode() { + return ImageCompareHelper.modeDefinitions[this.mode].layout == 'slide'; + } + + getSlideAxis() { + return ImageCompareHelper.modeDefinitions[this.mode].axis || 'x'; + } + + swapImages() { + if (!this.hasSelection()) { + return; + } + [this.left, this.right] = [this.right, this.left]; + this.render(); + } + + setMode(mode) { + if (mode == this.mode) { + this.updateModeControls(); + return; + } + this.mode = mode; + if (this.hasSelection()) { + this.render(); + } + else { + this.updateModeControls(); + } + } + + updateModeControls() { + for (let [mode, button] of Object.entries(this.modeButtonMap)) { + button.setAttribute('aria-pressed', this.mode == mode ? 'true' : 'false'); + } + } + + setStageContent(layout, html) { + for (let media of this.stage.querySelectorAll('video, audio')) { + media.pause(); + } + this.stage.classList.toggle('image_compare_stage_overlay', layout == 'overlay'); + this.stage.classList.toggle('image_compare_stage_side', layout == 'side'); + this.stage.classList.toggle('image_compare_stage_single', layout == 'single'); + this.stage.innerHTML = html; + } + + render() { + this.stopPanning(true); + this.updateModeControls(); + if (!this.hasSelection()) { + this.setStageContent('side', ''); + this.updateModeControls(); + return; + } + if (this.isOverlayMode()) { + this.renderOverlay(); + } + else if (ImageCompareHelper.modeDefinitions[this.mode].layout == 'single') { + this.setStageContent('single', `
${this.renderMedia(this.left)}
`); + } + else { + this.setStageContent('side', ` +
${this.renderMedia(this.left)}
+
${this.renderMedia(this.right)}
` + ); + } + this.applyView(); + } + + renderOverlay() { + let overlayClasses = ['image_compare_overlay']; + if (this.isSlideMode()) { + if (this.getSlideAxis() == 'y') { + overlayClasses.push('image_compare_overlay_slide_vertical'); + } + } + else { + overlayClasses.push('image_compare_overlay_transparency'); + } + this.setStageContent('overlay', ` +
+
+
${this.renderMedia(this.left)}
+
${this.renderMedia(this.right)}
+ ${this.isSlideMode() ? '
' : ''} +
+
` + ); + } + + updateOverlaySplitFromClientPosition(stage, clientX, clientY) { + let rect = stage.getBoundingClientRect(); + let split; + if (this.getSlideAxis() == 'y') { + if (!rect.height) { + return; + } + split = ((clientY - rect.top) / rect.height) * 100; + } + else { + if (!rect.width) { + return; + } + split = ((clientX - rect.left) / rect.width) * 100; + } + this.overlaySplitPercent = Math.max(2, Math.min(98, split)); + stage.style.setProperty('--image-compare-split', `${this.overlaySplitPercent}%`); + } + + stopPanning(ignoreDragClose = false) { + this.setViewportCursor('grab'); + this.isDragging = false; + this.isAdjustingOverlaySplit = false; + this.noClose = ignoreDragClose ? false : this.didDrag; + this.didDrag = false; + this.lastMouseX = 0; + this.lastMouseY = 0; + } + + getViewportLayout(viewport) { + if (!viewport) { + return; + } + let media = viewport.querySelector('.image_compare_media'); + if (!media) { + return; + } + return this.getMediaLayout(media.parentElement, media); + } + + clampPan(panX = this.getImgLeft(), panY = this.getImgTop()) { + let imgs = this.getImg(); + if (imgs.length == 0) { + return; + } + let minPanX = -Infinity; + let maxPanX = Infinity; + let minPanY = -Infinity; + let maxPanY = Infinity; + for (let img of imgs) { + let layout = this.getMediaLayout(img.parentElement, img); + if (!layout) { + continue; + } + let zoomedWidth = layout.mediaWidth * this.zoom; + let zoomedHeight = layout.mediaHeight * this.zoom; + let overWidth = layout.rect.width / 2; + let overHeight = layout.rect.height / 2; + minPanX = Math.max(minPanX, layout.rect.width - zoomedWidth - overWidth - layout.baseLeft); + maxPanX = Math.min(maxPanX, overWidth - layout.baseLeft); + minPanY = Math.max(minPanY, layout.rect.height - zoomedHeight - overHeight - layout.baseTop); + maxPanY = Math.min(maxPanY, overHeight - layout.baseTop); + } + if (minPanX > maxPanX) { + this.panX = (minPanX + maxPanX) / 2; + } + else { + this.panX = Math.min(maxPanX, Math.max(minPanX, panX)); + } + if (minPanY > maxPanY) { + this.panY = (minPanY + maxPanY) / 2; + } + else { + this.panY = Math.min(maxPanY, Math.max(minPanY, panY)); + } + } + + getViewportFromTarget(target) { + if (!target || !target.closest) { + return null; + } + if (this.isOverlayMode()) { + return target.closest('.image_compare_overlay'); + } + return target.closest('.image_compare_slot'); + } + + getOverlayDividerFromTarget(target) { + if (!this.isSlideMode() || !target || !target.closest) { + return null; + } + return target.closest('.image_compare_overlay_divider'); + } + + setViewportCursor(cursor) { + for (let viewport of this.getImgOrContainer()) { + viewport.style.cursor = cursor; + } + let divider = this.stage.querySelector('.image_compare_overlay_divider'); + if (divider) { + let idleCursor = this.getSlideAxis() == 'y' ? 'ns-resize' : 'ew-resize'; + divider.style.cursor = cursor == 'grab' ? idleCursor : cursor; + } + } + + getOverlay() { + return this.stage.querySelector('.image_compare_overlay'); + } + + applyView() { + let imgs = this.getImg(); + if (imgs.length == 0) { + return; + } + this.clampPan(this.panX, this.panY); + for (let img of imgs) { + let container = img.parentElement; + let layout = this.getMediaLayout(container, img); + if (!layout) { + continue; + } + img.style.left = `${layout.baseLeft + this.panX}px`; + img.style.top = `${layout.baseTop + this.panY}px`; + img.style.height = `${(layout.mediaHeight * this.zoom / layout.rect.height) * 100}%`; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + img.style.objectFit = 'unset'; + img.style.margin = '0'; + } + this.getOverlay()?.style.setProperty('--image-compare-split', `${this.overlaySplitPercent}%`); + this.updateImageRendering(); + } + + onWindowResize() { + if (!this.hasSelection() || !this.isOpen()) { + return; + } + this.applyView(); + } + + renderMedia(media) { + return this.renderMediaElement( + media.src, + 'image_compare_media', + 'alt="Compared media" onload="imageCompareHelper.onImgLoad()"', + 'autoplay loop muted playsinline onloadedmetadata="imageCompareHelper.onImgLoad()"', + '', + false, + ); + } + + hasSelection() { + return this.left && this.right; + } + + evaluateSelection(items) { + if (items.length == 0) { + return { state: 'partial', reason: 'Select 2 images or 2 videos to compare.' }; + } + if (items.length == 1) { + return { state: 'partial', reason: 'Select 1 more image or video to compare.' }; + } + if (items.length > 2) { + return { state: 'invalid', reason: 'Compare only supports exactly 2 selected items.' }; + } + if (items[0].mediaType == 'audio' || items[1].mediaType == 'audio') { + return { state: 'invalid', reason: 'Compare only supports images and videos.' }; + } + if (items[0].mediaType != items[1].mediaType) { + return { state: 'invalid', reason: 'Compare requires 2 items of the same media type.' }; + } + return { state: 'ready', reason: 'Compare the selected items.' }; + } +} + +let imageCompareHelper = new ImageCompareHelper(); diff --git a/src/wwwroot/js/genpage/gentab/outputhistory.js b/src/wwwroot/js/genpage/gentab/outputhistory.js index e8e14f9e8..b7703ebba 100644 --- a/src/wwwroot/js/genpage/gentab/outputhistory.js +++ b/src/wwwroot/js/genpage/gentab/outputhistory.js @@ -259,6 +259,34 @@ let imageHistoryBrowser = new GenPageBrowserClass('image_history', listOutputHis ` `); imageHistoryBrowser.allowMultiSelect = true; +// todo find a better place for this, leave for now for testing +registerMediaButton( + 'Compare', + function compareAction(src) { + let files = imageHistoryBrowser.getMultiSelectedFiles(); + let items = files.map(f => ({ src: f.data.src, mediaType: getMediaType(f.data.src) })); + let evaluation = imageCompareHelper.evaluateSelection(items); + if (evaluation.state != 'ready') { + showError(evaluation.reason || 'Cannot compare current selection.'); + return; + } + if (imageCompareHelper.isShowingPair(items[0], items[1])) { + return; + } + imageCompareHelper.reset(); + imageCompareHelper.showComparison(items[0], items[1]); + }, + 'Compare 2 images or 2 videos', + ['image', 'video'], + false, + true, + null, + false, + true, + true, + 2 +); + function storeImageToHistoryWithCurrentParams(img) { let data = getGenInput(); data['image'] = img; From e9c256179ad46f4a74fd0a5833510db6be473395 Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Mon, 25 May 2026 11:33:12 -0500 Subject: [PATCH 2/5] Feedback implemented --- src/Pages/_Generate/GenerateTab.cshtml | 7 +++ src/wwwroot/css/genpage.css | 41 ++++++++++++--- .../js/genpage/gentab/currentimagehandler.js | 21 ++++++-- .../js/genpage/gentab/outputhistory.js | 51 +++++++++---------- 4 files changed, 83 insertions(+), 37 deletions(-) diff --git a/src/Pages/_Generate/GenerateTab.cshtml b/src/Pages/_Generate/GenerateTab.cshtml index 80c781b5a..3fbe48a94 100644 --- a/src/Pages/_Generate/GenerateTab.cshtml +++ b/src/Pages/_Generate/GenerateTab.cshtml @@ -204,6 +204,13 @@
+
diff --git a/src/wwwroot/css/genpage.css b/src/wwwroot/css/genpage.css index 8325e8d90..5641d1548 100644 --- a/src/wwwroot/css/genpage.css +++ b/src/wwwroot/css/genpage.css @@ -812,13 +812,42 @@ body { #image_compare_modal .modal-dialog { display: none; } +#image_compare_modal .imageview_modal_inner_div { + display: flex; + flex-direction: column; + height: 100vh; +} #image_compare_modal .imageview_modal_imagewrap { - height: calc(100vh - 2rem); + flex: 1 1 auto; + min-height: 0; } #image_compare_modal .imageview_popup_modal_undertext { - height: 2rem; + flex: 0 0 auto; + min-height: 2rem; + height: auto; display: flex; - justify-content: center; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0; +} +#image_compare_transparency_row { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--bs-body-font-family); +} +#image_compare_transparency_row .auto-slider-range-wrapper { + width: 14rem; + max-width: 60vw; + margin-top: 0; + margin-bottom: 0; + flex-shrink: 0; +} +#image_compare_transparency_value { + min-width: 2.75rem; + text-align: left; + white-space: nowrap; } #image_compare_modal .image_fullview_extra_buttons { display: flex; @@ -906,14 +935,14 @@ body { clip-path: none; } #image_compare_stage .image_compare_overlay_transparency .image_compare_overlay_layer_right { - opacity: 0.5; + opacity: var(--image-compare-transparency, 0.5); } #image_compare_stage .image_compare_overlay_divider { position: absolute; top: 0; bottom: 0; left: var(--image-compare-split); - width: 1.5rem; + width: 6rem; transform: translateX(-50%); background-color: transparent; pointer-events: auto; @@ -925,7 +954,7 @@ body { left: 0; right: 0; width: 100%; - height: 1.5rem; + height: 6rem; transform: translateY(-50%); cursor: ns-resize; } diff --git a/src/wwwroot/js/genpage/gentab/currentimagehandler.js b/src/wwwroot/js/genpage/gentab/currentimagehandler.js index a5f4feaf0..d5e43c34a 100644 --- a/src/wwwroot/js/genpage/gentab/currentimagehandler.js +++ b/src/wwwroot/js/genpage/gentab/currentimagehandler.js @@ -1167,7 +1167,7 @@ function setCurrentImage(src, metadata = '', batchId = '', previewGrow = false, includeLinkButton(added.label, added.href, added.is_download, added.title); } else { - includeButton(added.label, added.onclick, '', added.title); + includeButton(added.label, added.onclick, '', added.title, null, added.can_multi, added.multi_only); } } renderButtonsFromDefs(); @@ -1442,6 +1442,14 @@ class ImageCompareHelper { } this.swapButton = getRequiredElementById('image_compare_swap_button'); this.swapButton.addEventListener('click', () => this.swapImages()); + this.transparencyRow = getRequiredElementById('image_compare_transparency_row'); + this.transparencySlider = getRequiredElementById('image_compare_transparency_slider'); + this.transparencyValue = getRequiredElementById('image_compare_transparency_value'); + this.transparencySlider.addEventListener('input', () => { + this.transparencyPercent = parseFloat(this.transparencySlider.value); + this.transparencyValue.innerText = `${Math.round(this.transparencyPercent)}%`; + this.getOverlay()?.style.setProperty('--image-compare-transparency', `${this.transparencyPercent / 100}`); + }); this.updateModeControls(); } @@ -1771,6 +1779,7 @@ class ImageCompareHelper { resetViewportState() { this.overlaySplitPercent = 50; + this.transparencyPercent = 50; this.zoom = 1; this.panX = 0; this.panY = 0; @@ -1830,6 +1839,10 @@ class ImageCompareHelper { for (let [mode, button] of Object.entries(this.modeButtonMap)) { button.setAttribute('aria-pressed', this.mode == mode ? 'true' : 'false'); } + this.transparencyRow.style.display = ImageCompareHelper.modeDefinitions[this.mode].layout == 'transparency' ? '' : 'none'; + this.transparencySlider.value = this.transparencyPercent; + updateRangeStyle(this.transparencySlider); + this.transparencyValue.innerText = `${Math.round(this.transparencyPercent)}%`; } setStageContent(layout, html) { @@ -1877,7 +1890,7 @@ class ImageCompareHelper { } this.setStageContent('overlay', `
-
+
${this.renderMedia(this.left)}
${this.renderMedia(this.right)}
${this.isSlideMode() ? '
' : ''} @@ -2015,7 +2028,9 @@ class ImageCompareHelper { img.style.objectFit = 'unset'; img.style.margin = '0'; } - this.getOverlay()?.style.setProperty('--image-compare-split', `${this.overlaySplitPercent}%`); + let overlay = this.getOverlay(); + overlay?.style.setProperty('--image-compare-split', `${this.overlaySplitPercent}%`); + overlay?.style.setProperty('--image-compare-transparency', `${this.transparencyPercent / 100}`); this.updateImageRendering(); } diff --git a/src/wwwroot/js/genpage/gentab/outputhistory.js b/src/wwwroot/js/genpage/gentab/outputhistory.js index b7703ebba..748379796 100644 --- a/src/wwwroot/js/genpage/gentab/outputhistory.js +++ b/src/wwwroot/js/genpage/gentab/outputhistory.js @@ -182,6 +182,29 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { can_multi: true }); } + if (mediaType == 'image' || mediaType == 'video') { + buttons.push({ + label: 'Compare', + title: 'Compare 2 images or 2 videos', + onclick: (e) => { + // TODO: Give browsers.js a real "run once with the full selection" bulk handler + let items = imageHistoryBrowser.getMultiSelectedFiles().map(f => ({ src: f.data.src, mediaType: getMediaType(f.data.src) })); + let valid = imageCompareHelper.evaluateSelection(items); + if (valid.state != 'ready') { + showError(valid.reason || 'Cannot compare current selection.'); + return; + } + if (imageCompareHelper.isShowingPair(items[0], items[1])) { + return; + } + imageCompareHelper.reset(); + imageCompareHelper.showComparison(items[0], items[1]); + }, + can_multi: true, + multi_only: true, + max_selected: 2 + }); + } for (let reg of registeredMediaButtons) { if ((isCurrentImage || reg.showInHistory) && (!reg.mediaTypes || reg.mediaTypes.includes(mediaType))) { buttons.push({ @@ -259,34 +282,6 @@ let imageHistoryBrowser = new GenPageBrowserClass('image_history', listOutputHis ` `); imageHistoryBrowser.allowMultiSelect = true; -// todo find a better place for this, leave for now for testing -registerMediaButton( - 'Compare', - function compareAction(src) { - let files = imageHistoryBrowser.getMultiSelectedFiles(); - let items = files.map(f => ({ src: f.data.src, mediaType: getMediaType(f.data.src) })); - let evaluation = imageCompareHelper.evaluateSelection(items); - if (evaluation.state != 'ready') { - showError(evaluation.reason || 'Cannot compare current selection.'); - return; - } - if (imageCompareHelper.isShowingPair(items[0], items[1])) { - return; - } - imageCompareHelper.reset(); - imageCompareHelper.showComparison(items[0], items[1]); - }, - 'Compare 2 images or 2 videos', - ['image', 'video'], - false, - true, - null, - false, - true, - true, - 2 -); - function storeImageToHistoryWithCurrentParams(img) { let data = getGenInput(); data['image'] = img; From 12a55fd7b25200d3ef1718457eb189b08851291e Mon Sep 17 00:00:00 2001 From: "Alex \"mcmonkey\" Goodwin" Date: Mon, 25 May 2026 19:36:55 -0700 Subject: [PATCH 3/5] wider --- src/wwwroot/css/genpage.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wwwroot/css/genpage.css b/src/wwwroot/css/genpage.css index 5641d1548..f86a08574 100644 --- a/src/wwwroot/css/genpage.css +++ b/src/wwwroot/css/genpage.css @@ -942,7 +942,7 @@ body { top: 0; bottom: 0; left: var(--image-compare-split); - width: 6rem; + width: 12rem; transform: translateX(-50%); background-color: transparent; pointer-events: auto; @@ -954,7 +954,7 @@ body { left: 0; right: 0; width: 100%; - height: 6rem; + height: 12rem; transform: translateY(-50%); cursor: ns-resize; } From bc9cbef24d516888e43b6264479433a3d45a5292 Mon Sep 17 00:00:00 2001 From: "Alex \"mcmonkey\" Goodwin" Date: Mon, 25 May 2026 20:04:05 -0700 Subject: [PATCH 4/5] minor cleanings --- src/Pages/_Generate/GenerateTab.cshtml | 4 +- src/wwwroot/css/genpage.css | 60 +++++++++---------- .../js/genpage/gentab/currentimagehandler.js | 13 ++-- 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/Pages/_Generate/GenerateTab.cshtml b/src/Pages/_Generate/GenerateTab.cshtml index 3fbe48a94..74711e3c2 100644 --- a/src/Pages/_Generate/GenerateTab.cshtml +++ b/src/Pages/_Generate/GenerateTab.cshtml @@ -197,11 +197,11 @@ [Close]
-