1672 lines
60 KiB
JavaScript
1672 lines
60 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 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") {
|
|
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 = "#";
|
|
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]");
|
|
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,
|
|
data: payload.scenes.map((scene, columnIndex) => {
|
|
const value = pointMap.get(scene.sceneId);
|
|
return value == null ? { x: columnIndex + 0.5, y: null } : { x: columnIndex + 0.5, y: value };
|
|
}),
|
|
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)
|
|
};
|
|
});
|
|
|
|
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);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener("click", (event) => {
|
|
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 drawerWidthKey = "plotline.timeline.inspectorWidth";
|
|
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"];
|
|
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 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)));
|
|
}
|
|
|
|
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);
|
|
};
|
|
|
|
const applyZoom = (value) => {
|
|
const nextValue = zoomValues.includes(value) ? value : "chapter";
|
|
applyMode("zoom", nextValue, zoomValues);
|
|
if (zoomSelect) {
|
|
zoomSelect.value = nextValue;
|
|
}
|
|
localStorage.setItem(zoomKey, nextValue);
|
|
};
|
|
|
|
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 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();
|
|
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(() => {
|
|
scrollActiveChapterIntoView("auto");
|
|
if (hadPendingScrollRestore) {
|
|
restoreScrollPosition();
|
|
}
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
})();
|