diff --git a/src/Pages/_Generate/GenerateTab.cshtml b/src/Pages/_Generate/GenerateTab.cshtml index f5fe592a1..9c232b251 100644 --- a/src/Pages/_Generate/GenerateTab.cshtml +++ b/src/Pages/_Generate/GenerateTab.cshtml @@ -197,6 +197,19 @@ [Close]
+
diff --git a/src/wwwroot/css/genpage.css b/src/wwwroot/css/genpage.css index d3a23ee9b..ce032804d 100644 --- a/src/wwwroot/css/genpage.css +++ b/src/wwwroot/css/genpage.css @@ -809,6 +809,194 @@ body { overflow: hidden; margin: auto; } +.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; @@ -835,6 +1023,16 @@ body { margin-left: 0.5rem; opacity: 0.8; } +.browser-multiselect-toggle-active, +.browser-multiselect-entry-selected { + outline: 2px solid var(--emphasis) !important; + outline-offset: 1px; +} +.browser-multiselect-action-select { + max-width: 11rem; + margin-left: 0.15rem; + vertical-align: middle; +} .browser-fullcontent-container { margin-left: 0.2rem; display: inline-block; diff --git a/src/wwwroot/js/genpage/gentab/currentimagehandler.js b/src/wwwroot/js/genpage/gentab/currentimagehandler.js index 460e7091c..3ca3c96e8 100644 --- a/src/wwwroot/js/genpage/gentab/currentimagehandler.js +++ b/src/wwwroot/js/genpage/gentab/currentimagehandler.js @@ -364,6 +364,805 @@ class ImageFullViewHelper { let imageFullView = new ImageFullViewHelper(); +/** Central helper class to handle the image compare modal across multiple compare modes. */ +class ImageCompareHelper { + constructor() { + 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(); + } + + 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'); + } + } + + openFromHistoryBulkSelection(files) { + if (!files || files.length != 2) { + return; + } + let left = this.historyFileToCompareMedia(files[0]); + let right = this.historyFileToCompareMedia(files[1]); + let validation = this.validateCompareMedia(left, right); + if (!validation.ok) { + doNoticePopover(validation.reason, validation.severe ? 'notice-pop-red' : ''); + return; + } + this.showComparison(left, right); + } + + historyFileToCompareMedia(file) { + let src = file.data.src; + return { src: src, metadata: file.data.metadata || '', mediaType: getMediaType(src) }; + } + + validateCompareMedia(left, right) { + if (left.mediaType == 'audio' || right.mediaType == 'audio') { + return { ok: false, reason: 'Compare only supports images and videos.', severe: true }; + } + if (left.mediaType != right.mediaType) { + return { ok: false, reason: 'Compare requires 2 items of the same media type.', severe: true }; + } + return { ok: true }; + } + + 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; + } +} + +let imageCompareHelper = new ImageCompareHelper(); + class CurrentImageHelper { getCurrentImage() { diff --git a/src/wwwroot/js/genpage/gentab/models.js b/src/wwwroot/js/genpage/gentab/models.js index 4aa7428f6..98bbbb71a 100644 --- a/src/wwwroot/js/genpage/gentab/models.js +++ b/src/wwwroot/js/genpage/gentab/models.js @@ -465,6 +465,7 @@ class ModelBrowserWrapper { let format = subType == 'Wildcards' ? 'Small Cards' : 'Cards'; extraHeader += ` `; this.browser = new GenPageBrowserClass(container, this.listModelFolderAndFiles.bind(this), id, format, this.describeModel.bind(this), this.selectModel.bind(this), extraHeader); + this.browser.enableBrowserMultiSelect = true; this.promptBox = getRequiredElementById('alt_prompt_textbox'); this.models = {}; this.browser.refreshHandler = (callback) => { @@ -717,7 +718,7 @@ class ModelBrowserWrapper { }, can_multi: true }]; } let isStarred = this.isStarred(model.data.name); - let starButton = { label: isStarred ? 'Unstar' : 'Star', onclick: () => { this.toggleStar(model.data.name); } }; + let starButton = { label: isStarred ? 'Unstar' : 'Star', onclick: () => { this.toggleStar(model.data.name); }, can_multi: true }; buttons.push(starButton); let name = cleanModelName(model.data.name); let display = (model.data.display || name).replaceAll('/', ' / '); diff --git a/src/wwwroot/js/genpage/gentab/outputhistory.js b/src/wwwroot/js/genpage/gentab/outputhistory.js index 25088870c..3a1861e0a 100644 --- a/src/wwwroot/js/genpage/gentab/outputhistory.js +++ b/src/wwwroot/js/genpage/gentab/outputhistory.js @@ -2,8 +2,8 @@ let registeredMediaButtons = []; /** Registers a media button for extensions. 'mediaTypes' filters by type eg ['audio'], null means all. 'isDefault' promotes to visible (vs More dropdown). 'showInHistory' controls whether button appears in the History panel. */ -function registerMediaButton(name, action, title = '', mediaTypes = null, isDefault = false, showInHistory = true, href = null, is_download = false, can_multi = false, multi_only = false) { - registeredMediaButtons.push({ name, action, title, mediaTypes, isDefault, showInHistory, href, is_download, can_multi, multi_only }); +function registerMediaButton(name, action, title = '', mediaTypes = null, isDefault = false, showInHistory = true, href = null, is_download = false, can_multi = false, multi_only = false, max_selected = null) { + registeredMediaButtons.push({ name, action, title, mediaTypes, isDefault, showInHistory, href, is_download, can_multi, multi_only, max_selected }); } function listOutputHistoryFolderAndFiles(path, isRefresh, callback, depth) { @@ -72,7 +72,8 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { className: (metadata && metaParsed.is_starred) ? ' star-button button-starred-image' : ' star-button', onclick: (e) => { toggleStar(fullsrc, src); - } + }, + can_multi: true }); buttons.push({ label: 'Enable Starred', @@ -173,6 +174,21 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { can_multi: true }); } + if (!isCurrentImage && !isDataImage) { + buttons.push({ + label: 'Compare', + title: 'Compare images', + can_multi: true, + multi_only: true, + max_selected: 2, + bulk_once: true, + onclick: function (ctx) { + if (ctx && ctx.files) { + imageCompareHelper.openFromHistoryBulkSelection(ctx.files); + } + } + }); + } for (let reg of registeredMediaButtons) { if ((isCurrentImage || reg.showInHistory) && (!reg.mediaTypes || reg.mediaTypes.includes(mediaType))) { buttons.push({ @@ -182,6 +198,7 @@ function buttonsForImage(fullsrc, src, metadata, isCurrentImage = false) { is_download: reg.is_download, can_multi: reg.can_multi, multi_only: reg.multi_only, + max_selected: reg.max_selected, onclick: () => reg.action(src) }); } @@ -247,6 +264,7 @@ function selectOutputInHistory(image, div) { let imageHistoryBrowser = new GenPageBrowserClass('image_history', listOutputHistoryFolderAndFiles, 'imagehistorybrowser', 'Thumbnails', describeOutputFile, selectOutputInHistory, ` `); +imageHistoryBrowser.enableBrowserMultiSelect = true; function storeImageToHistoryWithCurrentParams(img) { let data = getGenInput(); diff --git a/src/wwwroot/js/genpage/gentab/presets.js b/src/wwwroot/js/genpage/gentab/presets.js index 9daf9892b..30f338ff2 100644 --- a/src/wwwroot/js/genpage/gentab/presets.js +++ b/src/wwwroot/js/genpage/gentab/presets.js @@ -626,18 +626,18 @@ function listPresetFolderAndFiles(path, isRefresh, callback, depth) { function describePreset(preset) { let buttons = [ { label: 'Toggle', onclick: () => selectPreset(preset) }, - { label: 'Direct Apply', onclick: () => applyOnePreset(preset.data) }, - { label: preset.data.is_starred ? 'Unstar' : 'Star', onclick: () => togglePresetStar(preset) }, + { label: 'Direct Apply', onclick: () => applyOnePreset(preset.data), can_multi: true }, + { label: preset.data.is_starred ? 'Unstar' : 'Star', onclick: () => togglePresetStar(preset), can_multi: true }, { label: 'Edit Preset', onclick: () => editPreset(preset.data) }, { label: 'Duplicate Preset', onclick: () => duplicatePreset(preset.data) }, - { label: 'Export Preset', onclick: () => exportOnePresetButton(preset.data) }, + { label: 'Export Preset', onclick: () => exportOnePresetButton(preset.data), can_multi: true }, { label: 'Delete Preset', onclick: () => { if (confirm("Are you sure want to delete that preset?")) { genericRequest('DeletePreset', { preset: preset.data.title }, data => { loadUserData(); }); } - } } + }, can_multi: true } ]; let paramText = Object.keys(preset.data.param_map).map(key => `${key}: ${preset.data.param_map[key]}`); let description = `${preset.data.title}:\n${preset.data.description}\n\n${paramText.join('\n')}`; @@ -696,6 +696,7 @@ let presetBrowser = new GenPageBrowserClass('preset_list', listPresetFolderAndFi `); +presetBrowser.enableBrowserMultiSelect = true; function importPresetsButton() { getRequiredElementById('import_presets_textarea').value = ''; diff --git a/src/wwwroot/js/genpage/helpers/browsers.js b/src/wwwroot/js/genpage/helpers/browsers.js index 3f38fd88a..d8c5c664b 100644 --- a/src/wwwroot/js/genpage/helpers/browsers.js +++ b/src/wwwroot/js/genpage/helpers/browsers.js @@ -107,6 +107,11 @@ class GenPageBrowserClass { this.runAfterUpdate = []; this.refreshHandler = (callback) => callback(); this.checkIsSmall(); + this.enableBrowserMultiSelect = false; + this.multiSelectActive = false; + this.multiSelectedKeys = new Set(); + this.multiSelectToggleButton = null; + this.multiSelectActionSelect = null; } /** @@ -138,6 +143,10 @@ class GenPageBrowserClass { this.chunksRendered = 0; this.folder = folder; this.selected = null; + if (this.enableBrowserMultiSelect) { + this.multiSelectedKeys.clear(); + this.syncBrowserMultiSelectHeader(); + } this.update(false, callback); } @@ -455,6 +464,9 @@ class GenPageBrowserClass { } let img = document.createElement('img'); img.addEventListener('click', () => { + if (this.handleBrowserMultiSelectTileClick(file, div)) { + return; + } this.select(file, div); }); img.classList.add('image-block-img-inner'); @@ -466,6 +478,12 @@ class GenPageBrowserClass { let textBlock = createDiv(null, 'model-descblock'); textBlock.tabIndex = 0; textBlock.innerHTML = desc.description; + textBlock.addEventListener('click', (e) => { + if (this.handleBrowserMultiSelectTileClick(file, div, e)) { + return; + } + this.select(file, div); + }); div.appendChild(textBlock); } else if (this.format.includes('Thumbnails')) { @@ -495,6 +513,12 @@ class GenPageBrowserClass { else { textBlock.classList.add('image-preview-text-large'); } + textBlock.addEventListener('click', (e) => { + if (this.handleBrowserMultiSelectTileClick(file, div, e)) { + return; + } + this.select(file, div); + }); div.appendChild(textBlock); } else if (this.format == 'List') { @@ -502,6 +526,9 @@ class GenPageBrowserClass { let textBlock = createSpan(null, 'browser-list-entry-text'); textBlock.innerText = desc.display || desc.name; textBlock.addEventListener('click', () => { + if (this.handleBrowserMultiSelectTileClick(file, div)) { + return; + } this.select(file, div); }); div.appendChild(textBlock); @@ -520,6 +547,9 @@ class GenPageBrowserClass { textBlock.style.width = `calc(${percent}% - ${imgAdj}rem)`; textBlock.innerHTML = detail; textBlock.addEventListener('click', () => { + if (this.handleBrowserMultiSelectTileClick(file, div)) { + return; + } this.select(file, div); }); div.appendChild(textBlock); @@ -704,6 +734,37 @@ class GenPageBrowserClass { this.headerBar.appendChild(formatSelector); this.headerBar.appendChild(buttons); refreshButton.onclick = this.refresh.bind(this); + if (this.enableBrowserMultiSelect) { + this.multiSelectToggleButton = document.createElement('button'); + this.multiSelectToggleButton.type = 'button'; + this.multiSelectToggleButton.id = `${this.id}_multiselect_toggle`; + this.multiSelectToggleButton.className = 'refresh-button translate translate-no-text browser-multiselect-toggle'; + this.multiSelectToggleButton.title = 'Toggle multi-select mode'; + this.multiSelectToggleButton.innerHTML = '✓'; + this.multiSelectToggleButton.addEventListener('click', () => { + this.setBrowserMultiSelectActive(!this.multiSelectActive); + }); + this.multiSelectActionSelect = document.createElement('select'); + this.multiSelectActionSelect.id = `${this.id}_multiselect_action`; + this.multiSelectActionSelect.className = 'browser-format-selector browser-multiselect-action-select'; + this.multiSelectActionSelect.title = 'Bulk action'; + let placeholderOpt = document.createElement('option'); + placeholderOpt.value = ''; + placeholderOpt.className = 'translate'; + placeholderOpt.innerText = translate('Actions...'); + this.multiSelectActionSelect.appendChild(placeholderOpt); + this.multiSelectActionSelect.style.display = 'none'; + this.multiSelectActionSelect.addEventListener('change', () => { + let choice = this.multiSelectActionSelect.value; + if (!choice) { + return; + } + this.runBrowserMultiSelectAction(choice); + this.multiSelectActionSelect.value = ''; + }); + this.upButton.insertAdjacentElement('afterend', this.multiSelectToggleButton); + this.multiSelectToggleButton.insertAdjacentElement('afterend', this.multiSelectActionSelect); + } this.fullContentDiv.appendChild(this.headerBar); this.contentDiv = createDiv(`${this.id}-content`, 'browser-content-container'); this.contentDiv.addEventListener('scroll', () => { @@ -768,7 +829,10 @@ class GenPageBrowserClass { this.buildTreeElements(this.folderTreeDiv, '', this.tree); applyTranslations(this.headerBar); if (!this.noContentUpdates) { + this.pruneBrowserMultiSelectionToCurrentList(); this.buildContentList(this.contentDiv, files); + this.applyBrowserMultiSelectVisuals(); + this.syncBrowserMultiSelectHeader(); browserUtil.makeVisible(this.contentDiv); if (scrollOffset) { this.contentDiv.scrollTop = scrollOffset; @@ -783,4 +847,240 @@ class GenPageBrowserClass { this.builtEvent(); } } + + /** + * Removes all browser multi-select keys. + */ + clearBrowserMultiSelection() { + this.multiSelectedKeys.clear(); + this.syncBrowserMultiSelectHeader(); + if (this.contentDiv) { + this.applyBrowserMultiSelectVisuals(); + } + } + + /** + * Turns browser multi-select mode on or off; exiting clears the selection. + */ + setBrowserMultiSelectActive(active) { + if (!this.enableBrowserMultiSelect || this.multiSelectActive == active) { + return; + } + this.multiSelectActive = active; + if (!active) { + this.multiSelectedKeys.clear(); + } + this.syncBrowserMultiSelectHeader(); + if (this.contentDiv) { + this.applyBrowserMultiSelectVisuals(); + } + } + + /** + * Toggles whether a file row is selected for bulk actions. + */ + toggleBrowserMultiSelectForFile(file, div) { + let key = file.name; + if (this.multiSelectedKeys.has(key)) { + this.multiSelectedKeys.delete(key); + } + else { + this.multiSelectedKeys.add(key); + } + this.applyBrowserMultiSelectVisuals(); + this.syncBrowserMultiSelectHeader(); + } + + handleBrowserMultiSelectTileClick(file, div, event = null) { + if (!this.multiSelectActive || !this.enableBrowserMultiSelect) { + return false; + } + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + this.toggleBrowserMultiSelectForFile(file, div); + return true; + } + + /** + * Returns files in the current listing that are multi-selected. + */ + getMultiSelectedFiles() { + if (!this.lastFiles) { + return []; + } + let out = []; + for (let file of this.lastFiles) { + if (this.multiSelectedKeys.has(file.name)) { + out.push(file); + } + } + return out; + } + + /** + * Drops multi-select keys that no longer exist in the current lastFiles listing. + */ + pruneBrowserMultiSelectionToCurrentList() { + if (!this.lastFiles) { + return; + } + let names = new Set(this.lastFiles.map(f => f.name)); + for (let key of [...this.multiSelectedKeys]) { + if (!names.has(key)) { + this.multiSelectedKeys.delete(key); + } + } + } + + /** + * Labels for bulk actions shared by every selected item, respecting `can_multi` / `multi_only`. + */ + collectCommonBulkActionLabels() { + let files = this.getMultiSelectedFiles(); + let selCount = files.length; + if (selCount == 0) { + return []; + } + let eligiblePerFile = []; + for (let file of files) { + let desc = this.describe(file); + let labels = new Set(); + for (let button of desc.buttons) { + if (!button.onclick) { + continue; + } + if (button.multi_only && selCount < 2) { + continue; + } + if (!button.can_multi && !button.multi_only) { + continue; + } + if (button.max_selected != null && selCount > button.max_selected) { + continue; + } + labels.add(button.label); + } + eligiblePerFile.push(labels); + } + let first = eligiblePerFile[0]; + let common = []; + for (let label of first) { + if (eligiblePerFile.every(s => s.has(label))) { + common.push(label); + } + } + common.sort((a, b) => a.localeCompare(b)); + return common; + } + + /** + * Off: ✓ ✓ + * On: ☑ ☑ + */ + syncBrowserMultiSelectToggleAppearance() { + if (!this.multiSelectToggleButton) { + return; + } + this.multiSelectToggleButton.classList.toggle('browser-multiselect-toggle-active', this.multiSelectActive); + this.multiSelectToggleButton.innerHTML = this.multiSelectActive ? '☑' : '✓'; + } + + /** + * Updates multi-select toggle state and bulk action dropdown. + */ + syncBrowserMultiSelectHeader() { + this.syncBrowserMultiSelectToggleAppearance(); + if (!this.multiSelectActionSelect) { + return; + } + let show = this.multiSelectActive && this.multiSelectedKeys.size > 0; + this.multiSelectActionSelect.style.display = show ? '' : 'none'; + if (!show) { + return; + } + while (this.multiSelectActionSelect.options.length > 1) { + this.multiSelectActionSelect.remove(1); + } + let labels = this.collectCommonBulkActionLabels(); + for (let label of labels) { + let opt = document.createElement('option'); + opt.value = label; + opt.className = 'translate'; + opt.innerText = translate(label); + this.multiSelectActionSelect.appendChild(opt); + } + applyTranslations(this.multiSelectActionSelect); + } + + /** + * Runs a named bulk action (card popover label) once per selected item. + */ + runBrowserMultiSelectAction(label) { + let files = this.getMultiSelectedFiles(); + if (files.length > 0) { + for (let b of this.describe(files[0]).buttons) { + if (b.label == label && b.onclick && b.bulk_once) { + b.onclick({ files: files, browser: this, label }); + this.pruneBrowserMultiSelectionToCurrentList(); + this.applyBrowserMultiSelectVisuals(); + this.syncBrowserMultiSelectHeader(); + return; + } + } + } + + let failed = 0; + for (let file of files) { + let div = this.getVisibleEntry(file.name); + let desc = this.describe(file); + let button = null; + for (let b of desc.buttons) { + if (b.label == label && b.onclick) { + button = b; + break; + } + } + if (!button) { + failed++; + console.error(`No bulk action '${label}' for ${file.name}`); + continue; + } + try { + if (div) { + button.onclick(div); + } + else { + button.onclick(null); + } + } + catch (err) { + console.error('Browser bulk action error:', err); + failed++; + } + } + if (failed > 0) { + showError(`Bulk action finished: ${failed} of ${files.length} failed — see console for details.`); + } + this.pruneBrowserMultiSelectionToCurrentList(); + this.applyBrowserMultiSelectVisuals(); + this.syncBrowserMultiSelectHeader(); + } + + /** + * Applies multi-select highlight classes to visible rows. + */ + applyBrowserMultiSelectVisuals() { + if (!this.contentDiv) { + return; + } + for (let child of this.contentDiv.children) { + if (!child.dataset || !child.dataset.name) { + continue; + } + let on = this.multiSelectActive && this.multiSelectedKeys.has(child.dataset.name); + child.classList.toggle('browser-multiselect-entry-selected', on); + } + } }