From 8b1a54747d7d458012f24c65c0c88ae199193eda Mon Sep 17 00:00:00 2001 From: Likulisu Date: Thu, 20 Aug 2026 20:04:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=9B=9D=E9=9C=B2=E6=8C=89=E9=88=95API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/functions/instantMessenger.js | 13 ++++---- src/functions/pastProfiles.js | 48 ++++++++++++++++++++++++++-- src/functions/richOnlineProfile.js | 11 +++++-- src/util/publicApi.ts | 50 ++++++++++++++++++++++++++++++ types/wce.d.ts | 49 +++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 src/util/publicApi.ts diff --git a/src/functions/instantMessenger.js b/src/functions/instantMessenger.js index 20467d7..9391031 100644 --- a/src/functions/instantMessenger.js +++ b/src/functions/instantMessenger.js @@ -1,6 +1,7 @@ import { displayText } from "../util/localization"; import { debug } from "../util/logger"; import { SDK, HOOK_PRIORITIES } from "../util/modding"; +import { createPositionableButton, WCE_NAMESPACE } from "../util/publicApi"; import { fbcSettings } from "../util/settings"; import { parseJSON, objEntries, isNonNullObject, isString, fbcNotify } from "../util/utils"; import { registerSocketListener } from "./appendSocketListenersToInit"; @@ -446,27 +447,27 @@ export default function instantMessenger() { return next(args); }); - /** @type {[number, number, number, number]} */ - const buttonPosition = [70, 905, 60, 60]; + const { api: messengerButtonApi, getPosition: getButtonPosition, isHidden: isButtonHidden } = createPositionableButton([70, 905, 60, 60]); + WCE_NAMESPACE.Button = { ...WCE_NAMESPACE.Button, Messenger: messengerButtonApi }; SDK.hookFunction("DrawProcess", HOOK_PRIORITIES.AddBehaviour, (args, next) => { const ret = next(args); - if (fbcSettings.instantMessenger) { + if (fbcSettings.instantMessenger && !isButtonHidden()) { if ( !fbcSettings.allowIMBypassBCX && (BCXgetRuleState("speech_restrict_beep_receive")?.isEnforced || (BCXgetRuleState("alt_hide_friends")?.isEnforced && Player.GetBlindLevel() >= 3)) ) { if (!container.classList.contains("bce-hidden")) hideIM(); - DrawButton(...buttonPosition, "", "Gray", "Icons/Small/Chat.png", displayText("Instant Messenger (Disabled by BCX)"), false); + DrawButton(...getButtonPosition(), "", "Gray", "Icons/Small/Chat.png", displayText("Instant Messenger (Disabled by BCX)"), false); } else { - DrawButton(...buttonPosition, "", unreadSinceOpened ? "Red" : "White", "Icons/Small/Chat.png", displayText("Instant Messenger"), false); + DrawButton(...getButtonPosition(), "", unreadSinceOpened ? "Red" : "White", "Icons/Small/Chat.png", displayText("Instant Messenger"), false); } } return ret; }); SDK.hookFunction("CommonClick", HOOK_PRIORITIES.OverrideBehaviour, (args, next) => { - if (fbcSettings.instantMessenger && MouseIn(...buttonPosition)) { + if (fbcSettings.instantMessenger && !isButtonHidden() && MouseIn(...getButtonPosition())) { if (!container.classList.contains("bce-hidden")) { hideIM(); return null; diff --git a/src/functions/pastProfiles.js b/src/functions/pastProfiles.js index bc9b57c..4f4e15a 100644 --- a/src/functions/pastProfiles.js +++ b/src/functions/pastProfiles.js @@ -3,6 +3,7 @@ import { openDB } from "idb"; import { displayText } from "../util/localization"; import { debug, logInfo, logWarn, logError } from "../util/logger"; import { SDK, HOOK_PRIORITIES } from "../util/modding"; +import { createPositionableButton, WCE_NAMESPACE } from "../util/publicApi"; import { fbcSettings } from "../util/settings"; import { deepCopy, parseJSON, isCharacter, isNonNullObject, drawTextFitLeft, fbcChatNotify } from "../util/utils"; @@ -219,6 +220,47 @@ export default async function pastProfiles() { return isNonNullObject(n) && typeof n.note === "string"; } + /** + * Reads the saved note for a member number. Part of the public `WCE.PastProfiles.Notes` API. + * @param {number} memberNumber + * @returns {Promise} + */ + async function getNote(memberNumber) { + if (typeof memberNumber !== "number" || !Number.isFinite(memberNumber)) { + throw new TypeError("WCE.PastProfiles.Notes.get: memberNumber must be a finite number"); + } + const note = await db.get("notes", memberNumber); + return isNote(note) ? note : undefined; + } + + /** + * Writes (or overwrites) the saved note for a member number. Part of the public + * `WCE.PastProfiles.Notes` API. Keeps the note editor in sync if it's currently open for + * that same member. + * @param {number} memberNumber + * @param {string} note + * @returns {Promise} + */ + async function setNote(memberNumber, note) { + if (typeof memberNumber !== "number" || !Number.isFinite(memberNumber)) { + throw new TypeError("WCE.PastProfiles.Notes.set: memberNumber must be a finite number"); + } + if (typeof note !== "string") { + throw new TypeError("WCE.PastProfiles.Notes.set: note must be a string"); + } + await quotaSafetyCheck(); + const updatedAt = Date.now(); + await db.put("notes", { memberNumber, note, updatedAt }); + if (inNotes && InformationSheetSelection?.MemberNumber === memberNumber) { + noteInput.value = note; + noteUpdatedAt = updatedAt; + } + } + + const { api: notesButtonApi, getPosition: getNotesButtonPosition, isHidden: isNotesButtonHidden } = createPositionableButton([1520, 60, 90, 90]); + WCE_NAMESPACE.Button = { ...WCE_NAMESPACE.Button, pastProfiles: notesButtonApi }; + WCE_NAMESPACE.pastProfiles = { get: getNote, set: setNote }; + function showNoteInput() { if (!InformationSheetSelection?.MemberNumber) { throw new Error("invalid InformationSheetSelection in notes"); @@ -284,7 +326,9 @@ export default async function pastProfiles() { DrawButton(1820, 60, 90, 90, "", "White", "Icons/Cancel.png", TextGet("LeaveNoSave")); return null; } - DrawButton(1520, 60, 90, 90, "", "White", "Icons/Notifications.png", displayText("[WCE] Notes")); + if (!isNotesButtonHidden()) { + DrawButton(...getNotesButtonPosition(), "", "White", "Icons/Notifications.png", displayText("[WCE] Notes")); + } return next(args); }); @@ -302,7 +346,7 @@ export default async function pastProfiles() { hideNoteInput(); } return null; - } else if (!inNotes && MouseIn(1520, 60, 90, 90)) showNoteInput(); + } else if (!inNotes && !isNotesButtonHidden() && MouseIn(...getNotesButtonPosition())) showNoteInput(); return next(args); }); diff --git a/src/functions/richOnlineProfile.js b/src/functions/richOnlineProfile.js index 1d2a2f6..ac9fcfe 100644 --- a/src/functions/richOnlineProfile.js +++ b/src/functions/richOnlineProfile.js @@ -1,5 +1,6 @@ import { displayText } from "../util/localization"; import { SDK, HOOK_PRIORITIES } from "../util/modding"; +import { createPositionableButton, WCE_NAMESPACE } from "../util/publicApi"; import { fbcSettings } from "../util/settings"; import { processChatAugmentsForLine } from "./chatAugments"; @@ -74,12 +75,16 @@ export default function richOnlineProfile() { return next(args); }); - const toggleEditButtonPos = /** @type {const} */ ([90, 60, 90, 90]); + const { api: editButtonApi, getPosition: getEditButtonPosition, isHidden: isEditButtonHidden } = createPositionableButton([90, 60, 90, 90]); + WCE_NAMESPACE.Button = { ...WCE_NAMESPACE.Button, EditProfile: editButtonApi }; + SDK.hookFunction("OnlineProfileRun", HOOK_PRIORITIES.ModifyBehaviourMedium, (args, next) => { if (!fbcSettings.richOnlineProfile) { return next(args); } - DrawButton(...toggleEditButtonPos, "", "White", "Icons/Crafting.png", displayText("Toggle Editing Mode")); + if (!isEditButtonHidden()) { + DrawButton(...getEditButtonPosition(), "", "White", "Icons/Crafting.png", displayText("Toggle Editing Mode")); + } const ret = next(args); if (!originalShown) { @@ -93,7 +98,7 @@ export default function richOnlineProfile() { if (!fbcSettings.richOnlineProfile) { return next(args); } - if (MouseIn(...toggleEditButtonPos)) { + if (!isEditButtonHidden() && MouseIn(...getEditButtonPosition())) { if (originalShown) { enableRichTextArea(); } else { diff --git a/src/util/publicApi.ts b/src/util/publicApi.ts new file mode 100644 index 0000000..bc6819d --- /dev/null +++ b/src/util/publicApi.ts @@ -0,0 +1,50 @@ +// Shared infrastructure for WCE's public JS API (`window.WCE.*`), used by other addons/userscripts +// to integrate with WCE without needing to hook its internals directly. + +if (!globalThis.WCE) { + globalThis.WCE = {}; +} + +/** The single shared `WCE` namespace object. Feature modules attach their own sub-namespace to this. */ +export const WCE_NAMESPACE: WCEPublicAPI = globalThis.WCE; + +/** + * Creates a small stateful API that lets other addons reposition or temporarily hide a + * WCE-drawn screen button (e.g. because it overlaps with their own UI), without needing to know + * about the feature's internals. Returns the public API object plus internal accessors for the + * owning feature to use when drawing / hit-testing the button. + */ +export function createPositionableButton(defaultPosition: [number, number, number, number]): { + api: WCEPositionableButtonAPI; + getPosition: () => [number, number, number, number]; + isHidden: () => boolean; +} { + let position: [number, number, number, number] = [...defaultPosition]; + let hidden = false; + + function setPosition(x: number, y: number, w: number, h: number): void { + for (const n of [x, y, w, h]) { + if (typeof n !== "number" || !Number.isFinite(n)) { + throw new TypeError("setPosition: x, y, w, h must all be finite numbers"); + } + } + position = [x, y, w, h]; + } + + const api: WCEPositionableButtonAPI = { + getPosition: () => [...position], + setPosition, + resetPosition: () => { + position = [...defaultPosition]; + }, + hide: () => { + hidden = true; + }, + show: () => { + hidden = false; + }, + isHidden: () => hidden, + }; + + return { api, getPosition: () => position, isHidden: () => hidden }; +} diff --git a/types/wce.d.ts b/types/wce.d.ts index 44c53b4..81e7e68 100644 --- a/types/wce.d.ts +++ b/types/wce.d.ts @@ -15,6 +15,55 @@ declare global { var bcx: import("./bcxExternalInterface").BCX_ConsoleInterface | undefined; var bcModSdk: import("bondage-club-mod-sdk").ModSDKGlobalAPI | undefined; var FUSAM: FUSAMPublicAPI | undefined; + + /** + * WCE's public JS API, exposed as `window.WCE`, so other addons/userscripts can integrate with + * it without hooking its internals directly. Sub-namespaces are added by the feature that owns + * them (e.g. `WCE.Messenger`), so this type grows as more features expose an API. + */ + var WCE: WCEPublicAPI; + type WCEPublicAPI = { + /** WCE-drawn screen buttons that other addons can reposition or temporarily hide. */ + Button?: { + /** + * The instant messenger toggle button. Default position/size (in game canvas coordinates) + * is `[x, y, w, h] = [70, 905, 60, 60]`. + */ + Messenger?: WCEPositionableButtonAPI; + /** + * The "Toggle Editing Mode" (rich BIO) button on the online profile screen. Default + * position/size (in game canvas coordinates) is `[x, y, w, h] = [90, 60, 90, 90]`. + */ + EditProfile?: WCEPositionableButtonAPI; + /** + * The "[WCE] Notes" toggle button on the online profile screen. Default position/size (in + * game canvas coordinates) is `[x, y, w, h] = [1520, 60, 90, 90]`. + */ + pastProfiles?: WCEPositionableButtonAPI; + }; + /** Reads and writes the per-member personal notes saved by the past-profiles feature. */ + pastProfiles?: { + /** Returns the saved note for a member number, or `undefined` if none exists. */ + get: (memberNumber: number) => Promise; + /** Saves (overwriting) the note for a member number. */ + set: (memberNumber: number, note: string) => Promise; + }; + }; + /** Generic API for a WCE-drawn screen button that other addons can move or hide. */ + type WCEPositionableButtonAPI = { + /** Returns the current `[x, y, w, h]` of the button in game canvas coordinates. */ + getPosition: () => [number, number, number, number]; + /** Moves the button to a new `x, y, w, h` position in game canvas coordinates. */ + setPosition: (x: number, y: number, w: number, h: number) => void; + /** Restores the button to its default position. */ + resetPosition: () => void; + /** Hides the button (and disables its click area) until `show()` is called. */ + hide: () => void; + /** Reveals the button again after a previous `hide()` call. */ + show: () => void; + /** Whether the button is currently hidden via this API. */ + isHidden: () => boolean; + }; type FUSAMPublicAPI = { present: true; addons: Record; From 40ef8d2524dd5f9beb576bb663d1191e6bd3e4ab Mon Sep 17 00:00:00 2001 From: Likulisu Date: Thu, 20 Aug 2026 20:23:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?API=E6=9A=B4=E9=9C=B2=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/functions/instantMessenger.js | 35 +++++++++++++++++++++++++++---- src/util/publicApi.ts | 11 +++++++++- types/wce.d.ts | 17 ++++++++++++++- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/functions/instantMessenger.js b/src/functions/instantMessenger.js index 9391031..88bff54 100644 --- a/src/functions/instantMessenger.js +++ b/src/functions/instantMessenger.js @@ -222,7 +222,9 @@ export default function instantMessenger() { friend.history.appendChild(divider); } - if (container.classList.contains("bce-hidden")) { + // A visually hidden button is commonly covered by another addon's button. Do not retain a + // red unread state that suddenly appears when WCE's own button is shown again. + if (container.classList.contains("bce-hidden") && !isButtonHidden() && !isButtonVisualHidden()) { unreadSinceOpened++; } } @@ -447,12 +449,37 @@ export default function instantMessenger() { return next(args); }); - const { api: messengerButtonApi, getPosition: getButtonPosition, isHidden: isButtonHidden } = createPositionableButton([70, 905, 60, 60]); - WCE_NAMESPACE.Button = { ...WCE_NAMESPACE.Button, Messenger: messengerButtonApi }; + const DEFAULT_MESSENGER_Z_INDEX = 100; + const { + api: messengerButtonApi, + getPosition: getButtonPosition, + isHidden: isButtonHidden, + isVisualHidden: isButtonVisualHidden, + } = createPositionableButton([70, 905, 60, 60]); + WCE_NAMESPACE.Button = { + ...WCE_NAMESPACE.Button, + Messenger: { + ...messengerButtonApi, + isEnabled: () => fbcSettings.instantMessenger, + getZIndex: () => { + const zIndex = Number(container.style.zIndex || getComputedStyle(container).zIndex); + return Number.isFinite(zIndex) ? zIndex : DEFAULT_MESSENGER_Z_INDEX; + }, + setZIndex: zIndex => { + if (typeof zIndex !== "number" || !Number.isFinite(zIndex)) { + throw new TypeError("setZIndex: zIndex must be a finite number"); + } + container.style.zIndex = String(zIndex); + }, + resetZIndex: () => { + container.style.zIndex = String(DEFAULT_MESSENGER_Z_INDEX); + }, + }, + }; SDK.hookFunction("DrawProcess", HOOK_PRIORITIES.AddBehaviour, (args, next) => { const ret = next(args); - if (fbcSettings.instantMessenger && !isButtonHidden()) { + if (fbcSettings.instantMessenger && !isButtonHidden() && !isButtonVisualHidden()) { if ( !fbcSettings.allowIMBypassBCX && (BCXgetRuleState("speech_restrict_beep_receive")?.isEnforced || (BCXgetRuleState("alt_hide_friends")?.isEnforced && Player.GetBlindLevel() >= 3)) diff --git a/src/util/publicApi.ts b/src/util/publicApi.ts index bc6819d..a074fa5 100644 --- a/src/util/publicApi.ts +++ b/src/util/publicApi.ts @@ -18,9 +18,11 @@ export function createPositionableButton(defaultPosition: [number, number, numbe api: WCEPositionableButtonAPI; getPosition: () => [number, number, number, number]; isHidden: () => boolean; + isVisualHidden: () => boolean; } { let position: [number, number, number, number] = [...defaultPosition]; let hidden = false; + let visualHidden = false; function setPosition(x: number, y: number, w: number, h: number): void { for (const n of [x, y, w, h]) { @@ -44,7 +46,14 @@ export function createPositionableButton(defaultPosition: [number, number, numbe hidden = false; }, isHidden: () => hidden, + hideVisual: () => { + visualHidden = true; + }, + showVisual: () => { + visualHidden = false; + }, + isVisualHidden: () => visualHidden, }; - return { api, getPosition: () => position, isHidden: () => hidden }; + return { api, getPosition: () => position, isHidden: () => hidden, isVisualHidden: () => visualHidden }; } diff --git a/types/wce.d.ts b/types/wce.d.ts index 81e7e68..436fed3 100644 --- a/types/wce.d.ts +++ b/types/wce.d.ts @@ -29,7 +29,16 @@ declare global { * The instant messenger toggle button. Default position/size (in game canvas coordinates) * is `[x, y, w, h] = [70, 905, 60, 60]`. */ - Messenger?: WCEPositionableButtonAPI; + Messenger?: WCEPositionableButtonAPI & { + /** Whether WCE's instant messenger feature is enabled in the user's settings. */ + isEnabled: () => boolean; + /** Returns the CSS z-index of the instant messenger window. */ + getZIndex: () => number; + /** Changes the CSS z-index of the instant messenger window. */ + setZIndex: (zIndex: number) => void; + /** Restores the instant messenger window's default z-index (`100`). */ + resetZIndex: () => void; + }; /** * The "Toggle Editing Mode" (rich BIO) button on the online profile screen. Default * position/size (in game canvas coordinates) is `[x, y, w, h] = [90, 60, 90, 90]`. @@ -63,6 +72,12 @@ declare global { show: () => void; /** Whether the button is currently hidden via this API. */ isHidden: () => boolean; + /** Hides only the button drawing while preserving its click area. */ + hideVisual: () => void; + /** Draws the button again after `hideVisual()` was called. */ + showVisual: () => void; + /** Whether only the button drawing is currently hidden. */ + isVisualHidden: () => boolean; }; type FUSAMPublicAPI = { present: true;