From 7a94875775514d08a1bae0043a53acbc636b9d9e Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Mon, 13 Apr 2026 22:23:21 -0500 Subject: [PATCH] Adding Multi-Select + Image Comparison --- src/Pages/Text2Image.cshtml | 1 + src/Pages/_Generate/GenerateTab.cshtml | 26 + src/wwwroot/css/genpage.css | 276 ++++++ .../js/genpage/gentab/currentimagehandler.js | 819 ++++++++++++++++++ src/wwwroot/js/genpage/gentab/multiselect.js | 354 ++++++++ 5 files changed, 1476 insertions(+) create mode 100644 src/wwwroot/js/genpage/gentab/multiselect.js diff --git a/src/Pages/Text2Image.cshtml b/src/Pages/Text2Image.cshtml index 6e3117d02..3affb8a70 100644 --- a/src/Pages/Text2Image.cshtml +++ b/src/Pages/Text2Image.cshtml @@ -110,6 +110,7 @@ + diff --git a/src/Pages/_Generate/GenerateTab.cshtml b/src/Pages/_Generate/GenerateTab.cshtml index f5fe592a1..69fd157a0 100644 --- a/src/Pages/_Generate/GenerateTab.cshtml +++ b/src/Pages/_Generate/GenerateTab.cshtml @@ -84,6 +84,18 @@ +
+
+ Multi-Select +
+ + +
+
+
+
+
+
@@ -140,6 +152,7 @@
Reset Page Layout
Reload Parameter Values
Reset Params to Default
+
Multi-Select
Open Empty Image Editor
Interrupt Current Session
Interrupt All Sessions
@@ -197,6 +210,19 @@ [Close]
+
diff --git a/src/wwwroot/css/genpage.css b/src/wwwroot/css/genpage.css index e63a69333..b9d00e549 100644 --- a/src/wwwroot/css/genpage.css +++ b/src/wwwroot/css/genpage.css @@ -809,6 +809,282 @@ body { overflow: hidden; margin: auto; } +.multiselect-tools-panel { + display: none; + margin: 0.5rem 0 0.75rem 0; + padding: 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--light-border); + background-color: var(--background-soft); +} +.multiselect-tools-panel.multiselect-tools-panel-visible { + display: block; +} +.multiselect-tools-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.multiselect-tools-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.multiselect-tools-summary { + font-size: 90%; + font-weight: 600; +} +.multiselect-tools-hint { + margin-top: 0.25rem; + font-size: 85%; + opacity: 0.85; +} +.multiselect-tools-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; +} +.multiselect-tool-entry { + padding: 0.5rem; + border-radius: 0.45rem; + border: 1px solid var(--light-border); + background-color: color-mix(in srgb, var(--background-panel) 88%, transparent); +} +.multiselect-tool-button { + width: 100%; + text-align: left; +} +.multiselect-tool-button.multiselect-tool-button-disabled { + opacity: 0.65; +} +.multiselect-tool-note { + margin-top: 0.35rem; + font-size: 80%; + white-space: normal; +} +.image-block-multiselect-checkbox { + display: none; + position: absolute; + top: 0.35rem; + right: 0.35rem; + width: 1.15rem; + height: 1.15rem; + align-items: center; + justify-content: center; + border-radius: 0.2rem; + border: 1px solid var(--light-border); + background-color: color-mix(in srgb, var(--background) 82%, transparent); + box-shadow: 0 0 0.35rem color-mix(in srgb, black 25%, transparent); + z-index: 2; +} +.current-image-batch-multiselect-active .image-block .image-block-multiselect-checkbox { + display: flex; +} +.image-block.image-block-multiselect-selected { + outline: 3px solid var(--box-selected-border-stronger); +} +.image-block.image-block-multiselect-selected .image-block-multiselect-checkbox { + border-color: var(--box-selected-border-stronger); + background-color: var(--box-selected-border-stronger); + color: var(--background); +} +.image-block.image-block-multiselect-selected .image-block-multiselect-checkbox::before { + content: '\2713'; + font-size: 0.9rem; + font-weight: 700; + line-height: 1; +} +.image-compare-modal-inner { + width: 95vw; + margin: auto; + min-width: 70vw; + background-color: transparent; + height: 100vh; + display: flex; + flex-direction: column; +} +.image-compare-modal-imagewrap { + flex: 1 1 auto; + min-height: 0; + height: auto; + background-color: transparent; +} +.image-compare-undertext { + min-height: 3rem; + height: auto; + overflow-x: hidden; + overflow-y: auto; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0.5rem 0.75rem 0.75rem 0.75rem; +} +.image-compare-controls { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} +.image-compare-mode-buttons { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} +.image-compare-mode-button-active { + background-color: var(--button-background-hover); + color: var(--button-foreground-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-side { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + gap: 0; +} +.image-compare-stage-single { + display: block; +} +.image-compare-stage-side .image-compare-slot, +.image-compare-stage-overlay .image-compare-slot, +.image-compare-stage-single .image-compare-slot { + width: 100%; + height: 100%; +} +.image-compare-slot { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + border: none; + border-radius: 0; + background-color: transparent; + display: block; + text-align: left; + cursor: grab; +} +.image-compare-media { + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + background-color: transparent; + display: block; +} +.image-compare-stage-overlay { + display: flex; +} +.image-compare-overlay { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + --image-compare-split: 50%; + cursor: grab; +} +.image-compare-overlay-layer { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + display: block; + text-align: left; +} +.image-compare-overlay-layer-left { + clip-path: inset(0 calc(100% - var(--image-compare-split)) 0 0); +} +.image-compare-overlay-layer-right { + clip-path: inset(0 0 0 var(--image-compare-split)); +} +.image-compare-overlay-slide-vertical .image-compare-overlay-layer-left { + clip-path: inset(0 0 calc(100% - var(--image-compare-split)) 0); +} +.image-compare-overlay-slide-vertical .image-compare-overlay-layer-right { + clip-path: inset(var(--image-compare-split) 0 0 0); +} +.image-compare-overlay-transparency .image-compare-overlay-layer-left, +.image-compare-overlay-transparency .image-compare-overlay-layer-right { + clip-path: none; +} +.image-compare-overlay-transparency .image-compare-overlay-layer-right { + opacity: 0.5; +} +.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-overlay-slide-vertical .image-compare-overlay-divider { + top: var(--image-compare-split); + bottom: auto; + left: 0; + right: 0; + width: 100%; + height: 1.5rem; + transform: translateY(-50%); + cursor: ns-resize; +} +.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-overlay-slide-vertical .image-compare-overlay-divider::before { + top: calc(50% - 1px); + bottom: auto; + left: 0; + right: 0; + width: auto; + height: 2px; +} +.image-compare-overlay-divider::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 1rem; + height: 1rem; + border-radius: 1rem; + transform: translate(-50%, -50%); + background-color: var(--emphasis); + pointer-events: none; +} +.image-compare-overlay-transparency .image-compare-overlay-divider { + display: none; +} +@media (max-width: 900px) { + .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 5b0a2e084..0f35e8896 100644 --- a/src/wwwroot/js/genpage/gentab/currentimagehandler.js +++ b/src/wwwroot/js/genpage/gentab/currentimagehandler.js @@ -364,6 +364,817 @@ class ImageFullViewHelper { let imageFullView = new ImageFullViewHelper(); +/** Central helper class to handle the image compare modal across multiple compare modes. */ +class ImageCompareHelper extends MultiSelectTool { + constructor() { + super('compare', 'Compare', 'Compare exactly 2 images or 2 videos.'); + this.zoomRate = 1.1; + this.modal = getRequiredElementById('image_compare_modal'); + this.modalJq = $('#image_compare_modal'); + this.stage = getRequiredElementById('image_compare_stage'); + this.noClose = false; + 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.lastMouseX = 0; + this.lastMouseY = 0; + this.isDragging = false; + this.didDrag = false; + 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.isAdjustingOverlaySplit = false; + this.resetViewportState(); + this.modeButtons = getRequiredElementById('image_compare_mode_buttons'); + this.modeDefinitions = { + side: { label: 'Side by Side', layout: 'side' }, + slide_horizontal: { label: 'Horizontal Slide', layout: 'slide', axis: 'x' }, + slide_vertical: { label: 'Vertical Slide', layout: 'slide', axis: 'y' }, + transparency: { label: 'Transparency Overlay', layout: 'transparency' }, + single_left: { label: 'Left Only', layout: 'single', side: 'left' }, + single_right: { label: 'Right Only', layout: 'single', side: 'right' } + }; + this.modeOrder = Object.keys(this.modeDefinitions); + this.modeButtonMap = {}; + this.renderModeButtons(); + this.register(multiSelectManager); + } + + getImgOrContainer() { + if (this.isOverlayMode()) { + return this.getOverlay(); + } + return this.stage.querySelector('.image-compare-slot'); + } + + getImgOrContainers() { + if (this.isOverlayMode()) { + let overlay = this.getOverlay(); + return overlay ? [overlay] : []; + } + return [...this.stage.querySelectorAll('.image-compare-slot')]; + } + + getImg() { + return this.stage.querySelector('.image-compare-media'); + } + + getImgs() { + return [...this.stage.querySelectorAll('.image-compare-media')]; + } + + getImgContainers() { + let containers = []; + for (let img of this.getImgs()) { + if (img.parentElement && !containers.includes(img.parentElement)) { + containers.push(img.parentElement); + } + } + return containers; + } + + getContainerAlignment(container) { + if (this.getModeLayout() != 'side' || window.matchMedia('(max-width: 900px)').matches) { + return 'center'; + } + let containers = this.getImgContainers(); + if (containers[0] == container) { + return 'right'; + } + return 'left'; + } + + getHeightPercent() { + let img = this.getImg(); + 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(); + 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(); + 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) { + let img = this.getImgOrContainer(); + if (!img) { + 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(); + } + + copyState() { + return { + left: this.getImgLeft(), + top: this.getImgTop(), + height: this.getHeightPercent(), + mode: this.mode, + overlaySplitPercent: this.overlaySplitPercent + }; + } + + pasteState(state) { + if (!state || state.height == null) { + return; + } + let didModeChange = false; + if (state.mode) { + let normalizedMode = this.normalizeMode(state.mode); + didModeChange = normalizedMode != this.mode; + this.mode = normalizedMode; + } + if (state.overlaySplitPercent != null) { + this.overlaySplitPercent = state.overlaySplitPercent; + } + this.panX = state.left; + this.panY = state.top; + if (didModeChange) { + this.render(); + } + else { + this.applyView(); + } + this.setHeightPercent(state.height); + this.applyView(); + } + + 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()) { + this.modalJq.modal('hide'); + } + this.reset(); + } + + isOpen() { + return this.modalJq.is(':visible'); + } + + 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; + } + let baseTop = (rect.height - mediaHeight) / 2; + return { + viewport: container, + media: media, + rect: rect, + mediaWidth: mediaWidth, + mediaHeight: mediaHeight, + baseLeft: baseLeft, + baseTop: baseTop + }; + } + + getStateLayout() { + return this.getViewportLayout(this.getImgOrContainer()); + } + + 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.getImgs()) { + maxHeight = Math.max(maxHeight, this.getMediaMaxHeight(img)); + } + return maxHeight; + } + + updateImageRendering(heightPercent = this.getHeightPercent()) { + for (let img of this.getImgs()) { + 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.clearStage(); + } + + normalizeMode(mode) { + if (mode == 'overlay') { + return 'slide_horizontal'; + } + if (this.modeDefinitions[mode]) { + return mode; + } + return 'side'; + } + + getModeDefinition(mode = this.mode) { + return this.modeDefinitions[this.normalizeMode(mode)]; + } + + getModeLayout(mode = this.mode) { + return this.getModeDefinition(mode).layout; + } + + isOverlayMode(mode = this.mode) { + let layout = this.getModeLayout(mode); + return layout == 'slide' || layout == 'transparency'; + } + + isSlideMode(mode = this.mode) { + return this.getModeLayout(mode) == 'slide'; + } + + getSlideAxis(mode = this.mode) { + let definition = this.getModeDefinition(mode); + return definition.axis || 'x'; + } + + getSingleSide(mode = this.mode) { + let definition = this.getModeDefinition(mode); + return definition.side || 'left'; + } + + renderModeButtons() { + this.modeButtons.innerHTML = ''; + this.modeButtonMap = {}; + for (let mode of this.modeOrder) { + let definition = this.getModeDefinition(mode); + let button = document.createElement('button'); + button.type = 'button'; + button.className = 'basic-button image-compare-mode-button'; + button.textContent = translate(definition.label); + button.title = translate(definition.label); + button.setAttribute('aria-pressed', 'false'); + button.addEventListener('click', () => this.setMode(mode)); + this.modeButtons.appendChild(button); + this.modeButtonMap[mode] = button; + } + this.updateModeButtons(); + } + + updateModeButtons() { + for (let mode of this.modeOrder) { + let button = this.modeButtonMap[mode]; + if (!button) { + continue; + } + let isActive = mode == this.mode; + button.classList.toggle('image-compare-mode-button-active', isActive); + button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + } + } + + setMode(mode) { + let normalized = this.normalizeMode(mode); + if (this.mode == normalized) { + this.updateModeButtons(); + return; + } + this.mode = normalized; + if (this.hasSelection()) { + this.render(); + } + else { + this.updateModeButtons(); + } + } + + clearStage() { + this.stage.classList.toggle('image-compare-stage-overlay', false); + this.stage.classList.toggle('image-compare-stage-side', false); + this.stage.classList.toggle('image-compare-stage-single', false); + this.stage.innerHTML = ''; + this.updateModeButtons(); + } + + render() { + this.stopPanning(true); + this.updateModeButtons(); + if (!this.hasSelection()) { + this.clearStage(); + return; + } + if (this.isOverlayMode()) { + this.renderOverlay(); + } + else if (this.getModeLayout() == 'single') { + this.renderSingle(); + } + else { + this.renderSideBySide(); + } + this.applyView(); + } + + renderSideBySide() { + this.stage.classList.toggle('image-compare-stage-overlay', false); + this.stage.classList.toggle('image-compare-stage-side', true); + this.stage.classList.toggle('image-compare-stage-single', false); + this.stage.innerHTML = ` +
+ ${this.renderMedia(this.left)} +
+
+ ${this.renderMedia(this.right)} +
`; + } + + renderOverlay() { + let overlayClasses = ['image-compare-overlay']; + if (this.isSlideMode()) { + overlayClasses.push('image-compare-overlay-slide'); + if (this.getSlideAxis() == 'y') { + overlayClasses.push('image-compare-overlay-slide-vertical'); + } + } + else { + overlayClasses.push('image-compare-overlay-transparency'); + } + let divider = this.isSlideMode() ? '
' : ''; + this.stage.classList.toggle('image-compare-stage-overlay', true); + this.stage.classList.toggle('image-compare-stage-side', false); + this.stage.classList.toggle('image-compare-stage-single', false); + this.stage.innerHTML = ` +
+
+
+ ${this.renderMedia(this.left)} +
+
+ ${this.renderMedia(this.right)} +
+ ${divider} +
+
`; + } + + renderSingle() { + let media = this.getSingleSide() == 'right' ? this.right : this.left; + this.stage.classList.toggle('image-compare-stage-overlay', false); + this.stage.classList.toggle('image-compare-stage-side', false); + this.stage.classList.toggle('image-compare-stage-single', true); + this.stage.innerHTML = ` +
+ ${this.renderMedia(media)} +
`; + } + + updateOverlaySplitFromClientPosition(stage, clientX, clientY) { + let rect = stage.getBoundingClientRect(); + let split = 50; + 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.getImgs(); + 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.getImgOrContainers()) { + 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.getImgs(); + 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'; + } + let overlay = this.getOverlay(); + if (overlay) { + overlay.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" style="cursor:grab;max-width:100%;max-height:100%;object-fit:contain;position:relative;margin:auto;" onload="imageCompareHelper.onImgLoad()"', 'style="cursor:grab;max-width:100%;max-height:100%;object-fit:contain;position:relative;margin:auto;" autoplay loop muted playsinline onloadedmetadata="imageCompareHelper.onImgLoad()"', '', false); + } + + hasSelection() { + return this.left && this.right; + } + + getSelectionHint(items, context) { + return 'Works with exactly 2 images or 2 videos.'; + } + + evaluateSelection(items, context) { + 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.' }; + } + + normalizeSelection(items, context) { + return items.slice(0, 2); + } + + execute(items, context) { + if (this.evaluateSelection(items, context).state != 'ready') { + return; + } + this.reset(); + this.showComparison(items[0], items[1]); + } + + cleanup(context) { + this.close(); + } +} + +let imageCompareHelper = new ImageCompareHelper(); + class CurrentImageHelper { getCurrentImage() { @@ -392,6 +1203,7 @@ function clearBatch() { let currentImageBatchDiv = getRequiredElementById('current_image_batch'); currentImageBatchDiv.innerHTML = ''; currentImageBatchDiv.dataset.numImages = 0; + multiSelectManager.onBatchCleared(); } /** Reference to the auto-clear-batch toggle checkbox. */ @@ -435,6 +1247,9 @@ function toggleSeparateBatches() { } function clickImageInBatch(div) { + if (multiSelectManager.handleBatchClick(div)) { + return; + } let imgElem = div.getElementsByTagName('img')[0]; if (currentImgSrc == div.dataset.src) { imageFullView.showImage(div.dataset.src, div.dataset.metadata, div.dataset.batch_id); @@ -445,6 +1260,9 @@ function clickImageInBatch(div) { /** Removes a preview thumbnail and moves to either previous or next image. */ function removeImageBlockFromBatch(div, shift = false) { + if (div.classList.contains('image-block-multiselect-selected')) { + multiSelectManager.clearSelection(); + } if (!div.classList.contains('image-block-current')) { div.remove(); return; @@ -1286,6 +2104,7 @@ function appendImage(container, imageSrc, batchId, textPreview, metadata = '', t srcTarget.src = imageSrc; img.classList.add('image-block-img-inner'); div.appendChild(img); + div.appendChild(createDiv(null, 'image-block-multiselect-checkbox')); if (type == 'legacy') { let textBlock = createDiv(null, 'image-preview-text'); textBlock.innerText = textPreview; diff --git a/src/wwwroot/js/genpage/gentab/multiselect.js b/src/wwwroot/js/genpage/gentab/multiselect.js new file mode 100644 index 000000000..77a18e65f --- /dev/null +++ b/src/wwwroot/js/genpage/gentab/multiselect.js @@ -0,0 +1,354 @@ +/** + * Suggested optional overrides for multi-select tools: + * - isAvailable(context) + * - getActionLabel(items, context) + * - getSelectionHint(items, context) + * - getConfirmMessage(items, context) + */ +class MultiSelectTool { + constructor(id, label, description = '') { + this.id = id; + this.label = label; + this.description = description; + this.manager = null; + } + + register(manager) { + this.manager = manager; + manager.registerTool(this); + return this; + } + + isAvailable(context) { + return true; + } + + getActionLabel(items, context) { + return this.label; + } + + getSelectionHint(items, context) { + return this.description; + } + + getConfirmMessage(items, context) { + return null; + } + + evaluateSelection(items, context) { + return { + state: 'invalid', + reason: `${this.label} has not implemented evaluateSelection().` + }; + } + + normalizeSelection(items, context) { + return items; + } + + execute(items, context) { + } + + cleanup(context) { + } +} + +/** Central helper class for batch multi-select mode and tool execution. */ +class MultiSelectManager { + constructor() { + this.panel = getRequiredElementById('multiselect_tools_panel'); + this.summary = getRequiredElementById('multiselect_tools_summary'); + this.hint = getRequiredElementById('multiselect_tools_hint'); + this.list = getRequiredElementById('multiselect_tools_list'); + this.batch = getRequiredElementById('current_image_batch'); + this.tools = []; + this.selectedItems = []; + this.isActive = false; + this.render(); + } + + registerTool(tool) { + let existingIndex = this.tools.findIndex(existing => existing.id == tool.id); + if (existingIndex >= 0) { + this.tools.splice(existingIndex, 1, tool); + } + else { + this.tools.push(tool); + } + tool.manager = this; + this.render(); + } + + getContext(items = this.selectedItems) { + return { + manager: this, + batch: this.batch, + isActive: this.isActive, + selectedCount: items.length + }; + } + + getSelectionKey(itemOrDiv) { + if (itemOrDiv.dataset) { + return `${itemOrDiv.dataset.batch_id || ''}::${itemOrDiv.dataset.src || ''}`; + } + return `${itemOrDiv.batchId || ''}::${itemOrDiv.src || ''}`; + } + + getMediaType(src) { + if (isAudioExt(src)) { + return 'audio'; + } + if (isVideoExt(src)) { + return 'video'; + } + return 'image'; + } + + buildSelectionItem(div) { + return { + key: this.getSelectionKey(div), + src: div.dataset.src, + metadata: div.dataset.metadata || '', + batchId: div.dataset.batch_id || '', + requestId: div.dataset.request_id || '', + previewText: div.dataset.preview_text || '', + mediaType: this.getMediaType(div.dataset.src), + element: div + }; + } + + hasSelectableBatchItems() { + for (let block of this.batch.getElementsByClassName('image-block')) { + if (block.dataset.is_placeholder == 'true') { + continue; + } + if (block.dataset.src) { + return true; + } + } + return false; + } + + toggle() { + if (this.isActive) { + this.deactivate(); + } + else { + this.activate(); + } + } + + activate() { + if (this.isActive) { + return; + } + if (!this.hasSelectableBatchItems()) { + doNoticePopover('No images, videos, or audio in batch to multi-select.', 'notice-pop-red'); + return; + } + this.isActive = true; + this.render(); + } + + deactivate() { + if (!this.isActive) { + return; + } + this.cleanupAllTools(); + this.isActive = false; + this.selectedItems = []; + this.render(); + } + + clearSelection() { + this.cleanupAllTools(); + this.selectedItems = []; + this.render(); + } + + onBatchCleared() { + this.cleanupAllTools(); + this.selectedItems = []; + this.render(); + } + + pruneSelection() { + let pruned = false; + this.selectedItems = this.selectedItems.filter(item => { + let keep = item.element && item.element.isConnected && item.element.dataset && item.element.dataset.src == item.src; + if (!keep) { + pruned = true; + } + return keep; + }); + return pruned; + } + + handleBatchClick(div) { + if (!this.isActive) { + return false; + } + if (!div || !div.dataset || !div.dataset.src) { + return true; + } + if (div.dataset.is_placeholder == 'true') { + doNoticePopover('Wait for generation to finish before selecting it.', 'notice-pop-red'); + return true; + } + this.cleanupAllTools(); + let key = this.getSelectionKey(div); + let existingIndex = this.selectedItems.findIndex(item => item.key == key); + if (existingIndex >= 0) { + this.selectedItems.splice(existingIndex, 1); + } + else { + this.selectedItems.push(this.buildSelectionItem(div)); + } + this.render(); + return true; + } + + refreshBatchSelections() { + let selectedKeys = new Set(this.selectedItems.map(item => item.key)); + for (let block of this.batch.getElementsByClassName('image-block')) { + block.classList.toggle('image-block-multiselect-selected', selectedKeys.has(this.getSelectionKey(block))); + } + } + + normalizeEvaluation(evaluation) { + if (!evaluation || typeof evaluation != 'object') { + return { state: 'invalid', reason: 'Selection is not valid for this tool.' }; + } + let state = evaluation.state; + if (state != 'ready' && state != 'partial' && state != 'invalid') { + state = 'invalid'; + } + return { + state, + reason: evaluation.reason || '' + }; + } + + getEvaluationForTool(tool, items = this.selectedItems) { + let context = this.getContext(items); + if (tool.isAvailable(context) == false) { + return null; + } + let rawItems = [...items]; + let evaluation = this.normalizeEvaluation(tool.evaluateSelection(rawItems, context)); + let normalizedItems = rawItems; + if (evaluation.state == 'ready') { + normalizedItems = tool.normalizeSelection([...rawItems], context); + if (!Array.isArray(normalizedItems)) { + normalizedItems = rawItems; + } + } + return { + tool, + items: normalizedItems, + rawItems, + context, + state: evaluation.state, + reason: evaluation.reason + }; + } + + handleToolClick(tool) { + let evaluation = this.getEvaluationForTool(tool); + if (!evaluation) { + return; + } + if (evaluation.state != 'ready') { + doNoticePopover(evaluation.reason || 'Selection is not ready for this tool.', evaluation.state == 'invalid' ? 'notice-pop-red' : ''); + return; + } + let confirmMessage = tool.getConfirmMessage(evaluation.items, evaluation.context); + if (confirmMessage && !confirm(confirmMessage)) { + return; + } + try { + tool.execute(evaluation.items, evaluation.context); + } + catch (error) { + console.error(`Error running multi-select tool '${tool.id}':`, error); + showError(`Failed to run multi-select tool '${tool.label}': ${error}`); + } + this.render(); + } + + cleanupAllTools() { + let context = this.getContext(); + for (let tool of this.tools) { + try { + tool.cleanup(context); + } + catch (error) { + console.error(`Error cleaning up multi-select tool '${tool.id}':`, error); + } + } + } + + render() { + if (this.pruneSelection()) { + this.cleanupAllTools(); + } + this.panel.classList.toggle('multiselect-tools-panel-visible', this.isActive); + this.batch.classList.toggle('current-image-batch-multiselect-active', this.isActive); + this.refreshBatchSelections(); + if (!this.isActive) { + this.summary.textContent = ''; + this.hint.textContent = ''; + this.list.innerHTML = ''; + return; + } + let itemCount = this.selectedItems.length; + this.summary.textContent = `${itemCount} item${itemCount == 1 ? '' : 's'} selected`; + this.list.innerHTML = ''; + let readyTools = 0; + for (let tool of this.tools) { + let evaluation = this.getEvaluationForTool(tool); + if (!evaluation) { + continue; + } + if (evaluation.state == 'ready') { + readyTools++; + } + let entry = createDiv(null, 'multiselect-tool-entry'); + let button = document.createElement('button'); + button.type = 'button'; + button.className = 'basic-button multiselect-tool-button'; + if (evaluation.state != 'ready') { + button.classList.add('multiselect-tool-button-disabled'); + } + button.textContent = tool.getActionLabel(evaluation.items, evaluation.context); + button.title = tool.description || evaluation.reason || ''; + button.addEventListener('click', () => this.handleToolClick(tool)); + entry.appendChild(button); + let note = createDiv(null, 'multiselect-tool-note'); + note.textContent = evaluation.reason || tool.getSelectionHint(evaluation.items, evaluation.context) || ''; + entry.appendChild(note); + this.list.appendChild(entry); + } + if (itemCount == 0) { + this.hint.textContent = 'Select batch items, then choose a tool.'; + } + else if (readyTools > 0) { + this.hint.textContent = `${readyTools} tool${readyTools == 1 ? '' : 's'} ready for the current selection.`; + } + else { + this.hint.textContent = 'Update the selection to enable a tool.'; + } + } +} + +let multiSelectManager = new MultiSelectManager(); + +function toggleMultiSelectMode() { + hidePopover('quicktools'); + multiSelectManager.toggle(); +} + +function clearMultiSelectSelection() { + multiSelectManager.clearSelection(); +}