(() => { const defaults = { rootSelector: "", contentSelector: "", fragmentSelector: "", statusTextSelector: "", tabSelector: "", panelSelector: "", selectSelector: "", tabDataKey: "", panelDataKey: "", storageKey: "", defaultTab: "", defaultEndpoint: "", source: "WritingMode", allowChapterOnly: false, waitingStatus: "Connected, waiting for scene", updatingStatus: "Updating", disconnectedStatus: "Word not connected" }; const parsePositiveInt = (value) => { const parsed = Number.parseInt(value || "", 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; }; const initialize = (options) => { const config = { ...defaults, ...options }; const root = document.querySelector(config.rootSelector); if (!root) { return null; } const contentHost = root.querySelector(config.contentSelector); const statusText = root.querySelector(config.statusTextSelector); const endpoint = root.dataset.contextUrl || config.defaultEndpoint; const scopedProjectId = parsePositiveInt(root.dataset.scopeProjectId); const scopedBookId = parsePositiveInt(root.dataset.scopeBookId); let currentFragment = contentHost?.querySelector(config.fragmentSelector); let currentSceneId = parsePositiveInt(currentFragment?.dataset.sceneId); let currentChapterId = parsePositiveInt(currentFragment?.dataset.chapterId); let loadingSceneId = null; let loadingChapterId = null; const setStatus = (value) => { if (statusText && value) { statusText.textContent = value; } }; const registerWorkspaceRole = () => { const role = config.source === "WritingContext" ? window.PlotDirectorWritingWorkspace?.roles?.context : config.source === "WritingIntent" ? window.PlotDirectorWritingWorkspace?.roles?.intent : null; if (!role || !window.PlotDirectorWritingWorkspace?.registerWindow) { return; } const fragment = contentHost?.querySelector(config.fragmentSelector); void window.PlotDirectorWritingWorkspace.registerWindow({ projectID: fragment?.dataset.projectId || root.dataset.scopeProjectId, bookID: fragment?.dataset.bookId || root.dataset.scopeBookId, writingRole: role, displayLabel: config.source === "WritingContext" ? "Writing Context" : "Writing Intent" }).catch(() => null); }; const readStoredTab = () => { try { return localStorage.getItem(config.storageKey) || config.defaultTab; } catch { return config.defaultTab; } }; const activeTab = () => { const active = contentHost?.querySelector(`${config.tabSelector}.active`)?.dataset[config.tabDataKey]; return active || readStoredTab(); }; const activateTab = (tabName) => { const nextTab = tabName || config.defaultTab; contentHost?.querySelectorAll(config.tabSelector).forEach((button) => { const active = button.dataset[config.tabDataKey] === nextTab; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); }); contentHost?.querySelectorAll(config.panelSelector).forEach((panel) => { const active = panel.dataset[config.panelDataKey] === nextTab; panel.classList.toggle("active", active); panel.hidden = !active; }); try { localStorage.setItem(config.storageKey, nextTab); } catch { // Remembering the selected tab is a convenience only. } }; const wireFragment = () => { activateTab(activeTab()); const fragment = contentHost?.querySelector(config.fragmentSelector); const fragmentStatus = fragment?.dataset.followStatus; if (fragmentStatus) { setStatus(fragmentStatus); } registerWorkspaceRole(); const fragmentSceneId = parsePositiveInt(fragment?.dataset.sceneId); if (fragmentSceneId) { currentSceneId = fragmentSceneId; } const fragmentChapterId = parsePositiveInt(fragment?.dataset.chapterId); if (fragmentChapterId) { currentChapterId = fragmentChapterId; } contentHost?.querySelectorAll(config.tabSelector).forEach((button) => { button.addEventListener("click", () => activateTab(button.dataset[config.tabDataKey])); }); contentHost?.querySelector(config.selectSelector)?.addEventListener("change", (event) => { const sceneId = parsePositiveInt(event.target.value); if (!sceneId) { return; } const fragment = contentHost?.querySelector(config.fragmentSelector); window.PlotDirectorStoryPosition?.publishChanged({ source: config.source, projectId: fragment?.dataset.projectId, bookId: fragment?.dataset.bookId, chapterId: fragment?.dataset.chapterId, sceneId }, config.source); }); contentHost?.querySelector("[data-writing-intent-chapter-select]")?.addEventListener("change", (event) => { const chapterId = parsePositiveInt(event.target.value); if (!chapterId) { return; } const fragment = contentHost?.querySelector(config.fragmentSelector); window.PlotDirectorStoryPosition?.publishChanged({ source: config.source, projectId: fragment?.dataset.projectId, bookId: fragment?.dataset.bookId, chapterId }, config.source); }); }; const inScope = (position) => { if (scopedProjectId && position.projectId !== scopedProjectId) { return false; } if (scopedBookId && position.bookId && position.bookId !== scopedBookId) { return false; } return true; }; const loadPosition = async (position) => { const hasScene = Number.isInteger(position?.sceneId) && position.sceneId > 0; const hasChapter = Number.isInteger(position?.chapterId) && position.chapterId > 0; if (!position || !inScope(position) || (!hasScene && (!config.allowChapterOnly || !hasChapter))) { return; } if (hasScene && (position.sceneId === currentSceneId || position.sceneId === loadingSceneId)) { return; } if (!hasScene && (position.chapterId === currentChapterId || position.chapterId === loadingChapterId)) { return; } const tab = activeTab(); loadingSceneId = hasScene ? position.sceneId : null; loadingChapterId = !hasScene ? position.chapterId : null; setStatus(config.updatingStatus); const url = new URL(endpoint, window.location.origin); if (position.projectId) url.searchParams.set("projectId", position.projectId); if (position.bookId) url.searchParams.set("bookId", position.bookId); if (position.chapterId) url.searchParams.set("chapterId", position.chapterId); if (hasScene) url.searchParams.set("sceneId", position.sceneId); try { const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } }); if (!response.ok) { throw new Error(`${config.source} request failed: ${response.status}`); } contentHost.innerHTML = await response.text(); if (hasScene) { currentSceneId = position.sceneId; } if (hasChapter) { currentChapterId = position.chapterId; } wireFragment(); activateTab(tab); } catch (error) { console.debug(`${config.source} could not update.`, error); setStatus(config.waitingStatus); } finally { loadingSceneId = null; loadingChapterId = null; } }; document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => { void loadPosition(event.detail); }); if (window.signalR) { const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/word-companion-follow") .withAutomaticReconnect() .build(); connection.on("storyPositionChanged", (payload) => { window.PlotDirectorStoryPosition?.publishChanged(payload, payload?.source || payload?.Source || "WordCompanion"); }); connection.start() .then(() => { if (!currentSceneId) { setStatus(config.waitingStatus); } }) .catch((error) => { console.debug(`${config.source} follow connection unavailable.`, error); setStatus(config.disconnectedStatus); }); } else { setStatus(config.disconnectedStatus); } wireFragment(); return { loadPosition, activateTab }; }; window.PlotDirectorWritingFollow = { initialize }; })();