import { LitElement, html, css, type PropertyValues } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { property } from "lit/decorators.js"; import { wrapCss } from "./misc"; import rwpLogo from "~assets/brand/replaywebpage-icon-color.svg"; import type { ItemType } from "./types"; import type { ReplayLoadingDetail, TabNavEvent } from "./events"; /** * @fires update-title * @fires coll-tab-nav * @fires update-title * @fires replay-favicons * @fires replay-loading ReplayLoadingDetail * @fires cancel-click-download * @fires update-download-res-url */ class Replay extends LitElement { @property({ type: Object }) collInfo: ItemType | Record | null = null; @property({ type: String }) sourceUrl: string | null = null; // external url set from parent @property({ type: String }) url = ""; @property({ type: String }) ts = ""; @property({ type: String }) waczhash = ""; // actual replay url @property({ type: String }) replayUrl = ""; @property({ type: String }) replayTS = ""; @property({ type: String }) actualTS = ""; @property({ type: String }) title = ""; @property({ type: String }) iframeUrl: string | null = null; @property({ type: Boolean }) showAuth = false; @property({ type: Boolean }) replayNotFoundError = false; @property({ type: Object }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- requestPermission() type mismatch authFileHandle: any = null; @property({ type: String }) downloadResUrl = ""; private reauthWait: null | Promise = null; private _loadPoll: null | number = null; private hiliter: HoverHiliter | null = null; firstUpdated() { window.addEventListener("message", (event) => this.onReplayMessage(event)); // TODO: Fix this the next time the file is edited. // eslint-disable-next-line @typescript-eslint/promise-function-async navigator.serviceWorker.addEventListener("message", (event) => this.handleSWMessage(event), ); } async handleSWMessage(event: MessageEvent) { if ( event.data.type === "authneeded" && this.collInfo && event.data.coll === this.collInfo.coll ) { if (event.data.fileHandle) { this.authFileHandle = event.data.fileHandle; try { if ( (await this.authFileHandle.requestPermission({ mode: "read" })) === "granted" ) { this.showAuth = false; this.reauthWait = null; this.refresh(); return; } } catch (e) { console.warn(e); } } else { this.authFileHandle = null; } if (this.reauthWait) { await this.reauthWait; } else { this.showAuth = true; } } else if (event.data.type) { window.parent.postMessage(event.data); } } doSetIframeUrl() { this.iframeUrl = this.url && this.collInfo ? `${this.collInfo.replayPrefix}/${ this.waczhash ? `:${this.waczhash}/` : "" }${this.ts || ""}mp_/${this.url}` : ""; } updated(changedProperties: PropertyValues) { if ( changedProperties.has("sourceUrl") || changedProperties.has("collInfo") ) { this.reauthWait = null; } if ( this.url && (this.replayUrl != this.url || this.replayTS != this.ts) && (changedProperties.has("url") || changedProperties.has("ts")) ) { this.replayUrl = this.url; this.replayTS = this.ts; this.showAuth = false; this.reauthWait = null; this.doSetIframeUrl(); } if (this.iframeUrl && changedProperties.has("iframeUrl")) { this.waitForLoad(); const detail = { title: "Archived Page", replayTitle: false }; this.dispatchEvent( new CustomEvent("update-title", { bubbles: true, composed: true, detail, }), ); } if ( (this.replayUrl && changedProperties.has("replayUrl")) || (this.replayTS && changedProperties.has("replayTS")) ) { const data = { url: this.replayUrl, ts: this.replayTS, waczhash: this.waczhash, }; this.dispatchEvent( new CustomEvent("coll-tab-nav", { detail: { replaceLoc: true, data, replayNotFoundError: this.replayNotFoundError, }, }), ); } if ( this.title && (changedProperties.has("title") || changedProperties.has("actualTS")) ) { const detail = { title: this.title, url: this.replayUrl, // send actual ts even if live ts: this.actualTS, replayTitle: true, }; this.dispatchEvent( new CustomEvent("update-title", { bubbles: true, composed: true, detail, }), ); } } setDisablePointer(disable: boolean) { const iframe = this.renderRoot.querySelector("iframe"); if (iframe) { iframe.style.pointerEvents = disable ? "none" : "all"; } } onReplayMessage(event: MessageEvent) { const iframe = this.renderRoot.querySelector("iframe"); if (iframe && event.source === iframe.contentWindow) { if ( event.data.wb_type === "load" || event.data.wb_type === "replace-url" || event.data.wb_type === "archive-not-found" ) { this.replayTS = event.data.is_live ? "" : event.data.ts; this.actualTS = event.data.ts; this.replayUrl = event.data.url; this.title = event.data.title || this.title; this.replayNotFoundError = event.data.wb_type === "archive-not-found"; this.clearLoading(iframe); if (event.data.icons) { const icons = event.data.icons; this.dispatchEvent( new CustomEvent("replay-favicons", { bubbles: true, composed: true, detail: { icons }, }), ); } } else if (event.data.wb_type === "title") { this.title = event.data.title; } else { const passEvent = { type: event.data.wb_type, ...event.data }; delete passEvent.wb_type; window.parent.postMessage(passEvent); } } } // @ts-expect-error [// TODO: Fix this the next time the file is edited.] - TS7006 - Parameter 'event' implicitly has an 'any' type. onReAuthed(event) { this.reauthWait = (async () => { if (!this.authFileHandle) { // google drive reauth const headers = event.detail.headers; await fetch(`${this.collInfo!.apiPrefix}/updateAuth`, { method: "POST", body: JSON.stringify({ headers }), }); } else { if ( (await this.authFileHandle.requestPermission({ mode: "read" })) !== "granted" ) { this.reauthWait = null; return; } this.authFileHandle = null; } if (this.showAuth) { this.showAuth = false; this.reauthWait = null; } this.refresh(); })(); } waitForLoad() { this.setLoading(); this._loadPoll = window.setInterval(() => { const iframe = this.renderRoot.querySelector("iframe"); if ( !iframe?.contentDocument || !iframe.contentWindow || (iframe.contentDocument.readyState === "complete" && !(iframe.contentWindow as Window & { _WBWombat: unknown })._WBWombat) ) { this.clearLoading(iframe); } }, 5000); } clearLoading(iframe: HTMLIFrameElement | null) { this.dispatchEvent( new CustomEvent("replay-loading", { detail: { loading: false, replayNotFoundError: this.replayNotFoundError, }, }), ); if (this._loadPoll) { window.clearInterval(this._loadPoll); this._loadPoll = null; } const iframeWin = iframe?.contentWindow; if (iframeWin) { try { iframeWin.addEventListener("beforeunload", () => { this.setLoading(); }); } catch (e) { // ignore } } } setLoading() { this.clearHilite(true); this.dispatchEvent( new CustomEvent("replay-loading", { detail: { loading: true, }, }), ); } refresh() { const iframe = this.renderRoot.querySelector("iframe"); if (!iframe) { return; } const oldIframeUrl = this.iframeUrl; // set iframe url to expected, refresh if same url this.doSetIframeUrl(); if (oldIframeUrl === this.iframeUrl || this.url === this.replayUrl) { this.waitForLoad(); iframe.contentWindow?.location.reload(); } } static get styles() { return wrapCss(css` :host { display: flex; flex-direction: column; height: 100%; color-scheme: light dark; } .iframe-container { position: relative; width: 100%; height: 100%; border: 0px; } .iframe-main { position: absolute; top: 0px; left: 0px; right: 0px; bottom: 0px; width: 100%; height: 100%; } .intro-panel .panel-heading { font-size: 1em; display: inline-block; } .iframe-main.modal-bg { z-index: 200; background-color: rgba(10, 10, 10, 0.7); } #wrlogo { vertical-align: middle; } .intro-panel .panel-block { padding: 1em; flex-direction: column; line-height: 2.5em; } div.intro-panel.panel { min-width: 40%; display: flex; flex-direction: column; margin: 3em; background-color: white; } .hilite-overlay { display: none; position: absolute; z-index: 9999; background-color: rgba(0, 0, 255, 0.5); border: solid 10px blue; cursor: crosshair; } `); } render() { const title = `Replay of ${this.title ? `${this.title}:` : ""} ${this.url}`; return html` ${title} ${!this.iframeUrl ? html` Replay Web Page Enter a URL above to replay it from the web archive! (Or, check out Pages or URLs to explore the contents of this archive.) ` : html` ${this.showAuth ? html` Authorization Needed ${this.authFileHandle ? html` This archive is loaded from a local file: ${this.authFileHandle.name} The browser needs to confirm your permission to continue loading from this file. Show Confirmation ` : html` `} ` : ""} `}`; } clearHilite(removeListeners = false) { if (this.hiliter) { this.hiliter.clearHilite(removeListeners); this.hiliter = null; } if (removeListeners) { this.removeEventListener( "update-download-res-url", this.onUpdateDownloadResUrl, ); this.dispatchEvent(new CustomEvent("cancel-click-download")); } } hiliteClicked() { this.clearHilite(true); return true; } onUpdateDownloadResUrl(e: Event) { const { url } = (e as CustomEvent).detail; this.downloadResUrl = url; } setClickToDownload() { if (this.hiliter) { return; } this.addEventListener( "update-download-res-url", this.onUpdateDownloadResUrl, ); try { this.hiliter = new HoverHiliter(this); } catch (e) { this.hiliter = null; } } } // =========================================================================== class HoverHiliter { doc: Document; replay: Replay; hiliteElem: HTMLElement | null = null; hiliteOverlay: HTMLElement; iframe: HTMLIFrameElement; onMove: (event: MouseEvent) => void; onRecompute: () => void; constructor(replay: Replay) { this.replay = replay; const iframe = this.replay.renderRoot.querySelector("iframe"); const hiliteOverlay = this.replay.renderRoot.querySelector(".hilite-overlay"); if (!hiliteOverlay || !iframe?.contentDocument) { throw new Error("missing elements"); } this.iframe = iframe; this.hiliteOverlay = hiliteOverlay as HTMLElement; this.onMove = (event: MouseEvent) => this.hiliteOnMove(event); this.onRecompute = () => this.hiliteRecompute(); const doc = iframe.contentDocument; this.doc = doc; doc.addEventListener("mousemove", this.onMove); doc.addEventListener("scroll", this.onRecompute); doc.defaultView?.addEventListener("resize", this.onRecompute); } clearHilite(removeListeners = false) { if (removeListeners) { this.doc.removeEventListener("mousemove", this.onMove); this.doc.removeEventListener("scroll", this.onRecompute); this.doc.defaultView?.removeEventListener("scroll", this.onRecompute); } this.hiliteElem = null; this.hiliteOverlay.style.display = "none"; } hiliteRecompute() { if (!this.hiliteElem) { return; } const iframeRect = this.iframe.getBoundingClientRect(); const elemRect = this.hiliteElem.getBoundingClientRect(); const offset = 10; const leftX = iframeRect.left + window.scrollX + elemRect.left - offset; const topY = iframeRect.top + window.scrollY + elemRect.top - offset; const hilite = this.hiliteOverlay; hilite.style.left = leftX + "px"; hilite.style.top = topY + "px"; hilite.style.width = elemRect.width + offset * 2 + "px"; hilite.style.height = elemRect.height + offset * 2 + "px"; hilite.style.display = "block"; } hiliteOnMove(event: MouseEvent) { const elem = this.hiliteFindBestElement(event.clientX, event.clientY); if (elem && this.hiliteElem === elem) { return; } const getSrc = (elem: HTMLElement | null): string => { if (!elem) { return ""; } if ((elem as HTMLImageElement).currentSrc) { return (elem as HTMLImageElement).currentSrc; } if (elem.tagName === "image") { const href = elem.getAttribute("href"); if (href) { return href; } } if (elem.tagName === "svg") { if (!elem.getAttribute("xmlns")) { elem.setAttribute("xmlns", "http://www.w3.org/2000/svg"); } const buff = new TextEncoder().encode(elem.outerHTML); const blob = new Blob([buff], { type: "image/svg+xml" }); return URL.createObjectURL(blob); } const src = HTMLElement.prototype.getAttribute.call(elem, "src"); if (src) { return src; } if (elem.style.backgroundImage) { return elem.style.backgroundImage.replace( /(url\s*\(\s*[\\"']*)([^)'"]+)([\\"']*\s*\)?)/i, "$2", ); } return ""; }; const src = getSrc(elem); if (src) { const newSrc = src.replace( /([\d]*)([\w][\w]_)(\/(https:|http:)?\/)/, "$1dl_$3", ); this.replay.dispatchEvent( new CustomEvent("update-download-res-url", { detail: { url: newSrc } }), ); this.hiliteElem = elem; this.hiliteRecompute(); } else { this.clearHilite(); } } hiliteFindBestElement(x: number, y: number): HTMLElement | null { const elems = this.doc.elementsFromPoint(x, y) as HTMLElement[]; if (!elems.length) { return null; } const firstElem = elems[0]; const containsRect = (rect: DOMRect) => { return ( rect.x >= firstRect.x && rect.y >= firstRect.y && rect.width <= firstRect.width && rect.height <= firstRect.height ); }; // allow other elements only if they're within same bounding rect // as the deepest element const firstRect = firstElem.getBoundingClientRect(); let isLast = false; for (const elem of elems) { const rect = elem.getBoundingClientRect(); if (elem === firstElem || containsRect(rect)) { if ( (elem as HTMLImageElement).currentSrc || (elem.hasAttribute("src") && !(elem instanceof HTMLIFrameElement)) ) { return elem; } if (elem.tagName === "image" && elem.hasAttribute("href")) { return elem; } if (elem.tagName === "svg") { return elem; } } else { isLast = true; } const subelem = elem.querySelector("[src]:not(script)") || elem.querySelector(`div[style*="background-image: url("]`); if (subelem) { const subRect = subelem.getBoundingClientRect(); if ( containsRect(subRect) && x >= subRect.left && y >= subRect.top && x < subRect.right && y <= subRect.bottom ) { return subelem as HTMLElement | null; } } if (isLast) { break; } } return null; } } customElements.define("wr-coll-replay", Replay); export { Replay };
Replay Web Page
Enter a URL above to replay it from the web archive!
(Or, check out Pages or URLs to explore the contents of this archive.)
Authorization Needed
This archive is loaded from a local file: ${this.authFileHandle.name}
The browser needs to confirm your permission to continue loading from this file.