Skip to content

Commit 455ff3b

Browse files
author
Gus
committed
v5.8.0
1 parent d6629e8 commit 455ff3b

8 files changed

Lines changed: 941 additions & 128 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ https://github.com/mordachai/investigation-board/releases/download/v4.10.1/modul
1919

2020
A Foundry VTT module that lets everyone create, edit, and move sticky and photo notes on the scene. A must-have for investigative games like City of Mist, Call of Cthulhu, and all your conspiracy adventures.
2121

22+
## NEW FEATURE >> Batch Edit Connections
23+
24+
Now we have a button in the Drawing Tools that displays a panel where you can batch edit pins and yarn connections
25+
2226
## NEW FEATURE >> Stamps Overlays!
2327

2428
Wanna make your investigators feel they are in power? Let them stamp their notes! Right click >> Stamp >> Choose it. The stamp will appear below the cursor on the note.

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": "5.7.1",
5+
"version": "5.8.0",
66
"socket": true,
77
"authors": [
88
{

scripts/apps/batch-edit-dialog.js

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { MODULE_ID } from '../config.js';
2+
import { getAvailablePinFiles, resolvePinImage } from '../utils/helpers.js';
3+
import { collaborativeUpdate } from '../utils/socket-handler.js';
4+
import { drawAllConnectionLines } from '../canvas/connection-manager.js';
5+
6+
const ApplicationV2 = foundry.applications.api.ApplicationV2;
7+
const HandlebarsApplicationMixin = foundry.applications.api.HandlebarsApplicationMixin;
8+
9+
export class BatchEditDialog extends HandlebarsApplicationMixin(ApplicationV2) {
10+
11+
static DEFAULT_OPTIONS = {
12+
id: 'ib-batch-edit',
13+
tag: 'div',
14+
classes: ['investigation-board-dialog', 'ib-batch-edit-dialog'],
15+
window: {
16+
title: 'Batch Edit Notes',
17+
resizable: false,
18+
minimizable: false,
19+
icon: 'fas fa-layer-group',
20+
},
21+
position: { width: 460, height: 'auto' },
22+
};
23+
24+
static PARTS = {
25+
content: {
26+
template: 'modules/investigation-board/templates/batch-edit-dialog.html',
27+
},
28+
};
29+
30+
// null = "keep current" (don't touch this field)
31+
// '' = random/auto
32+
// 'foo' = specific filename
33+
#pinColor = null;
34+
35+
// null = keep current
36+
// '#hex' = apply this colour
37+
#connColor = null;
38+
39+
// ──────────────────────────────────────────────────────────────────────────────
40+
// Rendering
41+
// ──────────────────────────────────────────────────────────────────────────────
42+
43+
async _prepareContext(options) {
44+
const pinFiles = await getAvailablePinFiles();
45+
46+
const noteTypes = [
47+
{ id: 'sticky', label: 'Sticky Notes' },
48+
{ id: 'photo', label: 'Photo Notes' },
49+
{ id: 'index', label: 'Index Cards' },
50+
{ id: 'handout', label: 'Handouts' },
51+
{ id: 'media', label: 'Media Notes' },
52+
{ id: 'pin', label: 'Pins' },
53+
{ id: 'document', label: 'Document Notes' },
54+
];
55+
56+
const pinFileList = pinFiles.map(f => ({
57+
filename: f,
58+
src: resolvePinImage(f),
59+
label: f.replace(/\.[^.]+$/, ''),
60+
}));
61+
62+
return { noteTypes, pinFileList };
63+
}
64+
65+
_onRender(context, options) {
66+
const html = this.element;
67+
68+
// ── Scope ──────────────────────────────────────────────────────────────────
69+
html.querySelectorAll('input[name="scope"]').forEach(radio => {
70+
radio.addEventListener('change', () => this._syncScopeUI());
71+
});
72+
html.querySelector('[name="scopeType"]')?.addEventListener('change', () => this._updatePreview());
73+
this._syncScopeUI();
74+
75+
// ── Pin colour swatches ────────────────────────────────────────────────────
76+
html.querySelectorAll('.ib-batch-pin-swatch').forEach(swatch => {
77+
swatch.addEventListener('click', () => {
78+
html.querySelectorAll('.ib-batch-pin-swatch, .ib-batch-pin-keep').forEach(s => s.classList.remove('active'));
79+
swatch.classList.add('active');
80+
this.#pinColor = swatch.dataset.filename ?? null;
81+
this._updatePreview();
82+
});
83+
});
84+
85+
const keepBtn = html.querySelector('.ib-batch-pin-keep');
86+
keepBtn?.addEventListener('click', () => {
87+
html.querySelectorAll('.ib-batch-pin-swatch, .ib-batch-pin-keep').forEach(s => s.classList.remove('active'));
88+
keepBtn.classList.add('active');
89+
this.#pinColor = null;
90+
this._updatePreview();
91+
});
92+
keepBtn?.classList.add('active');
93+
94+
// ── Connection colour ──────────────────────────────────────────────────────
95+
const connCheck = html.querySelector('#ib-batch-conn-enable');
96+
const connInput = html.querySelector('[name="connColor"]');
97+
connCheck?.addEventListener('change', () => {
98+
if (connInput) connInput.disabled = !connCheck.checked;
99+
this.#connColor = connCheck?.checked ? (connInput?.value ?? '#FF0000') : null;
100+
this._updatePreview();
101+
});
102+
connInput?.addEventListener('input', () => {
103+
if (connCheck?.checked) this.#connColor = connInput.value;
104+
this._updatePreview();
105+
});
106+
107+
// ── Buttons ────────────────────────────────────────────────────────────────
108+
html.querySelector('#ib-batch-apply')?.addEventListener('click', () => this._applyChanges());
109+
html.querySelector('#ib-batch-cancel')?.addEventListener('click', () => this.close());
110+
111+
this._updatePreview();
112+
}
113+
114+
// ──────────────────────────────────────────────────────────────────────────────
115+
// Scope helpers
116+
// ──────────────────────────────────────────────────────────────────────────────
117+
118+
_syncScopeUI() {
119+
const html = this.element;
120+
const mode = html.querySelector('input[name="scope"]:checked')?.value ?? 'selected';
121+
html.querySelector('[name="scopeType"]').disabled = (mode !== 'type');
122+
this._updatePreview();
123+
}
124+
125+
_getScope() {
126+
const html = this.element;
127+
const mode = html.querySelector('input[name="scope"]:checked')?.value ?? 'selected';
128+
const value = mode === 'type' ? (html.querySelector('[name="scopeType"]')?.value ?? 'sticky') : null;
129+
return { mode, value };
130+
}
131+
132+
_allNotes() {
133+
return canvas.drawings?.placeables.filter(d => d.document.flags[MODULE_ID]?.type) ?? [];
134+
}
135+
136+
_selectedNotes() {
137+
return canvas.drawings?.controlled.filter(d => d.document.flags[MODULE_ID]?.type) ?? [];
138+
}
139+
140+
_getTargetNotes(scope) {
141+
const all = this._allNotes();
142+
if (scope.mode === 'selected') return this._selectedNotes();
143+
if (scope.mode === 'all') return all;
144+
return all.filter(d => d.document.flags[MODULE_ID].type === scope.value);
145+
}
146+
147+
// ──────────────────────────────────────────────────────────────────────────────
148+
// Preview counter
149+
// ──────────────────────────────────────────────────────────────────────────────
150+
151+
_updatePreview() {
152+
const scope = this._getScope();
153+
const targets = this._getTargetNotes(scope);
154+
const el = this.element?.querySelector('#ib-batch-preview');
155+
if (!el) return;
156+
157+
let connCount = 0;
158+
if (this.#connColor !== null) {
159+
const targetIds = new Set(targets.map(n => n.document.id));
160+
// Outgoing from each target note
161+
connCount += targets.reduce((sum, n) =>
162+
sum + (n.document.flags[MODULE_ID]?.connections?.length ?? 0), 0);
163+
// Incoming from notes outside the target set
164+
for (const note of this._allNotes()) {
165+
if (targetIds.has(note.document.id)) continue;
166+
connCount += (note.document.flags[MODULE_ID]?.connections ?? [])
167+
.filter(c => targetIds.has(c.targetId)).length;
168+
}
169+
}
170+
171+
const nLabel = `<strong>${targets.length}</strong> note${targets.length !== 1 ? 's' : ''}`;
172+
const pinPart = this.#pinColor !== null ? ' · pin colour' : '';
173+
const connPart = this.#connColor !== null
174+
? ` · <strong>${connCount}</strong> connection${connCount !== 1 ? 's' : ''}`
175+
: '';
176+
177+
el.innerHTML = targets.length
178+
? `Will affect ${nLabel}${pinPart}${connPart}`
179+
: `<em>No notes match this scope.</em>`;
180+
}
181+
182+
// ──────────────────────────────────────────────────────────────────────────────
183+
// Apply
184+
// ──────────────────────────────────────────────────────────────────────────────
185+
186+
async _applyChanges() {
187+
if (this.#pinColor === null && this.#connColor === null) {
188+
ui.notifications.warn("Investigation Board: Select at least one change to apply.");
189+
return;
190+
}
191+
192+
const scope = this._getScope();
193+
const targets = this._getTargetNotes(scope);
194+
if (!targets.length) {
195+
ui.notifications.warn("Investigation Board: No notes matched the selected scope.");
196+
return;
197+
}
198+
199+
const targetIds = new Set(targets.map(n => n.document.id));
200+
let pinCount = 0;
201+
let connCount = 0;
202+
203+
// First pass — target notes: pin colour + outgoing connection colour
204+
for (const note of targets) {
205+
const update = {};
206+
const flags = note.document.flags[MODULE_ID];
207+
208+
const isVideoMedia = flags.type === 'media' && (flags.mediaMode === 'video' || !!flags.videoPath);
209+
if (this.#pinColor !== null && flags.type !== 'handout' && !isVideoMedia) {
210+
update[`flags.${MODULE_ID}.pinColor`] = this.#pinColor;
211+
pinCount++;
212+
}
213+
214+
if (this.#connColor !== null) {
215+
const conns = flags.connections ?? [];
216+
if (conns.length) {
217+
update[`flags.${MODULE_ID}.connections`] = conns.map(c => ({ ...c, color: this.#connColor }));
218+
connCount += conns.length;
219+
}
220+
}
221+
222+
if (Object.keys(update).length) await collaborativeUpdate(note.document.id, update);
223+
}
224+
225+
// Second pass — notes outside the target set that have connections pointing INTO a target
226+
if (this.#connColor !== null) {
227+
for (const note of this._allNotes()) {
228+
if (targetIds.has(note.document.id)) continue;
229+
const conns = note.document.flags[MODULE_ID]?.connections ?? [];
230+
const changed = conns.filter(c => targetIds.has(c.targetId));
231+
if (!changed.length) continue;
232+
const updated = conns.map(c =>
233+
targetIds.has(c.targetId) ? { ...c, color: this.#connColor } : c
234+
);
235+
await collaborativeUpdate(note.document.id, { [`flags.${MODULE_ID}.connections`]: updated });
236+
connCount += changed.length;
237+
}
238+
}
239+
240+
drawAllConnectionLines();
241+
242+
const parts = [];
243+
if (pinCount) parts.push(`${pinCount} pin${pinCount !== 1 ? 's' : ''}`);
244+
if (connCount) parts.push(`${connCount} connection${connCount !== 1 ? 's' : ''}`);
245+
ui.notifications.info(`Investigation Board: Updated ${parts.join(' and ')}.`);
246+
this.close();
247+
}
248+
}

scripts/canvas/connection-manager.js

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,10 @@ export function updatePins() {
269269
drawing.pinSprite.removeAllListeners();
270270
drawing.pinSprite.on('pointerdown', (event) => {
271271
if (event.button === 2 && InvestigationBoardState.isActive && noteData?.type === "pin") {
272-
// Pin-only notes: right-click opens the convert/manage context menu.
273-
// For all other note types, right-click falls through to onPinPointerDown which
274-
// starts connection mode; the subsequent rightclick event on canvas then triggers
275-
// onCanvasRightClick and shows the "create connected note" menu.
272+
// Right-click on a standalone pin: open the context menu.
273+
// _showContextMenu synchronously cancels connection mode (resetPinConnectionState)
274+
// before its first await, which removes the stage-level onCanvasRightClick listener
275+
// before pointerup/rightclick can fire it. The Create-note menu never appears.
276276
event.stopPropagation();
277277
drawing._showContextMenu(event);
278278
return;
@@ -876,6 +876,24 @@ export function resetPinConnectionState() {
876876
canvas.stage.off('rightclick', onCanvasRightClick);
877877
}
878878

879+
/**
880+
* Returns true when a yarn connection is currently being drawn (first pin selected).
881+
* Used by the keybinding handler to decide whether to consume the Escape key.
882+
*/
883+
export function isInConnectionMode() {
884+
return pinConnectionFirstNote !== null;
885+
}
886+
887+
/**
888+
* Start connection-creation mode from a given drawing programmatically —
889+
* equivalent to a user clicking that note's pin. Safe to call from context menus.
890+
* @param {CustomDrawing} drawing
891+
*/
892+
export function beginConnectionFrom(drawing) {
893+
if (!InvestigationBoardState.isActive) return;
894+
_startConnectionMode(drawing);
895+
}
896+
879897
export function cleanupConnectionLines() {
880898
if (connectionLinesContainer) {
881899
try {

0 commit comments

Comments
 (0)