2026-06-20 16:36:18 +01:00

2415 lines
87 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 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 = "#";
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");
});
});
})();