3 changed files with 102 additions and 134 deletions
@ -1,135 +1,98 @@ |
|||||||
import { Controller } from "@hotwired/stimulus"; |
import { Controller } from "@hotwired/stimulus"; |
||||||
|
|
||||||
/** |
|
||||||
* Server-driven comments via Mercure + Symfony UX LiveComponent. |
|
||||||
* |
|
||||||
* Usage in Twig (root element is the LiveComponent root): |
|
||||||
* <div {{ attributes }} |
|
||||||
* data-controller="comments-mercure" |
|
||||||
* data-comments-mercure-coordinate-value="{{ current }}" |
|
||||||
* data-comments-mercure-target="list" |
|
||||||
* data-comments-mercure-target="loading"> |
|
||||||
* ... |
|
||||||
* </div> |
|
||||||
*/ |
|
||||||
export default class extends Controller { |
export default class extends Controller { |
||||||
static values = { |
static values = { coordinate: String } |
||||||
coordinate: String, |
static targets = ["list", "loading"] |
||||||
}; |
|
||||||
|
|
||||||
static targets = ["list", "loading"]; |
|
||||||
|
|
||||||
connect() { |
connect() { |
||||||
console.log("[comments-mercure] Connecting to Mercure for comments at", this.coordinateValue); |
|
||||||
this._debounceId = null; |
this._debounceId = null; |
||||||
this._opened = false; |
this._liveRoot = this._findLiveRoot(); |
||||||
|
console.log(this._liveRoot); |
||||||
// Initial paint: ask the LiveComponent to render once (gets cached HTML immediately)
|
|
||||||
this._renderLiveComponent(); |
// If the live controller isn't ready yet, wait for it.
|
||||||
|
if (!this._getLiveController()) { |
||||||
// Subscribe to Mercure for live updates
|
this._onLiveConnect = () => { |
||||||
const topic = `/comments/${this.coordinateValue}`; |
// Once live connects, do an initial render to paint cached HTML
|
||||||
const hubUrl = |
this._renderLiveComponent(); |
||||||
window.MercureHubUrl || |
}; |
||||||
document.querySelector('meta[name="mercure-hub"]')?.content; |
this._liveRoot?.addEventListener('live:connect', this._onLiveConnect, { once: true }); |
||||||
|
} else { |
||||||
|
// Live controller already attached -> initial render now
|
||||||
|
this._renderLiveComponent(); |
||||||
|
} |
||||||
|
|
||||||
|
// Subscribe to Mercure updates
|
||||||
|
const hubUrl = window.MercureHubUrl || document.querySelector('meta[name="mercure-hub"]')?.content; |
||||||
if (!hubUrl) { |
if (!hubUrl) { |
||||||
console.warn( |
console.warn("[comments-mercure] Missing Mercure hub URL meta"); |
||||||
"[comments-mercure] Missing Mercure hub URL (meta[name=mercure-hub])" |
|
||||||
); |
|
||||||
this._hideLoading(); |
this._hideLoading(); |
||||||
return; |
return; |
||||||
} |
} |
||||||
|
|
||||||
const url = new URL(hubUrl); |
const topic = `/comments/${this.coordinateValue}`; |
||||||
|
const url = new URL(hubUrl); |
||||||
url.searchParams.append("topic", topic); |
url.searchParams.append("topic", topic); |
||||||
|
|
||||||
this.eventSource = new EventSource(url.toString()); |
this.eventSource = new EventSource(url.toString()); |
||||||
this.eventSource.onopen = () => { |
this.eventSource.onopen = () => this._debouncedRefresh(50); |
||||||
this._opened = true; |
this.eventSource.onerror = (e) => console.warn("[comments-mercure] EventSource error", e); |
||||||
// When the connection opens, do a quick refresh to capture anything new
|
this.eventSource.onmessage = () => this._debouncedRefresh(); |
||||||
this._debouncedRefresh(); |
|
||||||
}; |
|
||||||
this.eventSource.onerror = (e) => { |
|
||||||
console.warn("[comments-mercure] EventSource error", e); |
|
||||||
// Keep the UI usable even if Mercure hiccups
|
|
||||||
this._hideLoading(); |
|
||||||
}; |
|
||||||
this.eventSource.onmessage = (e) => { |
|
||||||
console.log('Mercure MSG', e.data); |
|
||||||
// We ignore the payload; Mercure is just a signal to re-render the live component
|
|
||||||
this._debouncedRefresh(); |
|
||||||
}; |
|
||||||
} |
} |
||||||
|
|
||||||
disconnect() { |
disconnect() { |
||||||
if (this.eventSource) { |
if (this.eventSource) { try { this.eventSource.close(); } catch {} } |
||||||
try { |
if (this._debounceId) { clearTimeout(this._debounceId); } |
||||||
this.eventSource.close(); |
if (this._liveRoot && this._onLiveConnect) { |
||||||
} catch {} |
this._liveRoot.removeEventListener('live:connect', this._onLiveConnect); |
||||||
} |
|
||||||
if (this._debounceId) { |
|
||||||
clearTimeout(this._debounceId); |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
// ---- private helpers -----------------------------------------------------
|
// ---- private helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
_findLiveRoot() { |
||||||
|
// Works for both modern ("live") and older namespaced identifiers
|
||||||
|
return this.element.closest( |
||||||
|
'[data-controller~="live"]' |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
_getLiveController() { |
||||||
|
if (!this._liveRoot) return null; |
||||||
|
// Try both identifiers
|
||||||
|
return ( |
||||||
|
this.application.getControllerForElementAndIdentifier(this._liveRoot, 'live') || |
||||||
|
this.application.getControllerForElementAndIdentifier(this._liveRoot, 'symfony--ux-live-component--live') |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
_debouncedRefresh(delay = 150) { |
_debouncedRefresh(delay = 150) { |
||||||
if (this._debounceId) clearTimeout(this._debounceId); |
if (this._debounceId) clearTimeout(this._debounceId); |
||||||
this._debounceId = setTimeout(() => { |
this._debounceId = setTimeout(() => this._renderLiveComponent(), delay); |
||||||
this._renderLiveComponent(); |
|
||||||
}, delay); |
|
||||||
} |
} |
||||||
|
|
||||||
_renderLiveComponent() { |
_renderLiveComponent() { |
||||||
// Show loading spinner (if present) only while we’re actually fetching
|
const live = this._getLiveController(); |
||||||
this._showLoading(); |
if (!live || typeof live.render !== 'function') { |
||||||
|
// Live not ready yet—try again very soon (and don't spam logs)
|
||||||
// The live component controller is bound to the same root element.
|
setTimeout(() => this._renderLiveComponent(), 50); |
||||||
const liveRoot = |
|
||||||
this.element.closest( |
|
||||||
'[data-controller~="symfony--ux-live-component--live"]' |
|
||||||
) || this.element; |
|
||||||
|
|
||||||
const liveController = |
|
||||||
this.application.getControllerForElementAndIdentifier( |
|
||||||
liveRoot, |
|
||||||
"symfony--ux-live-component--live" |
|
||||||
); |
|
||||||
|
|
||||||
if (!liveController || typeof liveController.render !== "function") { |
|
||||||
console.warn( |
|
||||||
"[comments-mercure] LiveComponent controller not found on element:", |
|
||||||
liveRoot |
|
||||||
); |
|
||||||
this._hideLoading(); |
|
||||||
return; |
return; |
||||||
} |
} |
||||||
|
|
||||||
// Ask server for the fresh HTML; morphdom will patch the DOM in place.
|
this._showLoading(); |
||||||
// render() returns a Promise (in recent UX versions). Handle both cases.
|
const p = live.render(); |
||||||
try { |
if (p && typeof p.finally === 'function') { |
||||||
const maybePromise = liveController.render(); |
p.finally(() => this._hideLoading()); |
||||||
if (maybePromise && typeof maybePromise.then === "function") { |
} else { |
||||||
maybePromise.finally(() => this._hideLoading()); |
setTimeout(() => this._hideLoading(), 0); |
||||||
} else { |
|
||||||
// Older versions might not return a promise—hide the spinner soon.
|
|
||||||
setTimeout(() => this._hideLoading(), 0); |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
console.error("[comments-mercure] live.render() failed", e); |
|
||||||
this._hideLoading(); |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
_showLoading() { |
_showLoading() { |
||||||
if (this.hasLoadingTarget) this.loadingTarget.style.display = ""; |
if (this.hasLoadingTarget) this.loadingTarget.style.display = ""; |
||||||
if (this.hasListTarget) this.listTarget.style.opacity = "0.6"; |
if (this.hasListTarget) this.listTarget.style.opacity = "0.6"; |
||||||
} |
} |
||||||
|
|
||||||
_hideLoading() { |
_hideLoading() { |
||||||
if (this.hasLoadingTarget) this.loadingTarget.style.display = "none"; |
if (this.hasLoadingTarget) this.loadingTarget.style.display = "none"; |
||||||
if (this.hasListTarget) this.listTarget.style.opacity = ""; |
if (this.hasListTarget) this.listTarget.style.opacity = ""; |
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
Loading…
Reference in new issue