import { waitFor } from '@factor/api/utils' import type { Ref } from 'vue' import { computed, ref, watch } from 'vue' // lightweight for tag import { Obj } from '@factor/api/obj' // lightweight for tag export interface FrameMessage { from?: 'factor' url?: URL messageType: 'frameReady' | 'navigate' | 'close' | string data: T } interface MsgAuth { from?: 'factor' } interface MsgBase { messageType: string, data: unknown } type MsgStandard = | { messageType: 'frameReady', data: string } | { messageType: 'close', data: boolean } type FrameUtilitySettings = { sel?: string frameEl?: HTMLIFrameElement windowEl?: () => Window // window will change with iframe src onMessage?: MessageListener onFrameLoad?: LoadListener relation: 'parent' | 'child' waitForReadySignal?: boolean src?: Ref handleKeyboardEvents?: boolean } type MessageListener = ( message: (T | MsgStandard) & MsgAuth, { frameUtility }: { frameUtility: FrameUtility }, ) => void type LoadListener = (util: FrameUtility) => void export class FrameUtility extends Obj< FrameUtilitySettings > { from: 'factor' = 'factor' as const relation = this.settings.relation sel = this.settings.sel frameEl = this.settings.frameEl || this.getFrameEl() frameWindow = () => this.frameEl?.contentWindow /** * window handling is to make this utility testable * necessary to assign 'window' to the frameEl window in tests */ win = () => { const fw = this.frameWindow() const out = this.relation === 'child' ? (this.frameEl ? fw : window) : window if (!out) throw new Error('no window') return out } messageWindow = () => { const fw = this.frameWindow() const out = this.relation === 'child' ? this.win().parent : fw return out } messageBuffer: ((T | MsgStandard) & MsgAuth)[] = [] loaded = ref(false) waitForReadySignal = this.settings.waitForReadySignal ?? false hasReadySignal = ref(!this.waitForReadySignal) isFrameReady = computed(() => { return this.hasReadySignal.value }) src = this.settings.src || ref('') keyListening = false handleKeyboardEvents = this.settings.handleKeyboardEvents ?? false eventOrigins = ['localhost', 'factor'] onFrameLoad = this.settings.onFrameLoad ?? (() => {}) messageCallbacks = new Set>([ this.settings.onMessage ?? (() => {}), ]) constructor(settings: FrameUtilitySettings) { super(`FrameUtility:${settings.relation}`, settings) } init() { watch( () => this.hasReadySignal.value, (v) => { this.log.info('hasReadySignal', { data: { v } }) }, ) if (this.relation === 'child') { this.sendReadySignal() } else { if (!this.frameEl) { this.log.error('missing frame element', { data: { sel: this.sel } }) return } // initialize src attribute const frameSrc = this.frameEl.getAttribute('src') ?? '' if (frameSrc && !this.src.value) this.src.value = frameSrc /** * Watch for changes to src attribute and reload frame if changed */ watch( () => this.src.value, (v) => { if (!v || !this.frameEl) return this.frameEl.setAttribute('src', v) this.loaded.value = false }, { immediate: true }, ) this.frameEl.addEventListener('load', async () => { if (!this.frameEl) return this.frameEl.dataset.loaded = this.src.value this.loaded.value = true await waitFor(400) this.onFrameLoad(this) }) } watch( () => this.isFrameReady.value, (v) => { if (v) this.flushBuffer() }, ) this.sendKeyboardEvents() this.listenForMessages() } getFrameEl(): HTMLIFrameElement | undefined { if (!this.sel || this.relation === 'child') return undefined const frameEl = document.querySelector(this.sel) as HTMLIFrameElement | undefined return frameEl ?? undefined } getUrl(): URL | undefined { if (this.src.value) { const fullSrc = this.src.value.includes('http') ? this.src.value : `${window.location.origin}${this.src.value}` return new URL(fullSrc) } else { return undefined } } onMessage(cb: MessageListener) { this.messageCallbacks.add(cb) } onMessageRecieved(event: MessageEvent) { const msg = event.data as FrameMessage // if (!this.eventOrigins.some((o) => event.origin.includes(o))) return if (!msg || typeof msg !== 'object' || msg.from !== 'factor') return this.log.info('postMessage received', { data: msg }) msg.url = this.getUrl() // if child send frameReady, it's ready for messages if (msg.messageType === 'frameReady' && !this.hasReadySignal.value) this.hasReadySignal.value = true this.messageCallbacks.forEach(cb => cb(msg, { frameUtility: this })) } private messageListener: ((e: MessageEvent) => void) | null = null clear() { if (this.messageListener) this.win().removeEventListener('message', this.messageListener) } listenForMessages(): void { this.messageListener = e => this.onMessageRecieved(e) this.win().addEventListener('message', this.messageListener, false) } sendReadySignal(): void { const mWindow = this.messageWindow() if (!mWindow) throw new Error('no message window') mWindow.postMessage( { from: 'factor', messageType: 'frameReady', data: window.origin }, '*', ) } flushBuffer() { const mWindow = this.messageWindow() this.log.info('flush', { data: { mWindow, frameReady: this.isFrameReady.value, sig: this.hasReadySignal.value, }, }) if (!mWindow) return if (!this.isFrameReady.value) return this.messageBuffer.forEach(async (message) => { this.log.info(`postMessage send`, { data: message }) message.from = this.from const sendMessage = JSON.parse(JSON.stringify(message)) as T mWindow.postMessage(sendMessage, '*') }) this.messageBuffer = [] } sendMessage(args: { message: (T | MsgStandard) & MsgAuth }): void { const { message } = args this.messageBuffer.push(message) this.flushBuffer() } sendKeyboardEvents(): void { if (!this.keyListening && this.handleKeyboardEvents) { this.keyListening = true const sendOnKeys = new Set(['Alt', 'Control', 'Meta']) document.addEventListener('keydown', (event: KeyboardEvent) => { const { key } = event if (!sendOnKeys.has(key)) return this.sendMessage({ message: { messageType: 'keypress', data: { direction: 'down', key }, } as T, }) }) document.addEventListener('keyup', (event: KeyboardEvent) => { const { key } = event if (!sendOnKeys.has(key)) return this.sendMessage({ message: { messageType: 'keypress', data: { direction: 'up', key }, } as T, }) }) } } } /** * Makes the frame width draggable to allow for responsive visualization */ // export const makeDraggableFrame = (): void => { // const dragActive = ref(false) // const frameOverlay = document.querySelector("#frameOverlay") as HTMLElement // const frameArea = document.querySelector("#frameArea") as HTMLElement // const handle = document.querySelector("#handle") as HTMLElement // const frameContainer = document.querySelector( // "#frameContainer", // ) as HTMLElement // const frameWidth = ref() // const resize = (e: MouseEvent): void => { // const mouseX = e.x // const frameAreaLeft = frameArea?.getBoundingClientRect().left ?? 0 // const handleWidth = handle?.offsetWidth ?? 0 // const frameAreaWidth = frameArea?.offsetWidth ?? 0 // if (!dragActive.value) return // if (!frameAreaLeft || !handleWidth || !frameAreaWidth) { // frameWidth.value = undefined // } else if (mouseX > frameAreaLeft + frameAreaWidth - handleWidth) { // frameWidth.value = undefined // } else if (mouseX < frameAreaLeft + 400) { // frameWidth.value = 400 // } else { // frameWidth.value = mouseX - frameAreaLeft + handleWidth / 2 // } // if (frameContainer) { // frameContainer.style.width = frameWidth.value // ? `${frameWidth.value}px` // : "" // } // } // if (handle) { // handle.addEventListener("mousedown", () => { // if (frameOverlay) { // frameOverlay.style.display = "block" // } // dragActive.value = true // document.addEventListener("mousemove", resize, false) // document.addEventListener( // "mouseup", // () => { // dragActive.value = false // document.removeEventListener("mousemove", resize, false) // }, // false, // ) // }) // } // document.addEventListener( // "mouseup", // () => { // if (frameOverlay) { // frameOverlay.style.display = "none" // } // }, // false, // ) // }