PlotDirector/PlotLine/wwwroot/js/writing-workspace.js

350 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),
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
};
})();