Implemented PD-009 Timeline Navigation Discoverability.
This commit is contained in:
parent
0410afb124
commit
c577235949
@ -345,7 +345,7 @@
|
||||
<option value="@locationScene.SceneID">Location: @locationScene.PrimaryLocationName</option>
|
||||
}
|
||||
</select>
|
||||
<span class="timeline-shortcut-hint">Shortcuts: arrows scroll, +/- zoom, Esc clears focus</span>
|
||||
<span class="timeline-shortcut-hint">Shortcuts: Shift + scroll moves sideways, arrows scroll, +/- zoom, Esc clears focus</span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@ -421,11 +421,6 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="timeline-scroll-hint" data-timeline-scroll-hint hidden>
|
||||
<span>Tip: Hold Shift while scrolling to move left and right through the timeline.</span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-timeline-scroll-hint-dismiss>Dismiss</button>
|
||||
</section>
|
||||
|
||||
<details class="timeline-drag-palette" data-drag-palette>
|
||||
<summary>Drag Sources</summary>
|
||||
<div class="drag-chip-group">
|
||||
@ -602,6 +597,19 @@ else
|
||||
{
|
||||
<div class="workspace-layout timeline-filtered-root @(hasSelectedScene ? "inspector-drawer-open" : "inspector-drawer-closed")" data-timeline-root>
|
||||
<main class="timeline-workspace">
|
||||
<section class="timeline-navigation-card" data-timeline-navigation-card hidden>
|
||||
<div class="timeline-navigation-card-icon" aria-hidden="true">↔</div>
|
||||
<div class="timeline-navigation-card-copy">
|
||||
<h2>Your timeline continues off-screen</h2>
|
||||
<p>Use Jump to move straight to another chapter.</p>
|
||||
<p>To browse nearby scenes sideways, hold Shift while using your mouse wheel.</p>
|
||||
<p>On a trackpad, hold Shift and use a two-finger scroll gesture.</p>
|
||||
</div>
|
||||
<div class="timeline-navigation-card-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-timeline-navigation-demo aria-label="Show timeline horizontal scrolling demonstration">Show Me</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-timeline-navigation-dismiss aria-label="Got it, do not show timeline navigation guidance again">Got It</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="timeline-shell workspace-timeline" data-timeline-section="scene-cards" style="--scene-count:@timelineColumns.Count">
|
||||
@if (Model.Settings.ShowSceneCards)
|
||||
{
|
||||
|
||||
@ -2437,22 +2437,98 @@ body.timeline-inspector-resizing {
|
||||
background: rgba(47, 111, 99, 0.08);
|
||||
}
|
||||
|
||||
.timeline-scroll-hint {
|
||||
display: flex;
|
||||
.timeline-navigation-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid rgba(47, 111, 99, 0.22);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(47, 111, 99, 0.08);
|
||||
border: 1px solid rgba(47, 111, 99, 0.42);
|
||||
border-left: 5px solid var(--plotline-accent);
|
||||
border-radius: 8px;
|
||||
margin: 0 0 12px;
|
||||
padding: 14px 16px;
|
||||
color: var(--plotline-ink);
|
||||
background: linear-gradient(135deg, rgba(238, 245, 242, 0.98), rgba(255, 255, 255, 0.94));
|
||||
box-shadow: 0 10px 26px rgba(30, 37, 43, 0.08);
|
||||
}
|
||||
|
||||
.timeline-scroll-hint[hidden] {
|
||||
.timeline-navigation-card[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-navigation-card-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: var(--plotline-accent);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.timeline-navigation-card-copy h2 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--plotline-accent-dark);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.timeline-navigation-card-copy p {
|
||||
margin: 2px 0;
|
||||
color: var(--plotline-ink);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.timeline-navigation-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.timeline-horizontal-overflow {
|
||||
transition: box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.timeline-horizontal-overflow.has-scroll-left {
|
||||
box-shadow: inset 18px 0 18px -20px rgba(30, 37, 43, 0.55);
|
||||
}
|
||||
|
||||
.timeline-horizontal-overflow.has-scroll-right {
|
||||
box-shadow: inset -18px 0 18px -20px rgba(30, 37, 43, 0.55);
|
||||
}
|
||||
|
||||
.timeline-horizontal-overflow.has-scroll-left.has-scroll-right {
|
||||
box-shadow: inset 18px 0 18px -20px rgba(30, 37, 43, 0.55), inset -18px 0 18px -20px rgba(30, 37, 43, 0.55);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-navigation-card {
|
||||
border-color: rgba(111, 169, 155, 0.52);
|
||||
border-left-color: #6fa99b;
|
||||
color: #f4eadc;
|
||||
background: linear-gradient(135deg, rgba(34, 83, 74, 0.72), rgba(32, 31, 34, 0.96));
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-navigation-card-copy h2,
|
||||
[data-theme="dark"] .timeline-navigation-card-copy p {
|
||||
color: #f4eadc;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-horizontal-overflow.has-scroll-left {
|
||||
box-shadow: inset 18px 0 18px -20px rgba(244, 234, 220, 0.55);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-horizontal-overflow.has-scroll-right {
|
||||
box-shadow: inset -18px 0 18px -20px rgba(244, 234, 220, 0.55);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-horizontal-overflow.has-scroll-left.has-scroll-right {
|
||||
box-shadow: inset 18px 0 18px -20px rgba(244, 234, 220, 0.55), inset -18px 0 18px -20px rgba(244, 234, 220, 0.55);
|
||||
}
|
||||
|
||||
.timeline-scene-card.filter-dim,
|
||||
.timeline-scene-card.focus-dim,
|
||||
.lane-dim {
|
||||
@ -4041,6 +4117,18 @@ body.dragging-location [data-drag-type="location"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timeline-navigation-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.timeline-navigation-card-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-navigation-card-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.story-bible-search,
|
||||
.story-bible-section-heading {
|
||||
align-items: stretch;
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
@ -1205,8 +1205,9 @@
|
||||
const densityKey = "plotline.timeline.density";
|
||||
const zoomKey = "plotline.timeline.zoom";
|
||||
const scrollKey = "plotline.timeline.pending-scroll";
|
||||
const horizontalHintKey = "plotline.timeline.horizontalScrollHint.seen";
|
||||
const navigationDiscoveryKey = "plotdirector.timelineNavigationDiscovered";
|
||||
const drawerWidthKey = "plotline.timeline.inspectorWidth";
|
||||
const horizontalOverflowTolerance = 8;
|
||||
const scrollContainerSelector = [
|
||||
".timeline-shell",
|
||||
".plot-lane-board",
|
||||
@ -1234,72 +1235,144 @@
|
||||
|
||||
const getScrollContainers = () => [...root.querySelectorAll(scrollContainerSelector)];
|
||||
|
||||
const setupHorizontalScrollHint = () => {
|
||||
const hint = document.querySelector("[data-timeline-scroll-hint]");
|
||||
if (!hint) {
|
||||
const setupTimelineNavigationGuidance = () => {
|
||||
const card = root.querySelector("[data-timeline-navigation-card]");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dismissButton = hint.querySelector("[data-timeline-scroll-hint-dismiss]");
|
||||
const readSeen = () => {
|
||||
const dismissButton = card.querySelector("[data-timeline-navigation-dismiss]");
|
||||
const demoButton = card.querySelector("[data-timeline-navigation-demo]");
|
||||
const readDiscovered = () => {
|
||||
try {
|
||||
return localStorage.getItem(horizontalHintKey) === "true";
|
||||
return localStorage.getItem(navigationDiscoveryKey) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const markSeen = () => {
|
||||
hint.hidden = true;
|
||||
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;
|
||||
const knownLeft = new WeakMap();
|
||||
|
||||
const rememberPosition = (container) => knownLeft.set(container, container.scrollLeft);
|
||||
|
||||
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;
|
||||
card.hidden = !shouldShow;
|
||||
allScrollContainers().forEach(updateEdgeIndicators);
|
||||
};
|
||||
|
||||
const markDiscovered = () => {
|
||||
dismissedForPage = true;
|
||||
card.hidden = true;
|
||||
try {
|
||||
localStorage.setItem(horizontalHintKey, "true");
|
||||
localStorage.setItem(navigationDiscoveryKey, "true");
|
||||
} catch {
|
||||
// The hint can still disappear for this page view if storage is unavailable.
|
||||
// The card can still disappear for this page view if storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
if (readSeen()) {
|
||||
hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
hint.hidden = false;
|
||||
dismissButton?.addEventListener("click", markSeen);
|
||||
|
||||
let isArmed = false;
|
||||
window.setTimeout(() => {
|
||||
isArmed = true;
|
||||
containers.forEach(rememberPosition);
|
||||
}, 800);
|
||||
|
||||
const knownLeft = new WeakMap();
|
||||
const rememberPosition = (container) => knownLeft.set(container, container.scrollLeft);
|
||||
const detectHorizontalScroll = (container) => {
|
||||
if (!isArmed) {
|
||||
updateEdgeIndicators(container);
|
||||
if (!isArmed || isDemoing) {
|
||||
rememberPosition(container);
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = knownLeft.get(container) ?? container.scrollLeft;
|
||||
if (Math.abs(container.scrollLeft - previous) > 4) {
|
||||
markSeen();
|
||||
if (Math.abs(container.scrollLeft - previous) > 4 && hasHorizontalOverflow(container)) {
|
||||
markDiscovered();
|
||||
return;
|
||||
}
|
||||
|
||||
rememberPosition(container);
|
||||
};
|
||||
|
||||
const containers = [workspace, ...getScrollContainers()];
|
||||
const containers = allScrollContainers();
|
||||
containers.forEach((container) => {
|
||||
rememberPosition(container);
|
||||
container.addEventListener("wheel", (event) => {
|
||||
if (event.shiftKey && Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
|
||||
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;
|
||||
markSeen();
|
||||
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 = () => {
|
||||
@ -1324,7 +1397,7 @@
|
||||
window.addEventListener("resize", () => applyDrawerWidth(localStorage.getItem(drawerWidthKey)));
|
||||
}
|
||||
|
||||
setupHorizontalScrollHint();
|
||||
setupTimelineNavigationGuidance();
|
||||
|
||||
inspectorResizer?.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user