From 0ffe678d87887fd16ed8f1bd66d5b04932d2180a Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 14 Jun 2026 15:22:56 +0100 Subject: [PATCH] Phase 10N: Word Companion Auto Context Refresh --- PlotLine/Controllers/AccountController.cs | 17 +- PlotLine/ViewModels/AuthViewModels.cs | 2 + PlotLine/Views/Account/Login.cshtml | 160 ++++++--- PlotLine/Views/WordCompanionHost/Index.cshtml | 2 + PlotLine/wwwroot/css/word-companion.css | 151 ++++++++ PlotLine/wwwroot/js/word-companion-host.js | 336 ++++++++++++++++-- 6 files changed, 598 insertions(+), 70 deletions(-) diff --git a/PlotLine/Controllers/AccountController.cs b/PlotLine/Controllers/AccountController.cs index 61bd715..fb5a6f7 100644 --- a/PlotLine/Controllers/AccountController.cs +++ b/PlotLine/Controllers/AccountController.cs @@ -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 Logout() diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs index 0ef5c16..0b481ca 100644 --- a/PlotLine/ViewModels/AuthViewModels.cs +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -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 diff --git a/PlotLine/Views/Account/Login.cshtml b/PlotLine/Views/Account/Login.cshtml index 2469baf..0847aed 100644 --- a/PlotLine/Views/Account/Login.cshtml +++ b/PlotLine/Views/Account/Login.cshtml @@ -1,56 +1,122 @@ @model LoginViewModel @{ ViewData["Title"] = "Login"; + if (Model.IsCompanionLogin) + { + Layout = null; + } } -
-
-

Account

-

Login

-

Sign in to PlotDirector.

-
-
- -@if (TempData["AuthMessage"] is string authMessage) +@if (Model.IsCompanionLogin) { -
@authMessage
+ + + + + + Word Companion Login - PlotDirector + + + +
+ + + @if (TempData["AuthMessage"] is string authMessage) + { + + } + + +
+ + +} +else +{ +
+
+

Account

+

Login

+

Sign in to PlotDirector.

+
+
+ + @if (TempData["AuthMessage"] is string authMessage) + { +
@authMessage
+ } + +
+
+ + +
+ +
+
+ + + +
+
+ + + +
+
+ +
+
+ + +

+ Need a new verification email? +

+
+
} -
-
- -
- -
-
- - - -
-
- - - -
-
- -
-
- - -

- Need a new verification email? -

-
-
- -@section Scripts { - +@if (!Model.IsCompanionLogin) +{ + @section Scripts { + + } } diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 21121c2..9472c71 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -87,6 +87,7 @@
Not detected Not attached + Scene Tracking: Manual
@@ -172,6 +173,7 @@

Link a PlotDirector scene first.

+
Characters
diff --git a/PlotLine/wwwroot/css/word-companion.css b/PlotLine/wwwroot/css/word-companion.css index f2a9422..c67417b 100644 --- a/PlotLine/wwwroot/css/word-companion.css +++ b/PlotLine/wwwroot/css/word-companion.css @@ -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, diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index e6325c8..b89a9ca 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -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) => {