753 lines
27 KiB
JavaScript
753 lines
27 KiB
JavaScript
(() => {
|
|
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.querySelector("[name='SceneID']")?.value || "new";
|
|
const storagePrefix = `plotline.inspector.${sceneId}.`;
|
|
const defaultOpen = new Set(["overview", "timing", "characters"]);
|
|
const sections = [...root.querySelectorAll(".inspector-section")];
|
|
|
|
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");
|
|
const count = section.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>`;
|
|
|
|
section.append(button, body);
|
|
|
|
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 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";
|
|
|
|
let previousChapter = null;
|
|
scenes.forEach((scene, index) => {
|
|
if (scene.chapterLabel === previousChapter) {
|
|
return;
|
|
}
|
|
|
|
const x = scales.x.getPixelForValue(index);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, chartArea.top);
|
|
ctx.lineTo(x, chartArea.bottom);
|
|
ctx.stroke();
|
|
ctx.fillText(scene.chapterLabel, Math.min(x + 4, chartArea.right - 70), chartArea.bottom + 22);
|
|
previousChapter = scene.chapterLabel;
|
|
});
|
|
|
|
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 labels = payload.scenes.map((scene) => scene.axisLabel);
|
|
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) => pointMap.get(scene.sceneId) ?? null),
|
|
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: { labels, 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: {
|
|
ticks: {
|
|
maxRotation: 0,
|
|
autoSkip: true,
|
|
maxTicksLimit: 16
|
|
},
|
|
grid: { display: false }
|
|
}
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
plotlineScenes: payload.scenes,
|
|
tooltip: {
|
|
callbacks: {
|
|
title(items) {
|
|
const scene = payload.scenes[items[0].dataIndex];
|
|
return `${scene.chapterLabel}, Scene ${scene.sceneNumber}: ${scene.sceneTitle}`;
|
|
},
|
|
label(item) {
|
|
return `${item.dataset.label}: ${item.formattedValue}`;
|
|
},
|
|
afterBody(items) {
|
|
const scene = payload.scenes[items[0].dataIndex];
|
|
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 scene = payload.scenes[points[0].index];
|
|
if (scene?.timelineUrl) {
|
|
window.location.href = scene.timelineUrl;
|
|
}
|
|
});
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
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 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 densityValues = ["compact", "comfortable", "expanded"];
|
|
const zoomValues = ["book", "chapter", "scene-detail"];
|
|
|
|
const readSet = (key) => {
|
|
try {
|
|
return new Set(JSON.parse(localStorage.getItem(key) || "[]"));
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
};
|
|
|
|
const collapsed = readSet(collapsedKey);
|
|
|
|
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 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;
|
|
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();
|
|
});
|
|
});
|
|
|
|
densitySelect?.addEventListener("change", () => applyDensity(densitySelect.value));
|
|
zoomSelect?.addEventListener("change", () => applyZoom(zoomSelect.value));
|
|
applyDensity(localStorage.getItem(densityKey) || "comfortable");
|
|
applyZoom(localStorage.getItem(zoomKey) || "chapter");
|
|
|
|
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([...panel.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]");
|
|
let currentDrag = null;
|
|
|
|
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 showForm = (key, heading, body, actionLabel) => {
|
|
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 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;
|
|
document.querySelectorAll(".drop-target-active, .drop-before, .drop-after").forEach((target) => {
|
|
target.classList.remove("drop-target-active", "drop-before", "drop-after");
|
|
});
|
|
});
|
|
});
|
|
|
|
panel.querySelectorAll("[data-drag-cancel]").forEach((button) => button.addEventListener("click", hidePanel));
|
|
|
|
root?.querySelectorAll(".timeline-scene-card[data-scene-id]").forEach((card) => {
|
|
card.addEventListener("dragover", (event) => {
|
|
const payload = readPayload(event);
|
|
if (["scene", "character", "asset"].includes(payload.type)) {
|
|
event.preventDefault();
|
|
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();
|
|
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";
|
|
setValue(form, "SceneID", payload.id);
|
|
setValue(form, "AnchorSceneID", sceneId);
|
|
setValue(form, "Position", position);
|
|
showForm("scene", "Move scene?", `Move ${payload.label} ${position.toLowerCase()} ${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");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
})();
|