Spaces:
Running
Running
| /** | |
| * 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; | |