From b5a6de4fb2f5b963681857ed0801825cb617f78a Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Wed, 3 Jun 2026 09:16:17 +0100 Subject: [PATCH] Scene editor changed to flyout drawer --- PlotLine/PlotLine.csproj | 4 +- PlotLine/Views/Timeline/Index.cshtml | 33 +++++----- PlotLine/wwwroot/css/site.css | 94 +++++++++++++++++++++++----- PlotLine/wwwroot/js/site.js | 70 +++++++++++++++++++++ 4 files changed, 167 insertions(+), 34 deletions(-) diff --git a/PlotLine/PlotLine.csproj b/PlotLine/PlotLine.csproj index f518737..3039166 100644 --- a/PlotLine/PlotLine.csproj +++ b/PlotLine/PlotLine.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/PlotLine/Views/Timeline/Index.cshtml b/PlotLine/Views/Timeline/Index.cshtml index e0d64d3..1775ef6 100644 --- a/PlotLine/Views/Timeline/Index.cshtml +++ b/PlotLine/Views/Timeline/Index.cshtml @@ -51,6 +51,7 @@ } int ChapterSceneColumns(ChapterTimelineViewModel chapter) => Math.Max(chapter.Scenes.Count, 1); int BookSceneColumns(BookTimelineViewModel book) => Math.Max(book.Chapters.Any() ? book.Chapters.Sum(ChapterSceneColumns) : 1, 1); + var hasSelectedScene = Model.SelectedScene is not null; int CharacterAppearanceCount(CharacterLaneViewModel lane) => lane.SceneSlots.Sum(slot => slot.Appearances.Count); bool HasLeadSceneRole(CharacterLaneViewModel lane) => lane.SceneSlots .SelectMany(slot => slot.Appearances) @@ -572,7 +573,7 @@ } else { -
+
@@ -976,24 +977,22 @@ else
- + }
} diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css index b2003e0..8590088 100644 --- a/PlotLine/wwwroot/css/site.css +++ b/PlotLine/wwwroot/css/site.css @@ -386,9 +386,15 @@ td.text-end { .workspace-layout { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(360px, 430px); - gap: 18px; + grid-template-columns: minmax(0, 1fr); + gap: 0; align-items: start; + --timeline-inspector-width: min(40vw, 560px); +} + +.workspace-layout.inspector-drawer-open { + grid-template-columns: minmax(420px, 1fr) 10px minmax(420px, var(--timeline-inspector-width)); + gap: 12px; } .timeline-workspace { @@ -396,6 +402,38 @@ td.text-end { max-width: 100%; } +.timeline-inspector-resizer { + position: sticky; + top: 76px; + z-index: 25; + width: 10px; + height: calc(100vh - 96px); + border-radius: 999px; + cursor: col-resize; + background: linear-gradient(180deg, rgba(47, 111, 99, 0.16), rgba(47, 111, 99, 0.04)); +} + +.timeline-inspector-resizer::before { + content: ""; + display: block; + width: 2px; + height: 48px; + margin: 18px auto; + border-radius: 999px; + background: var(--plotline-accent); + opacity: 0.55; +} + +.timeline-inspector-resizer:hover, +.timeline-inspector-resizer:focus-visible { + background: rgba(47, 111, 99, 0.16); +} + +body.timeline-inspector-resizing { + cursor: col-resize; + user-select: none; +} + .pacing-panel { margin-top: 14px; margin-bottom: 0; @@ -1201,30 +1239,42 @@ td.text-end { .scene-inspector-panel { position: sticky; top: 76px; + display: flex; + flex-direction: column; max-height: calc(100vh - 96px); - overflow: auto; + min-width: 0; + overflow: hidden; border: 1px solid var(--plotline-line); border-radius: 8px; - padding: 16px; background: #fff; box-shadow: 0 14px 32px rgba(30, 37, 43, 0.08); } -.inspector-header, -.inspector-empty { - margin-bottom: 14px; +.inspector-header { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--plotline-line); + padding: 16px; + background: #fff; } -.inspector-header h2, -.inspector-empty h2 { +.scene-inspector-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 16px; +} + +.inspector-header h2 { margin: 0; font-size: 1.25rem; } -.inspector-empty p:last-child { - color: var(--plotline-muted); -} - .scene-inspector-form { display: grid; gap: 14px; @@ -3429,16 +3479,30 @@ body.dragging-location [data-drag-type="location"] { } } -@media (max-width: 1100px) { - .workspace-layout { +@media (max-width: 900px) { + .workspace-layout, + .workspace-layout.inspector-drawer-open { grid-template-columns: 1fr; + gap: 14px; + } + + .timeline-inspector-resizer { + display: none; } .scene-inspector-panel { position: static; max-height: none; + overflow: visible; } + .scene-inspector-body { + max-height: none; + overflow: visible; + } +} + +@media (max-width: 1100px) { .metric-graph-row { grid-template-columns: 1fr; } diff --git a/PlotLine/wwwroot/js/site.js b/PlotLine/wwwroot/js/site.js index e22835a..13011b7 100644 --- a/PlotLine/wwwroot/js/site.js +++ b/PlotLine/wwwroot/js/site.js @@ -536,6 +536,7 @@ 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", @@ -546,6 +547,9 @@ ].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 { @@ -560,6 +564,72 @@ 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)); + } + 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({