diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..372a073 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..63241c4 --- /dev/null +++ b/README.md @@ -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. diff --git a/module.json b/module.json new file mode 100644 index 0000000..846058a --- /dev/null +++ b/module.json @@ -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": [] +} \ No newline at end of file diff --git a/scripts/user-notes.js b/scripts/user-notes.js new file mode 100644 index 0000000..c55e7bc --- /dev/null +++ b/scripts/user-notes.js @@ -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 = ` +
+
+ + User Notes +
+
+ Gespeichert + + +
+
+ + `; + + 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 = ``; + + 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)); +}); diff --git a/styles/user-notes.css b/styles/user-notes.css new file mode 100644 index 0000000..f9bd872 --- /dev/null +++ b/styles/user-notes.css @@ -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; +}