@@ -303,46 +303,12 @@ Hooks.once("ready", async () => {
303303 if ( ! file ) continue ;
304304
305305 ui . notifications . info ( "Investigation Board: Processing clipboard image..." ) ;
306-
307- const folderPath = "assets/ib-handouts" ;
308-
309306 try {
310- // 1. Ensure directories exist (assets -> assets/ib-handouts)
311- try {
312- await FilePicker . browse ( "data" , "assets" ) ;
313- } catch {
314- await FilePicker . createDirectory ( "data" , "assets" ) ;
315- }
316-
317- try {
318- await FilePicker . browse ( "data" , folderPath ) ;
319- } catch {
320- await FilePicker . createDirectory ( "data" , folderPath ) ;
321- }
322-
323- // 2. Upload file
324- const timestamp = Date . now ( ) ;
325- const uniqueId = foundry . utils . randomID ( ) ;
326-
327- // Use original name extension if available, defaulting to png
328- let ext = "png" ;
329- if ( file . name ) {
330- const parts = file . name . split ( "." ) ;
331- if ( parts . length > 1 ) ext = parts . pop ( ) ;
332- }
333- const newFileName = `pasted_handout_${ timestamp } _${ uniqueId } .${ ext } ` ;
334-
335- // Create a new File object with the correct name to ensure it's respected
336- const renamedFile = new File ( [ file ] , newFileName , { type : file . type } ) ;
337-
338- const response = await FilePicker . upload ( "data" , folderPath , renamedFile , { fileName : newFileName } ) ;
339-
340- // 3. Create Note
341- if ( response . path ) {
342- await createHandoutNoteFromImage ( response . path ) ;
307+ const path = await _uploadImageHandout ( file , "pasted" ) ;
308+ if ( path ) {
309+ await createHandoutNoteFromImage ( path ) ;
343310 ui . notifications . info ( "Investigation Board: Created handout from clipboard." ) ;
344311 }
345-
346312 } catch ( err ) {
347313 console . error ( "Investigation Board: Paste failed" , err ) ;
348314 ui . notifications . warn ( "Investigation Board: Failed to upload pasted image. Check console for details." ) ;
@@ -353,8 +319,104 @@ Hooks.once("ready", async () => {
353319 }
354320 }
355321 } ) ;
322+
323+ // Drag-and-drop image listener — creates a handout note at the drop position.
324+ // Handles file drops (WhatsApp, Google Images, desktop) and URL-only drops (Discord web).
325+ // Attached to document (like the paste listener) so it works regardless of canvas init timing.
326+ document . addEventListener ( "dragover" , ( e ) => {
327+ if ( ! InvestigationBoardState . isActive ) return ;
328+ if ( e . target ?. id !== "board" ) return ; // only intercept drags over the game canvas
329+ if ( ! game . permissions . DRAWING_CREATE . includes ( game . user . role ) ) return ;
330+
331+ const hasImageFile = [ ...e . dataTransfer . items ] . some ( i => i . kind === "file" && i . type . startsWith ( "image/" ) ) ;
332+ const hasUriList = e . dataTransfer . types . includes ( "text/uri-list" ) ;
333+ if ( hasImageFile || hasUriList ) e . preventDefault ( ) ;
334+ } ) ;
335+
336+ document . addEventListener ( "drop" , async ( e ) => {
337+ if ( ! InvestigationBoardState . isActive ) return ;
338+ if ( e . target ?. id !== "board" ) return ; // only intercept drags over the game canvas
339+ if ( ! game . permissions . DRAWING_CREATE . includes ( game . user . role ) ) return ;
340+
341+ // Convert screen coordinates to canvas world coordinates
342+ const boardEl = e . target ;
343+ const rect = boardEl . getBoundingClientRect ( ) ;
344+ const worldPos = canvas . stage . toLocal ( new PIXI . Point ( e . clientX - rect . left , e . clientY - rect . top ) ) ;
345+
346+ // Priority 1: actual image file(s) in the drop (WhatsApp, Google Images, desktop)
347+ const imageFiles = [ ...e . dataTransfer . files ] . filter ( f => f . type . startsWith ( "image/" ) ) ;
348+ if ( imageFiles . length > 0 ) {
349+ e . preventDefault ( ) ;
350+ e . stopPropagation ( ) ;
351+ ui . notifications . info ( "Investigation Board: Processing dropped image..." ) ;
352+ try {
353+ const path = await _uploadImageHandout ( imageFiles [ 0 ] , "dropped" ) ;
354+ if ( path ) await createHandoutNoteFromImage ( path , { x : worldPos . x , y : worldPos . y } ) ;
355+ } catch ( err ) {
356+ console . error ( "Investigation Board: Drop failed" , err ) ;
357+ ui . notifications . warn ( "Investigation Board: Failed to upload dropped image. Check console for details." ) ;
358+ }
359+ return ;
360+ }
361+
362+ // Priority 2: URL-only drop (Discord web, links from browser tabs)
363+ const uriList = e . dataTransfer . getData ( "text/uri-list" ) ;
364+ if ( ! uriList ) return ;
365+
366+ const url = uriList . split ( "\n" ) . map ( u => u . trim ( ) ) . find ( u => u && ! u . startsWith ( "#" ) ) ;
367+ if ( ! url ) return ;
368+
369+ // Only treat it as an image if the URL path ends with a known image extension
370+ const imageExtPattern = / \. ( p n g | j p e ? g | g i f | w e b p | a v i f | s v g | b m p | t i f f ? ) ( \? .* ) ? $ / i;
371+ if ( ! imageExtPattern . test ( url ) ) return ;
372+
373+ e . preventDefault ( ) ;
374+ e . stopPropagation ( ) ;
375+ ui . notifications . info ( "Investigation Board: Fetching dropped image URL..." ) ;
376+ try {
377+ const res = await fetch ( url ) ;
378+ if ( ! res . ok ) throw new Error ( `HTTP ${ res . status } ` ) ;
379+ const blob = await res . blob ( ) ;
380+ const ext = ( blob . type . split ( "/" ) [ 1 ] || "png" ) . replace ( "jpeg" , "jpg" ) ;
381+ const file = new File ( [ blob ] , `dropped.${ ext } ` , { type : blob . type } ) ;
382+ const path = await _uploadImageHandout ( file , "dropped" ) ;
383+ if ( path ) await createHandoutNoteFromImage ( path , { x : worldPos . x , y : worldPos . y } ) ;
384+ } catch ( err ) {
385+ console . error ( "Investigation Board: Could not fetch image URL" , err ) ;
386+ ui . notifications . warn ( "Investigation Board: Couldn't download that image — try right-clicking it → Copy Image, then paste (Ctrl+V) instead." ) ;
387+ }
388+ } ) ;
356389} ) ;
357390
391+ /**
392+ * Uploads an image File to assets/ib-handouts/ and returns the server path.
393+ * @param {File } file
394+ * @param {string } [prefix] - Filename prefix, e.g. "pasted" or "dropped"
395+ * @returns {Promise<string|null> }
396+ */
397+ async function _uploadImageHandout ( file , prefix = "handout" ) {
398+ const folderPath = "assets/ib-handouts" ;
399+
400+ try { await FilePicker . browse ( "data" , "assets" ) ; }
401+ catch { await FilePicker . createDirectory ( "data" , "assets" ) ; }
402+
403+ try { await FilePicker . browse ( "data" , folderPath ) ; }
404+ catch { await FilePicker . createDirectory ( "data" , folderPath ) ; }
405+
406+ const timestamp = Date . now ( ) ;
407+ const uniqueId = foundry . utils . randomID ( ) ;
408+ let ext = "png" ;
409+ if ( file . name && file . name . includes ( "." ) ) {
410+ ext = file . name . split ( "." ) . pop ( ) ;
411+ } else if ( file . type ) {
412+ ext = ( file . type . split ( "/" ) [ 1 ] || "png" ) . replace ( "jpeg" , "jpg" ) ;
413+ }
414+ const newFileName = `${ prefix } _handout_${ timestamp } _${ uniqueId } .${ ext } ` ;
415+ const renamedFile = new File ( [ file ] , newFileName , { type : file . type } ) ;
416+ const response = await FilePicker . upload ( "data" , folderPath , renamedFile , { fileName : newFileName } ) ;
417+ return response . path || null ;
418+ }
419+
358420// Hook to intercept drawing creation to handle Copy/Paste logic
359421Hooks . on ( "preCreateDrawing" , ( drawing , data , options , userId ) => {
360422 // Only affect Investigation Board notes
0 commit comments