2026-06-08 21:50:18 +01:00

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");
});
});
})();