/** * 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= (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} 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;