From 5d1a22fb8ff6543d97e91654ca8a9c4208eff391 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 18 Aug 2026 20:22:32 +0800 Subject: [PATCH] fix(ui): ignore internal card drags in model card preview drop (#1034) Tag move-to-folder drags with a custom dataTransfer MIME type so card preview-drop handlers skip them entirely (no highlight, no upload), and mark the preview image non-draggable so the browser no longer synthesizes a File payload when a drag starts on the image. Fixes card-on-card drops and click-jitter self-drops replacing the preview with itself. --- static/js/components/SidebarManager.js | 4 ++ static/js/components/shared/ModelCard.js | 15 ++++++- static/js/utils/constants.js | 4 ++ .../components/modelCard.previewDrop.test.js | 42 ++++++++++++++++++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index f1933d69..5ecdcc20 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -9,6 +9,7 @@ import { bulkManager } from '../managers/BulkManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; import { escapeHtml, escapeAttribute } from './shared/utils.js'; +import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js'; export class SidebarManager { constructor() { @@ -252,6 +253,9 @@ export class SidebarManager { if (dataTransfer) { dataTransfer.effectAllowed = 'move'; dataTransfer.setData('text/plain', filePaths.join(',')); + // Tag the drag as an internal card drag so preview-drop handlers on + // other cards ignore it (no highlight, no preview replacement). + dataTransfer.setData(MODEL_CARD_DRAG_MIME_TYPE, filePaths.join(',')); try { dataTransfer.setData('application/json', JSON.stringify({ filePaths })); } catch (error) { diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index f51c2741..f6252f64 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -3,7 +3,7 @@ import { state, getCurrentPageState } from '../../state/index.js'; import { showModelModal } from './ModelModal.js'; import { bulkManager } from '../../managers/BulkManager.js'; import { modalManager } from '../../managers/ModalManager.js'; -import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js'; +import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js'; import { MODEL_TYPES } from '../../api/apiConfig.js'; import { getModelApiClient } from '../../api/modelApiFactory.js'; import { showDeleteModal } from '../../utils/modalUtils.js'; @@ -455,6 +455,9 @@ function showExampleAccessModal(card, modelType) { export function createModelCard(model, modelType) { const card = document.createElement('div'); card.className = 'model-card'; // Reuse the same class for styling + // Always draggable (move-to-folder in the sidebar). Accidental micro-drags + // from click jitter are rendered harmless by the preview-drop handlers + // below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE. card.draggable = true; card.dataset.sha256 = model.sha256; card.dataset.filepath = model.file_path; @@ -647,7 +650,7 @@ export function createModelCard(model, modelType) {
${isVideo ? `` : - `${model.model_name}` + `${model.model_name}` }
${shouldBlur ? @@ -741,6 +744,11 @@ export function createModelCard(model, modelType) { // Dropping an image/video onto the card replaces the model preview via the // existing replace-preview endpoint (overwrites file on disk, refreshes card). + // Internal card drags (move-to-folder) are tagged with a custom MIME type by + // SidebarManager and must be ignored here entirely: no highlight, no upload. + const isInternalCardDrag = (event) => + Boolean(event.dataTransfer?.types?.includes(MODEL_CARD_DRAG_MIME_TYPE)); + const preventDragDefaults = (event) => { event.preventDefault(); event.stopPropagation(); @@ -748,17 +756,20 @@ export function createModelCard(model, modelType) { ['dragenter', 'dragover'].forEach((eventName) => { card.addEventListener(eventName, (event) => { + if (isInternalCardDrag(event)) return; preventDragDefaults(event); card.classList.add('drag-over'); }); }); card.addEventListener('dragleave', (event) => { + if (isInternalCardDrag(event)) return; preventDragDefaults(event); card.classList.remove('drag-over'); }); card.addEventListener('drop', (event) => { + if (isInternalCardDrag(event)) return; preventDragDefaults(event); card.classList.remove('drag-over'); diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index c191ee3a..97f83b17 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -87,6 +87,10 @@ export const BASE_MODELS = { UNKNOWN: "Other" }; +// Custom dataTransfer MIME type tagging internal model-card drags (move-to-folder). +// Preview-drop handlers use it to ignore drags that did not come from the OS file system. +export const MODEL_CARD_DRAG_MIME_TYPE = 'application/x-lora-manager-model-card'; + // Model sub-type display names (new canonical field: sub_type) export const MODEL_SUBTYPE_DISPLAY_NAMES = { // LoRA sub-types diff --git a/tests/frontend/components/modelCard.previewDrop.test.js b/tests/frontend/components/modelCard.previewDrop.test.js index 10c9b515..504e98b4 100644 --- a/tests/frontend/components/modelCard.previewDrop.test.js +++ b/tests/frontend/components/modelCard.previewDrop.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MODEL_CARD_DRAG_MIME_TYPE } from '../../../static/js/utils/constants.js'; const { MODEL_CARD_MODULE, @@ -108,9 +109,9 @@ describe('ModelCard drag & drop preview upload', () => { return createModelCard(model, 'loras'); } - function dispatchDrop(card, files) { + function dispatchDrop(card, files, types = []) { const event = new Event('drop', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'dataTransfer', { value: { files } }); + Object.defineProperty(event, 'dataTransfer', { value: { files, types } }); card.dispatchEvent(event); return event; } @@ -179,4 +180,41 @@ describe('ModelCard drag & drop preview upload', () => { expect(event.defaultPrevented).toBe(true); expect(card.classList.contains('drag-over')).toBe(false); }); + + it('ignores drops tagged as internal card drags (move-to-folder)', () => { + const card = createCard(); + const file = new File(['data'], 'preview.png', { type: 'image/png' }); + + const event = dispatchDrop(card, [file], [MODEL_CARD_DRAG_MIME_TYPE]); + + expect(uploadPreviewMock).not.toHaveBeenCalled(); + expect(showToastMock).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + expect(card.classList.contains('drag-over')).toBe(false); + }); + + it('does not highlight or intercept internal card drags during dragover', () => { + const card = createCard(); + + const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }); + Object.defineProperty(dragOverEvent, 'dataTransfer', { + value: { types: [MODEL_CARD_DRAG_MIME_TYPE] }, + }); + card.dispatchEvent(dragOverEvent); + + expect(dragOverEvent.defaultPrevented).toBe(false); + expect(card.classList.contains('drag-over')).toBe(false); + }); + + it('keeps the card draggable (move-to-folder) but the preview image non-draggable', () => { + const card = createCard(); + + // The card itself must stay draggable for sidebar move-to-folder drags. + expect(card.draggable).toBe(true); + // The preview image must not start a native image drag: the browser would + // synthesize a File payload from it, which the drop handler would mistake + // for an external preview replacement. + const img = card.querySelector('.card-preview img'); + expect(img.getAttribute('draggable')).toBe('false'); + }); });