model-explorer / js /core /shareableURL.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* ShareableURL - Manages URL hash state for model sharing
* Encodes/decodes model ID in URL hash so users can share direct links
* to specific models from models.json.
*
* Format: #model=<modelId> (e.g. #model=ppe)
*
* Requirements: 25.1, 25.2, 25.3, 25.4, 25.5
*/
class ShareableURL {
constructor() {
/** @type {Array<{id: string}>|null} Cached model list for validation */
this._modelList = null;
/** @type {Function|null} Unsubscribe from EventBus */
this._unsubModelSelected = null;
/** @type {Function|null} Unsubscribe from hashchange */
this._boundHashChange = null;
}
// ─── Public API ───────────────────────────────────────────────────────────
/**
* Initialise the component. Call once after the model list is available.
* @param {Array<{id: string}>} modelList - The models from models.json
*/
init(modelList) {
this._modelList = Array.isArray(modelList) ? modelList : [];
// Listen for model:selected events to update the hash (Req 25.1)
if (window.EventBus) {
this._unsubModelSelected = window.EventBus.on(
CONFIG.EVENTS.MODEL_SELECTED,
(model) => this._onModelSelected(model)
);
}
// Listen for hashchange so the browser back/forward buttons work
this._boundHashChange = () => this._onHashChange();
window.addEventListener('hashchange', this._boundHashChange);
}
/**
* Read the current URL hash. If it contains a valid model ID, return
* the matching model object so the caller can auto-select it.
* If the ID is invalid, show an error and return null. (Req 25.2, 25.4)
*
* @returns {{ model: object|null, error: string|null }}
*/
readHash() {
const modelId = this._parseHash();
if (!modelId) {
return { model: null, error: null };
}
const model = this._findModelById(modelId);
if (!model) {
const errorMsg = `Model "${modelId}" not found. The shared link may be outdated or invalid.`;
console.warn('[ShareableURL]', errorMsg);
// Clear the invalid hash
this._clearHash();
return { model: null, error: errorMsg };
}
return { model, error: null };
}
/**
* Set the URL hash to point to the given model ID. (Req 25.1)
* Only applies to models from models.json (Req 25.5).
* @param {string} modelId
*/
setModel(modelId) {
if (!modelId || !this._isListModel(modelId)) {
return;
}
window.history.replaceState(null, '', '#model=' + encodeURIComponent(modelId));
}
/**
* Read the model ID from the current URL hash.
* @returns {string|null}
*/
getModel() {
return this._parseHash();
}
/**
* Copy the current page URL (including hash) to the clipboard. (Req 25.3)
* @returns {Promise<boolean>} true if copy succeeded
*/
async copyToClipboard() {
try {
await navigator.clipboard.writeText(window.location.href);
return true;
} catch (err) {
// Fallback for older browsers / insecure contexts
return this._fallbackCopy(window.location.href);
}
}
/**
* Clean up event listeners.
*/
destroy() {
if (this._unsubModelSelected) {
this._unsubModelSelected();
this._unsubModelSelected = null;
}
if (this._boundHashChange) {
window.removeEventListener('hashchange', this._boundHashChange);
this._boundHashChange = null;
}
}
// ─── Private ──────────────────────────────────────────────────────────────
/**
* Parse the URL hash and return the model ID, or null.
* @returns {string|null}
*/
_parseHash() {
const hash = window.location.hash; // e.g. "#model=ppe"
if (!hash || !hash.startsWith('#model=')) {
return null;
}
const raw = hash.substring('#model='.length);
const decoded = decodeURIComponent(raw).trim();
return decoded || null;
}
/**
* Clear the URL hash without triggering a page reload.
*/
_clearHash() {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
/**
* Find a model in the cached list by its id.
* @param {string} modelId
* @returns {object|null}
*/
_findModelById(modelId) {
if (!this._modelList) return null;
return this._modelList.find((m) => m.id === modelId) || null;
}
/**
* Check whether a model ID belongs to the models.json list (Req 25.5).
* @param {string} modelId
* @returns {boolean}
*/
_isListModel(modelId) {
return this._findModelById(modelId) !== null;
}
/**
* Handle model:selected events from the EventBus.
* Only update the hash for models that come from models.json (Req 25.5).
* @param {object} model
*/
_onModelSelected(model) {
if (!model || !model.id) return;
// Only set hash for list models, not uploaded files
if (this._isListModel(model.id)) {
this.setModel(model.id);
}
}
/**
* Handle browser hashchange (back/forward navigation).
*/
_onHashChange() {
const { model, error } = this.readHash();
if (error && window.EventBus) {
window.EventBus.emit(CONFIG.EVENTS.ERROR_OCCURRED, {
message: error,
type: 'warning'
});
return;
}
if (model && window.EventBus) {
window.EventBus.emit(CONFIG.EVENTS.MODEL_SELECTED, model);
}
}
/**
* Fallback clipboard copy for environments without navigator.clipboard.
* @param {string} text
* @returns {boolean}
*/
_fallbackCopy(text) {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch (_) {
return false;
}
}
}
// Export as global for browser usage
window.ShareableURL = ShareableURL;