357 lines
14 KiB
JavaScript
357 lines
14 KiB
JavaScript
(() => {
|
|
const storageKeys = {
|
|
windowId: "plotdirector.browserWindowId",
|
|
preferences: "plotdirector.writingWorkspace.preferences"
|
|
};
|
|
const roles = {
|
|
none: "None",
|
|
launcher: "Launcher",
|
|
context: "Context",
|
|
intent: "Intent"
|
|
};
|
|
const windowRefs = new Map();
|
|
let connection = null;
|
|
let connectionPromise = null;
|
|
let heartbeatTimer = null;
|
|
let registration = null;
|
|
let latestStatus = null;
|
|
let latestPosition = null;
|
|
|
|
const parsePositiveInt = (value) => {
|
|
const parsed = Number.parseInt(value || "", 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
};
|
|
|
|
const getBrowserWindowId = () => {
|
|
try {
|
|
let value = sessionStorage.getItem(storageKeys.windowId);
|
|
if (!value) {
|
|
value = window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
sessionStorage.setItem(storageKeys.windowId, value);
|
|
}
|
|
return value;
|
|
} catch {
|
|
return window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
};
|
|
|
|
const cleanRole = (role) => {
|
|
const value = String(role || "").toLowerCase();
|
|
return value === "launcher" ? roles.launcher
|
|
: value === "context" ? roles.context
|
|
: value === "intent" ? roles.intent
|
|
: roles.none;
|
|
};
|
|
|
|
const scopeFrom = (source = {}) => ({
|
|
projectID: parsePositiveInt(source.projectID ?? source.projectId ?? source.ProjectID ?? source.ProjectId),
|
|
bookID: parsePositiveInt(source.bookID ?? source.bookId ?? source.BookID ?? source.BookId)
|
|
});
|
|
|
|
const scopeMatches = (windowPresence, scope) => {
|
|
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
|
|
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
|
|
return (!projectId || parsePositiveInt(windowPresence?.projectID ?? windowPresence?.projectId) === projectId)
|
|
&& (!bookId || parsePositiveInt(windowPresence?.bookID ?? windowPresence?.bookId) === bookId);
|
|
};
|
|
|
|
const roleWindow = (status, role, scope) => (status?.windows || [])
|
|
.filter((windowPresence) => cleanRole(windowPresence.writingRole ?? windowPresence.WritingRole) === role && scopeMatches(windowPresence, scope))
|
|
.sort((a, b) => Date.parse(b.lastSeenUtc || b.LastSeenUtc || 0) - Date.parse(a.lastSeenUtc || a.LastSeenUtc || 0))[0] || null;
|
|
|
|
const currentRoute = () => `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
|
|
const statusScope = (status, fallback = {}) => ({
|
|
projectID: parsePositiveInt(status?.projectID ?? status?.projectId ?? fallback.projectID ?? fallback.projectId),
|
|
bookID: parsePositiveInt(status?.bookID ?? status?.bookId ?? fallback.bookID ?? fallback.bookId)
|
|
});
|
|
|
|
const buildWritingUrl = (role, scope) => {
|
|
const url = new URL(role === roles.context ? "/Writing/Context" : "/Writing/Intent", window.location.origin);
|
|
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
|
|
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
|
|
const position = latestPosition || {};
|
|
if (projectId) url.searchParams.set("projectId", projectId);
|
|
if (bookId) url.searchParams.set("bookId", bookId);
|
|
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
|
|
if (position.sceneId) url.searchParams.set("sceneId", position.sceneId);
|
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
};
|
|
|
|
const stableWindowName = (role, scope) => {
|
|
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
|
|
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
|
|
const suffix = bookId ? `Book_${bookId}` : projectId ? `Project_${projectId}` : "General";
|
|
return `PlotDirector${role}_${suffix}`.replace(/[^A-Za-z0-9_]/g, "_");
|
|
};
|
|
|
|
const readPreferences = () => {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(storageKeys.preferences) || "{}") || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const savePreferences = (preferences) => {
|
|
try {
|
|
localStorage.setItem(storageKeys.preferences, JSON.stringify({ ...readPreferences(), ...preferences }));
|
|
} catch {
|
|
// Layout memory is a convenience only.
|
|
}
|
|
};
|
|
|
|
const sortScreens = (screens) => [...(screens || [])]
|
|
.map((screen, index) => ({
|
|
index,
|
|
left: Number(screen.availLeft ?? screen.left ?? 0),
|
|
top: Number(screen.availTop ?? screen.top ?? 0),
|
|
width: Math.max(320, Number(screen.availWidth ?? screen.width ?? 1200)),
|
|
height: Math.max(320, Number(screen.availHeight ?? screen.height ?? 800)),
|
|
isPrimary: Boolean(screen.isPrimary),
|
|
isInternal: Boolean(screen.isInternal),
|
|
key: `screen-${index}`,
|
|
label: screen.label || (screen.isPrimary ? "Primary display" : `Display ${index + 1}`)
|
|
}))
|
|
.sort((a, b) => a.left - b.left || a.top - b.top || a.index - b.index);
|
|
|
|
const displayByPreference = (ordered, preference, fallback) => {
|
|
const value = String(preference || "").trim().toLowerCase();
|
|
if (value.startsWith("screen-")) {
|
|
return ordered.find((screen) => screen.key.toLowerCase() === value) || fallback;
|
|
}
|
|
if (value === "left") return ordered[0] || fallback;
|
|
if (value === "right") return ordered[ordered.length - 1] || fallback;
|
|
if (value === "primary") return ordered.find((screen) => screen.isPrimary) || fallback;
|
|
if (value === "middle" || value === "centre" || value === "center") return ordered[Math.floor(ordered.length / 2)] || fallback;
|
|
return fallback;
|
|
};
|
|
|
|
const chooseLayout = (screens, preferences = {}) => {
|
|
const ordered = sortScreens(screens);
|
|
if (ordered.length >= 3) {
|
|
const defaultContext = ordered[0];
|
|
const defaultIntent = ordered[ordered.length - 1];
|
|
return {
|
|
mode: "three-screen",
|
|
contextDisplay: displayByPreference(ordered, preferences.contextDisplay, defaultContext),
|
|
intentDisplay: displayByPreference(ordered, preferences.intentDisplay, defaultIntent),
|
|
wordDisplay: ordered[Math.floor(ordered.length / 2)],
|
|
displayCount: ordered.length
|
|
};
|
|
}
|
|
|
|
if (ordered.length === 2) {
|
|
const primaryRole = preferences.twoScreenPrimary === "intent" ? roles.intent : roles.context;
|
|
const secondary = ordered.find((screen) => !screen.isPrimary) || ordered[1];
|
|
const defaultContext = primaryRole === roles.context ? secondary : ordered[0];
|
|
const defaultIntent = primaryRole === roles.intent ? secondary : ordered[0];
|
|
return {
|
|
mode: "two-screen",
|
|
contextDisplay: displayByPreference(ordered, preferences.contextDisplay, defaultContext),
|
|
intentDisplay: displayByPreference(ordered, preferences.intentDisplay, defaultIntent),
|
|
wordDisplay: ordered.find((screen) => screen.isPrimary) || ordered[0],
|
|
displayCount: ordered.length
|
|
};
|
|
}
|
|
|
|
return {
|
|
mode: "single-screen",
|
|
contextDisplay: ordered[0] || null,
|
|
intentDisplay: ordered[0] || null,
|
|
wordDisplay: ordered[0] || null,
|
|
displayCount: ordered.length
|
|
};
|
|
};
|
|
|
|
const popupFeatures = (display) => {
|
|
if (!display) {
|
|
return "popup=yes,width=1120,height=900";
|
|
}
|
|
|
|
const width = Math.max(900, Math.floor(display.width * 0.92));
|
|
const height = Math.max(760, Math.floor(display.height * 0.92));
|
|
const left = Math.floor(display.left + (display.width - width) / 2);
|
|
const top = Math.floor(display.top + (display.height - height) / 2);
|
|
return `popup=yes,left=${left},top=${top},width=${width},height=${height}`;
|
|
};
|
|
|
|
const getScreenLayout = async (requestPermission, preferences = {}) => {
|
|
const mergedPreferences = { ...readPreferences(), ...preferences };
|
|
if (!("getScreenDetails" in window) || typeof window.getScreenDetails !== "function") {
|
|
return { supported: false, permission: "unavailable", layout: chooseLayout([], mergedPreferences) };
|
|
}
|
|
|
|
try {
|
|
if (navigator.permissions?.query && !requestPermission) {
|
|
const permission = await navigator.permissions.query({ name: "window-management" });
|
|
if (permission.state !== "granted") {
|
|
return { supported: true, permission: permission.state, layout: chooseLayout([], mergedPreferences) };
|
|
}
|
|
}
|
|
|
|
const details = await window.getScreenDetails();
|
|
const screens = sortScreens(details.screens || []);
|
|
return { supported: true, permission: "granted", screens, currentScreen: details.currentScreen, layout: chooseLayout(screens, mergedPreferences) };
|
|
} catch (error) {
|
|
return { supported: true, permission: error?.name === "NotAllowedError" ? "denied" : "unavailable", error, layout: chooseLayout([], mergedPreferences) };
|
|
}
|
|
};
|
|
|
|
const ensureConnection = async () => {
|
|
if (!window.signalR) {
|
|
throw new Error("SignalR is unavailable.");
|
|
}
|
|
if (connectionPromise) {
|
|
return connectionPromise;
|
|
}
|
|
connection = new signalR.HubConnectionBuilder()
|
|
.withUrl("/hubs/word-companion-follow")
|
|
.withAutomaticReconnect()
|
|
.build();
|
|
|
|
connection.on("WritingWorkspacePresenceChanged", (status) => {
|
|
latestStatus = status;
|
|
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: status }));
|
|
});
|
|
connection.on("WordCompanionPresenceChanged", (status) => {
|
|
latestStatus = latestStatus ? { ...latestStatus, wordCompanionConnected: Boolean(status?.isConnected ?? status?.IsConnected), wordDocumentOpen: Boolean(status?.documentOpen ?? status?.DocumentOpen) } : latestStatus;
|
|
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-word-status", { bubbles: true, detail: status }));
|
|
});
|
|
connection.on("WritingWorkspaceFocusRequested", () => {
|
|
try {
|
|
window.focus();
|
|
} catch {
|
|
// Browser focus restrictions are expected here.
|
|
}
|
|
});
|
|
connection.onreconnected(() => {
|
|
if (registration) {
|
|
void registerWindow(registration);
|
|
}
|
|
});
|
|
|
|
connectionPromise = connection.start().then(() => connection);
|
|
return connectionPromise;
|
|
};
|
|
|
|
const invoke = async (method, ...args) => {
|
|
const activeConnection = await ensureConnection();
|
|
return await activeConnection.invoke(method, ...args);
|
|
};
|
|
|
|
const registerWindow = async (options) => {
|
|
registration = {
|
|
browserWindowID: getBrowserWindowId(),
|
|
projectID: parsePositiveInt(options.projectID ?? options.projectId),
|
|
bookID: parsePositiveInt(options.bookID ?? options.bookId),
|
|
currentRoute: currentRoute(),
|
|
writingRole: cleanRole(options.writingRole || options.role),
|
|
displayLabel: options.displayLabel || ""
|
|
};
|
|
latestStatus = await invoke("RegisterWritingWindow", registration);
|
|
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: latestStatus }));
|
|
if (heartbeatTimer) {
|
|
window.clearInterval(heartbeatTimer);
|
|
}
|
|
heartbeatTimer = window.setInterval(() => {
|
|
if (!registration) return;
|
|
void invoke("WritingWindowHeartbeat", { ...registration, currentRoute: currentRoute() }).catch(() => null);
|
|
}, 15000);
|
|
return latestStatus;
|
|
};
|
|
|
|
const watchWorkspace = async (scope) => {
|
|
latestStatus = await invoke("WatchWritingWorkspace", scopeFrom(scope));
|
|
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: latestStatus }));
|
|
return latestStatus;
|
|
};
|
|
|
|
const openRoleWindow = (role, scope, display) => {
|
|
const url = buildWritingUrl(role, scope);
|
|
const name = stableWindowName(role, scope);
|
|
const opened = window.open(url, name, popupFeatures(display));
|
|
if (opened) {
|
|
windowRefs.set(role, opened);
|
|
try {
|
|
opened.focus();
|
|
} catch {
|
|
// Focus is best-effort only.
|
|
}
|
|
}
|
|
return opened;
|
|
};
|
|
|
|
const focusRole = async (role, scope, display = null) => {
|
|
const ref = windowRefs.get(role);
|
|
if (ref && !ref.closed) {
|
|
ref.focus();
|
|
return true;
|
|
}
|
|
|
|
const opened = window.open(buildWritingUrl(role, scope), stableWindowName(role, scope), popupFeatures(display));
|
|
if (opened) {
|
|
windowRefs.set(role, opened);
|
|
opened.focus();
|
|
return true;
|
|
}
|
|
|
|
await invoke("RequestWritingWindowFocus", scopeFrom(scope), role).catch(() => null);
|
|
return false;
|
|
};
|
|
|
|
const startWorkspace = async (scope, options = {}) => {
|
|
const cleanScope = scopeFrom(scope);
|
|
const status = await watchWorkspace(cleanScope);
|
|
const resolvedScope = statusScope(status, cleanScope);
|
|
if (!resolvedScope.projectID || !resolvedScope.bookID) {
|
|
return { ok: false, message: "Choose a book or open a linked Word manuscript before starting the writing workspace.", status };
|
|
}
|
|
|
|
const screenInfo = await getScreenLayout(Boolean(options.requestScreens), options.displayPreferences || {});
|
|
const layout = screenInfo.layout;
|
|
const existingContext = roleWindow(status, roles.context, resolvedScope);
|
|
const existingIntent = roleWindow(status, roles.intent, resolvedScope);
|
|
const opened = [];
|
|
const blocked = [];
|
|
|
|
if (!existingContext) {
|
|
const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay);
|
|
(contextWindow ? opened : blocked).push(roles.context);
|
|
} else {
|
|
await focusRole(roles.context, resolvedScope, layout.contextDisplay);
|
|
}
|
|
|
|
if (!existingIntent) {
|
|
const intentWindow = currentRole === roles.intent ? window : openRoleWindow(roles.intent, resolvedScope, layout.intentDisplay);
|
|
(intentWindow ? opened : blocked).push(roles.intent);
|
|
} else {
|
|
await focusRole(roles.intent, resolvedScope, layout.intentDisplay);
|
|
}
|
|
|
|
savePreferences({ lastLayoutMode: layout.mode });
|
|
return { ok: blocked.length === 0, opened, blocked, status: await watchWorkspace(resolvedScope), screenInfo };
|
|
};
|
|
|
|
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
|
|
latestPosition = window.PlotDirectorStoryPosition?.normalise?.(event.detail, event.detail?.source || event.detail?.Source) || event.detail;
|
|
});
|
|
|
|
window.PlotDirectorWritingWorkspace = {
|
|
roles,
|
|
getBrowserWindowId,
|
|
registerWindow,
|
|
watchWorkspace,
|
|
startWorkspace,
|
|
focusRole,
|
|
openRoleWindow,
|
|
stableWindowName,
|
|
buildWritingUrl,
|
|
sortScreens,
|
|
chooseLayout,
|
|
getScreenLayout,
|
|
scopeFrom,
|
|
statusScope
|
|
};
|
|
})();
|