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>
|
<option value="@locationScene.SceneID">Location: @locationScene.PrimaryLocationName</option>
|
||||||
}
|
}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@ -421,11 +421,6 @@
|
|||||||
</section>
|
</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>
|
<details class="timeline-drag-palette" data-drag-palette>
|
||||||
<summary>Drag Sources</summary>
|
<summary>Drag Sources</summary>
|
||||||
<div class="drag-chip-group">
|
<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>
|
<div class="workspace-layout timeline-filtered-root @(hasSelectedScene ? "inspector-drawer-open" : "inspector-drawer-closed")" data-timeline-root>
|
||||||
<main class="timeline-workspace">
|
<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">
|
<section class="timeline-shell workspace-timeline" data-timeline-section="scene-cards" style="--scene-count:@timelineColumns.Count">
|
||||||
@if (Model.Settings.ShowSceneCards)
|
@if (Model.Settings.ShowSceneCards)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2437,22 +2437,98 @@ body.timeline-inspector-resizing {
|
|||||||
background: rgba(47, 111, 99, 0.08);
|
background: rgba(47, 111, 99, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-scroll-hint {
|
.timeline-navigation-card {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 14px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
border: 1px solid rgba(47, 111, 99, 0.42);
|
||||||
gap: 12px;
|
border-left: 5px solid var(--plotline-accent);
|
||||||
border: 1px solid rgba(47, 111, 99, 0.22);
|
border-radius: 8px;
|
||||||
border-radius: 10px;
|
margin: 0 0 12px;
|
||||||
margin-bottom: 16px;
|
padding: 14px 16px;
|
||||||
padding: 10px 12px;
|
color: var(--plotline-ink);
|
||||||
background: rgba(47, 111, 99, 0.08);
|
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;
|
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.filter-dim,
|
||||||
.timeline-scene-card.focus-dim,
|
.timeline-scene-card.focus-dim,
|
||||||
.lane-dim {
|
.lane-dim {
|
||||||
@ -4041,6 +4117,18 @@ body.dragging-location [data-drag-type="location"] {
|
|||||||
flex-direction: column;
|
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-search,
|
||||||
.story-bible-section-heading {
|
.story-bible-section-heading {
|
||||||
align-items: stretch;
|
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 densityKey = "plotline.timeline.density";
|
||||||
const zoomKey = "plotline.timeline.zoom";
|
const zoomKey = "plotline.timeline.zoom";
|
||||||
const scrollKey = "plotline.timeline.pending-scroll";
|
const scrollKey = "plotline.timeline.pending-scroll";
|
||||||
const horizontalHintKey = "plotline.timeline.horizontalScrollHint.seen";
|
const navigationDiscoveryKey = "plotdirector.timelineNavigationDiscovered";
|
||||||
const drawerWidthKey = "plotline.timeline.inspectorWidth";
|
const drawerWidthKey = "plotline.timeline.inspectorWidth";
|
||||||
|
const horizontalOverflowTolerance = 8;
|
||||||
const scrollContainerSelector = [
|
const scrollContainerSelector = [
|
||||||
".timeline-shell",
|
".timeline-shell",
|
||||||
".plot-lane-board",
|
".plot-lane-board",
|
||||||
@ -1234,72 +1235,144 @@
|
|||||||
|
|
||||||
const getScrollContainers = () => [...root.querySelectorAll(scrollContainerSelector)];
|
const getScrollContainers = () => [...root.querySelectorAll(scrollContainerSelector)];
|
||||||
|
|
||||||
const setupHorizontalScrollHint = () => {
|
const setupTimelineNavigationGuidance = () => {
|
||||||
const hint = document.querySelector("[data-timeline-scroll-hint]");
|
const card = root.querySelector("[data-timeline-navigation-card]");
|
||||||
if (!hint) {
|
if (!card) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dismissButton = hint.querySelector("[data-timeline-scroll-hint-dismiss]");
|
const dismissButton = card.querySelector("[data-timeline-navigation-dismiss]");
|
||||||
const readSeen = () => {
|
const demoButton = card.querySelector("[data-timeline-navigation-demo]");
|
||||||
|
const readDiscovered = () => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(horizontalHintKey) === "true";
|
return localStorage.getItem(navigationDiscoveryKey) === "true";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const markSeen = () => {
|
const primaryScrollContainer = () => root.querySelector(".timeline-shell");
|
||||||
hint.hidden = true;
|
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 {
|
try {
|
||||||
localStorage.setItem(horizontalHintKey, "true");
|
localStorage.setItem(navigationDiscoveryKey, "true");
|
||||||
} catch {
|
} 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) => {
|
const detectHorizontalScroll = (container) => {
|
||||||
if (!isArmed) {
|
updateEdgeIndicators(container);
|
||||||
|
if (!isArmed || isDemoing) {
|
||||||
rememberPosition(container);
|
rememberPosition(container);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = knownLeft.get(container) ?? container.scrollLeft;
|
const previous = knownLeft.get(container) ?? container.scrollLeft;
|
||||||
if (Math.abs(container.scrollLeft - previous) > 4) {
|
if (Math.abs(container.scrollLeft - previous) > 4 && hasHorizontalOverflow(container)) {
|
||||||
markSeen();
|
markDiscovered();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
rememberPosition(container);
|
rememberPosition(container);
|
||||||
};
|
};
|
||||||
|
|
||||||
const containers = [workspace, ...getScrollContainers()];
|
const containers = allScrollContainers();
|
||||||
containers.forEach((container) => {
|
containers.forEach((container) => {
|
||||||
rememberPosition(container);
|
rememberPosition(container);
|
||||||
container.addEventListener("wheel", (event) => {
|
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();
|
event.preventDefault();
|
||||||
container.scrollLeft += event.deltaY;
|
container.scrollLeft += event.deltaY;
|
||||||
markSeen();
|
window.requestAnimationFrame(() => {
|
||||||
|
updateEdgeIndicators(container);
|
||||||
|
if (!isDemoing && Math.abs(container.scrollLeft - previous) > 4) {
|
||||||
|
markDiscovered();
|
||||||
|
}
|
||||||
|
rememberPosition(container);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
container.addEventListener("scroll", () => detectHorizontalScroll(container), { passive: true });
|
container.addEventListener("scroll", () => detectHorizontalScroll(container), { passive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dismissButton?.addEventListener("click", markDiscovered);
|
||||||
|
demoButton?.addEventListener("click", () => {
|
||||||
|
const container = primaryScrollContainer();
|
||||||
|
if (!hasHorizontalOverflow(container)) {
|
||||||
|
updateGuidanceState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLeft = container.scrollLeft;
|
||||||
|
const availableRight = maxScrollLeft(container) - originalLeft;
|
||||||
|
let distance = Math.min(220, availableRight);
|
||||||
|
let direction = 1;
|
||||||
|
if (distance < 24 && originalLeft > 0) {
|
||||||
|
distance = Math.min(220, originalLeft);
|
||||||
|
direction = -1;
|
||||||
|
}
|
||||||
|
if (distance < 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDemoing = true;
|
||||||
|
container.scrollTo({ left: originalLeft + (distance * direction), behavior: "smooth" });
|
||||||
|
window.setTimeout(() => {
|
||||||
|
container.scrollTo({ left: originalLeft, behavior: "smooth" });
|
||||||
|
}, 420);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
isDemoing = false;
|
||||||
|
allScrollContainers().forEach((scrollContainer) => {
|
||||||
|
rememberPosition(scrollContainer);
|
||||||
|
updateEdgeIndicators(scrollContainer);
|
||||||
|
});
|
||||||
|
updateGuidanceState();
|
||||||
|
}, 950);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
isArmed = true;
|
||||||
|
allScrollContainers().forEach(rememberPosition);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
window.requestAnimationFrame(updateGuidanceState);
|
||||||
|
window.addEventListener("resize", updateGuidanceState);
|
||||||
|
window.addEventListener("plotline:timeline-layout-changed", updateGuidanceState);
|
||||||
|
densitySelect?.addEventListener("change", () => window.requestAnimationFrame(updateGuidanceState));
|
||||||
|
zoomSelect?.addEventListener("change", () => window.requestAnimationFrame(updateGuidanceState));
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawerWidthBounds = () => {
|
const drawerWidthBounds = () => {
|
||||||
@ -1324,7 +1397,7 @@
|
|||||||
window.addEventListener("resize", () => applyDrawerWidth(localStorage.getItem(drawerWidthKey)));
|
window.addEventListener("resize", () => applyDrawerWidth(localStorage.getItem(drawerWidthKey)));
|
||||||
}
|
}
|
||||||
|
|
||||||
setupHorizontalScrollHint();
|
setupTimelineNavigationGuidance();
|
||||||
|
|
||||||
inspectorResizer?.addEventListener("pointerdown", (event) => {
|
inspectorResizer?.addEventListener("pointerdown", (event) => {
|
||||||
event.preventDefault();
|
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