Skip to content

Commit 13c8a5b

Browse files
committed
drag
1 parent 1b43c18 commit 13c8a5b

File tree

6 files changed

+228
-2
lines changed

6 files changed

+228
-2
lines changed

src/components/load3d/Load3D.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
:cleanup="cleanup"
1515
:loading="loading"
1616
:loading-message="loadingMessage"
17+
:on-model-drop="handleModelDrop"
1718
/>
1819
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
1920
<Load3DControls
@@ -128,6 +129,7 @@ const {
128129
handleClearRecording,
129130
handleBackgroundImageUpdate,
130131
handleExportModel,
132+
handleModelDrop,
131133
cleanup
132134
} = useLoad3d(node as Ref<LGraphNode | null>)
133135

src/components/load3d/Load3DScene.vue

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,89 @@
1010
@mousemove.stop
1111
@mouseup.stop
1212
@contextmenu.stop.prevent
13+
@dragover.prevent.stop="handleDragOver"
14+
@dragleave.stop="handleDragLeave"
15+
@drop.prevent.stop="handleDrop"
1316
>
1417
<LoadingOverlay
1518
ref="loadingOverlayRef"
1619
:loading="props.loading"
1720
:loading-message="props.loadingMessage"
1821
/>
22+
<div
23+
v-if="isDragging"
24+
class="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm pointer-events-none"
25+
>
26+
<div
27+
class="px-6 py-4 bg-blue-500/20 border-2 border-dashed border-blue-400 rounded-lg text-blue-100 text-lg font-medium"
28+
>
29+
{{ dragMessage }}
30+
</div>
31+
</div>
1932
</div>
2033
</template>
2134

2235
<script setup lang="ts">
2336
import { onMounted, onUnmounted, ref } from 'vue'
2437
2538
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
39+
import { t } from '@/i18n'
40+
import { useToastStore } from '@/platform/updates/common/toastStore'
2641
2742
const props = defineProps<{
2843
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>
2944
cleanup: () => void
3045
loading: boolean
3146
loadingMessage: string
47+
onModelDrop?: (file: File) => void | Promise<void>
3248
}>()
3349
3450
const container = ref<HTMLElement | null>(null)
3551
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
52+
const isDragging = ref(false)
53+
const dragMessage = ref('')
54+
55+
const SUPPORTED_EXTENSIONS = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
56+
57+
function isValidModelFile(file: File): boolean {
58+
const fileName = file.name.toLowerCase()
59+
return SUPPORTED_EXTENSIONS.some((ext) => fileName.endsWith(ext))
60+
}
61+
62+
function handleDragOver(event: DragEvent) {
63+
if (!event.dataTransfer) return
64+
65+
const hasFiles = event.dataTransfer.types.includes('Files')
66+
67+
if (!hasFiles) return
68+
69+
isDragging.value = true
70+
71+
event.dataTransfer.dropEffect = 'copy'
72+
dragMessage.value = t('load3d.dropToLoad')
73+
}
74+
75+
function handleDragLeave() {
76+
isDragging.value = false
77+
}
78+
79+
async function handleDrop(event: DragEvent) {
80+
isDragging.value = false
81+
82+
if (!event.dataTransfer || !props.onModelDrop) return
83+
84+
const files = Array.from(event.dataTransfer.files)
85+
86+
if (files.length === 0) return
87+
88+
const modelFile = files.find(isValidModelFile)
89+
90+
if (modelFile) {
91+
await props.onModelDrop(modelFile)
92+
} else {
93+
useToastStore().addAlert(t('load3d.unsupportedFileType'))
94+
}
95+
}
3696
3797
onMounted(() => {
3898
if (container.value) {

src/components/load3d/Load3dViewerContent.vue

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,20 @@
1111
ref="containerRef"
1212
class="absolute w-full h-full comfy-load-3d-viewer"
1313
@resize="viewer.handleResize"
14+
@dragover.prevent.stop="handleDragOver"
15+
@dragleave.stop="handleDragLeave"
16+
@drop.prevent.stop="handleDrop"
1417
/>
18+
<div
19+
v-if="isDragging"
20+
class="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm pointer-events-none"
21+
>
22+
<div
23+
class="px-6 py-4 bg-blue-500/20 border-2 border-dashed border-blue-400 rounded-lg text-blue-100 text-lg font-medium"
24+
>
25+
{{ dragMessage }}
26+
</div>
27+
</div>
1528
</div>
1629

1730
<div class="w-72 flex flex-col">
@@ -77,13 +90,24 @@ import ModelControls from '@/components/load3d/controls/viewer/ViewerModelContro
7790
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
7891
import { t } from '@/i18n'
7992
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
93+
import { useToastStore } from '@/platform/updates/common/toastStore'
8094
import { useLoad3dService } from '@/services/load3dService'
8195
import { useDialogStore } from '@/stores/dialogStore'
8296
8397
const props = defineProps<{
8498
node: LGraphNode
8599
}>()
86100
101+
// Drag and drop state
102+
const isDragging = ref(false)
103+
const dragMessage = ref('')
104+
const SUPPORTED_EXTENSIONS = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
105+
106+
function isValidModelFile(file: File): boolean {
107+
const fileName = file.name.toLowerCase()
108+
return SUPPORTED_EXTENSIONS.some((ext) => fileName.endsWith(ext))
109+
}
110+
87111
const viewerContentRef = ref<HTMLDivElement>()
88112
const containerRef = ref<HTMLDivElement>()
89113
const mainContentRef = ref<HTMLDivElement>()
@@ -92,6 +116,41 @@ const mutationObserver = ref<MutationObserver | null>(null)
92116
93117
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
94118
119+
function handleDragOver(event: DragEvent) {
120+
if (!event.dataTransfer) return
121+
122+
const hasFiles = event.dataTransfer.types.includes('Files')
123+
124+
if (!hasFiles) return
125+
126+
isDragging.value = true
127+
128+
event.dataTransfer.dropEffect = 'copy'
129+
dragMessage.value = t('load3d.dropToLoad')
130+
}
131+
132+
function handleDragLeave() {
133+
isDragging.value = false
134+
}
135+
136+
async function handleDrop(event: DragEvent) {
137+
isDragging.value = false
138+
139+
if (!event.dataTransfer) return
140+
141+
const files = Array.from(event.dataTransfer.files)
142+
143+
if (files.length === 0) return
144+
145+
const modelFile = files.find(isValidModelFile)
146+
147+
if (modelFile) {
148+
await viewer.handleModelDrop(modelFile)
149+
} else {
150+
useToastStore().addAlert(t('load3d.unsupportedFileType'))
151+
}
152+
}
153+
95154
onMounted(async () => {
96155
const source = useLoad3dService().getLoad3d(props.node)
97156
if (source && containerRef.value) {

src/composables/useLoad3d.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,61 @@ export const useLoad3d = (nodeOrRef: LGraphNode | Ref<LGraphNode | null>) => {
373373
}
374374
}
375375

376+
const handleModelDrop = async (file: File) => {
377+
if (!load3d) {
378+
useToastStore().addAlert(t('toastMessages.no3dScene'))
379+
return
380+
}
381+
382+
const node = toRaw(nodeRef.value)
383+
if (!node) return
384+
385+
try {
386+
const resourceFolder =
387+
(node.properties['Resource Folder'] as string) || ''
388+
389+
const subfolder = resourceFolder.trim()
390+
? `3d/${resourceFolder.trim()}`
391+
: '3d'
392+
393+
loading.value = true
394+
loadingMessage.value = t('load3d.uploadingModel')
395+
396+
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
397+
398+
if (!uploadedPath) {
399+
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
400+
return
401+
}
402+
403+
const modelUrl = api.apiURL(
404+
Load3dUtils.getResourceURL(
405+
...Load3dUtils.splitFilePath(uploadedPath),
406+
isPreview.value ? 'output' : 'input'
407+
)
408+
)
409+
410+
loadingMessage.value = t('load3d.loadingModel')
411+
await load3d.loadModel(modelUrl)
412+
413+
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
414+
415+
if (modelWidget) {
416+
const options = modelWidget.options as { values?: string[] } | undefined
417+
if (options?.values && !options.values.includes(uploadedPath)) {
418+
options.values.push(uploadedPath)
419+
}
420+
modelWidget.value = uploadedPath
421+
}
422+
} catch (error) {
423+
console.error('Model drop failed:', error)
424+
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
425+
} finally {
426+
loading.value = false
427+
loadingMessage.value = ''
428+
}
429+
}
430+
376431
const eventConfig = {
377432
materialModeChange: (value: string) => {
378433
modelConfig.value.materialMode = value as MaterialMode
@@ -483,6 +538,7 @@ export const useLoad3d = (nodeOrRef: LGraphNode | Ref<LGraphNode | null>) => {
483538
handleClearRecording,
484539
handleBackgroundImageUpdate,
485540
handleExportModel,
541+
handleModelDrop,
486542
cleanup
487543
}
488544
}

src/composables/useLoad3dViewer.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import { t } from '@/i18n'
1111
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
1212
import { useToastStore } from '@/platform/updates/common/toastStore'
13+
import { api } from '@/scripts/api'
1314
import { useLoad3dService } from '@/services/load3dService'
1415

1516
interface Load3dViewerState {
@@ -367,6 +368,49 @@ export const useLoad3dViewer = (node: LGraphNode) => {
367368
}
368369
}
369370

371+
const handleModelDrop = async (file: File) => {
372+
if (!load3d) {
373+
useToastStore().addAlert(t('toastMessages.no3dScene'))
374+
return
375+
}
376+
377+
try {
378+
const resourceFolder =
379+
(node.properties['Resource Folder'] as string) || ''
380+
const subfolder = resourceFolder.trim()
381+
? `3d/${resourceFolder.trim()}`
382+
: '3d'
383+
384+
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
385+
386+
if (!uploadedPath) {
387+
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
388+
return
389+
}
390+
391+
const modelUrl = api.apiURL(
392+
Load3dUtils.getResourceURL(
393+
...Load3dUtils.splitFilePath(uploadedPath),
394+
'input'
395+
)
396+
)
397+
398+
await load3d.loadModel(modelUrl)
399+
400+
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
401+
if (modelWidget) {
402+
const options = modelWidget.options as { values?: string[] } | undefined
403+
if (options?.values && !options.values.includes(uploadedPath)) {
404+
options.values.push(uploadedPath)
405+
}
406+
modelWidget.value = uploadedPath
407+
}
408+
} catch (error) {
409+
console.error('Model drop failed:', error)
410+
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
411+
}
412+
}
413+
370414
const cleanup = () => {
371415
load3d?.remove()
372416
load3d = null
@@ -396,6 +440,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
396440
applyChanges,
397441
refreshViewport,
398442
handleBackgroundImageUpdate,
443+
handleModelDrop,
399444
cleanup
400445
}
401446
}

src/locales/en/main.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,7 +1689,10 @@
16891689
"exportSettings": "Export Settings",
16901690
"modelSettings": "Model Settings"
16911691
},
1692-
"openIn3DViewer": "Open in 3D Viewer"
1692+
"openIn3DViewer": "Open in 3D Viewer",
1693+
"dropToLoad": "Drop 3D model to load",
1694+
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
1695+
"uploadingModel": "Uploading 3D model..."
16931696
},
16941697
"toastMessages": {
16951698
"nothingToQueue": "Nothing to queue",
@@ -1730,7 +1733,8 @@
17301733
"failedToConvertToSubgraph": "Failed to convert items to subgraph",
17311734
"failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer",
17321735
"failedToLoadBackgroundImage": "Failed to load background image",
1733-
"failedToLoadModel": "Failed to load 3D model"
1736+
"failedToLoadModel": "Failed to load 3D model",
1737+
"modelLoadedSuccessfully": "3D model loaded successfully"
17341738
},
17351739
"auth": {
17361740
"apiKey": {

0 commit comments

Comments
 (0)