Refactor AttachmentPreview render and discard (#4161)

This commit is contained in:
windingwind 2024-05-28 12:05:29 +08:00 committed by GitHub
parent a3857cb5e0
commit fe3dae2d15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 359 additions and 93 deletions

View file

@ -291,15 +291,10 @@
}
notify(event, _type, ids, _extraData) {
if (event != 'modify' || !this.item || !this.item.id) return;
for (let id of ids) {
if (id != this.item.id) {
continue;
}
if (event != 'modify' || !this.item?.id || !ids.includes(this.item.id)) return;
this._forceRenderAll();
break;
}
// Wait for the render finish and then refresh
this._waitForRender(this._forceRenderAll.bind(this));
}
async asyncRender() {
@ -313,11 +308,6 @@
// Cancel editing filename when refreshing
this._isEditingFilename = false;
if (this.usePreview) {
this._preview.item = this.item;
this._preview.render();
}
let fileNameRow = this._id('fileNameRow');
let urlField = this._id('url');
let accessed = this._id('accessedRow');
@ -457,6 +447,12 @@
else {
selectButton.hidden = true;
}
if (this.usePreview) {
this._preview.item = this.item;
await this._preview.render();
}
this._asyncRendering = false;
}
@ -658,6 +654,27 @@
_id(id) {
return this.querySelector(`#${id}`);
}
async _waitForRender(callback) {
let resolve, reject;
Promise.race([new Promise(((res, rej) => {
resolve = res;
reject = rej;
})), Zotero.Promise.delay(3000)]).then(() => callback());
let i = 0;
let finished = false;
// Wait for render to finish
while (i < 100) {
if (!this._asyncRendering) {
finished = true;
break;
}
await Zotero.Promise.delay(10);
i++;
}
if (finished) resolve();
else reject(new Error("AttachmentBox#_waitForRender timeout"));
}
}
customElements.define("attachment-box", AttachmentBox);

View file

@ -24,6 +24,12 @@
*/
{
class PreviewRenderAbortError extends Error {
constructor() {
super("AttachmentPreview render aborted");
}
}
class AttachmentPreview extends ItemPaneSectionElementBase {
static fileTypeMap = {
// TODO: support video and audio
@ -42,11 +48,52 @@
this._previewInitializePromise = Zotero.Promise.defer();
this._nextPreviewInitializePromise = Zotero.Promise.defer();
this._renderingItemID = null;
/**
* The most recent task to be processed
* @type {Object}
* @property {string} type
* @property {Object} data
* @property {number} data.itemID
* @property {string} data.previewType
*/
this._lastTask = null;
this._isDiscardPlanned = false;
/**
* The ID of the last item that was rendered
* @type {number}
*/
this._lastRenderID = null;
/**
* Whether a task is currently awaiting to be processed
* @type {boolean}
*/
this._isWaitingForTask = false;
/**
* Whether a task is currently being processed
* @type {boolean}
*/
this._isProcessingTask = false;
/**
* Whether a render task is currently being processed
* @type {boolean}
*/
this._isRendering = false;
/**
* Whether a discard task is currently being processed
* @type {boolean}
*/
this._isDiscarding = false;
this._failedCount = 0;
/**
* Whether the current preview reader is initialized by `Zotero.Reader.openPreview`.
* When the previous reader rendering task is aborted before initialization,
* reuse the reader; otherwise must discard the old reader first.
*/
this._isReaderInitialized = false;
this._resizeOb = new ResizeObserver(this._handleResize.bind(this));
}
@ -207,59 +254,182 @@
}
if (this.isMediaType) {
if (["refresh", "modify"].includes(event) && ids.includes(this.item.id)) {
this.discard().then(() => this.render());
this.render();
}
}
}
/**
* Queue a render task
* Immediately update the `_lastTask` property and wait for the current task to finish
* before processing the new task. This is to prevent multiple tasks from being processed
* at the same time. Only the most recent task will be processed.
* @returns {Promise<void>}
*/
async render() {
let itemID = this._item?.id;
if (!this.initialized && itemID === this._renderingItemID) {
this._lastTask = {
type: "render",
uid: `${Date.now()}-${Math.random()}`,
data: {
itemID: this._item?.id,
previewType: this.previewType
}
};
this._debug(`Queue render task, itemID: ${this._item?.id}, previewType: ${this.previewType}`);
await this._processTask();
}
/**
* Queue a discard task
*/
async discard() {
this._lastTask = {
type: "discard",
uid: `${Date.now()}-${Math.random()}`,
};
this._debug(`Queue discard task`);
await this._processTask();
}
/**
* Process the most recent task
* @returns {Promise<void>}
*/
async _processTask() {
if (!this.initialized || !this._lastTask || this._isWaitingForTask) {
this._debug("No task to process or already waiting for a processing task");
return;
}
// For tests
let resolve;
if (Zotero.test) {
this._renderPromise = new Promise(r => resolve = r);
// Expose `resolve` for `this.discard`
this._renderPromise.resolve = resolve;
this._isWaitingForTask = true;
// Wait for the current render/discard to finish
let i = 0;
while (i < 300 && (this._isRendering || this._isDiscarding || this._isProcessingTask)) {
await Zotero.Promise.delay(10);
i++;
}
this._renderingItemID = itemID;
this._debug("Current task finished, processing new task");
let task = this._lastTask;
if (!task) {
this._debug("No task to process");
this._isWaitingForTask = false;
return;
}
let uid = task.uid;
this._isWaitingForTask = false;
this._isProcessingTask = true;
// If no new task was queued while processing, clear the last task
if (this._lastTask.uid === uid) {
this._debug("Clear last task");
this._lastTask = null;
}
this._debug(`Processing task ${task.type} (${uid})`);
switch (task.type) {
case "render":
await Promise.race([this._processRender(task.data), Zotero.Promise.delay(3000)]);
break;
case "discard":
await Promise.race([this._processDiscard(task.data), Zotero.Promise.delay(3000)]);
break;
}
this._isProcessingTask = false;
// Force reset flag anyway to avoid blocking following tasks
this._isRendering = false;
this._isDiscarding = false;
this._debug(`Task ${task.type} (${uid}) processed`);
}
/**
* Render the preview for the given item
* First discard the current preview and then render the new preview
* The render task will be aborted if the item changes before the task is finished
* @returns {Promise<void>}
*/
async _processRender({ itemID, previewType }) {
if (this._lastRenderID === itemID && this.hasPreview) {
this._debug(`Item ${itemID} already rendered`);
return;
}
this._debug(`Rendering item ${itemID}, previewType: ${previewType}`);
this._isRendering = true;
let success = false;
if (this.isValidType && await this._item.fileExists()) {
try {
// Discard the current preview.
await this._processDiscard();
this._debug(`Discard finished, rendering item ${itemID}`);
this._tryAbortRender(itemID);
let item = Zotero.Items.get(itemID);
if (previewType !== "file" && await item.fileExists()) {
if (this.isReaderType) {
success = await this._renderReader();
success = await this._renderReader(itemID);
}
else if (this.isMediaType) {
success = await this._renderMedia();
}
}
if (itemID !== this._item?.id) {
return;
}
this._updateWidthHeightRatio();
this.setAttribute("data-preview-type", this.previewType);
this.setPreviewStatus(success ? "success" : "fail");
if (this._renderingItemID === itemID) {
this._renderingItemID = null;
}
if (Zotero.test) {
resolve();
success = await this._renderMedia(itemID);
}
}
async discard(force = false) {
if (!this.initialized) {
return;
}
this._isDiscardPlanned = false;
if (this._isDiscarding) {
return;
}
if (!force && (this.isVisible || !this._reader)) {
this._tryAbortRender(itemID);
this._updateWidthHeightRatio();
this.setAttribute("data-preview-type", this.previewType);
this._lastRenderID = itemID;
this._debug(`Render not aborted, item ${itemID}`);
}
catch (e) {
if (!(e instanceof PreviewRenderAbortError)) {
this.setPreviewStatus("fail");
this._debug(`Render failed: item ${itemID}, ${e}`);
throw e;
}
}
finally {
this.setPreviewStatus(success ? "success" : "fail");
this._isRendering = false;
this._debug(`Render processed, item ${itemID} ${success ? "succeeded" : "failed"}`);
}
}
/**
* @throws {PreviewRenderAbortError}
* @param {number} itemID
*/
_tryAbortRender(itemID) {
if (itemID !== this._item?.id) {
throw new PreviewRenderAbortError();
}
}
/**
* Discard the current preview if it exists and is initialized
* @returns {Promise<void>}
*/
async _processDiscard() {
if (!this._isReaderInitialized && !this._lastRenderID) {
this._debug("No preview to discard");
return;
}
this._debug("Discard preview");
this._isDiscarding = true;
if (this._reader) {
let _reader = this._reader;
@ -275,13 +445,23 @@
if (nextPreview) {
nextPreview.id = "preview";
}
this._debug("Preview discarded");
// Preload a new next-preview
await this._nextPreviewInitializePromise.promise;
this._nextPreviewInitializePromise = Zotero.Promise.defer();
this._debug("Next preview initialized");
this._id("preview")?.after(this.nextPreview);
this.setPreviewStatus("loading");
// Clean up after discarding
this._isDiscarding = false;
this._renderPromise?.resolve();
this._lastRenderID = null;
this._isReaderInitialized = false;
this._debug("Discard processed");
}
async openAttachment(event) {
@ -352,43 +532,45 @@
}
}
async _renderReader() {
/**
* Render the reader for the given item
* @throws {PreviewRenderAbortError}
* @param {number} itemID
*/
async _renderReader(itemID) {
this.setPreviewStatus("loading");
// This only need to be awaited during first load
await this._previewInitializePromise.promise;
// This should be awaited in the following refreshes
await this._nextPreviewInitializePromise.promise;
this._tryAbortRender(itemID);
let prev = this._id("prev");
let next = this._id("next");
prev && (prev.disabled = true);
next && (next.disabled = true);
let success = false;
if (this._reader?._item?.id !== this._item?.id) {
await this.discard(true);
this._reader = await Zotero.Reader.openPreview(this._item.id, this._id("preview"));
let preview = this._id("preview");
this._debug(`Loading preview render for item id ${itemID}, iframe is ${preview}`);
// The reader will be initialized if the operation is not aborted before this point
// and we'll need to discard the reader even if the operation is not finished
this._isReaderInitialized = true;
this._reader = await Zotero.Reader.openPreview(itemID, preview);
this._tryAbortRender(itemID);
success = await this._reader._open({});
// Retry 3 times if failed
if (!success && this._failedCount < 3) {
this._nextPreviewInitializePromise.resolve();
this._failedCount++;
// If failed on half-way of initialization, discard it
this.discard(true);
setTimeout(() => {
// Try to re-render later
this.render();
}, 500);
}
}
else {
success = true;
}
if (success) this._failedCount = 0;
prev && (prev.disabled = true);
next && (next.disabled = false);
return success;
}
async _renderMedia() {
this.setPreviewStatus("loading");
let mediaLoadPromise = new Zotero.Promise.defer();
let mediaID = `${this.previewType}-preview`;
let media = this._id(mediaID);
@ -465,6 +647,11 @@
_id(id) {
return this.querySelector(`#${id}`);
}
_debug(message, ...args) {
if (!Zotero.test) return;
Zotero.debug(`[AttachmentPreview] ${message}`, ...args);
}
}
customElements.define("attachment-preview", AttachmentPreview);

View file

@ -192,7 +192,7 @@
async updatePreview() {
// Skip if asyncRender is not finished/executed, which means the box is invisible
// The box will be rendered when it becomes visible
if (this._renderStage !== "final") {
if (!this.initialized || this._renderStage !== "final") {
return;
}
let attachment = await this._getPreviewAttachment();

View file

@ -2,24 +2,27 @@ describe("Item pane", function () {
var win, doc, ZoteroPane, Zotero_Tabs, ZoteroContextPane, itemsView;
async function waitForPreviewBoxRender(box) {
let success = await waitForCallback(
() => box._asyncRenderItemID && !box._asyncRendering);
if (!success) {
throw new Error("Wait for box render time out");
let res = await waitForCallback(
() => box._asyncRenderItemID && !box._asyncRendering,
100, 3);
if (res instanceof Error) {
throw res;
}
await box._preview._renderPromise;
return success;
return true;
}
async function waitForPreviewBoxReader(box, itemID) {
let preview = box._preview;
await waitForPreviewBoxRender(box);
let success = await waitForCallback(
() => box._preview._reader?.itemID == itemID, 100, 3000);
if (!success) {
throw new Error("Wait for box preview reader time out");
let res = await waitForCallback(
() => preview._reader?.itemID == itemID
&& !preview._isProcessingTask && !preview._lastTask
, 100, 3);
if (res instanceof Error) {
throw res;
}
await box._preview._reader._initPromise;
return success;
await preview._reader._initPromise;
return true;
}
function isPreviewDisplayed(box) {
@ -386,6 +389,7 @@ describe("Item pane", function () {
afterEach(function () {
Zotero_Tabs.select("zotero-pane");
Zotero_Tabs.closeAll();
});
it("should show attachments pane in library for regular item", async function () {
@ -676,7 +680,7 @@ describe("Item pane", function () {
* AttachmentsBox serves as a good example since it involves both sync and async rendering.
* If this test fails, it is not recommended to add timeouts as a quick fix.
*/
it.skip("should keep attachments pane status after changing selection", async function () {
it("should keep attachments pane status after changing selection", async function () {
let itemDetails = ZoteroPane.itemPane._itemDetails;
let attachmentsBox = itemDetails.getPane(paneID);
let preview = attachmentsBox._preview;
@ -845,6 +849,60 @@ describe("Item pane", function () {
// Should open attachment
assert.equal(reader.itemID, attachment.id);
});
it("should render preview robustly after making dense calls to render and discard", async function () {
let itemDetails = ZoteroPane.itemPane._itemDetails;
let attachmentsBox = itemDetails.getPane(paneID);
let preview = attachmentsBox._preview;
// Pin the pane to avoid always scrolling to the section
itemDetails.pinnedPane = paneID;
// item with attachment
let item1 = new Zotero.Item('book');
await item1.saveTx();
let file1 = getTestDataDirectory();
file1.append('test.pdf');
let attachment1 = await Zotero.Attachments.importFromFile({
file: file1,
parentItemID: item1.id
});
let item2 = new Zotero.Item('book');
await item2.saveTx();
let file2 = getTestDataDirectory();
file2.append('test.pdf');
let attachment2 = await Zotero.Attachments.importFromFile({
file: file2,
parentItemID: item2.id
});
let selectionMap = [item1.id, item2.id];
// Repeat render/discard multiple times
for (let i = 0; i < 10; i++) {
await ZoteroPane.selectItem(selectionMap[i % 2]);
// No await, since the render/discard may be triggered at any time in actual usage
preview.discard();
preview.render();
}
// Wait for the last render/discard task to finish
await waitForCallback(() => !preview._isRendering && !preview._isDiscarding
&& !preview._isProcessingTask && !preview._isWaitingForTask
&& !preview._lastTask);
// Should be able to render the correct preview
await ZoteroPane.selectItem(item1.id);
await waitForPreviewBoxReader(attachmentsBox, attachment1.id);
assert.isTrue(isPreviewDisplayed(attachmentsBox));
await ZoteroPane.selectItem(item2.id);
await waitForPreviewBoxReader(attachmentsBox, attachment2.id);
assert.isTrue(isPreviewDisplayed(attachmentsBox));
itemDetails.pinnedPane = "";
});
});
@ -972,6 +1030,7 @@ describe("Item pane", function () {
afterEach(function () {
Zotero_Tabs.select("zotero-pane");
Zotero_Tabs.closeAll();
});
it("should refresh on file rename", async function () {
@ -1002,9 +1061,12 @@ describe("Item pane", function () {
let paneHeader = doc.getElementById('zotero-item-pane-header');
let label = paneHeader.titleField;
let promise = waitForDOMAttributes(label, 'value', (newValue) => {
let promise = Promise.all([
waitForDOMAttributes(label, 'value', (newValue) => {
return newValue === newTitle;
});
}),
waitForItemEvent('modify')
]);
item.setField('title', newTitle);
await item.saveTx();
@ -1013,7 +1075,7 @@ describe("Item pane", function () {
assert.equal(label.value, newTitle);
});
it.skip("should show attachment pane in library for attachment item", async function () {
it("should show attachment pane in library for attachment item", async function () {
// Regular item: hide
let itemDetails = ZoteroPane.itemPane._itemDetails;
let box = itemDetails.getPane(paneID);