Skip to content

Commit 78a4869

Browse files
committed
v 4.10.1
1 parent 40b3e9c commit 78a4869

3 files changed

Lines changed: 116 additions & 44 deletions

File tree

module.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"id": "investigation-board",
33
"title": "Investigation Board",
44
"description": "<p>A Foundry VTT module that lets everyone create, edit, and move sticky and photo notes on the scene as collaborative investigation tools.</p>",
5-
"version": "4.10.0",
5+
"version": "4.10.1",
66
"socket": true,
77
"authors": [
88
{

scripts/main.js

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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 = /\.(png|jpe?g|gif|webp|avif|svg|bmp|tiff?)(\?.*)?$/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
359421
Hooks.on("preCreateDrawing", (drawing, data, options, userId) => {
360422
// Only affect Investigation Board notes

scripts/utils/creation-utils.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -764,10 +764,13 @@ export async function importPlaylistAsNotes(playlist) {
764764
}
765765

766766
/**
767-
* Creates a Handout Note from a raw image path (e.g. uploaded from clipboard).
767+
* Creates a Handout Note from a raw image path (e.g. uploaded from clipboard or drag-drop).
768768
* @param {string} imagePath - The path to the uploaded image
769+
* @param {object} [options]
770+
* @param {number|null} [options.x] - Canvas world X to center the note on. Defaults to viewport center.
771+
* @param {number|null} [options.y] - Canvas world Y to center the note on. Defaults to viewport center.
769772
*/
770-
export async function createHandoutNoteFromImage(imagePath) {
773+
export async function createHandoutNoteFromImage(imagePath, { x: dropX = null, y: dropY = null } = {}) {
771774
const scene = canvas.scene;
772775
if (!scene) {
773776
ui.notifications.error("Cannot create note: No active scene.");
@@ -778,10 +781,6 @@ export async function createHandoutNoteFromImage(imagePath) {
778781
const handoutH = game.settings.get(MODULE_ID, "handoutNoteHeight") || 400;
779782
const sceneScale = getEffectiveScale();
780783

781-
const viewCenter = canvas.stage.pivot;
782-
const x = viewCenter.x - (handoutW * sceneScale) / 2;
783-
const y = viewCenter.y - (handoutH * sceneScale) / 2;
784-
785784
// Attempt to get natural dimensions for better initial sizing
786785
let finalWidth = handoutW;
787786
let finalHeight = handoutH;
@@ -808,6 +807,17 @@ export async function createHandoutNoteFromImage(imagePath) {
808807
console.error("Investigation Board: Failed to get image dimensions for handout", err);
809808
}
810809

810+
// Center the note on the drop point, or fall back to viewport center
811+
let x, y;
812+
if (dropX !== null && dropY !== null) {
813+
x = dropX - (finalWidth * sceneScale) / 2;
814+
y = dropY - (finalHeight * sceneScale) / 2;
815+
} else {
816+
const viewCenter = canvas.stage.pivot;
817+
x = viewCenter.x - (finalWidth * sceneScale) / 2;
818+
y = viewCenter.y - (finalHeight * sceneScale) / 2;
819+
}
820+
811821
const created = await collaborativeCreate({
812822
type: "r",
813823
author: game.user.id,

0 commit comments

Comments
 (0)