Initial Commit

This commit is contained in:
Florian Zumpe 2026-05-19 12:37:08 +02:00
parent 519224bdcc
commit d2eda61c58
5 changed files with 390 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# OS / editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Node / build artifacts if the module is extended later
node_modules/
dist/
build/
*.log
# Archives
*.zip

18
README.md Normal file
View File

@ -0,0 +1,18 @@
# User Notes
Foundry VTT v14 Modul: Fügt der Benutzerliste ein kleines Notiz-Icon hinzu.
Ein Klick öffnet ein lokales Notizfenster.
## Speicherung
Die Notizen werden im `window.localStorage` des Browsers gespeichert, getrennt nach:
- Modul-ID
- Welt-ID
- Benutzer-ID
Die Daten werden nicht auf dem Foundry-Server gespeichert und nicht zwischen Browsern synchronisiert.
## Installation
Ordner `user-notes` nach `{FoundryUserData}/Data/modules/` kopieren und das Modul in der Welt aktivieren.

22
module.json Normal file
View File

@ -0,0 +1,22 @@
{
"id": "user-notes",
"title": "User Notes",
"description": "User Notes: ein einfaches lokales Notizfenster für Foundry VTT v14. Notizen werden im Browser-localStorage pro Welt und Benutzer gespeichert.",
"version": "1.0.0",
"authors": [
{
"name": "ChatGPT"
}
],
"compatibility": {
"minimum": "14",
"verified": "14"
},
"scripts": [
"scripts/user-notes.js"
],
"styles": [
"styles/user-notes.css"
],
"languages": []
}

236
scripts/user-notes.js Normal file
View File

@ -0,0 +1,236 @@
/*
* User Notes for Foundry VTT v14
* Stores notes in window.localStorage per world and per user.
*/
const LBN_MODULE_ID = "user-notes";
const LBN_WINDOW_ID = "lbn-notes-window";
const LBN_BUTTON_ID = "lbn-open-notes";
let lbnSaveTimer = null;
let lbnObserverTimer = null;
function lbnStorageKey() {
const worldId = game.world?.id ?? game.world?.data?.id ?? "unknown-world";
const userId = game.user?.id ?? "unknown-user";
return `${LBN_MODULE_ID}.${worldId}.${userId}.notes`;
}
function lbnPositionKey() {
const worldId = game.world?.id ?? game.world?.data?.id ?? "unknown-world";
const userId = game.user?.id ?? "unknown-user";
return `${LBN_MODULE_ID}.${worldId}.${userId}.position`;
}
function lbnLoadNotes() {
return window.localStorage.getItem(lbnStorageKey()) ?? "";
}
function lbnSaveNotes(value) {
window.localStorage.setItem(lbnStorageKey(), value);
}
function lbnSetStatus(text) {
const status = document.querySelector(`#${LBN_WINDOW_ID} .lbn-status`);
if (status) status.textContent = text;
}
function lbnDebouncedSave(value) {
window.clearTimeout(lbnSaveTimer);
lbnSetStatus("Ungespeichert …");
lbnSaveTimer = window.setTimeout(() => {
lbnSaveNotes(value);
lbnSetStatus("Gespeichert");
}, 250);
}
function lbnRestorePosition(win) {
try {
const raw = window.localStorage.getItem(lbnPositionKey());
if (!raw) return;
const pos = JSON.parse(raw);
if (Number.isFinite(pos.left)) win.style.left = `${pos.left}px`;
if (Number.isFinite(pos.top)) win.style.top = `${pos.top}px`;
if (Number.isFinite(pos.width)) win.style.width = `${pos.width}px`;
if (Number.isFinite(pos.height)) win.style.height = `${pos.height}px`;
} catch (err) {
console.warn(`${LBN_MODULE_ID} | Could not restore note window position`, err);
}
}
function lbnSavePosition(win) {
const rect = win.getBoundingClientRect();
window.localStorage.setItem(lbnPositionKey(), JSON.stringify({
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height)
}));
}
function lbnMakeDraggable(win) {
const handle = win.querySelector(".lbn-titlebar");
if (!handle) return;
let drag = null;
handle.addEventListener("pointerdown", event => {
if (event.target.closest("button")) return;
const rect = win.getBoundingClientRect();
drag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
left: rect.left,
top: rect.top
};
handle.setPointerCapture(event.pointerId);
event.preventDefault();
});
handle.addEventListener("pointermove", event => {
if (!drag || drag.pointerId !== event.pointerId) return;
const nextLeft = Math.max(0, Math.min(window.innerWidth - 80, drag.left + event.clientX - drag.startX));
const nextTop = Math.max(0, Math.min(window.innerHeight - 40, drag.top + event.clientY - drag.startY));
win.style.left = `${nextLeft}px`;
win.style.top = `${nextTop}px`;
});
handle.addEventListener("pointerup", event => {
if (!drag || drag.pointerId !== event.pointerId) return;
drag = null;
lbnSavePosition(win);
});
}
function lbnOpenNotes() {
let win = document.getElementById(LBN_WINDOW_ID);
if (win) {
win.hidden = false;
win.classList.add("active");
win.querySelector("textarea")?.focus();
return;
}
win = document.createElement("section");
win.id = LBN_WINDOW_ID;
win.className = "lbn-notes-window";
win.innerHTML = `
<header class="lbn-titlebar">
<div class="lbn-title">
<i class="fas fa-note-sticky" aria-hidden="true"></i>
<span>User Notes</span>
</div>
<div class="lbn-controls">
<span class="lbn-status">Gespeichert</span>
<button type="button" class="lbn-save" title="Jetzt speichern">
<i class="fas fa-save" aria-hidden="true"></i>
</button>
<button type="button" class="lbn-close" title="Schließen">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</header>
<textarea class="lbn-textarea" spellcheck="true" placeholder="Notizen für diese Welt und diesen Benutzer …"></textarea>
`;
document.body.appendChild(win);
const textarea = win.querySelector(".lbn-textarea");
textarea.value = lbnLoadNotes();
textarea.addEventListener("input", event => {
lbnDebouncedSave(event.currentTarget.value);
});
win.querySelector(".lbn-save").addEventListener("click", () => {
lbnSaveNotes(textarea.value);
lbnSetStatus("Gespeichert");
});
win.querySelector(".lbn-close").addEventListener("click", () => {
lbnSaveNotes(textarea.value);
lbnSavePosition(win);
win.hidden = true;
});
new ResizeObserver(() => lbnSavePosition(win)).observe(win);
lbnRestorePosition(win);
lbnMakeDraggable(win);
textarea.focus();
}
function lbnAsHTMLElement(html) {
if (html instanceof HTMLElement) return html;
if (html?.[0] instanceof HTMLElement) return html[0]; // legacy jQuery-style hook argument
if (html?.element instanceof HTMLElement) return html.element;
return null;
}
function lbnFindPlayersElement(renderedElement = null) {
return renderedElement?.id === "players"
? renderedElement
: renderedElement?.querySelector?.("#players")
?? document.querySelector("#players")
?? document.querySelector("[data-application-id='players']")
?? document.querySelector(".players");
}
function lbnInjectButton(renderedElement = null) {
const players = lbnFindPlayersElement(renderedElement);
if (!players || players.querySelector(`#${LBN_BUTTON_ID}`)) return;
const header =
players.querySelector(".window-header")
?? players.querySelector("header")
?? players.querySelector("h3")
?? players.querySelector("h2")
?? players;
const button = document.createElement("button");
button.id = LBN_BUTTON_ID;
button.type = "button";
button.className = "lbn-player-note-button";
button.title = "User Notes öffnen";
button.setAttribute("aria-label", "User Notes öffnen");
button.innerHTML = `<i class="fas fa-note-sticky" aria-hidden="true"></i>`;
button.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
lbnOpenNotes();
});
header.appendChild(button);
}
Hooks.once("ready", () => {
lbnInjectButton();
// Fallback: Die Spieler-/Benutzerliste kann bei Theme-, Layout- oder Popout-Änderungen neu gerendert werden.
const observer = new MutationObserver(() => {
window.clearTimeout(lbnObserverTimer);
lbnObserverTimer = window.setTimeout(() => lbnInjectButton(), 100);
});
observer.observe(document.body, { childList: true, subtree: true });
});
Hooks.on("renderApplicationV2", (app, html) => {
const element = lbnAsHTMLElement(html) ?? app?.element ?? null;
if (app?.constructor?.name === "Players" || lbnFindPlayersElement(element)) {
window.queueMicrotask(() => lbnInjectButton(element));
}
});
// Kompatibilitäts-Fallback für Installationen/Module, die noch einen spezifischen PlayerList-Hook auslösen.
Hooks.on("renderPlayerList", (_app, html) => {
const element = lbnAsHTMLElement(html);
window.queueMicrotask(() => lbnInjectButton(element));
});

100
styles/user-notes.css Normal file
View File

@ -0,0 +1,100 @@
.lbn-player-note-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.65rem;
height: 1.65rem;
margin-left: 0.35rem;
padding: 0;
border: 1px solid var(--color-border-light-primary, #7a695a);
border-radius: 4px;
background: rgba(0, 0, 0, 0.18);
color: var(--color-text-light-highlight, #f0f0e0);
cursor: pointer;
}
.lbn-player-note-button:hover {
background: rgba(255, 255, 255, 0.12);
}
.lbn-notes-window {
position: fixed;
z-index: 100000;
left: 140px;
top: 120px;
width: 420px;
height: 330px;
min-width: 260px;
min-height: 180px;
display: flex;
flex-direction: column;
resize: both;
overflow: hidden;
border: 1px solid var(--color-border-dark, #000);
border-radius: 6px;
background: var(--color-bg, #1f1f1f);
box-shadow: 0 0 16px rgba(0, 0, 0, 0.55);
}
.lbn-notes-window[hidden] {
display: none;
}
.lbn-titlebar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 2rem;
padding: 0.25rem 0.4rem;
background: rgba(0, 0, 0, 0.34);
color: var(--color-text-light-highlight, #f0f0e0);
cursor: move;
user-select: none;
}
.lbn-title,
.lbn-controls {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.lbn-status {
opacity: 0.75;
font-size: 0.78rem;
white-space: nowrap;
}
.lbn-controls button {
width: 1.55rem;
height: 1.55rem;
padding: 0;
border: 1px solid var(--color-border-light-primary, #7a695a);
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
color: inherit;
cursor: pointer;
}
.lbn-controls button:hover {
background: rgba(255, 255, 255, 0.16);
}
.lbn-textarea {
flex: 1 1 auto;
width: 100%;
min-height: 0;
box-sizing: border-box;
resize: none;
border: 0;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.65rem;
font-family: var(--font-primary, sans-serif);
font-size: 0.95rem;
line-height: 1.4;
color: var(--color-text-light-highlight, #f0f0e0);
background: rgba(255, 255, 255, 0.045);
outline: none;
}