fix: Fully load thumbnail before save (#2307)

Fixes https://github.com/webrecorder/browsertrix/issues/2306

## Changes

Refactors collection view configuration to wait for thumbnail preview
image (using `URL.createObjectURL`, like in QA screenshots) to be fully
loaded from `replay-web-page` before saving.
This commit is contained in:
sua yoo 2025-01-15 22:58:32 -08:00 committed by GitHub
parent 4583babecb
commit a64f3a6c4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 220 additions and 74 deletions

View File

@ -6,16 +6,14 @@ import { customElement, property, query, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js"; import { when } from "lit/directives/when.js";
import queryString from "query-string"; import queryString from "query-string";
import {
HomeView,
type CollectionSnapshotPreview,
} from "./collection-snapshot-preview";
import type { SelectSnapshotDetail } from "./select-collection-start-page"; import type { SelectSnapshotDetail } from "./select-collection-start-page";
import { BtrixElement } from "@/classes/BtrixElement"; import { BtrixElement } from "@/classes/BtrixElement";
import type { Dialog } from "@/components/ui/dialog"; import type { Dialog } from "@/components/ui/dialog";
import { formatRwpTimestamp } from "@/utils/replay";
enum HomeView {
Pages = "pages",
URL = "url",
}
/** /**
* @fires btrix-change * @fires btrix-change
@ -76,11 +74,11 @@ export class CollectionStartPageDialog extends BtrixElement {
private readonly form?: HTMLFormElement | null; private readonly form?: HTMLFormElement | null;
@query("#thumbnailPreview") @query("#thumbnailPreview")
private readonly thumbnailPreview?: HTMLIFrameElement | null; private readonly thumbnailPreview?: CollectionSnapshotPreview | null;
willUpdate(changedProperties: PropertyValues<this>) { willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("homeUrl") && this.homeUrl) { if (changedProperties.has("homeUrl")) {
this.homeView = HomeView.URL; this.homeView = this.homeUrl ? HomeView.URL : HomeView.Pages;
} }
} }
@ -89,7 +87,7 @@ export class CollectionStartPageDialog extends BtrixElement {
this.homeView === HomeView.URL && !this.selectedSnapshot; this.homeView === HomeView.URL && !this.selectedSnapshot;
return html` return html`
<btrix-dialog <btrix-dialog
.label=${msg("Configure Replay Home")} .label=${msg("Configure Replay View")}
.open=${this.open} .open=${this.open}
class="[--width:60rem]" class="[--width:60rem]"
@sl-show=${() => (this.showContent = true)} @sl-show=${() => (this.showContent = true)}
@ -142,11 +140,6 @@ export class CollectionStartPageDialog extends BtrixElement {
} }
private renderPreview() { private renderPreview() {
let urlPreview = html`
<p class="m-3 text-pretty text-neutral-500">
${msg("Enter a Page URL to preview it")}
</p>
`;
const snapshot = const snapshot =
this.selectedSnapshot || this.selectedSnapshot ||
(this.homeUrl (this.homeUrl
@ -154,22 +147,19 @@ export class CollectionStartPageDialog extends BtrixElement {
url: this.homeUrl, url: this.homeUrl,
ts: this.homeTs, ts: this.homeTs,
pageId: this.homePageId, pageId: this.homePageId,
status: 200,
} }
: null); : null);
if (snapshot) { const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`;
urlPreview = html` // TODO Get query from replay-web-page embed
<sl-tooltip hoist> const query = queryString.stringify({
<iframe source: replaySource,
class="inline-block size-full" customColl: this.collectionId,
id="thumbnailPreview" embed: "default",
src=${`/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`} noCache: 1,
> noSandbox: 1,
</iframe> });
<span slot="content" class="break-all">${snapshot.url}</span>
</sl-tooltip>
`;
}
return html` return html`
<div <div
@ -177,13 +167,15 @@ export class CollectionStartPageDialog extends BtrixElement {
? "flex items-center justify-center" ? "flex items-center justify-center"
: ""} relative aspect-video overflow-hidden rounded-lg border bg-slate-50" : ""} relative aspect-video overflow-hidden rounded-lg border bg-slate-50"
> >
${when( <btrix-collection-snapshot-preview
this.homeView === HomeView.URL && this.replayLoaded, class="contents"
() => urlPreview, id="thumbnailPreview"
)} collectionId=${this.collectionId || ""}
<div class="${this.homeView === HomeView.URL ? "offscreen" : ""}"> view=${this.homeView}
${this.renderReplay()} replaySrc=${`/replay/?${query}#view=pages`}
</div> .snapshot=${snapshot}
>
</btrix-collection-snapshot-preview>
${when( ${when(
!this.replayLoaded, !this.replayLoaded,
@ -281,34 +273,18 @@ export class CollectionStartPageDialog extends BtrixElement {
`; `;
} }
private renderReplay() {
const replaySource = `/api/orgs/${this.orgId}/collections/${this.collectionId}/replay.json`;
// TODO Get query from replay-web-page embed
const query = queryString.stringify({
source: replaySource,
customColl: this.collectionId,
embed: "default",
noCache: 1,
noSandbox: 1,
});
return html`<div class="aspect-video w-[200%]">
<div class="pointer-events-none aspect-video origin-top-left scale-50">
<iframe
class="inline-block size-full"
src=${`/replay/?${query}#view=pages`}
></iframe>
</div>
</div>`;
}
private async onSubmit(e: SubmitEvent) { private async onSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
const form = e.currentTarget as HTMLFormElement; const form = e.currentTarget as HTMLFormElement;
const { homeView, useThumbnail } = serialize(form); const { homeView, useThumbnail } = serialize(form);
if (homeView === HomeView.Pages && !this.homePageId) { if (
(homeView === HomeView.Pages && !this.homePageId) ||
(homeView === HomeView.URL &&
this.selectedSnapshot &&
this.homePageId === this.selectedSnapshot.pageId)
) {
// No changes to save // No changes to save
this.open = false; this.open = false;
return; return;
@ -322,8 +298,6 @@ export class CollectionStartPageDialog extends BtrixElement {
(homeView === HomeView.URL && this.selectedSnapshot?.pageId) || null, (homeView === HomeView.URL && this.selectedSnapshot?.pageId) || null,
}); });
this.dispatchEvent(new CustomEvent("btrix-change"));
const shouldUpload = const shouldUpload =
homeView === HomeView.URL && homeView === HomeView.URL &&
useThumbnail === "on" && useThumbnail === "on" &&
@ -333,19 +307,13 @@ export class CollectionStartPageDialog extends BtrixElement {
const fileName = `page-thumbnail_${this.selectedSnapshot?.pageId}.jpeg`; const fileName = `page-thumbnail_${this.selectedSnapshot?.pageId}.jpeg`;
let file: File | undefined; let file: File | undefined;
if (shouldUpload && this.thumbnailPreview?.src) { if (shouldUpload && this.thumbnailPreview) {
const { src } = this.thumbnailPreview; const blob = await this.thumbnailPreview.thumbnailBlob;
// Wait to get the thumbnail image before closing the dialog
try {
const resp = await this.thumbnailPreview.contentWindow!.fetch(src);
const blob = await resp.blob();
if (blob) {
file = new File([blob], fileName, { file = new File([blob], fileName, {
type: blob.type, type: blob.type,
}); });
} catch (err) {
console.debug(err);
} }
} else { } else {
this.notify.toast({ this.notify.toast({
@ -387,6 +355,8 @@ export class CollectionStartPageDialog extends BtrixElement {
}); });
} }
} }
this.dispatchEvent(new CustomEvent("btrix-change"));
} catch (err) { } catch (err) {
console.debug(err); console.debug(err);

View File

@ -0,0 +1,172 @@
import { localized, msg } from "@lit/localize";
import { Task } from "@lit/task";
import clsx from "clsx";
import { html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { SelectSnapshotDetail } from "./select-collection-start-page";
import { TailwindElement } from "@/classes/TailwindElement";
import { formatRwpTimestamp } from "@/utils/replay";
import { tw } from "@/utils/tailwind";
export enum HomeView {
Pages = "pages",
URL = "url",
}
/**
* Display preview of page snapshot.
*
* A previously loaded `replay-web-page` embed is required in order for preview to work.
*/
@customElement("btrix-collection-snapshot-preview")
@localized()
export class CollectionSnapshotPreview extends TailwindElement {
@property({ type: String })
collectionId = "";
@property({ type: String })
replaySrc = "";
@property({ type: String })
view?: HomeView;
@property({ type: Object })
snapshot?: SelectSnapshotDetail["item"];
@query("iframe")
private readonly iframe?: HTMLIFrameElement | null;
@state()
private iframeLoaded = false;
public get thumbnailBlob() {
return this.blobTask.taskComplete.finally(() => this.blobTask.value);
}
private readonly blobTask = new Task(this, {
task: async ([collectionId, snapshot, iframeLoaded]) => {
if (
!collectionId ||
!snapshot ||
!iframeLoaded ||
!this.iframe?.contentWindow
) {
return;
}
const resp = await this.iframe.contentWindow.fetch(
`/replay/w/${this.collectionId}/${formatRwpTimestamp(snapshot.ts)}id_/urn:thumbnail:${snapshot.url}`,
);
if (resp.status === 200) {
return await resp.blob();
}
throw new Error(`couldn't get thumbnail`);
},
args: () => [this.collectionId, this.snapshot, this.iframeLoaded] as const,
});
private readonly objectUrlTask = new Task(this, {
task: ([blob]) => {
if (!blob) return "";
const url = URL.createObjectURL(blob);
if (url) return url;
throw new Error("no object url");
},
args: () => [this.blobTask.value] as const,
});
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.objectUrlTask.value) {
URL.revokeObjectURL(this.objectUrlTask.value);
}
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
changedProperties.has("collectionId") ||
changedProperties.has("snapshot")
) {
if (this.objectUrlTask.value) {
URL.revokeObjectURL(this.objectUrlTask.value);
}
}
}
render() {
return html` ${this.renderSnapshot()} ${this.renderReplay()} `;
}
private renderSnapshot() {
if (this.view === HomeView.Pages) return;
return this.blobTask.render({
complete: this.renderImage,
pending: this.renderSpinner,
error: this.renderError,
});
}
private readonly renderImage = () => {
if (!this.snapshot) {
return html`
<p class="m-3 text-pretty text-neutral-500">
${msg("Enter a Page URL to preview it")}
</p>
`;
}
return html`
<div class="size-full">
<sl-tooltip hoist>
${this.objectUrlTask.render({
complete: (value) =>
value
? html`<img class="size-full" src=${value} />`
: this.renderSpinner(),
pending: () => "pending",
})}
<span slot="content" class="break-all">${this.snapshot.url}</span>
</sl-tooltip>
</div>
`;
};
private renderReplay() {
return html`<div
class=${clsx(tw`size-full`, this.view === HomeView.URL && tw`offscreen`)}
>
<div class="aspect-video w-[200%]">
<div class="pointer-events-none aspect-video origin-top-left scale-50">
<iframe
class="inline-block size-full"
src=${this.replaySrc}
@load=${() => {
this.iframeLoaded = true;
}}
></iframe>
</div>
</div>
</div>`;
}
private readonly renderError = () => html`
<p class="m-3 text-pretty text-danger">
${msg("Couldn't load preview. Try another snapshot")}
</p>
`;
private readonly renderSpinner = () => html`
<div class="flex size-full items-center justify-center text-2xl">
<sl-spinner></sl-spinner>
</div>
`;
}

View File

@ -246,7 +246,7 @@ export class ShareCollection extends BtrixElement {
@sl-after-hide=${() => { @sl-after-hide=${() => {
this.tabGroup?.show(Tab.Link); this.tabGroup?.show(Tab.Link);
}} }}
class="[--width:40rem] [--body-spacing:0]" class="[--body-spacing:0] [--width:40rem]"
> >
<sl-tab-group> <sl-tab-group>
<sl-tab slot="nav" panel=${Tab.Link} <sl-tab slot="nav" panel=${Tab.Link}

View File

@ -167,10 +167,14 @@ export class CollectionDetail extends BtrixElement {
<sl-button <sl-button
size="small" size="small"
@click=${() => (this.openDialogName = "editStartPage")} @click=${() => (this.openDialogName = "editStartPage")}
?disabled=${!this.collection?.crawlCount} ?disabled=${!this.collection?.crawlCount ||
!this.isRwpLoaded}
> >
<sl-icon name="house-gear" slot="prefix"></sl-icon> ${!this.collection ||
${msg("Configure Home")} Boolean(this.collection.crawlCount && !this.isRwpLoaded)
? html`<sl-spinner slot="prefix"></sl-spinner>`
: html`<sl-icon name="gear" slot="prefix"></sl-icon>`}
${msg("Configure View")}
</sl-button> </sl-button>
</sl-tooltip> </sl-tooltip>
`, `,
@ -400,8 +404,8 @@ export class CollectionDetail extends BtrixElement {
}} }}
?disabled=${!this.collection?.crawlCount} ?disabled=${!this.collection?.crawlCount}
> >
<sl-icon name="house-gear" slot="prefix"></sl-icon> <sl-icon name="gear" slot="prefix"></sl-icon>
${msg("Configure Replay Home")} ${msg("Configure Replay View")}
</sl-menu-item> </sl-menu-item>
<sl-menu-item <sl-menu-item
@click=${async () => { @click=${async () => {