Phase 10N: Word Companion Auto Context Refresh

This commit is contained in:
Nick Beckley 2026-06-14 15:22:56 +01:00
parent 2136c6d686
commit 0ffe678d87
6 changed files with 598 additions and 70 deletions

View File

@ -47,9 +47,9 @@ public sealed class AccountController(IAuthService authService) : Controller
[HttpGet]
[AllowAnonymous]
public IActionResult Login(string? returnUrl = null)
public IActionResult Login(string? returnUrl = null, bool companion = false)
{
return View(new LoginViewModel { ReturnUrl = returnUrl });
return View(new LoginViewModel { ReturnUrl = returnUrl, IsCompanionLogin = IsCompanionLogin(returnUrl, companion) });
}
[HttpPost]
@ -59,6 +59,7 @@ public sealed class AccountController(IAuthService authService) : Controller
{
if (!ModelState.IsValid)
{
model.IsCompanionLogin = IsCompanionLogin(model.ReturnUrl, model.IsCompanionLogin);
return View(model);
}
@ -71,6 +72,7 @@ public sealed class AccountController(IAuthService authService) : Controller
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Login could not be completed.");
model.IsCompanionLogin = IsCompanionLogin(model.ReturnUrl, model.IsCompanionLogin);
return View(model);
}
@ -82,6 +84,17 @@ public sealed class AccountController(IAuthService authService) : Controller
return RedirectToAction("Index", "Projects");
}
private static bool IsCompanionLogin(string? returnUrl, bool explicitCompanion)
{
if (explicitCompanion)
{
return true;
}
return !string.IsNullOrWhiteSpace(returnUrl)
&& returnUrl.StartsWith("/word-companion", StringComparison.OrdinalIgnoreCase);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()

View File

@ -67,6 +67,8 @@ public sealed class LoginViewModel
public bool RememberMe { get; set; }
public string? ReturnUrl { get; set; }
public bool IsCompanionLogin { get; set; }
}
public sealed class ForgotPasswordViewModel

View File

@ -1,8 +1,69 @@
@model LoginViewModel
@{
ViewData["Title"] = "Login";
if (Model.IsCompanionLogin)
{
Layout = null;
}
}
@if (Model.IsCompanionLogin)
{
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Word Companion Login - PlotDirector</title>
<link rel="stylesheet" href="~/css/word-companion.css" asp-append-version="true" />
</head>
<body class="word-companion-login-body">
<main class="word-companion-login-shell">
<header class="word-companion-login-header">
<h1>PlotDirector</h1>
<p>Word Companion</p>
</header>
@if (TempData["AuthMessage"] is string authMessage)
{
<div class="word-companion-login-alert">@authMessage</div>
}
<section class="word-companion-login-card">
<form asp-action="Login" method="post" class="word-companion-login-form">
<input asp-for="ReturnUrl" type="hidden" />
<input asp-for="IsCompanionLogin" type="hidden" />
<div asp-validation-summary="ModelOnly" class="word-companion-login-validation"></div>
<div>
<label asp-for="Email">Email address</label>
<input asp-for="Email" autocomplete="email" />
<span asp-validation-for="Email"></span>
</div>
<div>
<label asp-for="Password">Password</label>
<input asp-for="Password" autocomplete="current-password" />
<span asp-validation-for="Password"></span>
</div>
<label class="word-companion-login-check">
<input asp-for="RememberMe" />
<span>Remember me</span>
</label>
<button type="submit">Login</button>
<div class="word-companion-login-links">
<a asp-action="Register">Create account</a>
<a asp-action="ForgotPassword">Forgot password?</a>
<a asp-action="ResendVerificationEmail">Need a new verification email?</a>
</div>
</form>
</section>
</main>
</body>
</html>
}
else
{
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
@ -19,6 +80,7 @@
<section class="edit-panel">
<form asp-action="Login" method="post" class="asset-create-form">
<input asp-for="ReturnUrl" type="hidden" />
<input asp-for="IsCompanionLogin" type="hidden" />
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
@ -50,7 +112,11 @@
</p>
</form>
</section>
}
@if (!Model.IsCompanionLogin)
{
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
}

View File

@ -87,6 +87,7 @@
<div class="word-companion-badges">
<span class="word-companion-badge" data-plotdirector-scene-status>Not detected</span>
<span class="word-companion-badge" data-anchor-status>Not attached</span>
<span class="word-companion-badge">Scene Tracking: <strong data-scene-tracking-status>Manual</strong></span>
</div>
</div>
@ -172,6 +173,7 @@
<button type="button" data-save-scene-links disabled>Save Scene Links</button>
</div>
<p class="word-companion-message" data-scene-links-status>Link a PlotDirector scene first.</p>
<p class="word-companion-link-context" data-links-editing-scene>Link a PlotDirector scene first.</p>
<div>
<span>Characters</span>
<div class="word-companion-check-list" data-character-chips></div>

View File

@ -27,6 +27,140 @@ body {
font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
.word-companion-login-body {
min-width: 280px;
}
.word-companion-login-shell {
display: grid;
align-content: start;
gap: 12px;
width: 100%;
max-width: 420px;
padding: 14px 12px;
}
.word-companion-login-header {
display: grid;
gap: 2px;
border-bottom: 1px solid var(--companion-line);
padding-bottom: 9px;
}
.word-companion-login-header h1 {
margin: 0;
color: var(--companion-accent-dark);
font-size: 1.22rem;
line-height: 1.1;
}
.word-companion-login-header p {
margin: 0;
color: var(--companion-muted);
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
}
.word-companion-login-card,
.word-companion-login-alert {
border: 1px solid var(--companion-line);
border-radius: 10px;
background: var(--companion-panel);
box-shadow: 0 2px 8px rgba(74, 50, 31, 0.08);
}
.word-companion-login-card {
padding: 14px;
}
.word-companion-login-alert {
padding: 10px;
color: var(--companion-accent-dark);
font-size: 0.86rem;
}
.word-companion-login-form {
display: grid;
gap: 10px;
}
.word-companion-login-form div {
display: grid;
gap: 4px;
}
.word-companion-login-form label {
color: var(--companion-muted);
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
}
.word-companion-login-form input[type="email"],
.word-companion-login-form input[type="password"],
.word-companion-login-form input:not([type]) {
width: 100%;
min-height: 34px;
border: 1px solid var(--companion-line);
border-radius: 6px;
padding: 7px 8px;
background: #fffdf8;
color: var(--companion-ink);
font: inherit;
}
.word-companion-login-form span {
color: #8f3528;
font-size: 0.76rem;
}
.word-companion-login-check {
display: flex !important;
align-items: center;
gap: 7px;
color: var(--companion-ink) !important;
font-size: 0.84rem !important;
font-weight: 600 !important;
text-transform: none !important;
}
.word-companion-login-check input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--companion-accent);
}
.word-companion-login-form button {
min-height: 36px;
border: 1px solid var(--companion-accent);
border-radius: 8px;
background: linear-gradient(180deg, #7b5432 0%, var(--companion-accent-dark) 100%);
color: #fff9ef;
font: inherit;
font-size: 0.9rem;
font-weight: 800;
}
.word-companion-login-links {
display: grid;
gap: 6px;
border-top: 1px solid var(--companion-line);
padding-top: 9px;
}
.word-companion-login-links a {
color: var(--companion-accent-dark);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.word-companion-login-validation:empty {
display: none;
}
.word-companion-shell {
display: grid;
align-content: start;
@ -341,6 +475,7 @@ body {
.word-companion-badge {
display: inline-flex;
align-items: center;
gap: 4px;
width: fit-content;
min-height: 24px;
border: 1px solid rgba(111, 75, 45, 0.24);
@ -353,6 +488,22 @@ body {
text-transform: none !important;
}
.word-companion-badge strong {
color: var(--companion-accent-dark);
font-size: inherit;
}
.word-companion-link-context {
margin: -2px 0 2px;
border: 1px solid rgba(111, 75, 45, 0.16);
border-radius: 6px;
padding: 7px 8px;
background: var(--companion-soft);
color: var(--companion-accent-dark);
font-size: 0.82rem;
font-weight: 700;
}
.word-companion-scene-card div,
.word-companion-sync-meta div,
.word-companion-structure-grid div,

View File

@ -1,8 +1,10 @@
(() => {
const storageKeys = {
projectId: "plotdirector.word.projectId",
bookId: "plotdirector.word.bookId"
bookId: "plotdirector.word.bookId",
activeTab: "plotdirector.word.activeTab"
};
const validTabNames = new Set(["scene", "links", "structure", "diagnostics"]);
const shell = document.querySelector(".word-companion-shell");
const tabButtons = [...document.querySelectorAll("[data-tab-button]")];
@ -21,6 +23,7 @@
const currentChapter = document.querySelector("[data-current-chapter]");
const currentScene = document.querySelector("[data-current-scene]");
const currentWordCount = document.querySelector("[data-current-word-count]");
const sceneTrackingStatus = document.querySelector("[data-scene-tracking-status]");
const documentMessage = document.querySelector("[data-document-message]");
const syncNote = document.querySelector("[data-sync-note]");
const documentOutline = document.querySelector("[data-document-outline]");
@ -57,6 +60,7 @@
const primaryLocationSelect = document.querySelector("[data-primary-location-select]");
const saveSceneLinksButton = document.querySelector("[data-save-scene-links]");
const sceneLinksStatus = document.querySelector("[data-scene-links-status]");
const linksEditingScene = document.querySelector("[data-links-editing-scene]");
const chapterCreateLink = document.querySelector("[data-chapter-create-link]");
const chapterCreateLinkChapter = document.querySelector("[data-chapter-create-link-chapter]");
const createPlotDirectorChapterButton = document.querySelector("[data-create-plotdirector-chapter]");
@ -146,6 +150,13 @@
let structurePreviewModel = null;
let isApplyingStructureSync = false;
let pendingApplyStructureSyncConfirmation = null;
let sceneTrackingMode = "Manual";
let sceneContextStale = false;
let selectionRefreshTimer = null;
let selectionHandlerRegistered = false;
let isAutoRefreshingScene = false;
let lastLinkedSceneTitle = "";
let lastLinkedChapterTitle = "";
let currentSceneLinks = {
characters: [],
assets: [],
@ -159,18 +170,51 @@
}
};
const selectTab = (tabName) => {
const readStoredText = (key) => {
try {
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const writeStoredText = (key, value) => {
try {
if (value) {
window.localStorage.setItem(key, String(value));
} else {
window.localStorage.removeItem(key);
}
} catch {
// Some Office hosts can restrict storage. The pane still falls back to the Scene tab.
}
};
const selectTab = (tabName, persist = true) => {
const targetTab = validTabNames.has(tabName) && tabButtons.some((button) => button.dataset.tabButton === tabName)
? tabName
: "scene";
for (const button of tabButtons) {
const active = button.dataset.tabButton === tabName;
const active = button.dataset.tabButton === targetTab;
button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
}
for (const panel of tabPanels) {
const active = panel.dataset.tabPanel === tabName;
const active = panel.dataset.tabPanel === targetTab;
panel.classList.toggle("is-active", active);
panel.hidden = !active;
}
if (persist) {
writeStoredText(storageKeys.activeTab, targetTab);
}
};
const restoreActiveTab = () => {
const storedTab = readStoredText(storageKeys.activeTab);
selectTab(validTabNames.has(storedTab) ? storedTab : "scene", false);
};
const setConnectionStatus = (message) => {
@ -311,18 +355,63 @@
updateApplyStructureSyncButton();
};
const updateLinksEditingScene = () => {
if (!linksEditingScene) {
return;
}
linksEditingScene.textContent = resolvedSceneId
? `Editing links for: ${lastLinkedSceneTitle || detectedSceneTitle || "Current scene"}`
: "Link a PlotDirector scene first.";
};
const updateSceneActionButtons = () => {
const blocked = sceneContextStale || !resolvedSceneId;
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = blocked;
}
if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = blocked;
}
if (primaryLocationSelect) {
primaryLocationSelect.disabled = blocked;
}
for (const container of [characterChips, assetChips]) {
for (const checkbox of container ? [...container.querySelectorAll("input[type='checkbox']")] : []) {
checkbox.disabled = blocked;
}
}
};
const setSceneTrackingState = (status) => {
if (status === "Live" || status === "Manual") {
sceneTrackingMode = status;
}
setText(sceneTrackingStatus, status || sceneTrackingMode || "Manual");
};
const setSceneContextStale = (isStale, message = "") => {
sceneContextStale = !!isStale;
setText(sceneTrackingStatus, sceneContextStale ? "Stale" : sceneTrackingMode);
updateSceneActionButtons();
if (message) {
setDocumentMessage(message);
setSceneLinksStatus(message);
}
};
const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => {
resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null;
resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null;
currentResolutionMethod = resolutionMethod || "";
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId;
}
if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = !resolvedSceneId;
}
setSyncStatus(resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene.");
setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "Link a PlotDirector scene first.");
updateSceneActionButtons();
setSyncStatus(sceneContextStale
? "Refresh Current Scene before syncing."
: resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene.");
setSceneLinksStatus(sceneContextStale
? "Refresh Current Scene before saving."
: resolvedSceneId ? "Ready to save." : "Link a PlotDirector scene first.");
updateLinksEditingScene();
updateAnchorDisplay();
};
@ -431,6 +520,8 @@
hideChapterCreateLink();
hideSceneCreateLink();
currentResolutionMethod = "";
lastLinkedSceneTitle = "";
lastLinkedChapterTitle = "";
currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match";
storedAnchorInvalid = false;
currentSceneLinks = { characters: [], assets: [], locations: [], primaryLocationId: null };
@ -447,6 +538,7 @@
setText(plotDirectorBlockedReason, "-");
setHidden(plotDirectorBlockedReasonRow, true);
updateAnchorDisplay();
updateLinksEditingScene();
};
const linkItemId = (item, type) => {
@ -528,6 +620,8 @@
};
const renderCompanionData = (data) => {
lastLinkedSceneTitle = data?.sceneTitle || detectedSceneTitle || "";
lastLinkedChapterTitle = data?.chapterTitle || detectedChapterTitle || "";
setText(plotDirectorSceneTitle, displayValue(data?.sceneTitle));
setText(plotDirectorRevisionStatus, displayValue(data?.revisionStatus || data?.revisionStatusName || "Not provided"));
setText(plotDirectorEstimatedWords, displayValue(data?.estimatedWords));
@ -553,10 +647,13 @@
locations: Array.isArray(data?.locations) ? data.locations : [],
primaryLocationId: data?.primaryLocationId ?? null
};
renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId);
renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId);
renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId);
setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene.");
renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId && !sceneContextStale);
renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId && !sceneContextStale);
renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId && !sceneContextStale);
updateLinksEditingScene();
setSceneLinksStatus(sceneContextStale
? "Refresh Current Scene before saving."
: resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene.");
setHidden(plotDirectorSceneDetails, false);
setHidden(writingBriefBlock, false);
setHidden(plotDirectorLinkGroups, false);
@ -667,6 +764,7 @@
}
currentResolutionAnchorType = anchorType || "Title Match";
storedAnchorInvalid = false;
setSceneContextStale(false);
setPlotDirectorSceneStatus("Linked to PlotDirector");
setDocumentMessage("");
hideChapterCreateLink();
@ -1424,7 +1522,7 @@
const message = errorText(error);
console.error("Unable to scan the Word document.", error);
setCurrentStructure(null, null, null);
setDocumentMessage("Unable to scan the Word document.");
setDocumentMessage(`Unable to scan the Word document: ${message}`);
setDiagnostics({ lastScan: "Failure", lastError: message });
return false;
} finally {
@ -1780,8 +1878,9 @@
});
} catch (error) {
console.error("Word Companion diagnostics failed.", error);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
setDocumentMessage("Unable to scan the Word document.");
const message = errorText(error);
setDiagnostics({ lastScan: "Failure", lastError: message });
setDocumentMessage(`Unable to scan the Word document: ${message}`);
} finally {
if (runDiagnosticsButton) {
runDiagnosticsButton.disabled = false;
@ -1852,12 +1951,58 @@
};
};
const verifyCurrentWordSceneMatchesLinkedScene = async () => {
if (!resolvedSceneId) {
return false;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
try {
const structure = await window.Word.run(async (context) => {
return await readDocumentStructure(context);
});
const currentChapterTitle = structure.currentChapter?.title || "";
const currentSceneTitle = structure.currentScene?.title || "";
const currentSceneAnchorId = structure.currentScene?.anchorId || null;
const currentChapterAnchorId = structure.currentChapter?.anchorId || null;
const anchoredSceneMatches = Number.isInteger(currentSceneAnchorId) && currentSceneAnchorId === resolvedSceneId;
const anchoredChapterMatches = Number.isInteger(currentChapterAnchorId) && currentChapterAnchorId === resolvedChapterId;
const titleSceneMatches = normalizeTitleForResolve(currentSceneTitle, "scene").toLocaleLowerCase()
=== normalizeTitleForResolve(lastLinkedSceneTitle || detectedSceneTitle, "scene").toLocaleLowerCase();
const titleChapterMatches = normalizeTitleForResolve(currentChapterTitle, "chapter").toLocaleLowerCase()
=== normalizeTitleForResolve(lastLinkedChapterTitle || detectedChapterTitle, "chapter").toLocaleLowerCase();
const matches = anchoredSceneMatches || (titleSceneMatches && (anchoredChapterMatches || titleChapterMatches));
if (!matches) {
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
setSceneContextStale(false);
return true;
} catch (error) {
console.error("Unable to verify current Word scene before saving.", error);
setDiagnostics({ lastError: errorText(error) });
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
};
const saveSceneLinks = async () => {
if (!resolvedSceneId) {
setSceneLinksStatus("Link a PlotDirector scene first.");
return;
}
if (sceneContextStale || !(await verifyCurrentWordSceneMatchesLinkedScene())) {
setSceneLinksStatus("The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return;
}
if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = true;
saveSceneLinksButton.textContent = "Saving...";
@ -1878,7 +2023,7 @@
setDiagnostics({ lastError: errorText(error) });
} finally {
if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = !resolvedSceneId;
saveSceneLinksButton.disabled = !resolvedSceneId || sceneContextStale;
saveSceneLinksButton.textContent = "Save Scene Links";
}
}
@ -2461,6 +2606,11 @@
return;
}
if (sceneContextStale) {
setSyncStatus("Refresh Current Scene before syncing.");
return;
}
if (!Number.isInteger(detectedSceneWordCount)) {
setSyncStatus("Unable to sync progress.");
return;
@ -2485,7 +2635,7 @@
setSyncStatus("Unable to sync progress.");
} finally {
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId;
syncSceneProgressButton.disabled = !resolvedSceneId || sceneContextStale;
syncSceneProgressButton.textContent = "Sync Progress";
}
}
@ -2755,6 +2905,144 @@
}
};
const hasCurrentWordContextChanged = (structure) => {
const chapterTitle = structure.currentChapter?.title || "";
const sceneTitle = structure.currentScene?.title || "";
const chapterAnchorId = structure.currentChapter?.anchorId || null;
const sceneAnchorId = structure.currentScene?.anchorId || null;
return normalizeTitleForResolve(chapterTitle, "chapter") !== normalizeTitleForResolve(detectedChapterTitle, "chapter")
|| normalizeTitleForResolve(sceneTitle, "scene") !== normalizeTitleForResolve(detectedSceneTitle, "scene")
|| chapterAnchorId !== detectedChapterAnchorId
|| sceneAnchorId !== detectedSceneAnchorId;
};
const refreshCurrentSceneFromSelection = async () => {
if (isAutoRefreshingScene || !isAuthenticated || !selectedBook()) {
return;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
return;
}
isAutoRefreshingScene = true;
setSceneTrackingState("Refreshing");
setDocumentMessage("Updating current scene...");
try {
const structure = await window.Word.run(async (context) => {
return await readDocumentStructure(context);
});
const changed = hasCurrentWordContextChanged(structure);
setCurrentStructure(
structure.currentChapter?.title,
structure.currentScene?.title,
structure.currentScene?.wordCount,
structure.currentChapter?.anchorId,
structure.currentScene?.anchorId,
structure.currentChapter?.index
);
renderOutline(structure);
setDiagnostics({
lastScan: "Success",
lastError: "-",
paragraphs: String(structure.paragraphCount),
heading1: String(structure.heading1Count),
heading2: String(structure.heading2Count)
});
if (!changed) {
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("Current scene updated.");
return;
}
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
} else {
await refreshPlotDirectorScene();
}
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("Current scene updated.");
} catch (error) {
console.error("Unable to refresh current scene from Word selection.", error);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
} finally {
isAutoRefreshingScene = false;
}
};
const scheduleSelectionRefresh = () => {
if (selectionRefreshTimer) {
window.clearTimeout(selectionRefreshTimer);
}
selectionRefreshTimer = window.setTimeout(refreshCurrentSceneFromSelection, 850);
};
const handleDocumentSelectionChanged = () => {
scheduleSelectionRefresh();
};
const removeSelectionTracking = () => {
if (!selectionHandlerRegistered
|| !window.Office?.context?.document
|| typeof window.Office.context.document.removeHandlerAsync !== "function"
|| !window.Office?.EventType?.DocumentSelectionChanged) {
return;
}
try {
window.Office.context.document.removeHandlerAsync(
window.Office.EventType.DocumentSelectionChanged,
{ handler: handleDocumentSelectionChanged }
);
selectionHandlerRegistered = false;
} catch (error) {
console.warn("Unable to remove Word selection tracking handler.", error);
}
};
const setupSelectionTracking = () => {
if (!isAuthenticated) {
return;
}
if (!window.Office?.context?.document
|| typeof window.Office.context.document.addHandlerAsync !== "function"
|| !window.Office?.EventType?.DocumentSelectionChanged) {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
return;
}
try {
window.Office.context.document.addHandlerAsync(
window.Office.EventType.DocumentSelectionChanged,
handleDocumentSelectionChanged,
(result) => {
if (result?.status === window.Office.AsyncResultStatus.Succeeded) {
selectionHandlerRegistered = true;
setSceneTrackingState("Live");
} else {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
}
}
);
window.addEventListener("beforeunload", removeSelectionTracking);
} catch (error) {
console.warn("Unable to register Word selection tracking handler.", error);
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
}
};
const refreshCurrentScene = async () => {
if (!selectedBook()) {
setDocumentMessage("Select a PlotDirector book first.");
@ -2768,6 +3056,7 @@
if (!scanned) {
return;
}
setSceneContextStale(false);
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
@ -2880,6 +3169,7 @@
for (const button of tabButtons) {
button.addEventListener("click", () => selectTab(button.dataset.tabButton));
}
restoreActiveTab();
projectSelect?.addEventListener("change", async () => {
const projectId = Number.parseInt(projectSelect.value || "", 10);
@ -2949,6 +3239,7 @@
if (!window.Office || typeof window.Office.onReady !== "function") {
setOfficeStatus("Word Companion ready");
setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");
setDiagnostics({
host: "Unavailable",
platform: "Unavailable",
@ -2963,6 +3254,9 @@
setOfficeStatus("Word Companion ready");
if (!wordHostAvailable) {
setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");
} else {
setupSelectionTracking();
}
})
.catch((error) => {