(() => { 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), 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 chooseLayout = (screens, preferences = {}) => { const ordered = sortScreens(screens); if (ordered.length >= 3) { return { mode: "three-screen", contextDisplay: preferences.contextDisplay === "right" ? ordered[ordered.length - 1] : ordered[0], intentDisplay: preferences.intentDisplay === "left" ? ordered[0] : ordered[ordered.length - 1], 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]; return { mode: "two-screen", contextDisplay: primaryRole === roles.context ? secondary : ordered[0], intentDisplay: primaryRole === roles.intent ? secondary : ordered[0], 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) => { if (!("getScreenDetails" in window) || typeof window.getScreenDetails !== "function") { return { supported: false, permission: "unavailable", layout: chooseLayout([], readPreferences()) }; } 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([], readPreferences()) }; } } const details = await window.getScreenDetails(); const screens = sortScreens(details.screens || []); return { supported: true, permission: "granted", screens, currentScreen: details.currentScreen, layout: chooseLayout(screens, readPreferences()) }; } catch (error) { return { supported: true, permission: error?.name === "NotAllowedError" ? "denied" : "unavailable", error, layout: chooseLayout([], readPreferences()) }; } }; 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) => { const ref = windowRefs.get(role); if (ref && !ref.closed) { ref.focus(); return true; } const opened = window.open(buildWritingUrl(role, scope), stableWindowName(role, scope)); 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)); const layout = screenInfo.layout; const existingContext = roleWindow(status, roles.context, resolvedScope); const existingIntent = roleWindow(status, roles.intent, resolvedScope); const currentRole = cleanRole(registration?.writingRole); const opened = []; const blocked = []; if (!existingIntent && currentRole !== roles.context && currentRole !== roles.intent) { savePreferences({ reuseLauncherAs: roles.intent, lastLayoutMode: layout.mode }); if (!existingContext) { const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay); (contextWindow ? opened : blocked).push(roles.context); } window.location.assign(buildWritingUrl(roles.intent, resolvedScope)); return { ok: true, reusedCurrentWindowAs: roles.intent, opened, blocked, status, screenInfo }; } if (!existingContext) { const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay); (contextWindow ? opened : blocked).push(roles.context); } else { await focusRole(roles.context, resolvedScope); } 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); } savePreferences({ reuseLauncherAs: roles.intent, 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 }; })();