Replaced Scripts by ESModules and added cool stuff

This commit is contained in:
Florian Zumpe 2026-05-19 16:28:16 +02:00
parent 1045e66262
commit 3d09e19c52
10 changed files with 1179 additions and 329 deletions

View File

@ -1,22 +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.1",
"version": "1.0.0",
"authors": [
{
"name": "ChatGPT"
"name": "Florian Zumpe"
}
],
"compatibility": {
"minimum": "14",
"verified": "14"
},
"scripts": [
"esmodules": [
"scripts/user-notes.js"
],
"styles": [
"styles/user-notes.css"
"styles/user-notes-window.css",
"styles/user-notes-settings.css"
],
"languages": [],
"url": "https://github.com/fzumpe/foundry-usernotes",

View File

@ -0,0 +1,19 @@
export const USER_NOTES_MODULE_ID = "user-notes";
export const USER_NOTES_WINDOW_ID = "user-notes-window";
export const USER_NOTES_TOOL_ID = "user-notes-open";
export const USER_NOTES_DEFAULT_POSITION = {
left: 200,
top: 150,
width: 500,
height: 400
};
export const USER_NOTES_MIN_WIDTH = 280;
export const USER_NOTES_MIN_HEIGHT = 180;
export const USER_NOTES_VIEWPORT_MARGIN = 40;
export const USER_NOTES_DEFAULT_BACKGROUND = "rgba(25, 24, 19, 0.96)";
export const USER_NOTES_DEFAULT_TEXT_COLOR = "#f0f0e0";
export const USER_NOTES_DEFAULT_TEXTAREA_BACKGROUND = "rgba(255, 255, 255, 0.92)";
export const USER_NOTES_DEFAULT_TEXTAREA_COLOR = "#111111";

View File

@ -0,0 +1,48 @@
import {
USER_NOTES_MODULE_ID,
USER_NOTES_TOOL_ID
} from "./user-notes-constants.js";
import {
userNotesOpenNotes
} from "./user-notes-window.js";
export function userNotesRegisterTokenControl(controls) {
console.log("User Notes | registering token control", controls);
const tokenControl = controls?.tokens;
if (!tokenControl) {
console.warn("User Notes | controls.tokens fehlt", controls);
return;
}
if (!tokenControl.tools) {
console.warn("User Notes | controls.tokens.tools fehlt", tokenControl);
return;
}
tokenControl.tools[USER_NOTES_TOOL_ID] = {
name: USER_NOTES_TOOL_ID,
title: "User Notes öffnen",
icon: "fa-solid fa-note-sticky",
order: Object.keys(tokenControl.tools).length + 1,
button: true,
visible: true,
onChange: (event, active) => {
console.log("User Notes | onChange", { event, active });
userNotesOpenNotes();
},
onClick: event => {
console.log("User Notes | onClick", { event });
userNotesOpenNotes();
}
};
console.log(
"User Notes | token control registered",
tokenControl.tools[USER_NOTES_TOOL_ID]
);
}

View File

@ -0,0 +1,427 @@
import {
USER_NOTES_MODULE_ID,
USER_NOTES_WINDOW_ID
} from "./user-notes-constants.js";
import {
userNotesLoadAppearance,
userNotesSaveAppearance,
userNotesRemoveSavedAppearance
} from "./user-notes-storage.js";
let resetPositionCallback = null;
const USER_NOTES_APPEARANCE_DEFAULTS = {
windowBackgroundColor: "#191813",
windowBackgroundAlpha: 0.96,
windowTextColor: "#f0f0e0",
textareaBackgroundColor: "#ffffff",
textareaBackgroundAlpha: 0.92,
textareaTextColor: "#111111"
};
function userNotesClampAlpha(value, fallback = 1) {
const number = Number(value);
if (!Number.isFinite(number)) {
return fallback;
}
return Math.max(0, Math.min(1, number));
}
function userNotesNormalizeHexColor(value, fallback) {
const normalized = String(value ?? "").trim();
if (/^#[0-9a-f]{6}$/i.test(normalized)) {
return normalized;
}
return fallback;
}
function userNotesHexToRgba(hex, alpha) {
const normalized = userNotesNormalizeHexColor(hex, "#000000");
const match = normalized.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (!match) {
return `rgba(0, 0, 0, ${userNotesClampAlpha(alpha)})`;
}
const red = parseInt(match[1], 16);
const green = parseInt(match[2], 16);
const blue = parseInt(match[3], 16);
return `rgba(${red}, ${green}, ${blue}, ${userNotesClampAlpha(alpha)})`;
}
function userNotesLoadValidatedAppearance() {
const values = userNotesLoadAppearance(USER_NOTES_APPEARANCE_DEFAULTS);
return {
windowBackgroundColor: userNotesNormalizeHexColor(
values.windowBackgroundColor,
USER_NOTES_APPEARANCE_DEFAULTS.windowBackgroundColor
),
windowBackgroundAlpha: userNotesClampAlpha(
values.windowBackgroundAlpha,
USER_NOTES_APPEARANCE_DEFAULTS.windowBackgroundAlpha
),
windowTextColor: userNotesNormalizeHexColor(
values.windowTextColor,
USER_NOTES_APPEARANCE_DEFAULTS.windowTextColor
),
textareaBackgroundColor: userNotesNormalizeHexColor(
values.textareaBackgroundColor,
USER_NOTES_APPEARANCE_DEFAULTS.textareaBackgroundColor
),
textareaBackgroundAlpha: userNotesClampAlpha(
values.textareaBackgroundAlpha,
USER_NOTES_APPEARANCE_DEFAULTS.textareaBackgroundAlpha
),
textareaTextColor: userNotesNormalizeHexColor(
values.textareaTextColor,
USER_NOTES_APPEARANCE_DEFAULTS.textareaTextColor
)
};
}
export function userNotesRegisterSettings(onResetPosition) {
resetPositionCallback = onResetPosition;
game.settings.registerMenu(USER_NOTES_MODULE_ID, "appearanceSettings", {
name: "Darstellung",
label: "Farben und Transparenz einstellen",
hint: "Öffnet lokale Colorpicker für Fenster, Notizfeld und Schriftfarben. Die Werte werden nur im localStorage dieses Browsers gespeichert.",
icon: "fas fa-palette",
type: UserNotesAppearanceSettings,
restricted: false
});
game.settings.registerMenu(USER_NOTES_MODULE_ID, "resetWindowPosition", {
name: "Position und Größe zurücksetzen",
label: "Jetzt zurücksetzen",
hint: "Setzt nur Position und Größe des User-Notes-Fensters für diesen Browser zurück. Notizen, Farben und Transparenzwerte bleiben unverändert.",
icon: "fas fa-undo",
type: UserNotesDirectResetWindowPosition,
restricted: false
});
}
export function userNotesApplySettingsToOpenWindow() {
const win = document.getElementById(USER_NOTES_WINDOW_ID);
if (!win) {
return;
}
userNotesApplyWindowSettings(win);
}
export function userNotesApplyWindowSettings(win) {
const appearance = userNotesLoadValidatedAppearance();
win.style.setProperty(
"--user-notes-window-background",
userNotesHexToRgba(
appearance.windowBackgroundColor,
appearance.windowBackgroundAlpha
)
);
win.style.setProperty(
"--user-notes-window-text-color",
appearance.windowTextColor
);
win.style.setProperty(
"--user-notes-textarea-background",
userNotesHexToRgba(
appearance.textareaBackgroundColor,
appearance.textareaBackgroundAlpha
)
);
win.style.setProperty(
"--user-notes-textarea-color",
appearance.textareaTextColor
);
}
class UserNotesDirectResetWindowPosition extends FormApplication {
render(_force, _options) {
if (typeof resetPositionCallback === "function") {
resetPositionCallback();
ui.notifications?.info("User Notes: Position und Größe wurden zurückgesetzt.");
} else {
console.warn("User Notes | resetPositionCallback is not available");
ui.notifications?.warn("User Notes: Reset-Funktion ist nicht verfügbar.");
}
return this;
}
}
class UserNotesAppearanceSettings extends FormApplication {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "user-notes-appearance-settings",
title: "User Notes Darstellung",
template: null,
width: 520,
height: "auto",
closeOnSubmit: false,
submitOnChange: false,
submitOnClose: false
});
}
getData() {
const appearance = userNotesLoadValidatedAppearance();
return {
...appearance,
windowBackgroundAlphaPercent: Math.round(appearance.windowBackgroundAlpha * 100),
textareaBackgroundAlphaPercent: Math.round(appearance.textareaBackgroundAlpha * 100)
};
}
async _renderInner(data) {
const html = `
<div class="user-notes-appearance-settings">
<p class="notes">
Diese Werte werden lokal im Browser gespeichert.
Foundry-Settings werden dadurch nicht überschrieben und die Seite wird nicht neu geladen.
</p>
<fieldset>
<legend>Fenster</legend>
<div class="form-group">
<label>Hintergrundfarbe</label>
<div class="form-fields">
<input type="color" name="windowBackgroundColor" value="${data.windowBackgroundColor}">
<input type="text" name="windowBackgroundColorText" value="${data.windowBackgroundColor}">
</div>
<p class="hint">Farbe des äußeren Notizfensters.</p>
</div>
<div class="form-group">
<label>Hintergrundtransparenz</label>
<div class="form-fields user-notes-range-row">
<input type="range" name="windowBackgroundAlpha" min="0" max="1" step="0.01" value="${data.windowBackgroundAlpha}">
<output>${data.windowBackgroundAlphaPercent}%</output>
</div>
<p class="hint">0% ist vollständig transparent, 100% ist vollständig deckend.</p>
</div>
<div class="form-group">
<label>Schriftfarbe</label>
<div class="form-fields">
<input type="color" name="windowTextColor" value="${data.windowTextColor}">
<input type="text" name="windowTextColorText" value="${data.windowTextColor}">
</div>
<p class="hint">Farbe für Titelleiste, Status und Buttons.</p>
</div>
</fieldset>
<fieldset>
<legend>Notizfeld</legend>
<div class="form-group">
<label>Hintergrundfarbe</label>
<div class="form-fields">
<input type="color" name="textareaBackgroundColor" value="${data.textareaBackgroundColor}">
<input type="text" name="textareaBackgroundColorText" value="${data.textareaBackgroundColor}">
</div>
<p class="hint">Farbe des eigentlichen Textfeldes.</p>
</div>
<div class="form-group">
<label>Hintergrundtransparenz</label>
<div class="form-fields user-notes-range-row">
<input type="range" name="textareaBackgroundAlpha" min="0" max="1" step="0.01" value="${data.textareaBackgroundAlpha}">
<output>${data.textareaBackgroundAlphaPercent}%</output>
</div>
<p class="hint">0% ist vollständig transparent, 100% ist vollständig deckend.</p>
</div>
<div class="form-group">
<label>Schriftfarbe</label>
<div class="form-fields">
<input type="color" name="textareaTextColor" value="${data.textareaTextColor}">
<input type="text" name="textareaTextColorText" value="${data.textareaTextColor}">
</div>
<p class="hint">Farbe des Textes innerhalb des Notizfeldes.</p>
</div>
</fieldset>
<footer class="sheet-footer user-notes-appearance-footer">
<div class="user-notes-primary-actions">
<button type="button" class="user-notes-apply-appearance">
<i class="fas fa-check"></i>
Anwenden
</button>
<button type="button" class="user-notes-save-appearance">
<i class="fas fa-save"></i>
Speichern
</button>
</div>
<button type="button" class="user-notes-reset-appearance">
<i class="fas fa-undo"></i>
Standardfarben
</button>
</footer>
</div>
`;
return $(html);
}
activateListeners(html) {
super.activateListeners(html);
const root = html[0];
if (!root) {
console.warn("User Notes | appearance settings root element not found");
return;
}
html.on("submit", event => {
event.preventDefault();
event.stopPropagation();
return false;
});
const syncColorPair = (colorName, textName) => {
const colorInput = root.querySelector(`input[name="${colorName}"]`);
const textInput = root.querySelector(`input[name="${textName}"]`);
if (!colorInput || !textInput) {
return;
}
colorInput.addEventListener("input", () => {
textInput.value = colorInput.value;
});
textInput.addEventListener("input", () => {
if (/^#[0-9a-f]{6}$/i.test(textInput.value)) {
colorInput.value = textInput.value;
}
});
};
syncColorPair("windowBackgroundColor", "windowBackgroundColorText");
syncColorPair("windowTextColor", "windowTextColorText");
syncColorPair("textareaBackgroundColor", "textareaBackgroundColorText");
syncColorPair("textareaTextColor", "textareaTextColorText");
for (const range of root.querySelectorAll('input[type="range"]')) {
const output = range
.closest(".user-notes-range-row")
?.querySelector("output");
const updateOutput = () => {
if (output) {
output.textContent = `${Math.round(Number(range.value) * 100)}%`;
}
};
range.addEventListener("input", updateOutput);
range.addEventListener("change", updateOutput);
updateOutput();
}
root.querySelector(".user-notes-apply-appearance")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
this.userNotesSaveAppearanceFromDialog(root, {
closeDialog: false,
notify: true
});
});
root.querySelector(".user-notes-save-appearance")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
this.userNotesSaveAppearanceFromDialog(root, {
closeDialog: true,
notify: true
});
});
root.querySelector(".user-notes-reset-appearance")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
userNotesRemoveSavedAppearance();
userNotesApplySettingsToOpenWindow();
ui.notifications?.info("User Notes: Standardfarben wurden wiederhergestellt.");
this.render(true);
});
}
userNotesSaveAppearanceFromDialog(root, options = {}) {
const closeDialog = options.closeDialog ?? true;
const notify = options.notify ?? true;
const getInputValue = name => {
const input = root.querySelector(`[name="${name}"]`);
return input?.value;
};
const appearance = {
windowBackgroundColor: userNotesNormalizeHexColor(
getInputValue("windowBackgroundColorText") || getInputValue("windowBackgroundColor"),
USER_NOTES_APPEARANCE_DEFAULTS.windowBackgroundColor
),
windowBackgroundAlpha: userNotesClampAlpha(
getInputValue("windowBackgroundAlpha"),
USER_NOTES_APPEARANCE_DEFAULTS.windowBackgroundAlpha
),
windowTextColor: userNotesNormalizeHexColor(
getInputValue("windowTextColorText") || getInputValue("windowTextColor"),
USER_NOTES_APPEARANCE_DEFAULTS.windowTextColor
),
textareaBackgroundColor: userNotesNormalizeHexColor(
getInputValue("textareaBackgroundColorText") || getInputValue("textareaBackgroundColor"),
USER_NOTES_APPEARANCE_DEFAULTS.textareaBackgroundColor
),
textareaBackgroundAlpha: userNotesClampAlpha(
getInputValue("textareaBackgroundAlpha"),
USER_NOTES_APPEARANCE_DEFAULTS.textareaBackgroundAlpha
),
textareaTextColor: userNotesNormalizeHexColor(
getInputValue("textareaTextColorText") || getInputValue("textareaTextColor"),
USER_NOTES_APPEARANCE_DEFAULTS.textareaTextColor
)
};
userNotesSaveAppearance(appearance);
userNotesApplySettingsToOpenWindow();
if (notify) {
ui.notifications?.info(
closeDialog
? "User Notes: Darstellung wurde gespeichert."
: "User Notes: Darstellung wurde angewendet."
);
}
if (closeDialog) {
this.close();
}
}
async _updateObject(_event, _formData) {
// Wird absichtlich nicht verwendet.
// Die Buttons speichern direkt in localStorage, damit kein nativer GET-Submit stattfindet.
}
}

View File

@ -0,0 +1,67 @@
import {
USER_NOTES_MODULE_ID
} from "./user-notes-constants.js";
export function userNotesStorageKey() {
const worldId = game.world?.id ?? game.world?.data?.id ?? "unknown-world";
const userId = game.user?.id ?? "unknown-user";
return `${USER_NOTES_MODULE_ID}.${worldId}.${userId}.notes`;
}
export function userNotesPositionKey() {
const worldId = game.world?.id ?? game.world?.data?.id ?? "unknown-world";
const userId = game.user?.id ?? "unknown-user";
return `${USER_NOTES_MODULE_ID}.${worldId}.${userId}.position`;
}
export function userNotesAppearanceKey() {
const worldId = game.world?.id ?? game.world?.data?.id ?? "unknown-world";
const userId = game.user?.id ?? "unknown-user";
return `${USER_NOTES_MODULE_ID}.${worldId}.${userId}.appearance`;
}
export function userNotesLoadNotes() {
return window.localStorage.getItem(userNotesStorageKey()) ?? "";
}
export function userNotesSaveNotes(value) {
window.localStorage.setItem(userNotesStorageKey(), value);
}
export function userNotesRemoveSavedPosition() {
window.localStorage.removeItem(userNotesPositionKey());
}
export function userNotesLoadAppearance(defaults) {
try {
const raw = window.localStorage.getItem(userNotesAppearanceKey());
if (!raw) {
return { ...defaults };
}
const parsed = JSON.parse(raw);
return {
...defaults,
...parsed
};
} catch (err) {
console.warn("User Notes | Could not load appearance settings", err);
return { ...defaults };
}
}
export function userNotesSaveAppearance(values) {
window.localStorage.setItem(
userNotesAppearanceKey(),
JSON.stringify(values)
);
}
export function userNotesRemoveSavedAppearance() {
window.localStorage.removeItem(userNotesAppearanceKey());
}

View File

@ -0,0 +1,358 @@
import {
USER_NOTES_MODULE_ID,
USER_NOTES_WINDOW_ID,
USER_NOTES_DEFAULT_POSITION,
USER_NOTES_MIN_WIDTH,
USER_NOTES_MIN_HEIGHT,
USER_NOTES_VIEWPORT_MARGIN
} from "./user-notes-constants.js";
import {
userNotesLoadNotes,
userNotesSaveNotes,
userNotesPositionKey,
userNotesRemoveSavedPosition
} from "./user-notes-storage.js";
import {
userNotesApplyWindowSettings
} from "./user-notes-settings.js";
let userNotesSaveTimer = null;
export function userNotesSetStatus(text) {
const status = document.querySelector(
`#${USER_NOTES_WINDOW_ID} .user-notes-status`
);
if (status) {
status.textContent = text;
}
}
export function userNotesDebouncedSave(value) {
window.clearTimeout(userNotesSaveTimer);
userNotesSetStatus("Ungespeichert …");
userNotesSaveTimer = window.setTimeout(() => {
userNotesSaveNotes(value);
userNotesSetStatus("Gespeichert");
}, 250);
}
export function userNotesApplyPosition(win, position) {
win.style.left = `${position.left}px`;
win.style.top = `${position.top}px`;
win.style.width = `${position.width}px`;
win.style.height = `${position.height}px`;
}
export function userNotesClampPosition(position) {
const viewportWidth = Math.max(
window.innerWidth,
USER_NOTES_MIN_WIDTH + USER_NOTES_VIEWPORT_MARGIN
);
const viewportHeight = Math.max(
window.innerHeight,
USER_NOTES_MIN_HEIGHT + USER_NOTES_VIEWPORT_MARGIN
);
const maxWidth = Math.max(
USER_NOTES_MIN_WIDTH,
viewportWidth - USER_NOTES_VIEWPORT_MARGIN
);
const maxHeight = Math.max(
USER_NOTES_MIN_HEIGHT,
viewportHeight - USER_NOTES_VIEWPORT_MARGIN
);
const width = Math.max(
USER_NOTES_MIN_WIDTH,
Math.min(position.width, maxWidth)
);
const height = Math.max(
USER_NOTES_MIN_HEIGHT,
Math.min(position.height, maxHeight)
);
const maxLeft = Math.max(0, viewportWidth - width - 20);
const maxTop = Math.max(0, viewportHeight - height - 20);
const left = Math.max(0, Math.min(position.left, maxLeft));
const top = Math.max(0, Math.min(position.top, maxTop));
return {
left,
top,
width,
height
};
}
export function userNotesRestorePosition(win) {
try {
const raw = window.localStorage.getItem(userNotesPositionKey());
if (!raw) {
userNotesApplyPosition(
win,
userNotesClampPosition(USER_NOTES_DEFAULT_POSITION)
);
return;
}
const pos = JSON.parse(raw);
const restoredPosition = {
left: Number.isFinite(pos.left) ? pos.left : USER_NOTES_DEFAULT_POSITION.left,
top: Number.isFinite(pos.top) ? pos.top : USER_NOTES_DEFAULT_POSITION.top,
width: Number.isFinite(pos.width) ? pos.width : USER_NOTES_DEFAULT_POSITION.width,
height: Number.isFinite(pos.height) ? pos.height : USER_NOTES_DEFAULT_POSITION.height
};
userNotesApplyPosition(
win,
userNotesClampPosition(restoredPosition)
);
} catch (err) {
console.warn(
`${USER_NOTES_MODULE_ID} | Could not restore note window position`,
err
);
userNotesApplyPosition(
win,
userNotesClampPosition(USER_NOTES_DEFAULT_POSITION)
);
}
}
export function userNotesSavePosition(win) {
if (!win || win.hidden) {
return;
}
const rect = win.getBoundingClientRect();
if (rect.width < USER_NOTES_MIN_WIDTH || rect.height < USER_NOTES_MIN_HEIGHT) {
return;
}
const position = userNotesClampPosition({
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height)
});
window.localStorage.setItem(
userNotesPositionKey(),
JSON.stringify(position)
);
}
export function userNotesResetPositionAndSize() {
userNotesRemoveSavedPosition();
const win = document.getElementById(USER_NOTES_WINDOW_ID);
if (win) {
userNotesApplyPosition(
win,
userNotesClampPosition(USER_NOTES_DEFAULT_POSITION)
);
userNotesSavePosition(win);
}
}
export function userNotesBringToFront(win) {
const currentTop = Number.parseInt(win.style.zIndex || "100000", 10);
win.style.zIndex = String(Math.max(currentTop + 1, 100000));
}
export function userNotesMakeDraggable(win) {
const handle = win.querySelector(".user-notes-titlebar");
if (!handle) {
return;
}
let drag = null;
handle.addEventListener("pointerdown", event => {
const target = event.target;
if (target instanceof HTMLElement && target.closest("button")) {
return;
}
const rect = win.getBoundingClientRect();
drag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
left: rect.left,
top: rect.top
};
userNotesBringToFront(win);
handle.setPointerCapture(event.pointerId);
});
handle.addEventListener("pointermove", event => {
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
const rect = win.getBoundingClientRect();
const clamped = userNotesClampPosition({
left: drag.left + event.clientX - drag.startX,
top: drag.top + event.clientY - drag.startY,
width: rect.width,
height: rect.height
});
win.style.left = `${clamped.left}px`;
win.style.top = `${clamped.top}px`;
});
handle.addEventListener("pointerup", event => {
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
drag = null;
userNotesSavePosition(win);
});
handle.addEventListener("pointercancel", event => {
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
drag = null;
userNotesSavePosition(win);
});
}
export function userNotesOpenNotes() {
let win = document.getElementById(USER_NOTES_WINDOW_ID);
if (win) {
win.hidden = false;
userNotesRestorePosition(win);
userNotesApplyWindowSettings(win);
userNotesBringToFront(win);
win.querySelector("textarea")?.focus();
return;
}
win = document.createElement("section");
win.id = USER_NOTES_WINDOW_ID;
win.className = "user-notes-window";
win.innerHTML = `
<header class="user-notes-titlebar">
<div class="user-notes-title">
<i class="fas fa-note-sticky" aria-hidden="true"></i>
<span>User Notes</span>
</div>
<div class="user-notes-controls">
<span class="user-notes-status">Gespeichert</span>
<button type="button" class="user-notes-save" title="Jetzt speichern">
<i class="fas fa-save" aria-hidden="true"></i>
</button>
<button type="button" class="user-notes-close" title="Schließen">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</header>
<main class="user-notes-content">
<textarea class="user-notes-textarea" spellcheck="true" placeholder="Notizen für diese Welt und diesen Benutzer …"></textarea>
</main>
`;
document.body.appendChild(win);
userNotesApplyWindowSettings(win);
userNotesRestorePosition(win);
const textarea = win.querySelector(".user-notes-textarea");
if (!(textarea instanceof HTMLTextAreaElement)) {
console.error(`${USER_NOTES_MODULE_ID} | Notes textarea could not be created.`);
return;
}
textarea.value = userNotesLoadNotes();
textarea.addEventListener("input", event => {
userNotesDebouncedSave(event.currentTarget.value);
});
win.querySelector(".user-notes-save")?.addEventListener("click", () => {
userNotesSaveNotes(textarea.value);
userNotesSetStatus("Gespeichert");
userNotesSavePosition(win);
});
win.querySelector(".user-notes-close")?.addEventListener("click", () => {
userNotesSaveNotes(textarea.value);
userNotesSavePosition(win);
win.hidden = true;
});
win.addEventListener("pointerdown", () => {
userNotesBringToFront(win);
});
const resizeObserver = new ResizeObserver(() => {
if (win.hidden) {
return;
}
const rect = win.getBoundingClientRect();
if (rect.width < USER_NOTES_MIN_WIDTH || rect.height < USER_NOTES_MIN_HEIGHT) {
return;
}
userNotesSavePosition(win);
});
resizeObserver.observe(win);
userNotesMakeDraggable(win);
userNotesBringToFront(win);
textarea.focus();
}
export function userNotesRefreshOpenWindow() {
const oldWin = document.getElementById(USER_NOTES_WINDOW_ID);
if (!oldWin) {
return;
}
const textarea = oldWin.querySelector(".user-notes-textarea");
if (textarea instanceof HTMLTextAreaElement) {
userNotesSaveNotes(textarea.value);
}
userNotesSavePosition(oldWin);
oldWin.remove();
userNotesOpenNotes();
}

View File

@ -1,236 +1,44 @@
/*
* User Notes for Foundry VTT v14
* Stores notes in window.localStorage per world and per user.
*/
import {
userNotesRegisterSettings
} from "./user-notes-settings.js";
const LBN_MODULE_ID = "user-notes";
const LBN_WINDOW_ID = "lbn-notes-window";
const LBN_BUTTON_ID = "lbn-open-notes";
import {
userNotesRegisterTokenControl
} from "./user-notes-controls.js";
let lbnSaveTimer = null;
let lbnObserverTimer = null;
import {
userNotesOpenNotes,
userNotesResetPositionAndSize,
userNotesRefreshOpenWindow
} from "./user-notes-window.js";
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`;
}
console.log("User Notes | ES module loaded");
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`;
}
globalThis.UserNotes = {
open: userNotesOpenNotes,
resetPosition: userNotesResetPositionAndSize,
refresh: userNotesRefreshOpenWindow
};
function lbnLoadNotes() {
return window.localStorage.getItem(lbnStorageKey()) ?? "";
}
Hooks.once("init", () => {
console.log("User Notes | init hook fired");
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`;
userNotesRegisterSettings(userNotesResetPositionAndSize);
console.log("User Notes | settings registered");
} 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));
console.error("User Notes | error during init", err);
ui.notifications?.error("User Notes: Fehler beim Initialisieren. Details stehen in der Browser-Konsole.");
}
});
// 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));
Hooks.on("getSceneControlButtons", controls => {
console.log("User Notes | getSceneControlButtons fired");
try {
userNotesRegisterTokenControl(controls);
} catch (err) {
console.error("User Notes | error while registering token control", err);
ui.notifications?.error("User Notes: Fehler beim Registrieren des Token-Controls.");
}
});

View File

@ -0,0 +1,90 @@
.user-notes-appearance-settings {
padding: 0.75rem;
}
.user-notes-appearance-settings .notes {
margin-top: 0;
margin-bottom: 0.75rem;
}
.user-notes-appearance-settings fieldset {
margin: 0 0 0.75rem 0;
padding: 0.75rem;
border: 1px solid var(--color-border-light-primary, #7a695a);
border-radius: 4px;
}
.user-notes-appearance-settings legend {
padding: 0 0.35rem;
font-weight: bold;
}
.user-notes-appearance-settings .form-group {
align-items: center;
}
.user-notes-appearance-settings .form-fields {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-notes-appearance-settings input[type="color"] {
width: 3.5rem;
min-width: 3.5rem;
height: 2rem;
padding: 0;
border: 1px solid var(--color-border-light-primary, #7a695a);
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.user-notes-appearance-settings input[type="text"] {
flex: 1 1 auto;
min-width: 0;
}
.user-notes-range-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-notes-range-row input[type="range"] {
flex: 1 1 auto;
}
.user-notes-range-row output {
width: 3rem;
text-align: right;
white-space: nowrap;
}
.user-notes-appearance-settings .sheet-footer,
.user-notes-appearance-footer {
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-notes-primary-actions {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-notes-primary-actions button {
flex: 1 1 0;
}
.user-notes-appearance-footer > .user-notes-reset-appearance {
flex: 0 0 auto;
}

View File

@ -0,0 +1,133 @@
.user-notes-window {
position: fixed;
z-index: 100000;
left: 200px;
top: 150px;
width: 500px;
height: 400px;
min-width: 280px;
min-height: 180px;
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
resize: both;
overflow: hidden;
box-sizing: border-box;
border: 1px solid var(--color-border-dark, #000);
border-radius: 6px;
background: var(--user-notes-window-background, rgba(25, 24, 19, 0.96));
color: var(--user-notes-window-text-color, #f0f0e0);
box-shadow: 0 0 16px rgba(0, 0, 0, 0.55);
}
.user-notes-window[hidden] {
display: none;
}
.user-notes-titlebar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.55rem;
background: rgba(0, 0, 0, 0.35);
border-bottom: 1px solid var(--color-border-dark, #000);
cursor: move;
user-select: none;
}
.user-notes-title,
.user-notes-controls {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.user-notes-title {
min-width: 0;
font-weight: bold;
}
.user-notes-status {
opacity: 0.75;
font-size: 0.78rem;
white-space: nowrap;
}
.user-notes-controls button {
width: 1.55rem;
height: 1.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--color-border-light-primary, #7a695a);
border-radius: 4px;
background: rgba(0, 0, 0, 0.18);
color: var(--user-notes-window-text-color, #f0f0e0);
cursor: pointer;
}
.user-notes-controls button:hover {
background: rgba(255, 255, 255, 0.16);
}
.user-notes-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
padding: 0.5rem;
box-sizing: border-box;
}
.user-notes-textarea {
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
resize: none;
box-sizing: border-box;
padding: 0.55rem;
border: 1px solid var(--color-border-light-primary, #7a695a);
border-radius: 4px;
background: var(--user-notes-textarea-background, rgba(255, 255, 255, 0.92));
color: var(--user-notes-textarea-color, #111111);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.35;
}
.user-notes-textarea:focus {
outline: 1px solid var(--color-border-highlight, #ff6400);
outline-offset: 0;
}

View File

@ -1,100 +0,0 @@
.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;
}