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

270 lines
9.3 KiB
JavaScript

(() => {
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 storyPositionFromWorkspaceStatus = (status) => {
const position = status?.currentStoryPosition || status?.CurrentStoryPosition;
if (!position) {
return null;
}
return window.PlotDirectorStoryPosition?.normalise(position, "WordCompanion") || position;
};
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(async () => {
const status = await connection.invoke("WatchWritingWorkspace", {
projectID: scopedProjectId,
bookID: scopedBookId
}).catch(() => null);
const position = storyPositionFromWorkspaceStatus(status);
if (position?.sceneId || (config.allowChapterOnly && position?.chapterId)) {
await loadPosition(position);
}
if (!currentSceneId && !(config.allowChapterOnly && currentChapterId)) {
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 };
})();