@unabridged/midwest 0.23.3 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/assets/javascript/midwest/index.ts +4 -0
- package/app/assets/javascript/midwest.js +576 -8
- package/app/assets/javascript/midwest.js.map +1 -1
- package/app/assets/stylesheets/midwest/dark-mode.css +51 -1
- package/app/assets/stylesheets/midwest/form-group.css +5 -5
- package/app/assets/stylesheets/midwest/tailwind.css +101 -6
- package/app/assets/stylesheets/midwest/tokens.css +108 -0
- package/app/assets/stylesheets/midwest/utilities.css +20 -20
- package/app/assets/stylesheets/midwest.css +2 -2
- package/app/assets/stylesheets/midwest.tailwind.css +24 -1
- package/config/tailwind.theme.css +59 -0
- package/config/tailwind.theme.js +6 -2
- package/dist/css/midwest.css +2 -2
- package/dist/javascript/collection/app/assets/javascript/midwest/index.js +4 -0
- package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js +66 -8
- package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/panorama_component/panorama_component_controller.js +296 -0
- package/dist/javascript/collection/app/components/midwest/panorama_component/panorama_component_controller.js.map +1 -0
- package/dist/javascript/collection/app/components/midwest/playlist_component/playlist_component_controller.js +220 -0
- package/dist/javascript/collection/app/components/midwest/playlist_component/playlist_component_controller.js.map +1 -0
- package/dist/javascript/midwest.js +576 -8
- package/dist/javascript/midwest.js.map +1 -1
- package/package.json +6 -6
|
@@ -36,6 +36,8 @@ import IntersectionObserver from '../../../components/midwest/intersection_obser
|
|
|
36
36
|
import FocusTrap from '../../../components/midwest/focus_trap_component/focus_trap_component_controller'
|
|
37
37
|
import Onboarding from '../../../components/midwest/onboarding_component/onboarding_component_controller'
|
|
38
38
|
import ThemeSwitch from '../../../components/midwest/form/switch_component/theme_switch_controller'
|
|
39
|
+
import Panorama from '../../../components/midwest/panorama_component/panorama_component_controller'
|
|
40
|
+
import Playlist from '../../../components/midwest/playlist_component/playlist_component_controller'
|
|
39
41
|
/* IMPORTS */
|
|
40
42
|
|
|
41
43
|
export { Card, Banner, CountdownTimer, Chart }
|
|
@@ -79,5 +81,7 @@ export function registerMidwestControllers (application: any) {
|
|
|
79
81
|
application.register('midwest-focus-trap', FocusTrap)
|
|
80
82
|
application.register('midwest-onboarding', Onboarding)
|
|
81
83
|
application.register('midwest-theme-switch', ThemeSwitch)
|
|
84
|
+
application.register('midwest-panorama', Panorama)
|
|
85
|
+
application.register('midwest-playlist', Playlist)
|
|
82
86
|
/* EXPORTS */
|
|
83
87
|
}
|
|
@@ -5462,11 +5462,16 @@ class CommandPalette extends Controller {
|
|
|
5462
5462
|
commandPlaceholder = "";
|
|
5463
5463
|
messages = [];
|
|
5464
5464
|
streamAbort = null;
|
|
5465
|
+
// While true, new/streamed content pins the transcript to the bottom. Turns
|
|
5466
|
+
// false when the user scrolls up to read history so we don't yank them down.
|
|
5467
|
+
stickToBottom = true;
|
|
5465
5468
|
connect() {
|
|
5466
5469
|
this.allItems = this.itemsValue;
|
|
5467
5470
|
this.commandPlaceholder = this.inputTarget.placeholder;
|
|
5468
5471
|
document.addEventListener("keydown", this.handleGlobalKeydown);
|
|
5469
5472
|
this.element.addEventListener("click", this.handleBackdropClick);
|
|
5473
|
+
if (this.hasBodyTarget)
|
|
5474
|
+
this.bodyTarget.addEventListener("scroll", this.handleTranscriptScroll, { passive: true });
|
|
5470
5475
|
if (!this.turboFrameUrlValue) {
|
|
5471
5476
|
this.renderItems(this.allItems);
|
|
5472
5477
|
}
|
|
@@ -5474,6 +5479,8 @@ class CommandPalette extends Controller {
|
|
|
5474
5479
|
disconnect() {
|
|
5475
5480
|
document.removeEventListener("keydown", this.handleGlobalKeydown);
|
|
5476
5481
|
this.element.removeEventListener("click", this.handleBackdropClick);
|
|
5482
|
+
if (this.hasBodyTarget)
|
|
5483
|
+
this.bodyTarget.removeEventListener("scroll", this.handleTranscriptScroll);
|
|
5477
5484
|
if (this.fetchAbort)
|
|
5478
5485
|
this.fetchAbort.abort();
|
|
5479
5486
|
if (this.streamAbort)
|
|
@@ -5617,6 +5624,7 @@ class CommandPalette extends Controller {
|
|
|
5617
5624
|
this.transcriptTarget.innerHTML = "";
|
|
5618
5625
|
if (this.hasStartersTarget)
|
|
5619
5626
|
this.startersTarget.hidden = false;
|
|
5627
|
+
this.stickToBottom = true;
|
|
5620
5628
|
this.setBusy(false);
|
|
5621
5629
|
this.setMode("command");
|
|
5622
5630
|
}
|
|
@@ -5656,6 +5664,7 @@ class CommandPalette extends Controller {
|
|
|
5656
5664
|
const text = this.inputTarget.value.trim();
|
|
5657
5665
|
if (!text)
|
|
5658
5666
|
return;
|
|
5667
|
+
this.stickToBottom = true;
|
|
5659
5668
|
this.appendMessage("user", text);
|
|
5660
5669
|
this.messages.push({ role: "user", content: text });
|
|
5661
5670
|
this.inputTarget.value = "";
|
|
@@ -5727,16 +5736,31 @@ class CommandPalette extends Controller {
|
|
|
5727
5736
|
return null;
|
|
5728
5737
|
}
|
|
5729
5738
|
}
|
|
5730
|
-
// Agentic events: the server streams a tool "action row"
|
|
5731
|
-
// directive (navigate / run a palette command).
|
|
5732
|
-
// execution path as manual selection.
|
|
5739
|
+
// Agentic events: the server streams a tool "action row", a live preview
|
|
5740
|
+
// ("embed"), and/or a client directive (navigate / run a palette command).
|
|
5741
|
+
// Directives reuse the same execution path as manual selection.
|
|
5733
5742
|
handleAgentEvent(event) {
|
|
5734
5743
|
if (event.type === "tool") {
|
|
5735
5744
|
this.appendActionRow(event);
|
|
5745
|
+
} else if (event.type === "embed" && event.srcdoc) {
|
|
5746
|
+
this.appendEmbed(event);
|
|
5736
5747
|
} else if (event.type === "directive" && event.directive) {
|
|
5737
5748
|
this.performDirective(event.directive);
|
|
5749
|
+
} else if (event.type === "error") {
|
|
5750
|
+
this.renderStreamError(event.message);
|
|
5738
5751
|
}
|
|
5739
5752
|
}
|
|
5753
|
+
// A server-reported failure (e.g. provider billing/rate-limit error). Render
|
|
5754
|
+
// it into the current assistant bubble instead of failing silently.
|
|
5755
|
+
renderStreamError(message) {
|
|
5756
|
+
const last = this.currentAssistantMessage();
|
|
5757
|
+
if (!last)
|
|
5758
|
+
return;
|
|
5759
|
+
last.classList.add("is-error");
|
|
5760
|
+
const bubble = last.querySelector(".command-palette-message-bubble");
|
|
5761
|
+
if (bubble)
|
|
5762
|
+
bubble.textContent = message || "Something went wrong. Please try again.";
|
|
5763
|
+
}
|
|
5740
5764
|
// Action rows live in the current assistant message's card footer.
|
|
5741
5765
|
appendActionRow(event) {
|
|
5742
5766
|
if (!this.hasActionTemplateTarget)
|
|
@@ -5745,19 +5769,42 @@ class CommandPalette extends Controller {
|
|
|
5745
5769
|
if (!footer)
|
|
5746
5770
|
return;
|
|
5747
5771
|
const action = cloneTemplate(this.actionTemplateTarget);
|
|
5748
|
-
const label = action.querySelector(".
|
|
5772
|
+
const label = action.querySelector(".value");
|
|
5749
5773
|
if (label)
|
|
5750
5774
|
label.textContent = event.label || "Action";
|
|
5751
5775
|
footer.hidden = false;
|
|
5752
5776
|
footer.appendChild(action);
|
|
5753
5777
|
this.scrollTranscript();
|
|
5754
5778
|
}
|
|
5779
|
+
// A live preview streamed by an agentic tool (e.g. generate_iframe). The
|
|
5780
|
+
// server sends only the inner document; the client owns the <iframe> element
|
|
5781
|
+
// and hard-codes the sandbox (no allow-scripts) so injected markup is styled
|
|
5782
|
+
// but never executed — the markdown text path can't render HTML at all.
|
|
5783
|
+
appendEmbed(event) {
|
|
5784
|
+
const message = this.currentAssistantMessage();
|
|
5785
|
+
if (!message || !event.srcdoc)
|
|
5786
|
+
return;
|
|
5787
|
+
const body = message.querySelector(".command-palette-message-body") || message;
|
|
5788
|
+
const frame = document.createElement("iframe");
|
|
5789
|
+
frame.className = "command-palette-embed";
|
|
5790
|
+
frame.setAttribute("sandbox", "allow-same-origin");
|
|
5791
|
+
frame.setAttribute("loading", "lazy");
|
|
5792
|
+
frame.title = event.title || "Preview";
|
|
5793
|
+
frame.srcdoc = event.srcdoc;
|
|
5794
|
+
if (event.height)
|
|
5795
|
+
frame.style.height = `${event.height}px`;
|
|
5796
|
+
body.appendChild(frame);
|
|
5797
|
+
this.scrollTranscript();
|
|
5798
|
+
}
|
|
5755
5799
|
currentActionFooter() {
|
|
5800
|
+
const last = this.currentAssistantMessage();
|
|
5801
|
+
return last ? last.querySelector(".command-palette-action-footer") : null;
|
|
5802
|
+
}
|
|
5803
|
+
currentAssistantMessage() {
|
|
5756
5804
|
const messages = this.transcriptTarget.querySelectorAll(
|
|
5757
5805
|
".command-palette-message.role-assistant"
|
|
5758
5806
|
);
|
|
5759
|
-
|
|
5760
|
-
return last ? last.querySelector(".command-palette-action-footer") : null;
|
|
5807
|
+
return messages[messages.length - 1] || null;
|
|
5761
5808
|
}
|
|
5762
5809
|
performDirective(directive) {
|
|
5763
5810
|
if (directive.url) {
|
|
@@ -5797,10 +5844,21 @@ class CommandPalette extends Controller {
|
|
|
5797
5844
|
if (this.hasTranscriptTarget)
|
|
5798
5845
|
this.transcriptTarget.setAttribute("aria-busy", String(busy));
|
|
5799
5846
|
}
|
|
5847
|
+
// The body is the scrollable region; the transcript itself does not scroll.
|
|
5800
5848
|
scrollTranscript() {
|
|
5801
|
-
if (this.
|
|
5802
|
-
this.
|
|
5849
|
+
if (this.hasBodyTarget && this.stickToBottom) {
|
|
5850
|
+
this.bodyTarget.scrollTop = this.bodyTarget.scrollHeight;
|
|
5851
|
+
}
|
|
5803
5852
|
}
|
|
5853
|
+
// Track whether the user is anchored to the bottom. Once they scroll up to
|
|
5854
|
+
// read earlier messages we stop auto-scrolling; returning to the bottom
|
|
5855
|
+
// re-arms it so streamed text keeps the latest content on screen.
|
|
5856
|
+
handleTranscriptScroll = () => {
|
|
5857
|
+
if (!this.hasBodyTarget)
|
|
5858
|
+
return;
|
|
5859
|
+
const { scrollHeight, scrollTop, clientHeight } = this.bodyTarget;
|
|
5860
|
+
this.stickToBottom = scrollHeight - scrollTop - clientHeight < 24;
|
|
5861
|
+
};
|
|
5804
5862
|
appendMessage(role, text) {
|
|
5805
5863
|
const li = cloneTemplate(this.messageTemplateTarget);
|
|
5806
5864
|
li.classList.add(`role-${role}`);
|
|
@@ -7307,6 +7365,514 @@ class ThemeSwitch extends Controller {
|
|
|
7307
7365
|
}
|
|
7308
7366
|
}
|
|
7309
7367
|
|
|
7368
|
+
const FRICTION = 0.92;
|
|
7369
|
+
const MIN_VELOCITY = 0.05;
|
|
7370
|
+
const KEY_STEP_FRACTION = 0.12;
|
|
7371
|
+
class Panorama extends Controller {
|
|
7372
|
+
static targets = ["viewport", "stage", "media", "status", "hint", "reset"];
|
|
7373
|
+
static values = {
|
|
7374
|
+
wrap: { type: Boolean, default: false },
|
|
7375
|
+
autoRotate: { type: Number, default: 0 },
|
|
7376
|
+
start: { type: Number, default: 0 }
|
|
7377
|
+
};
|
|
7378
|
+
offset = 0;
|
|
7379
|
+
// current translateX, always <= 0
|
|
7380
|
+
mediaWidth = 0;
|
|
7381
|
+
// rendered width of a single media copy
|
|
7382
|
+
viewportWidth = 0;
|
|
7383
|
+
dragging = false;
|
|
7384
|
+
pointerId = null;
|
|
7385
|
+
lastX = 0;
|
|
7386
|
+
lastMoveTime = 0;
|
|
7387
|
+
velocity = 0;
|
|
7388
|
+
inertiaRaf = null;
|
|
7389
|
+
autoRaf = null;
|
|
7390
|
+
lastAutoTime = 0;
|
|
7391
|
+
clone = null;
|
|
7392
|
+
resizeObserver = null;
|
|
7393
|
+
interacted = false;
|
|
7394
|
+
connect() {
|
|
7395
|
+
this.viewportTarget.addEventListener("pointerdown", this.onPointerDown);
|
|
7396
|
+
this.element.addEventListener("keydown", this.onKeydown);
|
|
7397
|
+
this.element.addEventListener("pointerenter", this.pauseAuto);
|
|
7398
|
+
this.element.addEventListener("pointerleave", this.resumeAuto);
|
|
7399
|
+
this.element.addEventListener("focusin", this.pauseAuto);
|
|
7400
|
+
this.element.addEventListener("focusout", this.resumeAuto);
|
|
7401
|
+
this.resizeObserver = new ResizeObserver(() => this.measure());
|
|
7402
|
+
this.resizeObserver.observe(this.viewportTarget);
|
|
7403
|
+
this.whenMediaReady(() => {
|
|
7404
|
+
this.setupWrap();
|
|
7405
|
+
this.measure();
|
|
7406
|
+
this.offset = this.startOffset();
|
|
7407
|
+
this.apply();
|
|
7408
|
+
this.startAuto();
|
|
7409
|
+
});
|
|
7410
|
+
}
|
|
7411
|
+
disconnect() {
|
|
7412
|
+
this.viewportTarget.removeEventListener("pointerdown", this.onPointerDown);
|
|
7413
|
+
window.removeEventListener("pointermove", this.onPointerMove);
|
|
7414
|
+
window.removeEventListener("pointerup", this.onPointerUp);
|
|
7415
|
+
window.removeEventListener("pointercancel", this.onPointerUp);
|
|
7416
|
+
this.element.removeEventListener("keydown", this.onKeydown);
|
|
7417
|
+
this.element.removeEventListener("pointerenter", this.pauseAuto);
|
|
7418
|
+
this.element.removeEventListener("pointerleave", this.resumeAuto);
|
|
7419
|
+
this.element.removeEventListener("focusin", this.pauseAuto);
|
|
7420
|
+
this.element.removeEventListener("focusout", this.resumeAuto);
|
|
7421
|
+
this.resizeObserver?.disconnect();
|
|
7422
|
+
this.stopInertia();
|
|
7423
|
+
this.stopAuto();
|
|
7424
|
+
}
|
|
7425
|
+
// Recenters the view to its starting position (wired to the reset button).
|
|
7426
|
+
reset() {
|
|
7427
|
+
this.stopInertia();
|
|
7428
|
+
this.markInteracted();
|
|
7429
|
+
this.offset = this.startOffset();
|
|
7430
|
+
this.apply();
|
|
7431
|
+
this.announce("View reset");
|
|
7432
|
+
}
|
|
7433
|
+
// --- media readiness & measurement -----------------------------------------
|
|
7434
|
+
whenMediaReady(cb) {
|
|
7435
|
+
const media = this.mediaTarget;
|
|
7436
|
+
if (media instanceof HTMLImageElement) {
|
|
7437
|
+
if (media.complete && media.naturalWidth > 0) {
|
|
7438
|
+
cb();
|
|
7439
|
+
return;
|
|
7440
|
+
}
|
|
7441
|
+
media.addEventListener("load", cb, { once: true });
|
|
7442
|
+
media.addEventListener("error", cb, { once: true });
|
|
7443
|
+
} else {
|
|
7444
|
+
if (media.readyState >= 1 && media.videoWidth > 0) {
|
|
7445
|
+
cb();
|
|
7446
|
+
return;
|
|
7447
|
+
}
|
|
7448
|
+
media.addEventListener("loadedmetadata", cb, { once: true });
|
|
7449
|
+
}
|
|
7450
|
+
}
|
|
7451
|
+
measure() {
|
|
7452
|
+
this.viewportWidth = this.viewportTarget.clientWidth;
|
|
7453
|
+
const first = this.stageTarget.querySelector(".midwest-panorama-media");
|
|
7454
|
+
this.mediaWidth = first?.getBoundingClientRect().width ?? 0;
|
|
7455
|
+
this.offset = this.normalize(this.offset);
|
|
7456
|
+
this.apply();
|
|
7457
|
+
}
|
|
7458
|
+
// Duplicates the image once so the stage can loop seamlessly. Only images
|
|
7459
|
+
// wrap — a live <video> can't be cheaply cloned/kept in sync.
|
|
7460
|
+
setupWrap() {
|
|
7461
|
+
if (!this.wrapValue || this.clone)
|
|
7462
|
+
return;
|
|
7463
|
+
if (!(this.mediaTarget instanceof HTMLImageElement))
|
|
7464
|
+
return;
|
|
7465
|
+
const clone = this.mediaTarget.cloneNode(true);
|
|
7466
|
+
clone.setAttribute("aria-hidden", "true");
|
|
7467
|
+
clone.setAttribute("alt", "");
|
|
7468
|
+
clone.removeAttribute("data-midwest-panorama-target");
|
|
7469
|
+
this.stageTarget.appendChild(clone);
|
|
7470
|
+
this.clone = clone;
|
|
7471
|
+
}
|
|
7472
|
+
// --- geometry --------------------------------------------------------------
|
|
7473
|
+
canWrap() {
|
|
7474
|
+
return this.wrapValue && this.clone !== null && this.mediaWidth > 0;
|
|
7475
|
+
}
|
|
7476
|
+
// Constrains an offset: modulo for seamless wrap, hard clamp otherwise.
|
|
7477
|
+
normalize(x) {
|
|
7478
|
+
const w = this.mediaWidth;
|
|
7479
|
+
if (this.canWrap()) {
|
|
7480
|
+
if (w <= 0)
|
|
7481
|
+
return 0;
|
|
7482
|
+
let n = x % w;
|
|
7483
|
+
if (n > 0)
|
|
7484
|
+
n -= w;
|
|
7485
|
+
return n;
|
|
7486
|
+
}
|
|
7487
|
+
const min = Math.min(0, this.viewportWidth - w);
|
|
7488
|
+
return Math.max(min, Math.min(0, x));
|
|
7489
|
+
}
|
|
7490
|
+
startOffset() {
|
|
7491
|
+
if (this.canWrap())
|
|
7492
|
+
return this.normalize(-this.startValue * this.mediaWidth);
|
|
7493
|
+
const min = Math.min(0, this.viewportWidth - this.mediaWidth);
|
|
7494
|
+
return min * this.startValue;
|
|
7495
|
+
}
|
|
7496
|
+
apply() {
|
|
7497
|
+
this.stageTarget.style.transform = `translate3d(${this.offset}px, 0, 0)`;
|
|
7498
|
+
}
|
|
7499
|
+
// --- pointer dragging ------------------------------------------------------
|
|
7500
|
+
onPointerDown = (event) => {
|
|
7501
|
+
if (event.button !== 0)
|
|
7502
|
+
return;
|
|
7503
|
+
this.dragging = true;
|
|
7504
|
+
this.pointerId = event.pointerId;
|
|
7505
|
+
this.lastX = event.clientX;
|
|
7506
|
+
this.lastMoveTime = performance.now();
|
|
7507
|
+
this.velocity = 0;
|
|
7508
|
+
this.stopInertia();
|
|
7509
|
+
this.pauseAuto();
|
|
7510
|
+
this.markInteracted();
|
|
7511
|
+
this.element.classList.add("is-dragging");
|
|
7512
|
+
this.viewportTarget.setPointerCapture(event.pointerId);
|
|
7513
|
+
window.addEventListener("pointermove", this.onPointerMove);
|
|
7514
|
+
window.addEventListener("pointerup", this.onPointerUp);
|
|
7515
|
+
window.addEventListener("pointercancel", this.onPointerUp);
|
|
7516
|
+
};
|
|
7517
|
+
onPointerMove = (event) => {
|
|
7518
|
+
if (!this.dragging)
|
|
7519
|
+
return;
|
|
7520
|
+
const dx = event.clientX - this.lastX;
|
|
7521
|
+
const now = performance.now();
|
|
7522
|
+
const dt = now - this.lastMoveTime;
|
|
7523
|
+
if (dt > 0)
|
|
7524
|
+
this.velocity = dx / dt * 16;
|
|
7525
|
+
this.lastX = event.clientX;
|
|
7526
|
+
this.lastMoveTime = now;
|
|
7527
|
+
this.offset = this.normalize(this.offset + dx);
|
|
7528
|
+
this.apply();
|
|
7529
|
+
};
|
|
7530
|
+
onPointerUp = () => {
|
|
7531
|
+
if (!this.dragging)
|
|
7532
|
+
return;
|
|
7533
|
+
this.dragging = false;
|
|
7534
|
+
this.element.classList.remove("is-dragging");
|
|
7535
|
+
if (this.pointerId !== null) {
|
|
7536
|
+
try {
|
|
7537
|
+
this.viewportTarget.releasePointerCapture(this.pointerId);
|
|
7538
|
+
} catch {
|
|
7539
|
+
}
|
|
7540
|
+
}
|
|
7541
|
+
this.pointerId = null;
|
|
7542
|
+
window.removeEventListener("pointermove", this.onPointerMove);
|
|
7543
|
+
window.removeEventListener("pointerup", this.onPointerUp);
|
|
7544
|
+
window.removeEventListener("pointercancel", this.onPointerUp);
|
|
7545
|
+
this.startInertia();
|
|
7546
|
+
};
|
|
7547
|
+
startInertia() {
|
|
7548
|
+
if (Math.abs(this.velocity) < MIN_VELOCITY) {
|
|
7549
|
+
this.resumeAuto();
|
|
7550
|
+
return;
|
|
7551
|
+
}
|
|
7552
|
+
const step = () => {
|
|
7553
|
+
this.offset = this.normalize(this.offset + this.velocity);
|
|
7554
|
+
this.apply();
|
|
7555
|
+
this.velocity *= FRICTION;
|
|
7556
|
+
if (!this.canWrap()) {
|
|
7557
|
+
const min = Math.min(0, this.viewportWidth - this.mediaWidth);
|
|
7558
|
+
if (this.offset <= min || this.offset >= 0)
|
|
7559
|
+
this.velocity = 0;
|
|
7560
|
+
}
|
|
7561
|
+
if (Math.abs(this.velocity) < MIN_VELOCITY) {
|
|
7562
|
+
this.inertiaRaf = null;
|
|
7563
|
+
this.resumeAuto();
|
|
7564
|
+
return;
|
|
7565
|
+
}
|
|
7566
|
+
this.inertiaRaf = requestAnimationFrame(step);
|
|
7567
|
+
};
|
|
7568
|
+
this.inertiaRaf = requestAnimationFrame(step);
|
|
7569
|
+
}
|
|
7570
|
+
stopInertia() {
|
|
7571
|
+
if (this.inertiaRaf !== null) {
|
|
7572
|
+
cancelAnimationFrame(this.inertiaRaf);
|
|
7573
|
+
this.inertiaRaf = null;
|
|
7574
|
+
}
|
|
7575
|
+
}
|
|
7576
|
+
// --- keyboard --------------------------------------------------------------
|
|
7577
|
+
onKeydown = (event) => {
|
|
7578
|
+
const step = this.viewportWidth * KEY_STEP_FRACTION;
|
|
7579
|
+
let handled = true;
|
|
7580
|
+
switch (event.key) {
|
|
7581
|
+
case "ArrowLeft":
|
|
7582
|
+
this.offset = this.normalize(this.offset + step);
|
|
7583
|
+
break;
|
|
7584
|
+
case "ArrowRight":
|
|
7585
|
+
this.offset = this.normalize(this.offset - step);
|
|
7586
|
+
break;
|
|
7587
|
+
case "Home":
|
|
7588
|
+
this.offset = this.startOffset();
|
|
7589
|
+
break;
|
|
7590
|
+
default:
|
|
7591
|
+
handled = false;
|
|
7592
|
+
}
|
|
7593
|
+
if (!handled)
|
|
7594
|
+
return;
|
|
7595
|
+
event.preventDefault();
|
|
7596
|
+
this.stopInertia();
|
|
7597
|
+
this.pauseAuto();
|
|
7598
|
+
this.markInteracted();
|
|
7599
|
+
this.apply();
|
|
7600
|
+
this.announce();
|
|
7601
|
+
};
|
|
7602
|
+
// --- auto-rotate -----------------------------------------------------------
|
|
7603
|
+
startAuto() {
|
|
7604
|
+
if (this.autoRotateValue <= 0 || this.autoRaf !== null || this.dragging)
|
|
7605
|
+
return;
|
|
7606
|
+
if (this.prefersReducedMotion())
|
|
7607
|
+
return;
|
|
7608
|
+
this.lastAutoTime = performance.now();
|
|
7609
|
+
const step = (now) => {
|
|
7610
|
+
const dt = (now - this.lastAutoTime) / 1e3;
|
|
7611
|
+
this.lastAutoTime = now;
|
|
7612
|
+
this.offset = this.normalize(this.offset - this.autoRotateValue * dt);
|
|
7613
|
+
this.apply();
|
|
7614
|
+
this.autoRaf = requestAnimationFrame(step);
|
|
7615
|
+
};
|
|
7616
|
+
this.autoRaf = requestAnimationFrame(step);
|
|
7617
|
+
}
|
|
7618
|
+
stopAuto() {
|
|
7619
|
+
if (this.autoRaf !== null) {
|
|
7620
|
+
cancelAnimationFrame(this.autoRaf);
|
|
7621
|
+
this.autoRaf = null;
|
|
7622
|
+
}
|
|
7623
|
+
}
|
|
7624
|
+
pauseAuto = () => {
|
|
7625
|
+
this.stopAuto();
|
|
7626
|
+
};
|
|
7627
|
+
resumeAuto = () => {
|
|
7628
|
+
if (this.dragging)
|
|
7629
|
+
return;
|
|
7630
|
+
this.startAuto();
|
|
7631
|
+
};
|
|
7632
|
+
// --- helpers ---------------------------------------------------------------
|
|
7633
|
+
markInteracted() {
|
|
7634
|
+
if (this.interacted)
|
|
7635
|
+
return;
|
|
7636
|
+
this.interacted = true;
|
|
7637
|
+
this.element.classList.add("has-interacted");
|
|
7638
|
+
}
|
|
7639
|
+
announce(message) {
|
|
7640
|
+
if (!this.hasStatusTarget)
|
|
7641
|
+
return;
|
|
7642
|
+
this.statusTarget.textContent = message ?? `Viewing ${this.percent()}%`;
|
|
7643
|
+
}
|
|
7644
|
+
percent() {
|
|
7645
|
+
const w = this.mediaWidth;
|
|
7646
|
+
if (w <= 0)
|
|
7647
|
+
return 0;
|
|
7648
|
+
if (this.canWrap()) {
|
|
7649
|
+
const n = (-this.offset % w + w) % w;
|
|
7650
|
+
return Math.round(n / w * 100);
|
|
7651
|
+
}
|
|
7652
|
+
const min = Math.min(0, this.viewportWidth - w);
|
|
7653
|
+
return min === 0 ? 0 : Math.round(this.offset / min * 100);
|
|
7654
|
+
}
|
|
7655
|
+
prefersReducedMotion() {
|
|
7656
|
+
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
7657
|
+
}
|
|
7658
|
+
}
|
|
7659
|
+
|
|
7660
|
+
function formatTime(seconds) {
|
|
7661
|
+
const mins = Math.floor(seconds / 60);
|
|
7662
|
+
const secs = Math.floor(seconds % 60);
|
|
7663
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
7664
|
+
}
|
|
7665
|
+
class Playlist extends Controller {
|
|
7666
|
+
static targets = [
|
|
7667
|
+
"audio",
|
|
7668
|
+
"audioSource",
|
|
7669
|
+
"playIcon",
|
|
7670
|
+
"pauseIcon",
|
|
7671
|
+
"trackTitle",
|
|
7672
|
+
"trackArtist",
|
|
7673
|
+
"artworkContainer",
|
|
7674
|
+
"placeholderIcon",
|
|
7675
|
+
"progressBar",
|
|
7676
|
+
"currentTime",
|
|
7677
|
+
"totalTime",
|
|
7678
|
+
"songList",
|
|
7679
|
+
"details"
|
|
7680
|
+
];
|
|
7681
|
+
static values = {
|
|
7682
|
+
playlistName: { type: String, default: "Playlist" },
|
|
7683
|
+
autoplay: { type: Boolean, default: false },
|
|
7684
|
+
view: { type: String, default: "playlist" },
|
|
7685
|
+
playlistVisible: { type: Boolean, default: true },
|
|
7686
|
+
artwork: { type: Boolean, default: false }
|
|
7687
|
+
};
|
|
7688
|
+
currentIndex = 0;
|
|
7689
|
+
songElements = [];
|
|
7690
|
+
connect() {
|
|
7691
|
+
this.songElements = Array.from(this.songListTarget.querySelectorAll(".midwest-song button"));
|
|
7692
|
+
this.updatePlaylistVisibility();
|
|
7693
|
+
this.audioTarget.addEventListener("timeupdate", this.handleTimeUpdate);
|
|
7694
|
+
this.audioTarget.addEventListener("loadedmetadata", this.handleMetadataLoaded);
|
|
7695
|
+
this.audioTarget.addEventListener("ended", this.handleEnded);
|
|
7696
|
+
this.audioTarget.addEventListener("play", this.handlePlayEvent);
|
|
7697
|
+
this.audioTarget.addEventListener("pause", this.handlePauseEvent);
|
|
7698
|
+
this.element.addEventListener("keydown", this.handleKeydown);
|
|
7699
|
+
if (this.autoplayValue && this.songElements.length > 0) {
|
|
7700
|
+
this.loadSong(0);
|
|
7701
|
+
}
|
|
7702
|
+
}
|
|
7703
|
+
disconnect() {
|
|
7704
|
+
this.audioTarget.removeEventListener("timeupdate", this.handleTimeUpdate);
|
|
7705
|
+
this.audioTarget.removeEventListener("loadedmetadata", this.handleMetadataLoaded);
|
|
7706
|
+
this.audioTarget.removeEventListener("ended", this.handleEnded);
|
|
7707
|
+
this.audioTarget.removeEventListener("play", this.handlePlayEvent);
|
|
7708
|
+
this.audioTarget.removeEventListener("pause", this.handlePauseEvent);
|
|
7709
|
+
this.element.removeEventListener("keydown", this.handleKeydown);
|
|
7710
|
+
}
|
|
7711
|
+
// Play/pause toggle
|
|
7712
|
+
togglePlay() {
|
|
7713
|
+
if (this.audioTarget.paused) {
|
|
7714
|
+
if (this.audioSourceTarget.src) {
|
|
7715
|
+
this.audioTarget.play();
|
|
7716
|
+
} else if (this.songElements.length > 0) {
|
|
7717
|
+
this.loadSong(0);
|
|
7718
|
+
}
|
|
7719
|
+
} else {
|
|
7720
|
+
this.audioTarget.pause();
|
|
7721
|
+
}
|
|
7722
|
+
}
|
|
7723
|
+
// Play the song whose button was clicked (wired from the song template)
|
|
7724
|
+
playSong(event) {
|
|
7725
|
+
const button = event.currentTarget;
|
|
7726
|
+
const index = this.songElements.indexOf(button);
|
|
7727
|
+
if (index === -1)
|
|
7728
|
+
return;
|
|
7729
|
+
if (index === this.currentIndex && this.audioSourceTarget.src) {
|
|
7730
|
+
this.togglePlay();
|
|
7731
|
+
return;
|
|
7732
|
+
}
|
|
7733
|
+
this.loadSong(index);
|
|
7734
|
+
}
|
|
7735
|
+
// Load and play a specific song by index
|
|
7736
|
+
loadSong(index) {
|
|
7737
|
+
const songButton = this.songElements[index];
|
|
7738
|
+
if (!songButton)
|
|
7739
|
+
return;
|
|
7740
|
+
const songElement = songButton.closest(".midwest-song");
|
|
7741
|
+
if (!songElement)
|
|
7742
|
+
return;
|
|
7743
|
+
const src = songElement.dataset.src;
|
|
7744
|
+
const title = songElement.dataset.title || "Unknown";
|
|
7745
|
+
const artist = songElement.dataset.artist || "Unknown";
|
|
7746
|
+
const picture = songElement.dataset.picture;
|
|
7747
|
+
if (!src)
|
|
7748
|
+
return;
|
|
7749
|
+
this.audioSourceTarget.src = src;
|
|
7750
|
+
this.audioTarget.load();
|
|
7751
|
+
this.trackTitleTarget.textContent = title;
|
|
7752
|
+
this.trackArtistTarget.textContent = artist;
|
|
7753
|
+
if (picture) {
|
|
7754
|
+
const img = document.createElement("img");
|
|
7755
|
+
img.src = picture;
|
|
7756
|
+
img.alt = `Album art for ${title}`;
|
|
7757
|
+
img.className = "midwest-playlist__artwork-image";
|
|
7758
|
+
const existingImg = this.artworkContainerTarget.querySelector(".midwest-playlist__artwork-image");
|
|
7759
|
+
if (existingImg) {
|
|
7760
|
+
existingImg.replaceWith(img);
|
|
7761
|
+
} else {
|
|
7762
|
+
this.artworkContainerTarget.appendChild(img);
|
|
7763
|
+
}
|
|
7764
|
+
this.placeholderIconTarget.classList.add("hidden");
|
|
7765
|
+
} else {
|
|
7766
|
+
this.placeholderIconTarget.classList.remove("hidden");
|
|
7767
|
+
}
|
|
7768
|
+
this.songElements.forEach((btn, i) => {
|
|
7769
|
+
const song = btn.closest(".midwest-song");
|
|
7770
|
+
if (i === index) {
|
|
7771
|
+
song.classList.add("is-playing");
|
|
7772
|
+
} else {
|
|
7773
|
+
song.classList.remove("is-playing");
|
|
7774
|
+
}
|
|
7775
|
+
});
|
|
7776
|
+
this.currentIndex = index;
|
|
7777
|
+
this.audioTarget.play().catch(() => {
|
|
7778
|
+
});
|
|
7779
|
+
}
|
|
7780
|
+
// Play next song
|
|
7781
|
+
next() {
|
|
7782
|
+
const nextIndex = (this.currentIndex + 1) % this.songElements.length;
|
|
7783
|
+
this.loadSong(nextIndex);
|
|
7784
|
+
}
|
|
7785
|
+
// Play previous song
|
|
7786
|
+
previous() {
|
|
7787
|
+
const prevIndex = this.currentIndex === 0 ? this.songElements.length - 1 : this.currentIndex - 1;
|
|
7788
|
+
this.loadSong(prevIndex);
|
|
7789
|
+
}
|
|
7790
|
+
// Seek to position in track
|
|
7791
|
+
seek(event) {
|
|
7792
|
+
const rect = this.progressBarTarget.getBoundingClientRect();
|
|
7793
|
+
const x = event.clientX - rect.left;
|
|
7794
|
+
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
|
7795
|
+
if (this.audioTarget.duration) {
|
|
7796
|
+
this.audioTarget.currentTime = percentage * this.audioTarget.duration;
|
|
7797
|
+
}
|
|
7798
|
+
}
|
|
7799
|
+
// Toggle playlist visibility
|
|
7800
|
+
togglePlaylist() {
|
|
7801
|
+
this.playlistVisibleValue = !this.playlistVisibleValue;
|
|
7802
|
+
this.updatePlaylistVisibility();
|
|
7803
|
+
this.savePlaylistVisibility();
|
|
7804
|
+
}
|
|
7805
|
+
// Toggle between playlist and art view
|
|
7806
|
+
toggleView() {
|
|
7807
|
+
this.viewValue = this.viewValue === "playlist" ? "art" : "playlist";
|
|
7808
|
+
this.updateView();
|
|
7809
|
+
}
|
|
7810
|
+
// Private: Handle time update for progress bar
|
|
7811
|
+
handleTimeUpdate = () => {
|
|
7812
|
+
const { currentTime, duration } = this.audioTarget;
|
|
7813
|
+
if (duration) {
|
|
7814
|
+
const percentage = currentTime / duration * 100;
|
|
7815
|
+
this.progressBarTarget.value = percentage;
|
|
7816
|
+
this.currentTimeTarget.textContent = formatTime(currentTime);
|
|
7817
|
+
this.totalTimeTarget.textContent = formatTime(duration);
|
|
7818
|
+
}
|
|
7819
|
+
};
|
|
7820
|
+
// Private: Handle metadata loaded
|
|
7821
|
+
handleMetadataLoaded = () => {
|
|
7822
|
+
const { duration } = this.audioTarget;
|
|
7823
|
+
if (duration) {
|
|
7824
|
+
this.totalTimeTarget.textContent = formatTime(duration);
|
|
7825
|
+
}
|
|
7826
|
+
};
|
|
7827
|
+
// Private: advance to the next track when the current one finishes
|
|
7828
|
+
handleEnded = () => {
|
|
7829
|
+
this.next();
|
|
7830
|
+
};
|
|
7831
|
+
// Private: reflect the <audio> playing state in the UI
|
|
7832
|
+
handlePlayEvent = () => {
|
|
7833
|
+
this.playIconTarget.hidden = true;
|
|
7834
|
+
this.pauseIconTarget.hidden = false;
|
|
7835
|
+
this.element.classList.add("is-playing");
|
|
7836
|
+
};
|
|
7837
|
+
handlePauseEvent = () => {
|
|
7838
|
+
this.playIconTarget.hidden = false;
|
|
7839
|
+
this.pauseIconTarget.hidden = true;
|
|
7840
|
+
this.element.classList.remove("is-playing");
|
|
7841
|
+
};
|
|
7842
|
+
// Private: Handle keyboard controls
|
|
7843
|
+
handleKeydown = (event) => {
|
|
7844
|
+
if (event.key === " " || event.key === "k") {
|
|
7845
|
+
event.preventDefault();
|
|
7846
|
+
this.togglePlay();
|
|
7847
|
+
} else if (event.key === "ArrowLeft") {
|
|
7848
|
+
event.preventDefault();
|
|
7849
|
+
this.previous();
|
|
7850
|
+
} else if (event.key === "ArrowRight") {
|
|
7851
|
+
event.preventDefault();
|
|
7852
|
+
this.next();
|
|
7853
|
+
}
|
|
7854
|
+
};
|
|
7855
|
+
// Private: Update playlist visibility
|
|
7856
|
+
updatePlaylistVisibility() {
|
|
7857
|
+
if (this.playlistVisibleValue) {
|
|
7858
|
+
this.songListTarget.classList.remove("is-hidden");
|
|
7859
|
+
} else {
|
|
7860
|
+
this.songListTarget.classList.add("is-hidden");
|
|
7861
|
+
}
|
|
7862
|
+
}
|
|
7863
|
+
// Private: Update view mode
|
|
7864
|
+
updateView() {
|
|
7865
|
+
this.element.setAttribute("view", this.viewValue);
|
|
7866
|
+
}
|
|
7867
|
+
// Private: Save playlist visibility to localStorage
|
|
7868
|
+
savePlaylistVisibility() {
|
|
7869
|
+
try {
|
|
7870
|
+
localStorage.setItem(`playlist-${this.playlistNameValue}-visibility`, this.playlistVisibleValue.toString());
|
|
7871
|
+
} catch {
|
|
7872
|
+
}
|
|
7873
|
+
}
|
|
7874
|
+
}
|
|
7875
|
+
|
|
7310
7876
|
function registerMidwestControllers(application) {
|
|
7311
7877
|
application.register("midwest-card", Card);
|
|
7312
7878
|
application.register("midwest-banner", Banner);
|
|
@@ -7346,6 +7912,8 @@ function registerMidwestControllers(application) {
|
|
|
7346
7912
|
application.register("midwest-focus-trap", FocusTrap);
|
|
7347
7913
|
application.register("midwest-onboarding", Onboarding);
|
|
7348
7914
|
application.register("midwest-theme-switch", ThemeSwitch);
|
|
7915
|
+
application.register("midwest-panorama", Panorama);
|
|
7916
|
+
application.register("midwest-playlist", Playlist);
|
|
7349
7917
|
}
|
|
7350
7918
|
|
|
7351
7919
|
export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };
|