2518 lines
90 KiB
JavaScript
2518 lines
90 KiB
JavaScript
(() => {
|
|
const storageKey = "plotline.theme";
|
|
const choices = document.querySelectorAll("[data-theme-choice]");
|
|
|
|
const readTheme = () => {
|
|
try {
|
|
return localStorage.getItem(storageKey);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const saveTheme = (theme) => {
|
|
try {
|
|
localStorage.setItem(storageKey, theme);
|
|
} catch {
|
|
// Theme still applies for the current page if persistence is unavailable.
|
|
}
|
|
};
|
|
|
|
const applyTheme = (theme) => {
|
|
const nextTheme = theme === "dark" ? "dark" : "light";
|
|
document.documentElement.dataset.theme = nextTheme;
|
|
document.documentElement.dataset.bsTheme = nextTheme;
|
|
saveTheme(nextTheme);
|
|
choices.forEach((button) => {
|
|
const isActive = button.dataset.themeChoice === nextTheme;
|
|
button.classList.toggle("active", isActive);
|
|
button.setAttribute("aria-pressed", String(isActive));
|
|
});
|
|
};
|
|
|
|
choices.forEach((button) => {
|
|
button.addEventListener("click", () => applyTheme(button.dataset.themeChoice));
|
|
});
|
|
|
|
applyTheme(readTheme() || "light");
|
|
})();
|
|
|
|
(() => {
|
|
const banner = document.querySelector("[data-trial-banner]");
|
|
if (!banner) {
|
|
return;
|
|
}
|
|
|
|
const key = `plotline.trialBanner.dismissed.${banner.dataset.trialBannerKey || "current"}`;
|
|
try {
|
|
if (localStorage.getItem(key) === "true") {
|
|
banner.hidden = true;
|
|
return;
|
|
}
|
|
} catch {
|
|
// The banner can still be dismissed for this page view if storage is unavailable.
|
|
}
|
|
|
|
banner.querySelector("[data-trial-banner-dismiss]")?.addEventListener("click", () => {
|
|
banner.hidden = true;
|
|
try {
|
|
localStorage.setItem(key, "true");
|
|
} catch {
|
|
// Dismissal persistence is a convenience only.
|
|
}
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const inspectorRoots = document.querySelectorAll(".scene-inspector-root");
|
|
if (!inspectorRoots.length) {
|
|
return;
|
|
}
|
|
|
|
const currentReturnUrl = () => `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
|
|
inspectorRoots.forEach((root) => {
|
|
root.addEventListener("submit", (event) => {
|
|
const form = event.target;
|
|
if (!(form instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
|
|
let returnUrlInput = form.querySelector("input[name='ReturnUrl']");
|
|
if (!returnUrlInput) {
|
|
returnUrlInput = document.createElement("input");
|
|
returnUrlInput.type = "hidden";
|
|
returnUrlInput.name = "ReturnUrl";
|
|
form.appendChild(returnUrlInput);
|
|
}
|
|
|
|
returnUrlInput.value = currentReturnUrl();
|
|
}, true);
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const roots = document.querySelectorAll(".scene-inspector-root");
|
|
if (!roots.length) {
|
|
return;
|
|
}
|
|
|
|
const setStatus = (form, text, state) => {
|
|
const status = form.querySelector("[data-scene-occupancy-status]");
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
status.textContent = text;
|
|
status.dataset.saveState = state || "";
|
|
};
|
|
|
|
const replaceOccupancyForm = (form, html) => {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.innerHTML = html;
|
|
const nextForm = wrapper.querySelector("[data-scene-occupancy-form]");
|
|
if (nextForm) {
|
|
form.replaceWith(nextForm);
|
|
const sceneForm = nextForm.closest(".scene-inspector-root")?.querySelector(".scene-inspector-form");
|
|
const floorPlanInput = sceneForm?.querySelector("[name='FloorPlanID']");
|
|
const initialFloorInput = sceneForm?.querySelector("[name='InitialFloorPlanFloorID']");
|
|
const nextFloorPlan = nextForm.querySelector("[name='FloorPlanID']");
|
|
const nextInitialFloor = nextForm.querySelector("[name='InitialFloorPlanFloorID']");
|
|
if (floorPlanInput && nextFloorPlan) {
|
|
floorPlanInput.value = nextFloorPlan.value;
|
|
}
|
|
if (initialFloorInput && nextInitialFloor) {
|
|
initialFloorInput.value = nextInitialFloor.value;
|
|
}
|
|
}
|
|
};
|
|
|
|
roots.forEach((root) => {
|
|
root.addEventListener("change", async (event) => {
|
|
const planSelect = event.target.closest?.("[data-occupancy-plan-select]");
|
|
if (!planSelect) {
|
|
return;
|
|
}
|
|
|
|
const form = planSelect.closest("[data-scene-occupancy-form]");
|
|
const sceneId = form?.querySelector("[name='SceneID']")?.value;
|
|
const editorUrl = form?.dataset.editorUrl;
|
|
if (!form || !sceneId || !editorUrl) {
|
|
return;
|
|
}
|
|
|
|
setStatus(form, "Loading...", "saving");
|
|
const url = new URL(editorUrl, window.location.origin);
|
|
url.searchParams.set("sceneId", sceneId);
|
|
if (planSelect.value) {
|
|
url.searchParams.set("floorPlanId", planSelect.value);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
|
|
if (!response.ok) {
|
|
throw new Error("The occupancy editor could not be refreshed.");
|
|
}
|
|
|
|
replaceOccupancyForm(form, await response.text());
|
|
} catch (error) {
|
|
setStatus(form, error.message || "Load failed", "failed");
|
|
}
|
|
});
|
|
|
|
root.addEventListener("submit", async (event) => {
|
|
const form = event.target.closest?.("[data-scene-occupancy-form]");
|
|
if (!form) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
setStatus(form, "Saving...", "saving");
|
|
|
|
try {
|
|
const response = await fetch(form.action, {
|
|
method: "POST",
|
|
body: new FormData(form),
|
|
headers: { "X-Requested-With": "XMLHttpRequest" }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let message = "Save failed";
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (contentType.includes("application/json")) {
|
|
const payload = await response.json();
|
|
message = payload.message || message;
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
|
|
replaceOccupancyForm(form, await response.text());
|
|
} catch (error) {
|
|
setStatus(form, error.message || "Save failed", "failed");
|
|
}
|
|
});
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const modalElement = document.getElementById("plotlineConfirmModal");
|
|
const modal = modalElement && window.bootstrap ? new bootstrap.Modal(modalElement) : null;
|
|
const modalTitle = modalElement?.querySelector("#plotlineConfirmModalTitle");
|
|
const modalMessage = modalElement?.querySelector("#plotlineConfirmModalMessage");
|
|
const modalSubmit = modalElement?.querySelector("[data-confirm-submit]");
|
|
const deleteField = modalElement?.querySelector("[data-delete-confirm-field]");
|
|
const deleteInput = modalElement?.querySelector("[data-delete-confirm-input]");
|
|
let pendingForm = null;
|
|
let pendingSubmitter = null;
|
|
|
|
const splitMessage = (message) => {
|
|
const parts = message.split(/\n\s*\n/);
|
|
return {
|
|
title: parts.shift()?.trim() || "Confirm action",
|
|
body: parts.join("\n\n").trim()
|
|
};
|
|
};
|
|
|
|
const resetModal = () => {
|
|
pendingForm = null;
|
|
pendingSubmitter = null;
|
|
if (deleteInput) {
|
|
deleteInput.value = "";
|
|
}
|
|
deleteField?.classList.add("d-none");
|
|
modalSubmit?.removeAttribute("disabled");
|
|
};
|
|
|
|
modalElement?.addEventListener("hidden.bs.modal", resetModal);
|
|
|
|
deleteInput?.addEventListener("input", () => {
|
|
if (pendingForm?.dataset.requireDeleteText === "true" && modalSubmit) {
|
|
modalSubmit.disabled = deleteInput.value.trim() !== "DELETE";
|
|
}
|
|
});
|
|
|
|
modalSubmit?.addEventListener("click", () => {
|
|
if (!pendingForm) {
|
|
return;
|
|
}
|
|
|
|
if (pendingForm.dataset.requireDeleteText === "true") {
|
|
const target = pendingForm.querySelector("[data-delete-confirm-target], [name='confirmationText']");
|
|
if (target) {
|
|
target.value = deleteInput?.value.trim() || "";
|
|
}
|
|
}
|
|
|
|
pendingForm.dataset.confirmed = "true";
|
|
modal?.hide();
|
|
if (pendingForm.requestSubmit && pendingSubmitter) {
|
|
pendingForm.requestSubmit(pendingSubmitter);
|
|
} else if (pendingForm.requestSubmit) {
|
|
pendingForm.requestSubmit();
|
|
} else {
|
|
pendingForm.submit();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("submit", (event) => {
|
|
const form = event.target;
|
|
if (
|
|
!(form instanceof HTMLFormElement)
|
|
|| form.dataset.confirmed === "true"
|
|
|| form.dataset.confirmSkip === "true"
|
|
|| form.dataset.noDeleteConfirm === "true"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const explicitMessage = form.dataset.confirmMessage;
|
|
const submitter = event.submitter instanceof HTMLElement ? event.submitter : form.querySelector("button[type='submit']");
|
|
const buttonText = submitter?.textContent || "";
|
|
const actionPath = form.getAttribute("action") || "";
|
|
const restoreAction = /restore/i.test(buttonText) || /\/restore\b/i.test(actionPath);
|
|
const deleteForeverAction = /delete forever/i.test(buttonText) || /\/deleteforever\b/i.test(actionPath);
|
|
const archiveAction = !restoreAction && !deleteForeverAction && (/archive/i.test(buttonText) || /\/archive\b/i.test(actionPath));
|
|
const removeAction = !deleteForeverAction && /(delete|remove)/i.test(buttonText);
|
|
if (!explicitMessage && !archiveAction && !restoreAction && !removeAction && !deleteForeverAction) {
|
|
return;
|
|
}
|
|
|
|
const message = explicitMessage
|
|
|| (archiveAction
|
|
? "Archive this item?\n\nThis will hide it from active lists and timelines, but the data will be kept and can be restored later."
|
|
: restoreAction
|
|
? "Restore this item?\n\nThis will return it to active lists and timelines where applicable."
|
|
: deleteForeverAction
|
|
? "Permanently delete this item?\n\nThis cannot be undone. Any linked planning data may also be removed or become unavailable. Only continue if you are certain."
|
|
: "Remove this item?\n\nThis removes the link or supporting record. Major story items are archived instead of permanently deleted.");
|
|
|
|
if (!modal || !modalElement || !modalTitle || !modalMessage || !modalSubmit) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const content = splitMessage(message);
|
|
pendingForm = form;
|
|
pendingSubmitter = submitter;
|
|
modalTitle.textContent = form.dataset.confirmTitle || content.title;
|
|
modalMessage.textContent = form.dataset.confirmBody || content.body;
|
|
modalSubmit.textContent = form.dataset.confirmButtonText || buttonText.trim() || "Confirm";
|
|
modalSubmit.className = `btn ${form.dataset.confirmButtonClass || (restoreAction ? "btn-primary" : "btn-danger")}`;
|
|
|
|
const needsDeleteText = form.dataset.requireDeleteText === "true" || deleteForeverAction;
|
|
deleteField?.classList.toggle("d-none", !needsDeleteText);
|
|
if (needsDeleteText && modalSubmit) {
|
|
modalSubmit.disabled = true;
|
|
deleteInput?.focus();
|
|
}
|
|
|
|
modal.show();
|
|
if (needsDeleteText) {
|
|
window.setTimeout(() => deleteInput?.focus(), 200);
|
|
}
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const icons = document.querySelectorAll("[data-help-icon]");
|
|
if (!icons.length || !window.bootstrap) {
|
|
return;
|
|
}
|
|
|
|
const popovers = new Map();
|
|
let activeIcon = null;
|
|
|
|
const tooltipElement = (icon) => icon.querySelector("[data-help-tooltip]") || icon;
|
|
const hideTooltip = (icon) => bootstrap.Tooltip.getInstance(tooltipElement(icon))?.hide();
|
|
const hideActivePopover = (exceptIcon = null) => {
|
|
if (activeIcon && activeIcon !== exceptIcon) {
|
|
bootstrap.Popover.getInstance(activeIcon)?.hide();
|
|
}
|
|
};
|
|
const hideAllHelpTooltips = () => icons.forEach((icon) => hideTooltip(icon));
|
|
|
|
icons.forEach((icon) => {
|
|
const tooltipTarget = tooltipElement(icon);
|
|
const micro = tooltipTarget.dataset.helpMicro || "Help article not found.";
|
|
const title = icon.dataset.helpTitle || "Help unavailable";
|
|
const summary = icon.dataset.helpSummary || "Help content has not yet been written for this feature.";
|
|
|
|
bootstrap.Tooltip.getOrCreateInstance(tooltipTarget, {
|
|
title: micro,
|
|
trigger: "hover focus",
|
|
placement: "top",
|
|
container: "body"
|
|
});
|
|
|
|
const popover = bootstrap.Popover.getOrCreateInstance(icon, {
|
|
title,
|
|
trigger: "click",
|
|
placement: "auto",
|
|
container: "body",
|
|
html: true,
|
|
customClass: "plotline-help-popover",
|
|
content() {
|
|
const wrapper = document.createElement("div");
|
|
const summaryText = document.createElement("p");
|
|
summaryText.className = "mb-2";
|
|
summaryText.textContent = summary;
|
|
|
|
const moreLink = document.createElement("a");
|
|
moreLink.href = icon.dataset.helpUrl || "#";
|
|
moreLink.textContent = "More...";
|
|
moreLink.className = "plotline-help-more";
|
|
moreLink.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
hideActivePopover();
|
|
hideAllHelpTooltips();
|
|
icon.dispatchEvent(new CustomEvent("plotline:help-more", {
|
|
bubbles: true,
|
|
detail: { key: icon.dataset.helpKey || "" }
|
|
}));
|
|
});
|
|
|
|
wrapper.append(summaryText, moreLink);
|
|
return wrapper;
|
|
}
|
|
});
|
|
popovers.set(icon, popover);
|
|
|
|
icon.addEventListener("click", (event) => {
|
|
if (!window.PlotLineHelpDrawer?.isActive?.()) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
hideActivePopover();
|
|
hideTooltip(icon);
|
|
window.PlotLineHelpPopovers?.hideAll();
|
|
window.PlotLineHelpDrawer.open(icon.dataset.helpKey || "");
|
|
});
|
|
icon.addEventListener("show.bs.popover", (event) => {
|
|
if (window.PlotLineHelpDrawer?.isActive?.()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
hideActivePopover(icon);
|
|
hideTooltip(icon);
|
|
});
|
|
icon.addEventListener("shown.bs.popover", () => {
|
|
activeIcon = icon;
|
|
hideTooltip(icon);
|
|
});
|
|
icon.addEventListener("hidden.bs.popover", () => {
|
|
if (activeIcon === icon) {
|
|
activeIcon = null;
|
|
}
|
|
});
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
if (!target || !activeIcon) {
|
|
return;
|
|
}
|
|
|
|
if (target.closest("[data-help-icon]")) {
|
|
return;
|
|
}
|
|
|
|
const activePopoverElement = document.querySelector(`#${activeIcon.getAttribute("aria-describedby")}`);
|
|
if (activePopoverElement?.contains(target)) {
|
|
return;
|
|
}
|
|
|
|
hideActivePopover();
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
hideActivePopover();
|
|
}
|
|
});
|
|
|
|
window.PlotLineHelpPopovers = {
|
|
hideAll() {
|
|
popovers.forEach((popover) => popover.hide());
|
|
hideAllHelpTooltips();
|
|
activeIcon = null;
|
|
}
|
|
};
|
|
})();
|
|
|
|
(() => {
|
|
const layout = document.querySelector("[data-help-layout]");
|
|
const drawer = document.querySelector("[data-help-drawer]");
|
|
const content = document.querySelector("[data-help-content]");
|
|
const splitter = document.querySelector("[data-help-splitter]");
|
|
const closeButton = document.querySelector("[data-help-close]");
|
|
const collapseButton = document.querySelector("[data-help-collapse]");
|
|
const expandStrip = document.querySelector("[data-help-expand]");
|
|
const pinButton = document.querySelector("[data-help-pin]");
|
|
const heading = document.querySelector("[data-help-drawer-heading]");
|
|
if (!layout || !drawer || !content) {
|
|
return;
|
|
}
|
|
|
|
const storage = {
|
|
width: "plotline.helpDrawer.width",
|
|
pinned: "plotline.helpDrawer.pinned",
|
|
key: "plotline.helpDrawer.key",
|
|
collapsed: "plotline.helpDrawer.collapsed"
|
|
};
|
|
|
|
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
|
|
const readBool = (key) => {
|
|
try {
|
|
return localStorage.getItem(key) === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const writeValue = (key, value) => {
|
|
try {
|
|
localStorage.setItem(key, value);
|
|
} catch {
|
|
// Help drawer preferences are conveniences; blocked storage should not break help.
|
|
}
|
|
};
|
|
|
|
const setWidth = (value) => {
|
|
const width = `${clamp(Number.parseFloat(value) || 25, 20, 50)}%`;
|
|
layout.style.setProperty("--help-drawer-width", width);
|
|
writeValue(storage.width, width);
|
|
};
|
|
|
|
const setPinned = (isPinned) => {
|
|
writeValue(storage.pinned, String(isPinned));
|
|
pinButton?.setAttribute("aria-pressed", String(isPinned));
|
|
if (pinButton) {
|
|
pinButton.textContent = isPinned ? "Unpin" : "Pin";
|
|
}
|
|
};
|
|
|
|
const setCollapsed = (isCollapsed) => {
|
|
layout.classList.toggle("help-collapsed", isCollapsed);
|
|
writeValue(storage.collapsed, String(isCollapsed));
|
|
if (collapseButton) {
|
|
collapseButton.textContent = isCollapsed ? "Expand" : "Collapse";
|
|
}
|
|
};
|
|
|
|
const openDrawer = async (key, options = {}) => {
|
|
const contextKey = key || "";
|
|
layout.classList.add("help-open");
|
|
if (!options.keepCollapsed) {
|
|
setCollapsed(false);
|
|
}
|
|
writeValue(storage.key, contextKey);
|
|
if (heading) {
|
|
heading.textContent = contextKey || "Help";
|
|
}
|
|
content.innerHTML = "<p class=\"muted\">Loading help...</p>";
|
|
|
|
try {
|
|
const response = await fetch(`/HelpDrawer/Article?key=${encodeURIComponent(contextKey)}`, {
|
|
headers: { "X-Requested-With": "XMLHttpRequest" }
|
|
});
|
|
content.innerHTML = response.ok
|
|
? await response.text()
|
|
: "<p class=\"muted\">Help for this feature has not yet been written.</p>";
|
|
const title = content.querySelector(".help-drawer-article h2")?.textContent?.trim();
|
|
if (heading && title) {
|
|
heading.textContent = title;
|
|
}
|
|
} catch {
|
|
content.innerHTML = "<p class=\"muted\">Help for this feature has not yet been written.</p>";
|
|
}
|
|
};
|
|
|
|
const closeDrawer = () => {
|
|
layout.classList.remove("help-open", "help-collapsed");
|
|
setPinned(false);
|
|
};
|
|
|
|
window.PlotLineHelpDrawer = {
|
|
isActive() {
|
|
return layout.classList.contains("help-open");
|
|
},
|
|
open(key) {
|
|
return openDrawer(key);
|
|
}
|
|
};
|
|
|
|
setWidth(localStorage.getItem(storage.width) || "25%");
|
|
setPinned(readBool(storage.pinned));
|
|
setCollapsed(readBool(storage.collapsed));
|
|
|
|
document.addEventListener("plotline:help-more", (event) => {
|
|
const key = event.detail?.key || event.target?.dataset?.helpKey || "";
|
|
window.PlotLineHelpPopovers?.hideAll();
|
|
openDrawer(key);
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const opener = event.target instanceof Element ? event.target.closest("[data-help-open]") : null;
|
|
if (!opener) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
window.PlotLineHelpPopovers?.hideAll();
|
|
openDrawer(opener.dataset.helpOpen || "");
|
|
});
|
|
|
|
closeButton?.addEventListener("click", closeDrawer);
|
|
collapseButton?.addEventListener("click", () => setCollapsed(!layout.classList.contains("help-collapsed")));
|
|
expandStrip?.addEventListener("click", () => setCollapsed(false));
|
|
expandStrip?.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
setCollapsed(false);
|
|
}
|
|
});
|
|
pinButton?.addEventListener("click", () => setPinned(!readBool(storage.pinned)));
|
|
|
|
splitter?.addEventListener("pointerdown", (event) => {
|
|
if (!layout.classList.contains("help-open") || layout.classList.contains("help-collapsed")) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
document.body.classList.add("help-resizing");
|
|
splitter.setPointerCapture?.(event.pointerId);
|
|
|
|
const onMove = (moveEvent) => {
|
|
const rect = layout.getBoundingClientRect();
|
|
const widthPercent = ((rect.right - moveEvent.clientX) / rect.width) * 100;
|
|
setWidth(widthPercent);
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.body.classList.remove("help-resizing");
|
|
document.removeEventListener("pointermove", onMove);
|
|
document.removeEventListener("pointerup", onUp);
|
|
};
|
|
|
|
document.addEventListener("pointermove", onMove);
|
|
document.addEventListener("pointerup", onUp);
|
|
});
|
|
|
|
const pinnedKey = (() => {
|
|
try {
|
|
return localStorage.getItem(storage.key) || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
})();
|
|
if (readBool(storage.pinned) && pinnedKey) {
|
|
openDrawer(pinnedKey, { keepCollapsed: true });
|
|
}
|
|
})();
|
|
|
|
(() => {
|
|
const roots = document.querySelectorAll(".scene-inspector-root");
|
|
if (!roots.length) {
|
|
return;
|
|
}
|
|
|
|
const slugify = (value) => value
|
|
.toLowerCase()
|
|
.replace(/&/g, "and")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
|
|
roots.forEach((root) => {
|
|
const sceneId = root.dataset.sceneId
|
|
|| root.querySelector(".scene-inspector-form [name='SceneID']")?.value
|
|
|| "new";
|
|
const storagePrefix = `plotline.inspector.${sceneId}.`;
|
|
const defaultOpen = new Set(["overview", "timing", "characters"]);
|
|
const sections = [...root.querySelectorAll(".inspector-section")];
|
|
const form = root.querySelector(".scene-inspector-form");
|
|
const timeEditor = root.querySelector("[data-scene-time-editor]");
|
|
|
|
if (form && timeEditor && timeEditor.dataset.enhanced !== "true") {
|
|
timeEditor.dataset.enhanced = "true";
|
|
|
|
const modeSelect = form.querySelector("[name='TimeModeID']");
|
|
const exactDateTimeModeId = timeEditor.dataset.exactDatetimeModeId || "";
|
|
const exactDateModeId = timeEditor.dataset.exactDateModeId || "";
|
|
const timeFields = [...timeEditor.querySelectorAll("[data-time-part-field]")];
|
|
const dateInputs = {
|
|
StartDateTime: timeEditor.querySelector("[data-date-part-for='StartDateTime']"),
|
|
EndDateTime: timeEditor.querySelector("[data-date-part-for='EndDateTime']")
|
|
};
|
|
const timeInputs = {
|
|
StartDateTime: timeEditor.querySelector("[data-time-part-for='StartDateTime']"),
|
|
EndDateTime: timeEditor.querySelector("[data-time-part-for='EndDateTime']")
|
|
};
|
|
const hiddenInputs = {
|
|
StartDateTime: form.querySelector("input[type='hidden'][name='StartDateTime']"),
|
|
EndDateTime: form.querySelector("input[type='hidden'][name='EndDateTime']")
|
|
};
|
|
|
|
const isExactDateTime = () => modeSelect?.value === exactDateTimeModeId;
|
|
const isExactDate = () => modeSelect?.value === exactDateModeId;
|
|
|
|
const setTimeVisibility = () => {
|
|
const showTime = isExactDateTime();
|
|
timeFields.forEach((field) => {
|
|
field.hidden = !showTime;
|
|
field.classList.toggle("d-none", !showTime);
|
|
});
|
|
};
|
|
|
|
const writeCombinedDateTime = (fieldName) => {
|
|
const hidden = hiddenInputs[fieldName];
|
|
const date = dateInputs[fieldName]?.value || "";
|
|
const time = timeInputs[fieldName]?.value || "";
|
|
if (!hidden) {
|
|
return;
|
|
}
|
|
|
|
if (!date) {
|
|
hidden.value = "";
|
|
return;
|
|
}
|
|
|
|
if (isExactDateTime()) {
|
|
hidden.value = `${date}T${time || "00:00"}`;
|
|
return;
|
|
}
|
|
|
|
if (isExactDate()) {
|
|
hidden.value = date;
|
|
return;
|
|
}
|
|
|
|
const existingTime = hidden.value.match(/^\\d{4}-\\d{2}-\\d{2}T(\\d{2}:\\d{2})/)?.[1];
|
|
hidden.value = existingTime ? `${date}T${existingTime}` : date;
|
|
};
|
|
|
|
const syncPostedDateTimes = () => {
|
|
writeCombinedDateTime("StartDateTime");
|
|
writeCombinedDateTime("EndDateTime");
|
|
};
|
|
|
|
modeSelect?.addEventListener("change", setTimeVisibility);
|
|
form.addEventListener("submit", syncPostedDateTimes);
|
|
setTimeVisibility();
|
|
}
|
|
|
|
sections.forEach((section) => {
|
|
if (section.dataset.enhanced === "true") {
|
|
return;
|
|
}
|
|
|
|
const heading = section.querySelector("h3, h2");
|
|
const title = section.dataset.sectionTitle || heading?.textContent?.trim() || "Overview";
|
|
const slug = slugify(title);
|
|
const sectionId = section.dataset.sectionId || `inspector-${slug}`;
|
|
const storageKey = sectionId.replace(/^inspector-/, "");
|
|
section.id = sectionId;
|
|
section.dataset.searchText = `${title} ${section.textContent}`.toLowerCase();
|
|
section.dataset.enhanced = "true";
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "inspector-accordion-body";
|
|
while (section.firstChild) {
|
|
body.appendChild(section.firstChild);
|
|
}
|
|
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "inspector-accordion-toggle";
|
|
button.setAttribute("aria-expanded", "false");
|
|
|
|
section.append(button, body);
|
|
const count = body.querySelectorAll(".asset-event-item, .thread-event-item, .warning-mini-card, .dependency-card, .metric-slider").length;
|
|
button.innerHTML = `<span>${title}</span><span class="accordion-count">${count || ""}</span>`;
|
|
|
|
const stored = localStorage.getItem(`${storagePrefix}${storageKey}`);
|
|
const isOpen = stored === null ? defaultOpen.has(storageKey) : stored === "open";
|
|
section.classList.toggle("is-open", isOpen);
|
|
button.setAttribute("aria-expanded", String(isOpen));
|
|
|
|
button.addEventListener("click", () => {
|
|
const nextOpen = !section.classList.contains("is-open");
|
|
section.classList.toggle("is-open", nextOpen);
|
|
button.setAttribute("aria-expanded", String(nextOpen));
|
|
localStorage.setItem(`${storagePrefix}${storageKey}`, nextOpen ? "open" : "closed");
|
|
});
|
|
});
|
|
|
|
root.querySelectorAll(".inspector-nav-links a").forEach((link) => {
|
|
link.addEventListener("click", (event) => {
|
|
const target = root.querySelector(link.getAttribute("href"));
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
target.classList.add("is-open");
|
|
target.querySelector(".inspector-accordion-toggle")?.setAttribute("aria-expanded", "true");
|
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
});
|
|
});
|
|
|
|
const search = root.querySelector("[data-inspector-search]");
|
|
search?.addEventListener("input", () => {
|
|
const query = search.value.trim().toLowerCase();
|
|
sections.forEach((section) => {
|
|
const matches = !query || section.dataset.searchText.includes(query);
|
|
section.classList.toggle("is-filtered-out", !matches);
|
|
if (matches && query) {
|
|
section.classList.add("is-open");
|
|
section.querySelector(".inspector-accordion-toggle")?.setAttribute("aria-expanded", "true");
|
|
}
|
|
});
|
|
});
|
|
|
|
if ("IntersectionObserver" in window) {
|
|
const navLinks = [...root.querySelectorAll(".inspector-nav-links a")];
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) {
|
|
return;
|
|
}
|
|
|
|
navLinks.forEach((link) => {
|
|
link.classList.toggle("active", link.getAttribute("href") === `#${entry.target.id}`);
|
|
});
|
|
});
|
|
}, { root: root.closest(".scene-inspector-panel") || null, rootMargin: "-10% 0px -70% 0px" });
|
|
|
|
sections.forEach((section) => observer.observe(section));
|
|
}
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const chartRoots = document.querySelectorAll("[data-metric-shape-chart]");
|
|
if (!chartRoots.length) {
|
|
return;
|
|
}
|
|
|
|
const colours = [
|
|
"#2f6f63",
|
|
"#b35f3b",
|
|
"#6750a4",
|
|
"#c58b2b",
|
|
"#7d3f52",
|
|
"#33759b",
|
|
"#9c4d8f",
|
|
"#a33d3d",
|
|
"#6f8f3d",
|
|
"#d08a2f",
|
|
"#444b57"
|
|
];
|
|
|
|
const globalDefaultMetrics = new Set(["Overall Intensity", "Tension", "Emotional Weight", "Action"]);
|
|
const metricCharts = [];
|
|
const resizeMetricCharts = () => {
|
|
window.requestAnimationFrame(() => {
|
|
metricCharts.forEach((chart) => chart.resize());
|
|
});
|
|
};
|
|
window.PlotLineMetricCharts = { resizeAll: resizeMetricCharts };
|
|
window.addEventListener("plotline:timeline-layout-changed", resizeMetricCharts);
|
|
|
|
const chapterGuidePlugin = {
|
|
id: "plotlineChapterGuides",
|
|
afterDraw(chart) {
|
|
const scenes = chart.config.options.plugins.plotlineScenes || [];
|
|
const { ctx, chartArea, scales } = chart;
|
|
if (!scenes.length || !scales.x) {
|
|
return;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = "rgba(47, 111, 99, 0.16)";
|
|
ctx.fillStyle = "rgba(82, 75, 65, 0.58)";
|
|
ctx.lineWidth = 1;
|
|
ctx.font = "700 10px system-ui, -apple-system, Segoe UI, sans-serif";
|
|
|
|
const chapterStarts = [];
|
|
scenes.forEach((scene, index) => {
|
|
if (!index || scene.chapterLabel !== scenes[index - 1].chapterLabel) {
|
|
chapterStarts.push({ scene, index });
|
|
}
|
|
});
|
|
const realChapterStarts = chapterStarts.filter((entry) => !entry.scene.isPlaceholder);
|
|
const labelInterval = realChapterStarts.length > 40 ? 5 : realChapterStarts.length > 24 ? 4 : realChapterStarts.length > 14 ? 2 : 1;
|
|
|
|
realChapterStarts.forEach((entry, chapterIndex) => {
|
|
const { scene, index } = entry;
|
|
const x = scales.x.getPixelForValue(index + 0.5);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, chartArea.top);
|
|
ctx.lineTo(x, chartArea.bottom);
|
|
ctx.stroke();
|
|
|
|
const chapterNumber = Number.parseInt(String(scene.chapterLabel).replace(/[^0-9]/g, ""), 10);
|
|
const shouldLabel = chapterIndex === 0 ||
|
|
chapterIndex === realChapterStarts.length - 1 ||
|
|
labelInterval === 1 ||
|
|
(Number.isFinite(chapterNumber) && chapterNumber % labelInterval === 0);
|
|
if (shouldLabel) {
|
|
ctx.fillText(scene.chapterLabel, Math.min(x + 4, chartArea.right - 70), chartArea.bottom + 22);
|
|
}
|
|
});
|
|
|
|
ctx.restore();
|
|
}
|
|
};
|
|
|
|
if (window.Chart) {
|
|
Chart.register(chapterGuidePlugin);
|
|
}
|
|
|
|
chartRoots.forEach((root) => {
|
|
const payloadScript = root.querySelector("script[type='application/json']");
|
|
const canvas = root.querySelector("canvas");
|
|
const toggleHost = root.querySelector("[data-metric-toggles]");
|
|
const fallback = root.querySelector("[data-chart-fallback]");
|
|
const editToolbar = root.querySelector("[data-timeline-metric-edit-toolbar]");
|
|
const editStatus = root.querySelector("[data-edit-metric-status]");
|
|
const canEditTimelineMetric = root.hasAttribute("data-timeline-metric-editor") && editToolbar && editStatus;
|
|
if (!payloadScript || !canvas) {
|
|
return;
|
|
}
|
|
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(payloadScript.textContent || "{}");
|
|
} catch {
|
|
fallback?.classList.remove("d-none");
|
|
return;
|
|
}
|
|
|
|
if (!window.Chart || !payload.scenes?.length || !payload.metrics?.length) {
|
|
fallback?.classList.remove("d-none");
|
|
return;
|
|
}
|
|
|
|
const defaultMetrics = new Set(payload.defaultMetrics?.length ? payload.defaultMetrics : globalDefaultMetrics);
|
|
const columnCount = payload.scenes.length;
|
|
const tickLabelInterval = columnCount > 80 ? 10 : columnCount > 40 ? 5 : columnCount > 24 ? 4 : columnCount > 14 ? 2 : 1;
|
|
const datasets = payload.metrics.map((metric, metricIndex) => {
|
|
const pointMap = new Map(metric.points.map((point) => [point.sceneId, point.value]));
|
|
const colour = colours[metricIndex % colours.length];
|
|
return {
|
|
label: metric.metricName,
|
|
metricTypeId: metric.metricTypeId,
|
|
minValue: Number.isFinite(metric.minValue) ? metric.minValue : 1,
|
|
maxValue: Number.isFinite(metric.maxValue) ? metric.maxValue : 10,
|
|
data: payload.scenes.map((scene, columnIndex) => {
|
|
const value = pointMap.get(scene.sceneId);
|
|
return value == null
|
|
? { x: columnIndex + 0.5, y: null, sceneId: scene.sceneId }
|
|
: { x: columnIndex + 0.5, y: value, sceneId: scene.sceneId };
|
|
}),
|
|
borderColor: colour,
|
|
backgroundColor: colour,
|
|
pointBackgroundColor: colour,
|
|
pointBorderColor: "#fff",
|
|
pointBorderWidth: 1.5,
|
|
pointRadius: 3,
|
|
pointHoverRadius: 5,
|
|
borderWidth: 2,
|
|
tension: 0.35,
|
|
spanGaps: true,
|
|
hidden: !defaultMetrics.has(metric.metricName)
|
|
};
|
|
});
|
|
|
|
let activeMetricDrag = null;
|
|
let suppressMetricClick = false;
|
|
|
|
const setMetricEditStatus = (message) => {
|
|
if (editStatus) {
|
|
editStatus.textContent = message;
|
|
}
|
|
};
|
|
|
|
const getVisibleDatasetIndexes = () => datasets
|
|
.map((_, index) => index)
|
|
.filter((index) => chart?.isDatasetVisible(index));
|
|
|
|
const getEditableDatasetIndex = () => {
|
|
const visibleIndexes = getVisibleDatasetIndexes();
|
|
return visibleIndexes.length === 1 ? visibleIndexes[0] : -1;
|
|
};
|
|
|
|
const updateMetricEditUi = () => {
|
|
if (!canEditTimelineMetric) {
|
|
return;
|
|
}
|
|
|
|
const editableIndex = getEditableDatasetIndex();
|
|
datasets.forEach((dataset, index) => {
|
|
dataset.pointRadius = index === editableIndex ? 5 : 3;
|
|
dataset.pointHoverRadius = index === editableIndex ? 7 : 5;
|
|
dataset.borderWidth = index === editableIndex ? 3 : 2;
|
|
});
|
|
const visibleIndexes = getVisibleDatasetIndexes();
|
|
const canDrag = editableIndex >= 0;
|
|
canvas.classList.toggle("metric-curve-editing", canDrag);
|
|
editStatus.classList.toggle("d-none", visibleIndexes.length === 0);
|
|
if (visibleIndexes.length === 0) {
|
|
setMetricEditStatus("");
|
|
} else if (canDrag) {
|
|
setMetricEditStatus("Drag points up or down to edit this metric.");
|
|
} else {
|
|
setMetricEditStatus("Show only one metric line to edit its curve.");
|
|
}
|
|
chart?.update();
|
|
};
|
|
|
|
toggleHost.innerHTML = "";
|
|
datasets.forEach((dataset, index) => {
|
|
const id = `metric-toggle-${Math.random().toString(36).slice(2)}-${index}`;
|
|
const label = document.createElement("label");
|
|
label.className = "metric-toggle-pill";
|
|
label.style.setProperty("--metric-colour", dataset.borderColor);
|
|
label.innerHTML = `<input type="checkbox" id="${id}" ${dataset.hidden ? "" : "checked"}><span>${dataset.label}</span>`;
|
|
toggleHost.appendChild(label);
|
|
label.querySelector("input").addEventListener("change", (event) => {
|
|
chart.setDatasetVisibility(index, event.target.checked);
|
|
updateMetricEditUi();
|
|
chart.update();
|
|
});
|
|
});
|
|
|
|
const chart = new Chart(canvas, {
|
|
type: "line",
|
|
data: { datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: "nearest", intersect: false },
|
|
scales: {
|
|
y: {
|
|
min: 1,
|
|
max: 10,
|
|
ticks: { stepSize: 1 },
|
|
grid: { color: "rgba(82, 75, 65, 0.10)" }
|
|
},
|
|
x: {
|
|
type: "linear",
|
|
min: 0,
|
|
max: columnCount,
|
|
bounds: "ticks",
|
|
ticks: {
|
|
maxRotation: 0,
|
|
autoSkip: false,
|
|
maxTicksLimit: 16,
|
|
callback(value) {
|
|
const columnIndex = Math.floor(Number(value));
|
|
if (columnIndex < 0 || columnIndex >= columnCount) {
|
|
return "";
|
|
}
|
|
|
|
const scene = payload.scenes[columnIndex];
|
|
if (columnIndex !== 0 && columnIndex !== columnCount - 1 && columnIndex % tickLabelInterval !== 0) {
|
|
return "";
|
|
}
|
|
return scene?.isPlaceholder ? "" : scene?.axisLabel || "";
|
|
}
|
|
},
|
|
afterBuildTicks(scale) {
|
|
scale.ticks = payload.scenes.map((_, columnIndex) => ({ value: columnIndex + 0.5 }));
|
|
},
|
|
grid: { display: false }
|
|
}
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
plotlineScenes: payload.scenes,
|
|
tooltip: {
|
|
callbacks: {
|
|
title(items) {
|
|
const columnIndex = Math.floor(items[0].parsed.x);
|
|
const scene = payload.scenes[columnIndex];
|
|
if (scene.isPlaceholder) {
|
|
return scene.sceneTitle || "No scene";
|
|
}
|
|
return `${scene.chapterLabel}, Scene ${scene.sceneNumber}: ${scene.sceneTitle}`;
|
|
},
|
|
label(item) {
|
|
return `${item.dataset.label}: ${item.formattedValue}`;
|
|
},
|
|
afterBody(items) {
|
|
const columnIndex = Math.floor(items[0].parsed.x);
|
|
const scene = payload.scenes[columnIndex];
|
|
const lines = [];
|
|
if (scene.characterPresence) {
|
|
lines.push(scene.characterPresence);
|
|
} else if (scene.characterAppears !== undefined) {
|
|
lines.push(scene.characterAppears ? "Character appears" : "Character does not appear");
|
|
}
|
|
|
|
if (scene.bookTitle) {
|
|
lines.push(scene.bookTitle);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const getMetricDragPoint = (event) => {
|
|
if (!canEditTimelineMetric) {
|
|
return null;
|
|
}
|
|
|
|
const editableIndex = getEditableDatasetIndex();
|
|
if (editableIndex < 0) {
|
|
return null;
|
|
}
|
|
|
|
const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true);
|
|
const match = points.find((point) => point.datasetIndex === editableIndex);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const dataset = chart.data.datasets[match.datasetIndex];
|
|
const point = dataset?.data?.[match.index];
|
|
if (!point?.sceneId || point.y == null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
datasetIndex: match.datasetIndex,
|
|
pointIndex: match.index,
|
|
previousValue: point.y
|
|
};
|
|
};
|
|
|
|
const dragValueFromEvent = (event, dataset) => {
|
|
const rawValue = chart.scales.y.getValueForPixel(event.offsetY);
|
|
const roundedValue = Math.round(rawValue);
|
|
return Math.max(dataset.minValue, Math.min(dataset.maxValue, roundedValue));
|
|
};
|
|
|
|
const saveMetricDrag = async (dragState) => {
|
|
const dataset = chart.data.datasets[dragState.datasetIndex];
|
|
const point = dataset?.data?.[dragState.pointIndex];
|
|
if (!dataset || !point?.sceneId) {
|
|
return;
|
|
}
|
|
|
|
const token = editToolbar?.querySelector("input[name='__RequestVerificationToken']")?.value || "";
|
|
setMetricEditStatus("Saving...");
|
|
try {
|
|
const response = await fetch(root.dataset.updateUrl || "/SceneMetrics/UpdateSceneMetricValue", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"RequestVerificationToken": token
|
|
},
|
|
body: JSON.stringify({
|
|
sceneId: Number(point.sceneId),
|
|
metricId: Number(dataset.metricTypeId),
|
|
value: Number(point.y)
|
|
})
|
|
});
|
|
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok || !result.success) {
|
|
throw new Error(result.message || "Save failed");
|
|
}
|
|
|
|
point.y = result.value;
|
|
setMetricEditStatus("Saved");
|
|
chart.update();
|
|
} catch {
|
|
point.y = dragState.previousValue;
|
|
setMetricEditStatus("Save failed");
|
|
chart.update();
|
|
}
|
|
};
|
|
|
|
if (canEditTimelineMetric) {
|
|
updateMetricEditUi();
|
|
|
|
canvas.addEventListener("pointerdown", (event) => {
|
|
activeMetricDrag = getMetricDragPoint(event);
|
|
if (!activeMetricDrag) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
canvas.setPointerCapture?.(event.pointerId);
|
|
suppressMetricClick = true;
|
|
});
|
|
|
|
canvas.addEventListener("pointermove", (event) => {
|
|
if (!activeMetricDrag) {
|
|
return;
|
|
}
|
|
|
|
const dataset = chart.data.datasets[activeMetricDrag.datasetIndex];
|
|
const point = dataset?.data?.[activeMetricDrag.pointIndex];
|
|
if (!dataset || !point) {
|
|
return;
|
|
}
|
|
|
|
point.y = dragValueFromEvent(event, dataset);
|
|
chart.update("none");
|
|
});
|
|
|
|
canvas.addEventListener("pointerup", async (event) => {
|
|
if (!activeMetricDrag) {
|
|
return;
|
|
}
|
|
|
|
const dragState = activeMetricDrag;
|
|
activeMetricDrag = null;
|
|
canvas.releasePointerCapture?.(event.pointerId);
|
|
await saveMetricDrag(dragState);
|
|
window.setTimeout(() => {
|
|
suppressMetricClick = false;
|
|
}, 0);
|
|
});
|
|
|
|
canvas.addEventListener("pointercancel", (event) => {
|
|
if (!activeMetricDrag) {
|
|
return;
|
|
}
|
|
|
|
const dataset = chart.data.datasets[activeMetricDrag.datasetIndex];
|
|
const point = dataset?.data?.[activeMetricDrag.pointIndex];
|
|
if (point) {
|
|
point.y = activeMetricDrag.previousValue;
|
|
}
|
|
|
|
activeMetricDrag = null;
|
|
canvas.releasePointerCapture?.(event.pointerId);
|
|
suppressMetricClick = false;
|
|
chart.update();
|
|
});
|
|
}
|
|
|
|
canvas.addEventListener("click", (event) => {
|
|
if (suppressMetricClick) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true);
|
|
if (!points.length) {
|
|
return;
|
|
}
|
|
|
|
const point = chart.data.datasets[points[0].datasetIndex]?.data?.[points[0].index];
|
|
const columnIndex = point ? Math.floor(point.x) : points[0].index;
|
|
const scene = payload.scenes[columnIndex];
|
|
if (scene?.timelineUrl) {
|
|
window.PlotLineTimelineScroll?.save();
|
|
window.location.href = scene.timelineUrl;
|
|
}
|
|
});
|
|
|
|
metricCharts.push(chart);
|
|
const resizeObserver = window.ResizeObserver ? new ResizeObserver(() => chart.resize()) : null;
|
|
if (resizeObserver) {
|
|
resizeObserver.observe(root);
|
|
resizeObserver.observe(root.querySelector(".metric-chart-frame") || root);
|
|
}
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const root = document.querySelector("[data-timeline-root]");
|
|
const controls = document.querySelector("[data-timeline-controls]");
|
|
if (!root || !controls) {
|
|
return;
|
|
}
|
|
|
|
const storageKey = "plotline.timeline.visibility";
|
|
const defaultState = {
|
|
"scene-cards": true,
|
|
"plot-lines": true,
|
|
assets: true,
|
|
characters: true,
|
|
locations: true,
|
|
warnings: true,
|
|
metrics: true
|
|
};
|
|
|
|
const readState = () => {
|
|
try {
|
|
return { ...defaultState, ...JSON.parse(localStorage.getItem(storageKey) || "{}") };
|
|
} catch {
|
|
return { ...defaultState };
|
|
}
|
|
};
|
|
|
|
const applyState = (state) => {
|
|
Object.entries(state).forEach(([key, visible]) => {
|
|
root.classList.toggle(`hide-${key}`, !visible);
|
|
document.querySelectorAll(`[data-timeline-section='${key}']`).forEach((section) => {
|
|
section.classList.toggle("timeline-section-hidden", !visible);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("[data-location-marker]").forEach((marker) => {
|
|
marker.classList.toggle("timeline-section-hidden", !state.locations);
|
|
});
|
|
document.querySelectorAll("[data-warning-marker]").forEach((marker) => {
|
|
marker.classList.toggle("timeline-section-hidden", !state.warnings);
|
|
});
|
|
|
|
document.querySelector("[data-visibility-json]")?.setAttribute("value", JSON.stringify(state));
|
|
};
|
|
|
|
const state = readState();
|
|
controls.querySelectorAll("[data-timeline-toggle]").forEach((toggle) => {
|
|
const key = toggle.dataset.timelineToggle;
|
|
toggle.checked = state[key] ?? true;
|
|
toggle.addEventListener("change", () => {
|
|
state[key] = toggle.checked;
|
|
localStorage.setItem(storageKey, JSON.stringify(state));
|
|
applyState(state);
|
|
});
|
|
});
|
|
|
|
controls.querySelector("[data-timeline-preset-form]")?.addEventListener("submit", () => {
|
|
document.querySelector("[data-visibility-json]")?.setAttribute("value", JSON.stringify(state));
|
|
});
|
|
|
|
applyState(state);
|
|
})();
|
|
|
|
(() => {
|
|
const root = document.querySelector("[data-timeline-root]");
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
const workspace = root.querySelector(".timeline-workspace") || root;
|
|
const densitySelect = document.querySelector("[data-timeline-density]");
|
|
const zoomSelect = document.querySelector("[data-timeline-zoom]");
|
|
const searchInput = document.querySelector("[data-timeline-search]");
|
|
const minimap = root.querySelector("[data-timeline-minimap]");
|
|
const jumpSelects = document.querySelectorAll("[data-timeline-jump], [data-timeline-warning-jump], [data-timeline-entity-jump]");
|
|
const sceneCards = [...root.querySelectorAll(".timeline-scene-card[data-scene-id]")];
|
|
const collapsedKey = "plotline.timeline.collapsed";
|
|
const densityKey = "plotline.timeline.density";
|
|
const zoomKey = "plotline.timeline.zoom";
|
|
const scrollKey = "plotline.timeline.pending-scroll";
|
|
const navigationDiscoveryKey = "plotdirector.timelineNavigationDiscovered";
|
|
const drawerWidthKey = "plotline.timeline.inspectorWidth";
|
|
const horizontalOverflowTolerance = 8;
|
|
const scrollContainerSelector = [
|
|
".timeline-shell",
|
|
".plot-lane-board",
|
|
".asset-lane-board",
|
|
".character-lane-board",
|
|
".metric-graph-stack",
|
|
"[data-timeline-minimap]"
|
|
].join(",");
|
|
const densityValues = ["compact", "comfortable", "expanded"];
|
|
const zoomValues = ["book", "chapter", "scene-detail", "scene-overview"];
|
|
const inspectorDrawer = root.querySelector("[data-inspector-drawer]");
|
|
const inspectorResizer = root.querySelector("[data-inspector-resizer]");
|
|
const inspectorClose = root.querySelector("[data-inspector-close]");
|
|
|
|
const readSet = (key) => {
|
|
try {
|
|
return new Set(JSON.parse(localStorage.getItem(key) || "[]"));
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
};
|
|
|
|
const hasCollapsedPreference = localStorage.getItem(collapsedKey) !== null;
|
|
const collapsed = readSet(collapsedKey);
|
|
|
|
const getScrollContainers = () => [...root.querySelectorAll(scrollContainerSelector)];
|
|
|
|
const setupTimelineNavigationGuidance = () => {
|
|
const card = root.querySelector("[data-timeline-navigation-card]");
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const dismissButton = card.querySelector("[data-timeline-navigation-dismiss]");
|
|
const demoButton = card.querySelector("[data-timeline-navigation-demo]");
|
|
const readDiscovered = () => {
|
|
try {
|
|
return localStorage.getItem(navigationDiscoveryKey) === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const primaryScrollContainer = () => root.querySelector(".timeline-shell");
|
|
const hasHorizontalOverflow = (container) => Boolean(container && container.scrollWidth > container.clientWidth + horizontalOverflowTolerance);
|
|
const maxScrollLeft = (container) => Math.max(0, container.scrollWidth - container.clientWidth);
|
|
const allScrollContainers = () => [workspace, ...getScrollContainers()].filter((container, index, containers) => (
|
|
container && containers.indexOf(container) === index
|
|
));
|
|
|
|
let dismissedForPage = false;
|
|
let isDemoing = false;
|
|
let isArmed = false;
|
|
let dismissTimer = null;
|
|
const knownLeft = new WeakMap();
|
|
|
|
const rememberPosition = (container) => knownLeft.set(container, container.scrollLeft);
|
|
const prefersReducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
|
|
|
const showCard = () => {
|
|
window.clearTimeout(dismissTimer);
|
|
card.hidden = false;
|
|
card.classList.remove("is-dismissing");
|
|
};
|
|
|
|
const hideCard = (animate = false) => {
|
|
window.clearTimeout(dismissTimer);
|
|
if (!animate || prefersReducedMotion() || card.hidden) {
|
|
card.classList.remove("is-dismissing");
|
|
card.hidden = true;
|
|
return;
|
|
}
|
|
|
|
card.classList.add("is-dismissing");
|
|
dismissTimer = window.setTimeout(() => {
|
|
card.hidden = true;
|
|
card.classList.remove("is-dismissing");
|
|
}, 1300);
|
|
};
|
|
|
|
const updateEdgeIndicators = (container) => {
|
|
const hasOverflow = hasHorizontalOverflow(container);
|
|
const maxLeft = maxScrollLeft(container);
|
|
container.classList.toggle("timeline-horizontal-overflow", hasOverflow);
|
|
container.classList.toggle("has-scroll-left", hasOverflow && container.scrollLeft > horizontalOverflowTolerance);
|
|
container.classList.toggle("has-scroll-right", hasOverflow && container.scrollLeft < maxLeft - horizontalOverflowTolerance);
|
|
};
|
|
|
|
const updateGuidanceState = () => {
|
|
const shouldShow = hasHorizontalOverflow(primaryScrollContainer()) && !readDiscovered() && !dismissedForPage;
|
|
if (shouldShow) {
|
|
showCard();
|
|
} else if (!card.classList.contains("is-dismissing")) {
|
|
hideCard(false);
|
|
}
|
|
allScrollContainers().forEach(updateEdgeIndicators);
|
|
};
|
|
|
|
const markDiscovered = () => {
|
|
dismissedForPage = true;
|
|
hideCard(true);
|
|
try {
|
|
localStorage.setItem(navigationDiscoveryKey, "true");
|
|
} catch {
|
|
// The card can still disappear for this page view if storage is unavailable.
|
|
}
|
|
};
|
|
|
|
const detectHorizontalScroll = (container) => {
|
|
updateEdgeIndicators(container);
|
|
if (!isArmed || isDemoing) {
|
|
rememberPosition(container);
|
|
return;
|
|
}
|
|
|
|
const previous = knownLeft.get(container) ?? container.scrollLeft;
|
|
if (Math.abs(container.scrollLeft - previous) > 4 && hasHorizontalOverflow(container)) {
|
|
markDiscovered();
|
|
return;
|
|
}
|
|
|
|
rememberPosition(container);
|
|
};
|
|
|
|
const containers = allScrollContainers();
|
|
containers.forEach((container) => {
|
|
rememberPosition(container);
|
|
container.addEventListener("wheel", (event) => {
|
|
if (event.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
if (event.shiftKey && Math.abs(event.deltaY) > Math.abs(event.deltaX) && hasHorizontalOverflow(container)) {
|
|
const previous = container.scrollLeft;
|
|
event.preventDefault();
|
|
container.scrollLeft += event.deltaY;
|
|
window.requestAnimationFrame(() => {
|
|
updateEdgeIndicators(container);
|
|
if (!isDemoing && Math.abs(container.scrollLeft - previous) > 4) {
|
|
markDiscovered();
|
|
}
|
|
rememberPosition(container);
|
|
});
|
|
}
|
|
}, { passive: false });
|
|
container.addEventListener("scroll", () => detectHorizontalScroll(container), { passive: true });
|
|
});
|
|
|
|
dismissButton?.addEventListener("click", markDiscovered);
|
|
demoButton?.addEventListener("click", () => {
|
|
const container = primaryScrollContainer();
|
|
if (!hasHorizontalOverflow(container)) {
|
|
updateGuidanceState();
|
|
return;
|
|
}
|
|
|
|
const originalLeft = container.scrollLeft;
|
|
const availableRight = maxScrollLeft(container) - originalLeft;
|
|
let distance = Math.min(220, availableRight);
|
|
let direction = 1;
|
|
if (distance < 24 && originalLeft > 0) {
|
|
distance = Math.min(220, originalLeft);
|
|
direction = -1;
|
|
}
|
|
if (distance < 4) {
|
|
return;
|
|
}
|
|
|
|
isDemoing = true;
|
|
container.scrollTo({ left: originalLeft + (distance * direction), behavior: "smooth" });
|
|
window.setTimeout(() => {
|
|
container.scrollTo({ left: originalLeft, behavior: "smooth" });
|
|
}, 420);
|
|
window.setTimeout(() => {
|
|
isDemoing = false;
|
|
allScrollContainers().forEach((scrollContainer) => {
|
|
rememberPosition(scrollContainer);
|
|
updateEdgeIndicators(scrollContainer);
|
|
});
|
|
updateGuidanceState();
|
|
}, 950);
|
|
});
|
|
|
|
window.setTimeout(() => {
|
|
isArmed = true;
|
|
allScrollContainers().forEach(rememberPosition);
|
|
}, 800);
|
|
|
|
window.requestAnimationFrame(updateGuidanceState);
|
|
window.addEventListener("resize", updateGuidanceState);
|
|
window.addEventListener("plotline:timeline-layout-changed", updateGuidanceState);
|
|
densitySelect?.addEventListener("change", () => window.requestAnimationFrame(updateGuidanceState));
|
|
zoomSelect?.addEventListener("change", () => window.requestAnimationFrame(updateGuidanceState));
|
|
};
|
|
|
|
const drawerWidthBounds = () => {
|
|
const availableWidth = root.getBoundingClientRect().width || window.innerWidth;
|
|
const minimum = Math.min(420, Math.max(320, availableWidth - 360));
|
|
const maximum = Math.max(minimum, Math.min(availableWidth * 0.65, availableWidth - 420));
|
|
return { minimum, maximum, defaultWidth: Math.max(minimum, Math.min(maximum, availableWidth * 0.4)) };
|
|
};
|
|
|
|
const applyDrawerWidth = (width) => {
|
|
if (!inspectorDrawer) {
|
|
return;
|
|
}
|
|
|
|
const { minimum, maximum, defaultWidth } = drawerWidthBounds();
|
|
const nextWidth = Math.max(minimum, Math.min(maximum, Number(width) || defaultWidth));
|
|
root.style.setProperty("--timeline-inspector-width", `${Math.round(nextWidth)}px`);
|
|
};
|
|
|
|
if (inspectorDrawer) {
|
|
applyDrawerWidth(localStorage.getItem(drawerWidthKey));
|
|
window.addEventListener("resize", () => applyDrawerWidth(localStorage.getItem(drawerWidthKey)));
|
|
}
|
|
|
|
setupTimelineNavigationGuidance();
|
|
|
|
inspectorResizer?.addEventListener("pointerdown", (event) => {
|
|
event.preventDefault();
|
|
document.body.classList.add("timeline-inspector-resizing");
|
|
inspectorResizer.setPointerCapture(event.pointerId);
|
|
|
|
const resize = (moveEvent) => {
|
|
const rootRight = root.getBoundingClientRect().right;
|
|
const nextWidth = rootRight - moveEvent.clientX;
|
|
applyDrawerWidth(nextWidth);
|
|
};
|
|
|
|
const finish = () => {
|
|
document.body.classList.remove("timeline-inspector-resizing");
|
|
const width = Math.round(inspectorDrawer?.getBoundingClientRect().width || 0);
|
|
if (width > 0) {
|
|
localStorage.setItem(drawerWidthKey, String(width));
|
|
}
|
|
window.dispatchEvent(new Event("plotline:timeline-layout-changed"));
|
|
inspectorResizer.removeEventListener("pointermove", resize);
|
|
inspectorResizer.removeEventListener("pointerup", finish);
|
|
inspectorResizer.removeEventListener("pointercancel", finish);
|
|
};
|
|
|
|
inspectorResizer.addEventListener("pointermove", resize);
|
|
inspectorResizer.addEventListener("pointerup", finish);
|
|
inspectorResizer.addEventListener("pointercancel", finish);
|
|
});
|
|
|
|
inspectorClose?.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
const url = new URL(window.location.href);
|
|
const focusType = url.searchParams.get("FocusType") || url.searchParams.get("focusType");
|
|
url.searchParams.delete("SelectedSceneID");
|
|
url.searchParams.delete("selectedSceneId");
|
|
url.searchParams.delete("selectedsceneid");
|
|
if (!focusType || focusType.toLowerCase() === "scene") {
|
|
url.searchParams.delete("FocusType");
|
|
url.searchParams.delete("focusType");
|
|
url.searchParams.delete("FocusID");
|
|
url.searchParams.delete("focusId");
|
|
}
|
|
saveScrollPosition();
|
|
window.location.href = url.toString();
|
|
});
|
|
|
|
const saveScrollPosition = () => {
|
|
try {
|
|
sessionStorage.setItem(scrollKey, JSON.stringify({
|
|
path: window.location.pathname,
|
|
top: window.scrollY,
|
|
left: window.scrollX,
|
|
workspaceLeft: workspace.scrollLeft,
|
|
workspaceTop: workspace.scrollTop,
|
|
containers: getScrollContainers().map((container, index) => ({
|
|
index,
|
|
left: container.scrollLeft,
|
|
top: container.scrollTop
|
|
}))
|
|
}));
|
|
} catch {
|
|
// Storage can be unavailable in private or restricted browser modes.
|
|
}
|
|
};
|
|
|
|
const restoreScrollPosition = () => {
|
|
let scrollPosition = null;
|
|
try {
|
|
scrollPosition = JSON.parse(sessionStorage.getItem(scrollKey) || "null");
|
|
sessionStorage.removeItem(scrollKey);
|
|
} catch {
|
|
try {
|
|
sessionStorage.removeItem(scrollKey);
|
|
} catch {
|
|
// Ignore storage failures; normal navigation should continue.
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!scrollPosition || scrollPosition.path !== window.location.pathname) {
|
|
return;
|
|
}
|
|
|
|
window.requestAnimationFrame(() => {
|
|
workspace.scrollLeft = Number(scrollPosition.workspaceLeft) || 0;
|
|
workspace.scrollTop = Number(scrollPosition.workspaceTop) || 0;
|
|
const containers = getScrollContainers();
|
|
(scrollPosition.containers || []).forEach((saved) => {
|
|
const container = containers[saved.index];
|
|
if (container) {
|
|
container.scrollLeft = Number(saved.left) || 0;
|
|
container.scrollTop = Number(saved.top) || 0;
|
|
}
|
|
});
|
|
window.scrollTo(Number(scrollPosition.left) || 0, Number(scrollPosition.top) || 0);
|
|
});
|
|
};
|
|
|
|
window.PlotLineTimelineScroll = { save: saveScrollPosition };
|
|
|
|
const applyMode = (kind, value, values) => {
|
|
values.forEach((item) => root.classList.remove(`${kind}-${item}`));
|
|
root.classList.add(`${kind}-${value}`);
|
|
};
|
|
|
|
const applyDensity = (value) => {
|
|
const nextValue = densityValues.includes(value) ? value : "comfortable";
|
|
applyMode("density", nextValue, densityValues);
|
|
if (densitySelect) {
|
|
densitySelect.value = nextValue;
|
|
}
|
|
localStorage.setItem(densityKey, nextValue);
|
|
window.requestAnimationFrame(renderPlotLineSvg);
|
|
};
|
|
|
|
const applyZoom = (value) => {
|
|
const nextValue = zoomValues.includes(value) ? value : "chapter";
|
|
applyMode("zoom", nextValue, zoomValues);
|
|
if (zoomSelect) {
|
|
zoomSelect.value = nextValue;
|
|
}
|
|
localStorage.setItem(zoomKey, nextValue);
|
|
window.requestAnimationFrame(renderPlotLineSvg);
|
|
};
|
|
|
|
const getScene = (sceneId) => root.querySelector(`[data-scene-id='${CSS.escape(String(sceneId))}']`);
|
|
|
|
const scrollToScene = (sceneId, focus = true) => {
|
|
const target = getScene(sceneId);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
target.classList.add("keyboard-focus");
|
|
target.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
|
|
if (focus) {
|
|
target.focus({ preventScroll: true });
|
|
}
|
|
window.setTimeout(() => target.classList.remove("keyboard-focus"), 1800);
|
|
};
|
|
|
|
const scrollActiveChapterIntoView = (behavior = "auto") => {
|
|
const activeChapter = minimap?.querySelector("[data-active-chapter='true']");
|
|
if (!minimap || !activeChapter) {
|
|
return;
|
|
}
|
|
|
|
const hostRect = minimap.getBoundingClientRect();
|
|
const activeRect = activeChapter.getBoundingClientRect();
|
|
const activeCenter = activeRect.left + activeRect.width / 2;
|
|
const hostCenter = hostRect.left + hostRect.width / 2;
|
|
if (activeRect.left < hostRect.left || activeRect.right > hostRect.right) {
|
|
minimap.scrollBy({ left: activeCenter - hostCenter, behavior });
|
|
}
|
|
};
|
|
|
|
const saveCollapsed = () => localStorage.setItem(collapsedKey, JSON.stringify([...collapsed]));
|
|
|
|
const renderPlotLineSvg = () => {
|
|
root.querySelectorAll("[data-plot-line-svg-board]").forEach((board) => {
|
|
const svg = board.querySelector("[data-plot-line-svg]");
|
|
if (!svg) {
|
|
return;
|
|
}
|
|
let healthSvg = board.querySelector("[data-plot-line-health-svg]");
|
|
if (!healthSvg) {
|
|
healthSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
healthSvg.setAttribute("class", "plot-lane-svg-overlay plot-lane-svg-health-overlay");
|
|
healthSvg.setAttribute("data-plot-line-health-svg", "");
|
|
healthSvg.setAttribute("aria-hidden", "true");
|
|
svg.after(healthSvg);
|
|
}
|
|
|
|
svg.replaceChildren();
|
|
healthSvg.replaceChildren();
|
|
const boardRect = board.getBoundingClientRect();
|
|
const width = Math.max(board.scrollWidth, boardRect.width);
|
|
const height = Math.max(board.scrollHeight, boardRect.height);
|
|
[svg, healthSvg].forEach((layer) => {
|
|
layer.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
layer.setAttribute("width", String(width));
|
|
layer.setAttribute("height", String(height));
|
|
});
|
|
|
|
const makeSvg = (name, attributes = {}) => {
|
|
const element = document.createElementNS("http://www.w3.org/2000/svg", name);
|
|
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
|
|
return element;
|
|
};
|
|
|
|
const rowCenterY = (row) => {
|
|
const rowRect = row.getBoundingClientRect();
|
|
return rowRect.top - boardRect.top + rowRect.height / 2;
|
|
};
|
|
|
|
const pointFor = (row, marker) => {
|
|
const slot = marker.closest("[data-plot-line-slot]");
|
|
if (!slot) {
|
|
return null;
|
|
}
|
|
|
|
const slotRect = slot.getBoundingClientRect();
|
|
const markerRect = marker.getBoundingClientRect();
|
|
const markerCenterX = markerRect.left - boardRect.left + markerRect.width / 2;
|
|
const markerCenterY = markerRect.top - boardRect.top + markerRect.height / 2;
|
|
return {
|
|
x: markerCenterX,
|
|
y: markerCenterY,
|
|
sceneX: slotRect.left - boardRect.left + slotRect.width / 2,
|
|
radius: Math.max(markerRect.width, markerRect.height) / 2,
|
|
sceneIndex: 0,
|
|
type: (marker.dataset.plotEventType || "Progress").toLowerCase(),
|
|
targetPlotLineId: marker.dataset.targetPlotLineId || "",
|
|
targetPlotLineIds: (marker.dataset.targetPlotLineIds || "")
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
};
|
|
};
|
|
|
|
const sceneXs = [...new Set([...board.querySelectorAll("[data-plot-line-slot]")]
|
|
.map((slot) => {
|
|
const slotRect = slot.getBoundingClientRect();
|
|
return Math.round(slotRect.left - boardRect.left + slotRect.width / 2);
|
|
}))]
|
|
.sort((a, b) => a - b);
|
|
const latestSceneIndex = Math.max(sceneXs.length - 1, 0);
|
|
|
|
const rowLookup = new Map();
|
|
board.querySelectorAll("[data-plot-line-row]").forEach((row) => {
|
|
if (row.dataset.plotLineId) {
|
|
rowLookup.set(row.dataset.plotLineId, row);
|
|
}
|
|
});
|
|
|
|
const rowData = [...rowLookup.values()].map((row) => ({
|
|
row,
|
|
plotLineId: row.dataset.plotLineId,
|
|
colour: row.dataset.plotColour || "#2f6f63",
|
|
points: [...row.querySelectorAll("[data-plot-event-node]")]
|
|
.map((marker) => pointFor(row, marker))
|
|
.filter(Boolean)
|
|
.map((point) => ({
|
|
...point,
|
|
sceneIndex: Math.max(0, sceneXs.findIndex((x) => x === Math.round(point.sceneX)))
|
|
}))
|
|
.sort((a, b) => a.x - b.x),
|
|
incomingStartXs: []
|
|
}));
|
|
const rowDataById = new Map(rowData.map((item) => [item.plotLineId, item]));
|
|
|
|
const healthFor = (points) => {
|
|
const hasResolve = points.some((point) => point.type === "resolve");
|
|
const hasAbandon = points.some((point) => point.type === "abandon");
|
|
const resolvePoint = points.find((point) => point.type === "resolve");
|
|
const abandonPoint = points.find((point) => point.type === "abandon");
|
|
const hasMerge = points.some((point) => point.type === "merge" && point.targetPlotLineId);
|
|
const hasSplit = points.some((point) => point.type === "split" && point.targetPlotLineIds.length >= 2);
|
|
const hasBranchFromResolution = Boolean(resolvePoint) && points.some((point) =>
|
|
point.type === "branch" && point.targetPlotLineId && point.sceneIndex >= resolvePoint.sceneIndex);
|
|
const lastSceneIndex = points.reduce((max, point) => Math.max(max, point.sceneIndex), 0);
|
|
const isNeglected = !hasResolve && !hasAbandon && latestSceneIndex - lastSceneIndex >= 10;
|
|
const isDangling = !hasResolve && !hasAbandon && !hasMerge && !hasSplit && latestSceneIndex >= lastSceneIndex;
|
|
const isQuestionable = hasResolve && !hasMerge && !hasSplit && !hasBranchFromResolution;
|
|
let state = "active";
|
|
|
|
if (hasAbandon) {
|
|
state = "abandoned";
|
|
} else if (hasResolve) {
|
|
state = isQuestionable ? "questionable" : "resolved";
|
|
} else if (isNeglected) {
|
|
state = "neglected";
|
|
}
|
|
|
|
return {
|
|
state,
|
|
isNeglected,
|
|
isDangling,
|
|
isQuestionable,
|
|
isAbandoned: hasAbandon,
|
|
resolvePoint,
|
|
abandonPoint
|
|
};
|
|
};
|
|
|
|
const healthWarningTooltip = (warnings) => warnings.map((warning) => [
|
|
`Warning: ${warning.label}`,
|
|
`Reason: ${warning.reason}`,
|
|
`Suggested Action: ${warning.action}`
|
|
].join("\n")).join("\n\n");
|
|
|
|
const drawHealthMarker = (point, warnings) => {
|
|
const offsetX = Math.max(18, (point.radius || 12) + 8);
|
|
const primaryKind = warnings[0]?.kind || "warning";
|
|
const group = makeSvg("g", { class: `plotline-health-marker plotline-health-marker-${primaryKind}` });
|
|
const title = makeSvg("title");
|
|
title.textContent = healthWarningTooltip(warnings);
|
|
group.append(title);
|
|
|
|
const markerX = point.x + offsetX;
|
|
const markerY = point.y - 13;
|
|
if (primaryKind === "questionable") {
|
|
group.append(makeSvg("circle", { cx: markerX, cy: markerY, r: 9 }));
|
|
const text = makeSvg("text", { x: markerX, y: markerY + 4, "text-anchor": "middle" });
|
|
text.textContent = warnings.length > 1 ? String(warnings.length) : "?";
|
|
group.append(text);
|
|
} else {
|
|
group.append(makeSvg("circle", { cx: markerX, cy: markerY, r: 10 }));
|
|
group.append(makeSvg("path", {
|
|
d: `M ${markerX} ${markerY - 6} L ${markerX + 6} ${markerY + 5} L ${markerX - 6} ${markerY + 5} Z`
|
|
}));
|
|
group.append(makeSvg("line", { x1: markerX, y1: markerY - 2, x2: markerX, y2: markerY + 2.5 }));
|
|
group.append(makeSvg("circle", { cx: markerX, cy: markerY + 5.3, r: 1.2 }));
|
|
if (warnings.length > 1) {
|
|
const text = makeSvg("text", { x: markerX + 11, y: markerY - 6, "text-anchor": "middle", class: "plotline-health-count" });
|
|
text.textContent = String(warnings.length);
|
|
group.append(text);
|
|
}
|
|
}
|
|
|
|
healthSvg.append(group);
|
|
};
|
|
|
|
const drawConnector = (source, targetRow, colour, type) => {
|
|
if (!targetRow) {
|
|
if (window.console?.warn) {
|
|
window.console.warn(`Plot line ${type} connector skipped because target plot line data is missing.`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const target = { x: source.x, y: rowCenterY(targetRow) };
|
|
const flowX = Math.max(28, Math.min(46, Math.abs(target.y - source.y) * 0.28));
|
|
const sourceRadius = Math.max(8, source.radius || 12);
|
|
const startX = source.x + sourceRadius;
|
|
const targetX = startX + flowX;
|
|
const midX = startX + flowX / 2;
|
|
svg.append(makeSvg("path", {
|
|
class: `plot-line-svg-connector plot-line-svg-connector-${type}`,
|
|
d: `M ${startX} ${source.y} C ${midX} ${source.y}, ${midX} ${target.y}, ${targetX} ${target.y}`,
|
|
stroke: colour
|
|
}));
|
|
};
|
|
|
|
rowData.forEach((item) => {
|
|
item.points.forEach((point) => {
|
|
if (point.type === "branch" && point.targetPlotLineId) {
|
|
rowDataById.get(point.targetPlotLineId)?.incomingStartXs.push(point.x);
|
|
}
|
|
|
|
if (point.type === "split") {
|
|
point.targetPlotLineIds.forEach((targetPlotLineId) => {
|
|
rowDataById.get(targetPlotLineId)?.incomingStartXs.push(point.x);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
rowData.forEach((item) => {
|
|
item.health = healthFor(item.points);
|
|
item.state = item.health.state;
|
|
});
|
|
|
|
rowData.forEach((item) => {
|
|
const { colour, points, incomingStartXs, state } = item;
|
|
if (points.length > 0) {
|
|
const explicitStart = points.find((point) => point.type === "start");
|
|
const incomingStartX = incomingStartXs.length ? Math.min(...incomingStartXs) : null;
|
|
const terminatingPoint = points.find((point) => point.type === "split" || point.type === "merge");
|
|
const first = points[0];
|
|
const last = points[points.length - 1];
|
|
const startX = incomingStartX !== null && !(explicitStart && explicitStart.x < incomingStartX)
|
|
? incomingStartX
|
|
: first.x;
|
|
const endX = terminatingPoint ? terminatingPoint.x : last.x;
|
|
item.lineEndPoint = { x: endX, y: first.y };
|
|
const sortedPoints = [...points].sort((a, b) => a.x - b.x);
|
|
const terminatingIndex = terminatingPoint ? sortedPoints.indexOf(terminatingPoint) : -1;
|
|
const linePoints = terminatingIndex >= 0 ? sortedPoints.slice(0, terminatingIndex + 1) : sortedPoints;
|
|
if (incomingStartX !== null && incomingStartX < linePoints[0].x) {
|
|
linePoints.unshift({ ...linePoints[0], x: incomingStartX, sceneIndex: Math.max(0, linePoints[0].sceneIndex - 1) });
|
|
}
|
|
|
|
// Only the segment between two events is dormant when their scene gap crosses the threshold.
|
|
// Thread-level resolved/abandoned/neglected state must not make the entire historical line dotted.
|
|
const dormantGapThreshold = 10;
|
|
linePoints.forEach((point, index) => {
|
|
const nextPoint = linePoints[index + 1];
|
|
if (!nextPoint || nextPoint.x <= point.x) {
|
|
return;
|
|
}
|
|
const isDormantGap = Math.abs(nextPoint.sceneIndex - point.sceneIndex) >= dormantGapThreshold;
|
|
svg.append(makeSvg("path", {
|
|
class: `plot-line-svg-path plot-line-svg-segment ${isDormantGap ? "plot-line-svg-segment-dormant" : ""} plotline-state-${state}`,
|
|
d: `M ${point.x} ${point.y} L ${nextPoint.x} ${nextPoint.y}`,
|
|
stroke: colour
|
|
}));
|
|
});
|
|
}
|
|
});
|
|
|
|
rowData.forEach(({ points, colour }) => {
|
|
points.forEach((point) => {
|
|
if (point.type === "branch" || point.type === "merge") {
|
|
drawConnector(point, rowLookup.get(point.targetPlotLineId), colour, point.type);
|
|
}
|
|
|
|
if (point.type === "split" && point.targetPlotLineIds.length >= 2) {
|
|
point.targetPlotLineIds.forEach((targetPlotLineId) => {
|
|
drawConnector(point, rowLookup.get(targetPlotLineId), colour, point.type);
|
|
});
|
|
} else if (point.type === "split" && point.targetPlotLineIds.length > 0 && window.console?.warn) {
|
|
window.console.warn("Plot line split connector skipped because fewer than two targets are available.");
|
|
}
|
|
});
|
|
});
|
|
|
|
rowData.forEach((item) => {
|
|
const markerPoint = item.lineEndPoint || item.points[item.points.length - 1];
|
|
const healthWarnings = [];
|
|
if (item.health?.isNeglected && markerPoint) {
|
|
healthWarnings.push({
|
|
point: markerPoint,
|
|
kind: "neglected",
|
|
label: "Dormant Thread",
|
|
reason: "No activity for 10+ scenes.",
|
|
action: "Add a Progress, Reveal or Resolve event."
|
|
});
|
|
}
|
|
|
|
if (item.health?.isDangling && markerPoint) {
|
|
healthWarnings.push({
|
|
point: markerPoint,
|
|
kind: "dangling",
|
|
label: "Unresolved Thread",
|
|
reason: "Thread contains no Resolve or Abandon event by the end of the current timeline.",
|
|
action: "Review ending scenes and add Resolve or Abandon where appropriate."
|
|
});
|
|
}
|
|
|
|
if (item.health?.isQuestionable && item.health.resolvePoint) {
|
|
healthWarnings.push({
|
|
point: item.health.resolvePoint,
|
|
kind: "questionable",
|
|
label: "Missing Closure",
|
|
reason: "Resolved without merge, split, or branch.",
|
|
action: "Verify this thread has sufficient payoff."
|
|
});
|
|
}
|
|
|
|
if (item.health?.isAbandoned && item.health.abandonPoint) {
|
|
healthWarnings.push({
|
|
point: item.health.abandonPoint,
|
|
kind: "abandoned",
|
|
label: "Abandoned Thread",
|
|
reason: "This plot line was abandoned.",
|
|
action: "Check this is intentional."
|
|
});
|
|
}
|
|
|
|
const groupedWarnings = new Map();
|
|
healthWarnings.forEach((warning) => {
|
|
const key = `${Math.round(warning.point.x)}:${Math.round(warning.point.y)}`;
|
|
if (!groupedWarnings.has(key)) {
|
|
groupedWarnings.set(key, { point: warning.point, warnings: [] });
|
|
}
|
|
groupedWarnings.get(key).warnings.push(warning);
|
|
});
|
|
groupedWarnings.forEach(({ point, warnings }) => drawHealthMarker(point, warnings));
|
|
});
|
|
});
|
|
};
|
|
|
|
const setCollapsed = (id, shouldCollapse) => {
|
|
document.querySelectorAll(`[data-collapse-body='${CSS.escape(id)}']`).forEach((body) => {
|
|
body.classList.toggle("timeline-section-collapsed", shouldCollapse);
|
|
});
|
|
document.querySelectorAll(`[data-collapse-toggle='${CSS.escape(id)}']`).forEach((button) => {
|
|
button.classList.toggle("is-collapsed", shouldCollapse);
|
|
button.setAttribute("aria-expanded", String(!shouldCollapse));
|
|
});
|
|
};
|
|
|
|
document.querySelectorAll("[data-collapse-toggle]").forEach((button) => {
|
|
const id = button.dataset.collapseToggle;
|
|
if (!hasCollapsedPreference && button.dataset.collapseDefault === "collapsed") {
|
|
collapsed.add(id);
|
|
}
|
|
|
|
const isCollapsed = collapsed.has(id);
|
|
setCollapsed(id, isCollapsed);
|
|
button.addEventListener("click", () => {
|
|
const nextCollapsed = !collapsed.has(id);
|
|
if (nextCollapsed) {
|
|
collapsed.add(id);
|
|
} else {
|
|
collapsed.delete(id);
|
|
}
|
|
setCollapsed(id, nextCollapsed);
|
|
saveCollapsed();
|
|
renderPlotLineSvg();
|
|
window.dispatchEvent(new Event("plotline:timeline-layout-changed"));
|
|
});
|
|
});
|
|
|
|
densitySelect?.addEventListener("change", () => applyDensity(densitySelect.value));
|
|
zoomSelect?.addEventListener("change", () => applyZoom(zoomSelect.value));
|
|
applyDensity(localStorage.getItem(densityKey) || "comfortable");
|
|
applyZoom(localStorage.getItem(zoomKey) || "chapter");
|
|
const hadPendingScrollRestore = (() => {
|
|
try {
|
|
return sessionStorage.getItem(scrollKey) !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
window.requestAnimationFrame(() => {
|
|
renderPlotLineSvg();
|
|
scrollActiveChapterIntoView("auto");
|
|
if (hadPendingScrollRestore) {
|
|
restoreScrollPosition();
|
|
}
|
|
});
|
|
|
|
window.addEventListener("resize", renderPlotLineSvg);
|
|
window.addEventListener("plotline:timeline-layout-changed", renderPlotLineSvg);
|
|
|
|
const saveScrollForSceneSelection = (event) => {
|
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
const link = target?.closest("a[href]");
|
|
if (!link || !root.contains(link)) {
|
|
return;
|
|
}
|
|
|
|
const url = new URL(link.href, window.location.href);
|
|
if ([...url.searchParams.keys()].some((key) => key.toLowerCase() === "selectedsceneid")) {
|
|
saveScrollPosition();
|
|
}
|
|
};
|
|
|
|
root.addEventListener("click", saveScrollForSceneSelection, { capture: true });
|
|
|
|
const runSearch = () => {
|
|
const query = searchInput?.value.trim().toLowerCase() || "";
|
|
let firstMatch = null;
|
|
sceneCards.forEach((card) => {
|
|
const matches = !query || (card.dataset.timelineSearchText || "").toLowerCase().includes(query);
|
|
card.classList.toggle("search-hit", Boolean(query && matches));
|
|
card.classList.toggle("search-dim", Boolean(query && !matches));
|
|
if (!firstMatch && query && matches) {
|
|
firstMatch = card;
|
|
}
|
|
});
|
|
return firstMatch;
|
|
};
|
|
|
|
searchInput?.addEventListener("input", runSearch);
|
|
searchInput?.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Enter") {
|
|
return;
|
|
}
|
|
|
|
const firstMatch = runSearch();
|
|
if (firstMatch) {
|
|
event.preventDefault();
|
|
scrollToScene(firstMatch.dataset.sceneId);
|
|
}
|
|
});
|
|
|
|
jumpSelects.forEach((select) => {
|
|
select.addEventListener("change", () => {
|
|
if (select.value) {
|
|
scrollToScene(select.value);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("[data-jump-scene]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
if (button.dataset.jumpScene) {
|
|
scrollToScene(button.dataset.jumpScene);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
const activeTag = document.activeElement?.tagName?.toLowerCase();
|
|
const isTyping = ["input", "select", "textarea"].includes(activeTag) || document.activeElement?.isContentEditable;
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f" && searchInput) {
|
|
event.preventDefault();
|
|
searchInput.focus();
|
|
searchInput.select();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
const url = new URL(window.location.href);
|
|
const hadFocus = url.searchParams.has("FocusType") || url.searchParams.has("FocusID");
|
|
url.searchParams.delete("FocusType");
|
|
url.searchParams.delete("FocusID");
|
|
if (hadFocus) {
|
|
window.location.href = url.toString();
|
|
} else if (searchInput?.value) {
|
|
searchInput.value = "";
|
|
runSearch();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isTyping) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
event.preventDefault();
|
|
workspace.scrollBy({ left: event.key === "ArrowLeft" ? -260 : 260, behavior: "smooth" });
|
|
return;
|
|
}
|
|
|
|
if (event.key === "+" || event.key === "=" || event.key === "-") {
|
|
const currentIndex = zoomValues.indexOf(zoomSelect?.value || "chapter");
|
|
const direction = event.key === "-" ? -1 : 1;
|
|
const nextIndex = Math.min(zoomValues.length - 1, Math.max(0, currentIndex + direction));
|
|
applyZoom(zoomValues[nextIndex]);
|
|
}
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const root = document.querySelector("[data-timeline-root]");
|
|
const panel = document.querySelector("[data-drag-drop-panel]");
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
|
|
const forms = new Map([...document.querySelectorAll("[data-drag-form]")].map((form) => [form.dataset.dragForm, form]));
|
|
const title = panel.querySelector("[data-drag-panel-title]");
|
|
const kicker = panel.querySelector("[data-drag-panel-kicker]");
|
|
const summary = panel.querySelector("[data-drag-panel-summary]");
|
|
const sceneMoveModalElement = document.querySelector("[data-scene-move-modal]");
|
|
const sceneMoveModal = sceneMoveModalElement && window.bootstrap?.Modal
|
|
? window.bootstrap.Modal.getOrCreateInstance(sceneMoveModalElement)
|
|
: null;
|
|
const sceneMoveTitle = sceneMoveModalElement?.querySelector("[data-scene-move-title]");
|
|
const sceneMoveSummary = sceneMoveModalElement?.querySelector("[data-scene-move-summary]");
|
|
const sceneMoveSubmit = sceneMoveModalElement?.querySelector("[data-scene-move-submit]");
|
|
const scrollKey = "plotline.timeline.scroll";
|
|
let currentDrag = null;
|
|
let suppressSceneClickUntil = 0;
|
|
|
|
const setValue = (form, name, value) => {
|
|
const field = form?.querySelector(`[name='${CSS.escape(name)}']`);
|
|
if (field) {
|
|
field.value = value ?? "";
|
|
}
|
|
};
|
|
|
|
const selectText = (select) => select?.selectedOptions?.[0]?.textContent?.trim() || "";
|
|
const quoteScene = (label) => `\u201c${label || "this scene"}\u201d`;
|
|
const buildSceneMoveSummary = (payload, sourceChapter, targetChapter, position, anchorLabel) => {
|
|
if (sourceChapter && targetChapter && sourceChapter === targetChapter) {
|
|
return `Move ${quoteScene(payload.label)} to this new position within ${targetChapter}?`;
|
|
}
|
|
|
|
if (sourceChapter && targetChapter) {
|
|
return `Move ${quoteScene(payload.label)} from ${sourceChapter} to ${targetChapter}?`;
|
|
}
|
|
|
|
return `Move ${quoteScene(payload.label)} ${position.toLowerCase()} ${anchorLabel}?`;
|
|
};
|
|
|
|
const showForm = (key, heading, body, actionLabel) => {
|
|
if (key === "scene") {
|
|
panel.classList.add("d-none");
|
|
panel.querySelectorAll("[data-drag-form]").forEach((form) => form.classList.add("d-none"));
|
|
forms.get("scene")?.classList.remove("d-none");
|
|
if (sceneMoveTitle) {
|
|
sceneMoveTitle.textContent = heading;
|
|
}
|
|
if (sceneMoveSummary) {
|
|
sceneMoveSummary.textContent = body;
|
|
}
|
|
if (sceneMoveSubmit) {
|
|
sceneMoveSubmit.classList.toggle("d-none", heading.toLowerCase().startsWith("cannot") || heading.toLowerCase().startsWith("scene already"));
|
|
}
|
|
if (sceneMoveModal) {
|
|
sceneMoveModal.show();
|
|
} else {
|
|
panel.classList.remove("d-none");
|
|
}
|
|
return;
|
|
}
|
|
|
|
forms.forEach((form, formKey) => form.classList.toggle("d-none", formKey !== key));
|
|
panel.classList.remove("d-none");
|
|
title.textContent = heading;
|
|
kicker.textContent = actionLabel;
|
|
summary.textContent = body;
|
|
panel.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
};
|
|
|
|
const hidePanel = () => {
|
|
panel.classList.add("d-none");
|
|
forms.forEach((form) => form.classList.add("d-none"));
|
|
};
|
|
|
|
const clearDropHighlights = () => {
|
|
document
|
|
.querySelectorAll(".drop-target-active, .drop-before, .drop-after, .chapter-drop-active, .drop-chapter-end")
|
|
.forEach((target) => target.classList.remove("drop-target-active", "drop-before", "drop-after", "chapter-drop-active", "drop-chapter-end"));
|
|
};
|
|
|
|
const saveTimelineScroll = () => {
|
|
const workspace = root?.querySelector(".timeline-shell");
|
|
try {
|
|
sessionStorage.setItem(scrollKey, JSON.stringify({ left: workspace?.scrollLeft || 0, top: window.scrollY || 0 }));
|
|
} catch {
|
|
// Scroll restoration is a convenience; blocked storage should not stop the move.
|
|
}
|
|
};
|
|
|
|
try {
|
|
const storedScroll = JSON.parse(sessionStorage.getItem(scrollKey) || "null");
|
|
if (storedScroll) {
|
|
sessionStorage.removeItem(scrollKey);
|
|
requestAnimationFrame(() => {
|
|
const workspace = root?.querySelector(".timeline-shell");
|
|
if (workspace) {
|
|
workspace.scrollLeft = storedScroll.left || 0;
|
|
}
|
|
window.scrollTo({ top: storedScroll.top || 0 });
|
|
});
|
|
}
|
|
} catch {
|
|
sessionStorage.removeItem(scrollKey);
|
|
}
|
|
|
|
const readPayload = (event) => {
|
|
try {
|
|
const payload = JSON.parse(event.dataTransfer.getData("application/json") || "{}");
|
|
return payload.type ? payload : currentDrag || {};
|
|
} catch {
|
|
return currentDrag || {};
|
|
}
|
|
};
|
|
|
|
const writePayload = (event, element) => {
|
|
const payload = {
|
|
type: element.dataset.dragType,
|
|
id: element.dataset.dragId,
|
|
label: element.dataset.dragLabel || element.textContent.trim()
|
|
};
|
|
event.dataTransfer.effectAllowed = "copyMove";
|
|
event.dataTransfer.setData("application/json", JSON.stringify(payload));
|
|
event.dataTransfer.setData("text/plain", `${payload.type}:${payload.id}`);
|
|
currentDrag = payload;
|
|
document.body.classList.add("drag-active", `dragging-${payload.type}`);
|
|
element.classList.add("is-dragging");
|
|
};
|
|
|
|
document.querySelectorAll("[data-drag-type][draggable='true']").forEach((item) => {
|
|
item.addEventListener("dragstart", (event) => writePayload(event, item));
|
|
item.addEventListener("dragend", () => {
|
|
item.classList.remove("is-dragging");
|
|
document.body.classList.remove("drag-active", "dragging-scene", "dragging-character", "dragging-asset", "dragging-location");
|
|
currentDrag = null;
|
|
clearDropHighlights();
|
|
});
|
|
});
|
|
|
|
panel.querySelectorAll("[data-drag-cancel]").forEach((button) => button.addEventListener("click", hidePanel));
|
|
forms.get("scene")?.addEventListener("submit", saveTimelineScroll);
|
|
|
|
root?.querySelectorAll(".timeline-scene-card[data-scene-id]").forEach((card) => {
|
|
card.addEventListener("click", (event) => {
|
|
if (Date.now() < suppressSceneClickUntil) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
});
|
|
|
|
card.addEventListener("dragover", (event) => {
|
|
const payload = readPayload(event);
|
|
if (["scene", "character", "asset"].includes(payload.type)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.dataTransfer.dropEffect = payload.type === "scene" ? "move" : "copy";
|
|
card.classList.add("drop-target-active");
|
|
card.classList.toggle("drop-before", payload.type === "scene" && event.offsetX < card.clientWidth / 2);
|
|
card.classList.toggle("drop-after", payload.type === "scene" && event.offsetX >= card.clientWidth / 2);
|
|
}
|
|
});
|
|
|
|
card.addEventListener("dragleave", () => card.classList.remove("drop-target-active", "drop-before", "drop-after"));
|
|
|
|
card.addEventListener("drop", (event) => {
|
|
const payload = readPayload(event);
|
|
if (!["scene", "character", "asset"].includes(payload.type)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
suppressSceneClickUntil = Date.now() + 800;
|
|
card.classList.remove("drop-target-active", "drop-before", "drop-after");
|
|
const sceneId = card.dataset.sceneId;
|
|
|
|
if (payload.type === "scene") {
|
|
if (payload.id === sceneId) {
|
|
showForm("scene", "Cannot move scene", "Drop a scene before or after a different scene.", "Scene move");
|
|
return;
|
|
}
|
|
|
|
const form = forms.get("scene");
|
|
const position = event.offsetX < card.clientWidth / 2 ? "Before" : "After";
|
|
const sourceCard = root.querySelector(`.timeline-scene-card[data-scene-id='${CSS.escape(payload.id)}']`);
|
|
const sourceChapter = sourceCard?.dataset.chapterLabel || "";
|
|
const targetChapter = card.dataset.chapterLabel || "";
|
|
setValue(form, "SceneID", payload.id);
|
|
setValue(form, "AnchorSceneID", sceneId);
|
|
setValue(form, "TargetChapterID", "");
|
|
setValue(form, "Position", position);
|
|
showForm("scene", "Move Scene?", buildSceneMoveSummary(payload, sourceChapter, targetChapter, position, card.dataset.dragLabel), "Scene move");
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "character") {
|
|
const form = forms.get("character");
|
|
setValue(form, "SceneID", sceneId);
|
|
setValue(form, "CharacterID", payload.id);
|
|
showForm("character", "Add character to scene", `Add ${payload.label} to ${card.dataset.dragLabel}.`, "Character drop");
|
|
return;
|
|
}
|
|
|
|
const form = forms.get("asset-event");
|
|
setValue(form, "SceneID", sceneId);
|
|
setValue(form, "StoryAssetID", payload.id);
|
|
setValue(form, "EventTitle", "");
|
|
showForm("asset-event", "What happens to this asset?", `Add an event for ${payload.label} in ${card.dataset.dragLabel}.`, "Asset drop");
|
|
});
|
|
});
|
|
|
|
root?.querySelectorAll(".timeline-scenes[data-chapter-drop-target]").forEach((chapterBody) => {
|
|
const chapter = chapterBody.closest("[data-chapter-drop-target]");
|
|
const chapterId = chapterBody.dataset.chapterId || chapter?.dataset.chapterId;
|
|
const chapterLabel = chapterBody.dataset.chapterLabel || chapter?.dataset.chapterLabel || "this chapter";
|
|
|
|
chapterBody.addEventListener("dragover", (event) => {
|
|
const payload = readPayload(event);
|
|
if (payload.type !== "scene" || !chapterId) {
|
|
return;
|
|
}
|
|
|
|
if (event.target.closest(".timeline-scene-card[data-scene-id]")) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
chapterBody.classList.add("drop-target-active", "drop-chapter-end");
|
|
chapter?.classList.add("chapter-drop-active");
|
|
});
|
|
|
|
chapterBody.addEventListener("dragleave", (event) => {
|
|
if (chapterBody.contains(event.relatedTarget)) {
|
|
return;
|
|
}
|
|
|
|
chapterBody.classList.remove("drop-target-active", "drop-chapter-end");
|
|
chapter?.classList.remove("chapter-drop-active");
|
|
});
|
|
|
|
chapterBody.addEventListener("drop", (event) => {
|
|
const payload = readPayload(event);
|
|
if (payload.type !== "scene" || !chapterId) {
|
|
return;
|
|
}
|
|
|
|
if (event.target.closest(".timeline-scene-card[data-scene-id]")) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
suppressSceneClickUntil = Date.now() + 800;
|
|
clearDropHighlights();
|
|
const sourceCard = root.querySelector(`.timeline-scene-card[data-scene-id='${CSS.escape(payload.id)}']`);
|
|
if (sourceCard?.dataset.chapterId === chapterId && chapterBody.lastElementChild?.dataset?.sceneId === payload.id) {
|
|
showForm("scene", "Scene already here", `${payload.label} is already at the end of ${chapterLabel}.`, "Scene move");
|
|
return;
|
|
}
|
|
|
|
const form = forms.get("scene");
|
|
setValue(form, "SceneID", payload.id);
|
|
setValue(form, "AnchorSceneID", "");
|
|
setValue(form, "TargetChapterID", chapterId);
|
|
setValue(form, "Position", "End");
|
|
showForm("scene", "Move Scene?", buildSceneMoveSummary(payload, sourceCard?.dataset.chapterLabel || "", chapterLabel, "After", chapterLabel), "Scene move");
|
|
});
|
|
});
|
|
|
|
const assetDependencyForm = forms.get("asset-dependency");
|
|
const updateAssetPreview = () => {
|
|
const source = assetDependencyForm?.dataset.sourceLabel || "Source asset";
|
|
const target = assetDependencyForm?.dataset.targetLabel || "Target asset";
|
|
const dependency = selectText(assetDependencyForm?.querySelector("[name='AssetDependencyTypeID']")) || "Requires";
|
|
const sourceState = selectText(assetDependencyForm?.querySelector("[name='SourceRequiredStateID']")) || "the required state";
|
|
const targetState = selectText(assetDependencyForm?.querySelector("[name='TargetBlockedStateID']")) || "the blocked state";
|
|
const preview = panel.querySelector("[data-asset-dependency-preview]");
|
|
if (preview) {
|
|
preview.textContent = `${source} ${dependency.toLowerCase()} ${target}. ${source} should be ${sourceState} before ${target} can be ${targetState}.`;
|
|
}
|
|
};
|
|
|
|
assetDependencyForm?.querySelectorAll("select").forEach((select) => select.addEventListener("change", updateAssetPreview));
|
|
|
|
document.querySelectorAll("[data-asset-drop-target], .drag-chip.asset").forEach((target) => {
|
|
target.addEventListener("dragover", (event) => {
|
|
const payload = readPayload(event);
|
|
if (payload.type === "asset") {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "copy";
|
|
target.classList.add("drop-target-active");
|
|
}
|
|
});
|
|
target.addEventListener("dragleave", () => target.classList.remove("drop-target-active"));
|
|
target.addEventListener("drop", (event) => {
|
|
const payload = readPayload(event);
|
|
const targetId = target.dataset.assetId || target.dataset.dragId;
|
|
const targetLabel = target.dataset.assetLabel || target.dataset.dragLabel || target.textContent.trim();
|
|
if (payload.type !== "asset" || !targetId) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
target.classList.remove("drop-target-active");
|
|
if (payload.id === targetId) {
|
|
showForm("asset-dependency", "Cannot create dependency", "Drop one asset onto a different asset.", "Asset dependency");
|
|
return;
|
|
}
|
|
|
|
setValue(assetDependencyForm, "SourceAssetID", payload.id);
|
|
setValue(assetDependencyForm, "TargetAssetID", targetId);
|
|
assetDependencyForm.dataset.sourceLabel = payload.label;
|
|
assetDependencyForm.dataset.targetLabel = targetLabel;
|
|
updateAssetPreview();
|
|
showForm("asset-dependency", "Create asset dependency", `${payload.label} -> ${targetLabel}`, "Asset dependency");
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("[data-drag-type='location']").forEach((target) => {
|
|
target.addEventListener("dragover", (event) => {
|
|
const payload = readPayload(event);
|
|
if (payload.type === "location") {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "copy";
|
|
target.classList.add("drop-target-active");
|
|
}
|
|
});
|
|
target.addEventListener("dragleave", () => target.classList.remove("drop-target-active"));
|
|
target.addEventListener("drop", (event) => {
|
|
const payload = readPayload(event);
|
|
const targetId = target.dataset.dragId;
|
|
const targetLabel = target.dataset.dragLabel || target.textContent.trim();
|
|
if (payload.type !== "location") {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
target.classList.remove("drop-target-active");
|
|
if (payload.id === targetId) {
|
|
showForm("location-relationship", "Cannot create relationship", "Drop one location onto a different location.", "Location relationship");
|
|
return;
|
|
}
|
|
|
|
const form = forms.get("location-relationship");
|
|
setValue(form, "FromLocationID", payload.id);
|
|
setValue(form, "ToLocationID", targetId);
|
|
showForm("location-relationship", "Create location relationship", `${payload.label} -> ${targetLabel}`, "Location drop");
|
|
});
|
|
});
|
|
})();
|