github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/deck/static/spyglass/lens.ts (about) 1 import {parseQuery} from '../common/urls'; 2 import {isResponse, isTransitMessage, isUpdateHashMessage, Message, Response, serialiseHashes} from './common'; 3 4 // Solution is inspired by https://stackoverflow.com/questions/29055828/regex-to-make-links-clickable-in-only-a-href-and-not-img-src 5 const linkRegex = /((?:href|src)=")?(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; 6 7 export interface Spyglass { 8 /** 9 * Replaces the lens display with a new server-rendered page. 10 * The returned promise will be resolved once the page has been updated. 11 * 12 * @param data Some data to pass back to the server. JSON encoding is 13 * recommended, but not required. 14 */ 15 updatePage(data: string): Promise<void>; 16 /** 17 * Requests that the server re-render the lens with the provided data, and 18 * returns a promise that will resolve with that HTML as a string. 19 * 20 * This is equivalent to updatePage(), except that the displayed content is 21 * not automatically changed. 22 * 23 * @param data Some data to pass back to the server. JSON encoding is 24 * recommended, but not required. 25 */ 26 requestPage(data: string): Promise<string>; 27 /** 28 * Sends a request to the server-side lens backend with the provided data, and 29 * returns a promise that will resolve with the response as a string. 30 * 31 * @param data Some data to pass back to the server. JSON encoding is 32 * recommended, but not required. 33 */ 34 request(data: string): Promise<string>; 35 /** 36 * Inform Spyglass that the lens content has updated. This should be called whenever 37 * the visible content changes, so Spyglass can ensure that all content is visible. 38 */ 39 contentUpdated(): void; 40 /** 41 * Returns a top-level URL that will cause your lens to be loaded with the 42 * specified fragment. This is useful to construct copyable links, but generally 43 * should not be used for immediate navigation. 44 * 45 * @param fragment The fragment you want. If not prefixed with a #, one will 46 * be assumed. 47 */ 48 makeFragmentLink(fragment: string): string; 49 50 /** 51 * Scrolls the parent window so that the specified coordinates are visible. 52 * 53 * @param x The x coordinate relative to the lens document to scroll to. 54 * @param y The y coordinate relative to the lens document to scroll to. 55 */ 56 scrollTo(x: number, y: number): Promise<void>; 57 } 58 59 class SpyglassImpl implements Spyglass { 60 private pendingRequests = new Map<number, (v: Response) => void>(); 61 private messageId = 0; 62 private pendingUpdateTimer = 0; 63 private currentHash = ''; 64 private observer: MutationObserver; 65 66 constructor() { 67 this.currentHash = location.hash; 68 this.observer = new MutationObserver((mutations) => this.handleMutations(mutations)); 69 70 window.addEventListener('message', (e) => this.handleMessage(e)); 71 window.addEventListener('hashchange', (e) => this.handleHashChange(e)); 72 window.addEventListener('DOMContentLoaded', () => { 73 this.createHyperlinks(document.documentElement); 74 this.fixAnchorLinks(document.documentElement); 75 this.observer.observe(document.documentElement, {attributeFilter: ['href'], childList: true, subtree: true}); 76 }); 77 window.addEventListener('load', () => { 78 this.contentUpdated(); 79 // this needs a delay but I'm not sure what (if anything) we're racing. 80 setTimeout(() => { 81 if (location.hash !== '') { 82 this.tryMoveToHash(location.hash); 83 } 84 }, 100); 85 }); 86 } 87 88 public async updatePage(data: string): Promise<void> { 89 await this.postMessage({type: 'updatePage', data}); 90 this.contentUpdated(); 91 } 92 public async requestPage(data: string): Promise<string> { 93 const result = await this.postMessage({type: 'requestPage', data}); 94 return result.data; 95 } 96 public async request(data: string): Promise<string> { 97 const result = await this.postMessage({type: 'request', data}); 98 return result.data; 99 } 100 public contentUpdated(): void { 101 this.updateHeight(); 102 clearTimeout(this.pendingUpdateTimer); 103 // to be honest I have zero understanding of why this helps, but apparently it does. 104 this.pendingUpdateTimer = setTimeout(() => this.updateHeight(), 0); 105 } 106 107 public makeFragmentLink(fragment: string): string { 108 const q = parseQuery(location.search.substr(1)); 109 const topURL = q.topURL!; 110 const lensIndex = q.lensIndex!; 111 if (fragment[0] !== '#') { 112 fragment = `#${ fragment}`; 113 } 114 return `${topURL}#${serialiseHashes({[lensIndex]: fragment})}`; 115 } 116 117 public async scrollTo(x: number, y: number): Promise<void> { 118 await this.postMessage({type: 'showOffset', left: x, top: y}); 119 } 120 121 private updateHeight(): void { 122 // .then() to suppress complaints about unhandled promises (we just don't care here). 123 this.postMessage({type: 'contentUpdated', height: document.body.offsetHeight}).then(); 124 } 125 126 private postMessage(message: Message): Promise<Response> { 127 return new Promise<Response>((resolve, reject) => { 128 const id = ++this.messageId; 129 this.pendingRequests.set(id, resolve); 130 window.parent.postMessage({id, message}, document.location.origin); 131 }); 132 } 133 134 private handleMessage(e: MessageEvent): void { 135 if (e.origin !== document.location.origin) { 136 console.warn(`Got MessageEvent from unexpected origin ${e.origin}; expected ${document.location.origin}`, e); 137 return; 138 } 139 const data = e.data; 140 if (isTransitMessage(data)) { 141 if (isResponse(data.message)) { 142 if (this.pendingRequests.has(data.id)) { 143 this.pendingRequests.get(data.id)!(data.message); 144 this.pendingRequests.delete(data.id); 145 } 146 } 147 } else if (isUpdateHashMessage(data)) { 148 location.hash = data.hash; 149 } 150 } 151 152 // When any links on the page are added or mutated, we fix them up if they 153 // were anchor links to avoid developer confusion. 154 private handleMutations(mutations: MutationRecord[]): void { 155 for (const mutation of mutations) { 156 if (!(mutation.target instanceof HTMLElement)) { 157 continue; 158 } 159 if (mutation.type === 'childList') { 160 this.fixAnchorLinks(mutation.target); 161 162 if (mutation.target instanceof HTMLDivElement && 163 (mutation.target.classList.contains('shown') || 164 mutation.target.classList.contains('loglines'))) { 165 this.createHyperlinks(mutation.target); 166 } 167 } else if (mutation.type === 'attributes') { 168 if (mutation.target instanceof HTMLAnchorElement && mutation.attributeName === 'href') { 169 const href = mutation.target.getAttribute('href'); 170 if (href && href[0] === '#') { 171 this.fixAnchorLink(mutation.target); 172 } 173 } 174 } 175 } 176 } 177 178 private handleHashChange(e: HashChangeEvent): void { 179 if (location.hash === this.currentHash) { 180 return; 181 } 182 this.currentHash = location.hash; 183 this.postMessage({type: 'updateHash', hash: location.hash}).then(); 184 this.tryMoveToHash(location.hash); 185 } 186 187 // Because we're in an iframe that is exactly our height, anchor links don't 188 // actually do anything (and even if they did, it would not be something 189 // useful). We implement their intended behaviour manually by looking up the 190 // element referred to and requesting that our parent jump to that offset. 191 private tryMoveToHash(hash: string): void { 192 hash = hash.substr(1); 193 let el = document.getElementById(hash); 194 if (!el) { 195 el = document.getElementsByName(hash)[0]; 196 if (!el) { 197 return; 198 } 199 } 200 const top = el.getBoundingClientRect().top + window.pageYOffset; 201 this.scrollTo(0, top).then(); 202 } 203 204 private setLink(match: string, attr: string): string { 205 if (typeof attr !== 'undefined') { 206 return match; 207 } 208 return `</span><a target="_blank" href="${match}">${match}</a><span>`; 209 } 210 211 private createHyperlinks(parent: Element): void { 212 for (const elem of Array.from(parent.querySelectorAll<HTMLElement>('div.linetext>span'))) { 213 this.createHyperlink(elem); 214 } 215 } 216 217 private createHyperlink(elem: HTMLElement): void { 218 // Doing a light match check before running heavier replace regex manipulation 219 if (elem.innerText.match(linkRegex)) { 220 /* eslint-disable @typescript-eslint/unbound-method */ 221 elem.innerHTML = elem.innerText.replace(linkRegex, this.setLink); 222 } 223 } 224 225 // We need to fix up anchor links (i.e. links that only set the fragment) 226 // because we use <base> to make asset references Just Work, but that also 227 // applies to anchor links, which is surprising to developers. 228 // In order to mitigate this surprise, we find all the links that were 229 // supposed to be anchor links and fix them by adding the absolute URL 230 // of the current page. 231 private fixAnchorLinks(parent: Element): void { 232 for (const a of Array.from(parent.querySelectorAll<HTMLAnchorElement>('a[href^="#"]'))) { 233 this.fixAnchorLink(a); 234 } 235 } 236 237 private fixAnchorLink(a: HTMLAnchorElement): void { 238 if (!a.dataset.preserveAnchor) { 239 a.href = location.href.split('#')[0] + a.getAttribute('href'); 240 a.target = "_self"; 241 } 242 } 243 } 244 245 const spyglass = new SpyglassImpl(); 246 (window as any).spyglass = spyglass;