Content is user-generated and unverified.
// ============================================================================= // iframe-manager.service.ts — Gestion de N iframes avec file d'actions différées // ============================================================================= // Chaque iframe a un état de readiness indépendant. // Les actions envoyées à une iframe non prête sont mises en file d'attente // et dépilées automatiquement dès qu'elle signale qu'elle est ready. // Un mécanisme de « iframe active » permet de cibler une seule iframe. // ============================================================================= import { Injectable, signal, computed, effect, DestroyRef, inject, NgZone, } from '@angular/core'; import { MapSignalStore } from './map-store'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Types d'actions que le parent peut envoyer à une iframe carte */ export type IframeActionType = | 'SYNC_STATE' // Synchronise tout le state | 'FIT_BOUNDS' // Centre la carte sur des coordonnées | 'HIGHLIGHT' // Met en surbrillance un objet | 'CLEAR_HIGHLIGHT' // Annule la surbrillance | 'ZOOM_TO' // Zoom sur un point | 'DRAW' // Active un mode de dessin | 'CUSTOM'; // Action arbitraire export interface IframeAction { type: IframeActionType; payload?: unknown; /** Si true, remplace les actions du même type déjà en queue */ deduplicate?: boolean; /** Timestamp d'ajout en queue */ enqueuedAt?: number; } export interface IframeInstance { /** Identifiant unique de l'iframe */ id: string; /** Nom lisible (ex: "Carte principale", "Carte satellite") */ label: string; /** Référence vers l'élément iframe dans le DOM */ elementRef: HTMLIFrameElement | null; /** L'iframe a-t-elle signalé qu'elle est prête ? */ ready: boolean; /** File d'actions en attente tant que ready === false */ pendingActions: IframeAction[]; /** URL source de l'iframe */ src: string; /** Métadonnées libres */ meta?: Record<string, unknown>; } /** Messages postés entre parent ↔ iframe */ export interface IframeBridgeMessage { source: 'map-parent' | 'map-iframe'; iframeId: string; type: 'ACTION' | 'READY' | 'EVENT' | 'STATE_SYNC'; payload: unknown; } // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- @Injectable({ providedIn: 'root' }) export class IframeManagerService { private readonly store = inject(MapSignalStore); private readonly zone = inject(NgZone); private readonly destroyRef = inject(DestroyRef); // ---- State ---- private readonly _instances = signal<Map<string, IframeInstance>>(new Map()); private readonly _activeId = signal<string | null>(null); // ---- Selectors ---- readonly instances = this._instances.asReadonly(); readonly instanceList = computed(() => [...this._instances().values()]); readonly activeId = this._activeId.asReadonly(); readonly activeInstance = computed(() => { const id = this._activeId(); return id ? this._instances().get(id) ?? null : null; }); readonly readyInstances = computed(() => this.instanceList().filter(i => i.ready), ); readonly pendingActionCount = computed(() => this.instanceList().reduce((sum, i) => sum + i.pendingActions.length, 0), ); constructor() { // Écoute les messages venant des iframes this.listenToIframeMessages(); // Auto-sync : à chaque changement du store, envoie le state aux iframes ready effect(() => { const snapshot = this.store.snapshot(); // tracking dependency const readyFrames = this.readyInstances(); // tracking dependency for (const iframe of readyFrames) { this.postToIframe(iframe.id, { type: 'STATE_SYNC', payload: this.serializeSnapshot(snapshot), }); } }); // Nettoyage à la destruction this.destroyRef.onDestroy(() => this.disposeAll()); } // =========================================================================== // Gestion du cycle de vie des iframes // =========================================================================== /** * Enregistre une nouvelle instance d'iframe. * L'iframe n'est PAS encore prête — les actions seront mises en file. */ register(config: { id: string; label: string; src: string; meta?: Record<string, unknown>; }): void { const instance: IframeInstance = { id: config.id, label: config.label, src: config.src, elementRef: null, ready: false, pendingActions: [], meta: config.meta, }; this._instances.update(map => { const next = new Map(map); next.set(config.id, instance); return next; }); // La première iframe enregistrée devient active par défaut if (this._activeId() === null) { this._activeId.set(config.id); } } /** * Associe un élément <iframe> DOM à une instance enregistrée. * Appelé typiquement depuis une directive ou un composant host. */ attachElement(iframeId: string, el: HTMLIFrameElement): void { this.updateInstance(iframeId, inst => ({ ...inst, elementRef: el, })); } /** * Désenregistre une iframe et nettoie sa file d'attente. */ unregister(iframeId: string): void { this._instances.update(map => { const next = new Map(map); next.delete(iframeId); return next; }); if (this._activeId() === iframeId) { const remaining = this.instanceList(); this._activeId.set(remaining.length > 0 ? remaining[0].id : null); } } // =========================================================================== // Readiness // =========================================================================== /** * Marque une iframe comme prête et dépile toute la file d'actions. * Appelé automatiquement à réception du message READY de l'iframe, * ou manuellement si besoin. */ markReady(iframeId: string): void { const inst = this._instances().get(iframeId); if (!inst || inst.ready) return; console.log(`[IframeManager] ✓ iframe "${iframeId}" is ready — flushing ${inst.pendingActions.length} pending action(s)`); // On envoie d'abord un state sync complet this.postToIframe(iframeId, { type: 'STATE_SYNC', payload: this.serializeSnapshot(this.store.snapshot()), }); // Puis on dépile les actions en attente dans l'ordre for (const action of inst.pendingActions) { this.postToIframe(iframeId, { type: 'ACTION', payload: action, }); } // Met à jour l'état this.updateInstance(iframeId, i => ({ ...i, ready: true, pendingActions: [], })); } /** * Remet une iframe en état « non prête » (ex: rechargement). * Les nouvelles actions iront à nouveau en file d'attente. */ markNotReady(iframeId: string): void { this.updateInstance(iframeId, i => ({ ...i, ready: false, })); } // =========================================================================== // Actions // =========================================================================== /** * Envoie une action à une iframe spécifique. * Si l'iframe n'est pas prête, l'action est mise en file d'attente. */ dispatch(iframeId: string, action: IframeAction): void { const inst = this._instances().get(iframeId); if (!inst) { console.warn(`[IframeManager] iframe "${iframeId}" not found`); return; } const enrichedAction: IframeAction = { ...action, enqueuedAt: Date.now(), }; if (inst.ready) { // Envoi immédiat this.postToIframe(iframeId, { type: 'ACTION', payload: enrichedAction, }); } else { // File d'attente console.log(`[IframeManager] ⏳ queuing "${action.type}" for iframe "${iframeId}"`); this.updateInstance(iframeId, i => { let queue = [...i.pendingActions]; // Dé-duplication optionnelle if (action.deduplicate) { queue = queue.filter(a => a.type !== action.type); } queue.push(enrichedAction); return { ...i, pendingActions: queue }; }); } } /** * Envoie une action à TOUTES les iframes (broadcast). */ broadcast(action: IframeAction): void { for (const inst of this.instanceList()) { this.dispatch(inst.id, action); } } /** * Envoie une action à l'iframe active uniquement. */ dispatchToActive(action: IframeAction): void { const id = this._activeId(); if (id) this.dispatch(id, action); } /** * Vide la file d'attente d'une iframe sans exécuter les actions. */ clearPendingActions(iframeId: string): void { this.updateInstance(iframeId, i => ({ ...i, pendingActions: [], })); } // =========================================================================== // Iframe active // =========================================================================== setActive(iframeId: string): void { if (!this._instances().has(iframeId)) { console.warn(`[IframeManager] cannot activate unknown iframe "${iframeId}"`); return; } this._activeId.set(iframeId); } // =========================================================================== // Communication postMessage // =========================================================================== private postToIframe( iframeId: string, msg: Omit<IframeBridgeMessage, 'source' | 'iframeId'>, ): void { const inst = this._instances().get(iframeId); if (!inst?.elementRef?.contentWindow) { console.warn(`[IframeManager] no contentWindow for iframe "${iframeId}"`); return; } const fullMsg: IframeBridgeMessage = { source: 'map-parent', iframeId, ...msg, }; inst.elementRef.contentWindow.postMessage(fullMsg, '*'); } private listenToIframeMessages(): void { const handler = (event: MessageEvent<IframeBridgeMessage>) => { const data = event.data; if (!data || data.source !== 'map-iframe') return; this.zone.run(() => { switch (data.type) { case 'READY': this.markReady(data.iframeId); break; case 'EVENT': this.handleIframeEvent(data.iframeId, data.payload); break; default: console.log(`[IframeManager] unhandled message type: ${data.type}`); } }); }; window.addEventListener('message', handler); this.destroyRef.onDestroy(() => window.removeEventListener('message', handler)); } /** * Traite les événements remontés par les iframes (clics, dessins, etc.) * et les traduit en mutations du store. */ private handleIframeEvent(iframeId: string, payload: unknown): void { const event = payload as { name: string; data: any }; if (!event?.name) return; switch (event.name) { case 'object-clicked': this.store.toggleSelection(event.data.id); break; case 'object-hovered': this.store.setHovered(event.data.id); break; case 'viewport-changed': this.store.setViewport(event.data); break; case 'object-created': this.store.upsertObject(event.data); break; default: console.log(`[IframeManager] unhandled iframe event: ${event.name}`); } } // =========================================================================== // Helpers internes // =========================================================================== private updateInstance( id: string, updater: (inst: IframeInstance) => IframeInstance, ): void { this._instances.update(map => { const inst = map.get(id); if (!inst) return map; const next = new Map(map); next.set(id, updater(inst)); return next; }); } /** * Sérialise le snapshot pour postMessage * (Map et Set ne passent pas bien en structured clone dans certains browsers) */ private serializeSnapshot(snapshot: any): any { return { ...snapshot, objects: Object.fromEntries(snapshot.objects), selection: { ...snapshot.selection, selectedIds: [...snapshot.selection.selectedIds], }, }; } private disposeAll(): void { for (const inst of this.instanceList()) { this.unregister(inst.id); } } }
Content is user-generated and unverified.
    iframe-manager.service.ts: Angular Multi-iframe Management | Claude