citation dlg: suggest page locator from opened doc

Display a suggestion popup with the current page label
after an open document item is added as a bubble. Listen
to the notifications of the reader tab state changes
and update the page in the popup if the user scrolls the doc.

When the tab is being unloaded, store the last page label in
reader state file and use in the future to get page
suggestion for unloaded tab.

The suggestion popup appears below the bubble-input and
can be accepted via click or arrow down + Enter.
When the popup is visible, the first keypress
dismisses the popup. The popup does not consume keypress,
except for Escape, so one can continue typing.

Fixes: #6001
This commit is contained in:
Bogdan Abaev 2026-09-03 17:48:19 -07:00 • committed by Dan Stillman
parent 1086d80cec
commit c0d00a6f43
8 changed files with 514 additions and 3 deletions

View file

@ -196,6 +196,10 @@ function onUnload() {
}
function cleanupBeforeDialogClosing() {
if (IOManager._notifierID) {
Zotero.Notifier.unregisterObserver(IOManager._notifierID);
IOManager._notifierID = null;
}
if (!currentLayout || !libraryLayout) return;
// Save window params for current layout mode so we can restore it on next dialog open
@ -1274,6 +1278,8 @@ const IOManager = {
doc.addEventListener("focus-item-tree", ({ detail }) => this.focusItemTree(detail));
// update bubbles after citation item is updated by itemDetails popup
doc.addEventListener("item-details-updated", () => this.updateBubbleInput());
// add the page suggested from an opened reader tab as a locator
doc.addEventListener("page-suggestion-accepted", ({ detail: { page } }) => this._applyPageSuggestion(page));
doc.addEventListener("DOMMenuBarActive", () => this._handleMenuBarAppearance());
@ -1310,6 +1316,9 @@ const IOManager = {
_id("bubble-input").addEventListener("focusout", event => this._clearJustAddedBubbles(event));
// handle cmd/ctrl-z pressed from the input to undo added locator to a just-added bubble
_id("bubble-input").addEventListener("keydown", event => this._handleInputUndo(event));
// keep the suggested page in sync with the page displayed by the reader tab
this._notifierID = Zotero.Notifier.registerObserver(this, ['file'], 'citationDialog');
},
// switch between list and library modes
@ -1434,6 +1443,9 @@ const IOManager = {
// Add entries into the citation with the current locator if specified
let bubbleItems = items.map(item => BubbleItem.fromItem(item));
// Whether to suggest the page of a reader tab with an attachment of the
// added item as a locator once the bubble is added
let suggestPage = false;
if (locator) {
for (let bubbleItem of bubbleItems) {
bubbleItem.locator = locator.locator;
@ -1450,6 +1462,8 @@ const IOManager = {
// that, the user presumably knows about the shortcut
_id("bubble-input").showJustAddedPlaceholder = DIALOG_STATE.isCitingItems()
&& this._timesItemsAdded < 1;
// If the item is opened in a reader tab, its page is suggested as a locator
suggestPage = DIALOG_STATE.isCitingItems();
}
else {
// A multi-item add doesn't enter locator-typing mode, so typed text
@ -1466,12 +1480,14 @@ const IOManager = {
this.updateBubbleInput();
// Show guidance panel on the first run
let guidanceShown = false;
if (DIALOG_STATE.isCitingItems() && !Zotero.Prefs.get("firstRunGuidanceShown.citationDialog")) {
doc.querySelector(".bubble").id = "first-bubble";
// Center the panel on the first bubble
let width = doc.querySelector(".bubble").getBoundingClientRect().width;
doc.querySelector("guidance-panel").setAttribute("x", Math.round(width / 2));
IOManager.showFirstRunDialog();
guidanceShown = true;
}
// Render the preview before refreshing the list so resizeWindow measures its real height;
// otherwise the debounced render lands after the resize and overflows the window.
@ -1481,6 +1497,18 @@ const IOManager = {
if (!noInputRefocus) {
_id("bubble-input").refocusInput();
}
// Suggest the page of the opened document, unless the first run guidance panel
// is already displayed next to the same bubble
if (suggestPage && !guidanceShown) {
let page = await Helpers.getOpenTabPage(items[0]);
// The bubble the locator would be added to may be gone by now
if (page && this._justAddedBubbles) {
PopupsHandler.openPageSuggestion({
page,
anchor: _id("bubble-input").getCurrentInput()
});
}
}
dialogNotPristine();
},
@ -1910,6 +1938,33 @@ const IOManager = {
_processNumericLocatorInputDebounced: Zotero.Utilities.debounce(() => IOManager._processNumericLocatorInput(), NUMERIC_LOCATOR_TIMEOUT),
// The reader fires 'pageChange' for its attachment whenever the displayed page changes.
// If that attachment belongs to the item whose page is being suggested, refresh the suggestion.
async notify(action, type, ids) {
if (type != 'file' || action != 'pageChange') return;
if (!this._justAddedBubbles || !PopupsHandler.isPageSuggestionOpen()) return;
let item = this._justAddedBubbles[0].item;
let attachmentIDs = item.getAttachments();
if (!ids.some(id => attachmentIDs.includes(id))) return;
let page = await Helpers.getOpenTabPage(item);
// The suggestion may have been accepted or dismissed while the page was being fetched
if (!PopupsHandler.isPageSuggestionOpen()) return;
PopupsHandler.updatePageSuggestion(page);
},
// Add the page suggested from an opened reader tab as a locator of the just-added bubble
_applyPageSuggestion(page) {
if (!this._justAddedBubbles) return;
for (let bubbleItem of this._justAddedBubbles) {
bubbleItem.locator = page;
bubbleItem.label = "page";
}
// Clearing just-added bubbles refreshes bubble-input
this._clearJustAddedBubbles();
_id("bubble-input").refocusInput();
dialogNotPristine();
},
// Clear the record of which bubbles were just added. If a locator is typed
// and Enter is presses, just-added bubbles get that locator.
_clearJustAddedBubbles(event) {
@ -1926,6 +1981,8 @@ const IOManager = {
// clear just added bubbles and update bubble input to reflect that
this._justAddedBubbles = null;
_id("bubble-input").showJustAddedPlaceholder = false;
// the page suggestion has no bubble to apply the locator to anymore
PopupsHandler.closePageSuggestion();
this.updateBubbleInput();
},

View file

@ -197,6 +197,9 @@
</div>
</div>
</xul:panel>
<xul:panel id="page-suggestion" noautohide="true" noautofocus="true" consumeoutsideclicks="false" role="listbox">
<div id="page-suggestion-option" role="option" aria-selected="false"></div>
</xul:panel>
<xul:guidance-panel about="citationDialog" for="first-bubble"/>
</div>
</body>

View file

@ -506,4 +506,32 @@ export class CitationDialogHelpers {
resolve();
}, delay);
}
// Get the page the current page in reader tab for a given top level item.
// If the tab is unloaded, try to use the last page saved by the reader before the tab was
// unloaded.
// Returns the page if appropriate or null otherwise.
async getOpenTabPage(item) {
if (!item) return null;
let win = Zotero.getMainWindow();
if (!win) return null;
let tabs = item.getAttachments()
.map(attachmentID => ({ attachmentID, tabID: win.Zotero_Tabs.getTabIDByItemID(attachmentID) }))
.filter(({ tabID }) => tabID);
// Look at the selected tab first
tabs.sort((a, b) => {
if (a.tabID === win.Zotero_Tabs.selectedID) return -1;
if (b.tabID === win.Zotero_Tabs.selectedID) return 1;
return 0;
});
for (let { attachmentID, tabID } of tabs) {
let reader = Zotero.Reader.getByTabID(tabID);
let page = reader
? reader.getCurrentPage()
: await Zotero.Reader.getSavedPageLabel(attachmentID);
if (page) return page;
}
return null;
}
}

View file

@ -48,6 +48,8 @@ export class CitationDialogKeyboardHandler {
// capturing keydown listener to handle keypresses regardless of if they are handled by
// lower-level components
captureKeydown(event) {
// Page suggestion popup takes priority over all other keyboard handling.
if (this._handlePageSuggestionKeydown(event)) return;
let cmdOrCtrl = Zotero.isMac ? event.metaKey : event.ctrlKey;
// Cmd/Ctrl-Enter will always accept the dialog regardless of the target
if (event.key == "Enter" && cmdOrCtrl) {
@ -83,6 +85,52 @@ export class CitationDialogKeyboardHandler {
}
}
// Handle keypresses while the page suggestion popup is opened. ArrowDown highlights
// the suggestion and Enter then adds it as a locator. Escape dismisses the popup
// without closing the dialog, so a second Escape is needed to close it.
// Any other keypress dismisses the popup but is otherwise handled as usual.
// @returns {Boolean} true if the keypress was handled and should not propagate
_handlePageSuggestionKeydown(event) {
let panel = this._id("page-suggestion");
if (!["open", "showing"].includes(panel.state)) return false;
let option = this._id("page-suggestion-option");
let highlighted = option.classList.contains("highlighted");
let noModifiers = !['ctrlKey', 'metaKey', 'shiftKey', 'altKey'].some(key => event[key]);
let handled = true;
if (event.key == "ArrowDown" && !highlighted && noModifiers) {
this._highlightPageSuggestion(true);
}
else if (event.key == "ArrowUp" && highlighted && noModifiers) {
this._highlightPageSuggestion(false);
}
// Enter with a modifier is left alone, so that e.g. Cmd/Ctrl-Enter still accepts the dialog
else if (event.key == "Enter" && highlighted && noModifiers) {
option.click();
}
else if (event.key == "Escape") {
panel.hidePopup();
}
else {
handled = false;
// Modifiers on their own are not a keypress one can dismiss the popup with
if (!["Shift", "Control", "Alt", "Meta"].includes(event.key)) {
panel.hidePopup();
}
}
if (handled) {
event.stopPropagation();
event.preventDefault();
}
return handled;
}
_highlightPageSuggestion(highlighted) {
this.doc.dispatchEvent(new CustomEvent("page-suggestion-highlight", {
bubbles: true,
detail: { highlighted }
}));
}
_handleTopLevelKeydown(event) {
let handled = false;
let tgt = event.target;

View file

@ -34,6 +34,7 @@ export class CitationDialogPopupsHandler {
this.discardItemDetailsEdits = false;
this.itemDetailsWhenOpened = {};
this.itemDetailsTimeOpened = null;
this.pageSuggestion = null;
this.dialogState = dialogState;
@ -44,6 +45,8 @@ export class CitationDialogPopupsHandler {
this.doc.addEventListener("popupshown", (event) => {
// make sure overlay doesn't appear on tooltips and etc.
if (event.target.tagName !== "xul:panel") return;
// page suggestion is never focused, so typing can continue uninterrupted
if (event.target.id == "page-suggestion") return;
// if focus is not in the panel tab into it
if (!event.target.contains(this.doc.activeElement)) {
Services.focus.moveFocus(this.doc.defaultView, event.target, Services.focus.MOVEFOCUS_FORWARD, 0);
@ -85,6 +88,91 @@ export class CitationDialogPopupsHandler {
if (this._getNode("#itemDetails").state !== "open") return;
this.captureItemDetailsKeyDown(event);
}, true);
// Clicking the page suggestion adds it as a locator
this._getNode("#page-suggestion-option").addEventListener("click", () => this.acceptPageSuggestion());
// Highlight the suggestion as one arrows into and out of it
this.doc.addEventListener("page-suggestion-highlight", ({ detail: { highlighted } }) => {
this.setPageSuggestionHighlighted(highlighted);
});
// Any click outside of the page suggestion dismisses it. The click itself is not
// consumed, so it still does whatever it would have done otherwise.
this.doc.addEventListener("mousedown", (event) => {
if (!this.isPageSuggestionOpen()) return;
if (event.target.closest("#page-suggestion")) return;
this.closePageSuggestion();
}, true);
// Discard the suggestion if the popup is hidden by any other means
this._getNode("#page-suggestion").addEventListener("popuphidden", () => {
this.pageSuggestion = null;
});
}
// Suggest the page currently opened in a reader tab as a locator for the
// just-added bubble. The popup is anchored to the input one is typing in.
openPageSuggestion({ page, anchor }) {
if (!page || !anchor) return;
let panel = this._getNode("#page-suggestion");
// A popup that is still hiding cannot be opened again, so wait it out
if (panel.state == "hiding") {
panel.addEventListener("popuphidden", () => this.openPageSuggestion({ page, anchor }), { once: true });
return;
}
this._setPageSuggestion(page);
this.setPageSuggestionHighlighted(false);
if (panel.state == "closed") {
panel.openPopup(anchor, "after_start", 0, 4, false, false, null);
}
else {
panel.moveToAnchor(anchor, "after_start", 0, 4);
}
}
// Replace the suggested page while the popup is opened, e.g. after the reader
// tab is scrolled to another page. The highlight is kept, so a suggestion one
// has already arrowed into can still be accepted with Enter.
updatePageSuggestion(page) {
if (!this.isPageSuggestionOpen()) return;
if (!page) {
this.closePageSuggestion();
return;
}
this._setPageSuggestion(page);
}
_setPageSuggestion(page) {
this.pageSuggestion = { page };
// Show the page as a localized short locator (e.g. "p.10")
this._getNode("#page-suggestion-option").textContent = Zotero.Cite.getLocatorString("page", "short").toLowerCase() + " " + page;
}
closePageSuggestion() {
if (!this.isPageSuggestionOpen()) return;
this._getNode("#page-suggestion").hidePopup();
this.pageSuggestion = null;
}
// The popup counts as opened while it is still being shown, so that it can be
// dismissed by a keypress or a click that happens right after it appears
isPageSuggestionOpen() {
return ["open", "showing"].includes(this._getNode("#page-suggestion").state);
}
setPageSuggestionHighlighted(highlighted) {
let option = this._getNode("#page-suggestion-option");
option.classList.toggle("highlighted", highlighted);
option.setAttribute("aria-selected", highlighted);
}
// Tell the citation dialog to add the suggested page as a locator
acceptPageSuggestion() {
if (!this.pageSuggestion) return;
let { page } = this.pageSuggestion;
this.closePageSuggestion();
this.doc.dispatchEvent(new CustomEvent("page-suggestion-accepted", {
bubbles: true,
detail: { page }
}));
}
openItemDetails(bubbleItem, itemDescription) {

View file

@ -40,6 +40,7 @@ const ARRAYBUFFER_MAX_LENGTH = Services.appinfo.is64Bit
? Math.pow(2, 33)
: Math.pow(2, 32) - 1;
const READER_STATE_FILE_NAME = '.zotero-reader-state';
const READ_ALOUD_ENABLED_VOICES_PATH = PathUtils.join(Zotero.Profile.dir, 'readAloudEnabledVoices.json');
const READ_ALOUD_VOICE_DEFAULTS_PATH = PathUtils.join(Zotero.Profile.dir, 'readAloudVoiceDefaults.json');
@ -48,7 +49,7 @@ let readAloudCachePruned = false;
class ReaderInstance {
constructor(options) {
this.stateFileName = '.zotero-reader-state';
this.stateFileName = READER_STATE_FILE_NAME;
this.annotationItemIDs = [];
this._item = options.item;
this._instanceID = Zotero.Utilities.randomString();
@ -115,6 +116,25 @@ class ReaderInstance {
return state ? JSON.parse(JSON.stringify(state)) : undefined;
}
/**
* Get the page currently displayed in the active view, as shown in the reader's toolbar.
*
* @returns {String|null} - Page, or null if the reader has no pages.
*/
getCurrentPage() {
let state = this._internalReader?._state;
if (!state) return null;
let stats = state.primary ? state.primaryViewStats : state.secondaryViewStats;
if (!stats || stats.pageIndex === undefined) return null;
if (this._type === 'pdf') {
return stats.pageLabel || String(stats.pageIndex + 1);
}
if (this._type === 'epub' && stats.usePhysicalPageNumbers) {
return stats.pageLabel || null;
}
return null;
}
async migrateMendeleyColors(libraryID, annotations) {
let colorMap = new Map();
colorMap.set('#fff5ad', '#ffd400');
@ -1042,6 +1062,10 @@ class ReaderInstance {
}
}
// Save the page displayed at the time of this state, so that it is available
// while the reader is not loaded (e.g. for an unloaded tab)
state.pageLabel = this.getCurrentPage();
let file = Zotero.Attachments.getStorageDirectory(item);
if (!(await OS.File.exists(file.path))) {
await Zotero.Attachments.createDirectoryForItem(item);
@ -2886,7 +2910,41 @@ class Reader {
getByTabID(tabID) {
return this._readers.find(r => (r instanceof ReaderTab) && r.tabID === tabID);
}
/**
* Get the page that was displayed when the reader for the given attachment last saved
* its state by reading the state file.
* @param {Number} itemID - Attachment item ID
* @returns {Promise<String|null>} - Page, or null if none was saved or if it is
* out of date with the last page index stored for the item
*/
async getSavedPageLabel(itemID) {
try {
let item = Zotero.Items.get(itemID);
if (!item?.isFileAttachment()) return null;
let directory = Zotero.Attachments.getStorageDirectory(item);
let path = PathUtils.join(directory.path, READER_STATE_FILE_NAME);
if (!(await IOUtils.exists(path))) return null;
let state = await IOUtils.readJSON(path);
// The last page index is synced, so it can move past the position saved in the
// state file after reading on another device.
// If that happens, do not suggest the stale page label
let lastPageIndex = item.getAttachmentLastPageIndex();
if (lastPageIndex !== null) {
// PDFs store a page index, EPUBs store a CFI
let savedPosition = item.attachmentReaderType === 'epub' ? state.cfi : state.pageIndex;
if (savedPosition !== lastPageIndex) {
return null;
}
}
return state.pageLabel || null;
}
catch (e) {
Zotero.logError(e);
return null;
}
}
getWindowStates() {
return this._readers
.filter(r => r instanceof ReaderWindow)

View file

@ -720,6 +720,7 @@
#popups {
panel {
padding: 0;
background: var(--material-background);
.popup {
padding: 14px 16px;
}
@ -732,6 +733,29 @@
background-color: rgba(0, 0, 0, 0.1);
z-index: 1000;
}
#page-suggestion {
--panel-padding: 4px;
// Without this, the background and its corners are drawn natively,
// so the corner radius below has nothing to round
appearance: none;
border-radius: 5px;
@media (-moz-platform: macos) {
border: var(--material-border);
}
#page-suggestion-option {
height: 18px;
line-height: 16px;
cursor: default;
padding: 0 4px;
&:hover {
background-color: var(--accent-blue30);
}
// highlighted via arrow navigation, since the popup never takes focus
&.highlighted {
background-color: var(--accent-blue30);
}
}
}
#settings-popup {
top: 35px;
right: 5px;

View file

@ -16,7 +16,7 @@ describe("Citation Dialog", function () {
preview: () => {},
allCitedDataLoadedPromise: Promise.resolve(),
};
let dialog, win, doc, IOManager, CitationDataManager, SearchHandler;
let dialog, win, doc, IOManager, CitationDataManager, SearchHandler, Helpers;
before(async function () {
// Zotero.Cite.getLocatorString() requires styles to be initialized
@ -30,6 +30,7 @@ describe("Citation Dialog", function () {
IOManager = dialog.IOManager;
CitationDataManager = dialog.CitationDataManager;
SearchHandler = dialog.SearchHandler;
Helpers = dialog.Helpers;
// wait for everything (e.g. itemTree/collectionTree) inside of the dialog to be loaded.
while (!dialog.DIALOG_STATE.loaded) {
await Zotero.Promise.delay(10);
@ -433,6 +434,210 @@ describe("Citation Dialog", function () {
});
});
describe("Page suggestion", function () {
let panel, option, getOpenTabPageStub;
// Add an item to the citation, pretending that it is opened in a reader tab
// displaying the given page, and wait for the suggestion popup to appear
async function addItemWithOpenTabPage(page = "12") {
let item = await createDataObject('item');
getOpenTabPageStub = sinon.stub(Helpers, "getOpenTabPage").returns(page);
await IOManager.addItemsToCitation([item]);
await waitForCallback(() => panel.state == "open");
return item;
}
function keydown(key) {
let input = doc.getElementById("bubble-input").getCurrentInput();
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}
function pageSuggestionText(page) {
return Zotero.Cite.getLocatorString("page", "short").toLowerCase() + " " + page;
}
// Pretend that the reader tab of the given attachment scrolled to the given page
async function changeReaderPage(attachment, page) {
getOpenTabPageStub.returns(page);
await Zotero.Notifier.trigger('pageChange', 'file', [attachment.id]);
}
before(function () {
panel = doc.getElementById("page-suggestion");
option = doc.getElementById("page-suggestion-option");
});
beforeEach(function () {
CitationDataManager.items = [];
IOManager.updateBubbleInput();
// The guidance panel is displayed next to the same bubble, so the suggestion
// is not shown until it has been seen
Zotero.Prefs.set("firstRunGuidanceShown.citationDialog", true);
});
afterEach(async function () {
getOpenTabPageStub?.restore();
getOpenTabPageStub = null;
// Wait for the popup to be fully hidden, since a popup that is still
// hiding cannot be opened again by the next test
if (panel.state != "closed") {
panel.hidePopup();
await waitForCallback(() => panel.state == "closed");
}
});
it("should suggest the current page of an item opened in a reader tab", async function () {
let item = await createDataObject('item');
// A multi-page PDF, so that the reader can be navigated to another page
let attachment = await importFileAttachment('wonderland_short.pdf', {
contentType: 'application/pdf',
parentID: item.id
});
await Zotero.Reader.open(attachment.id);
let reader = Zotero.Reader.getByTabID(win.Zotero_Tabs.selectedID);
await reader._waitForReader();
// View stats, which the current page comes from, are set after the view renders
await waitForCallback(() => reader.getCurrentPage());
assert.equal(reader.getCurrentPage(), "1");
// Opening a tab refocuses the dialog, which kicks off a search
while (SearchHandler.searching) {
await Zotero.Promise.delay(10);
}
assert.equal(await Helpers.getOpenTabPage(item), "1");
await IOManager.addItemsToCitation([item]);
await waitForCallback(() => panel.state == "open");
assert.equal(option.textContent, pageSuggestionText("1"));
// Navigating the reader to another page fires a 'pageChange' notification,
// which the dialog picks up to refresh the suggestion
await reader.navigate({ pageIndex: 2 });
await waitForCallback(() => option.textContent == pageSuggestionText("3"));
assert.equal(panel.state, "open");
// Cleanup
panel.hidePopup();
win.Zotero_Tabs.close(win.Zotero_Tabs.selectedID);
SearchHandler.clearNonLibraryItemsCache();
});
it("should suggest the last saved page of an item opened in an unloaded tab", async function () {
let item = await createDataObject('item');
let attachment = await importPDFAttachment(item);
await Zotero.Reader.open(attachment.id);
let tabID = win.Zotero_Tabs.selectedID;
let reader = Zotero.Reader.getByTabID(tabID);
await reader._waitForReader();
await waitForCallback(() => reader.getCurrentPage());
// The reader saves its state on a debounce, and there is nothing to flush
// until it has done so at least once
await waitForCallback(() => Number.isInteger(attachment.getAttachmentLastPageIndex()));
// Only an unselected tab can be unloaded. Unloading closes the reader,
// which flushes its state -- including the page -- to disk.
win.Zotero_Tabs.select("zotero-pane");
win.Zotero_Tabs.unload(tabID);
// The reader is discarded once the tab close notification is processed
await waitForCallback(() => !Zotero.Reader.getByTabID(tabID));
// The state is written asynchronously
let savedPage;
for (let i = 0; i < 100 && !savedPage; i++) {
savedPage = await Zotero.Reader.getSavedPageLabel(attachment.id);
await Zotero.Promise.delay(10);
}
assert.equal(savedPage, "1");
assert.equal(await Helpers.getOpenTabPage(item), "1");
// A last page index that moved past the saved state (e.g. via sync) makes the
// saved page stale, so nothing is suggested until the reader saves again
await attachment.setAttachmentLastPageIndex(5);
assert.isNull(await Zotero.Reader.getSavedPageLabel(attachment.id));
await attachment.setAttachmentLastPageIndex(0);
assert.equal(await Zotero.Reader.getSavedPageLabel(attachment.id), "1");
while (SearchHandler.searching) {
await Zotero.Promise.delay(10);
}
await IOManager.addItemsToCitation([item]);
await waitForCallback(() => panel.state == "open");
assert.equal(option.textContent, pageSuggestionText("1"));
// Cleanup
panel.hidePopup();
win.Zotero_Tabs.close(tabID);
SearchHandler.clearNonLibraryItemsCache();
});
it("should not suggest a saved EPUB page whose position is out of date", async function () {
let item = await createDataObject('item');
let attachment = await importFileAttachment('stub.epub', {
contentType: 'application/epub+zip',
parentID: item.id
});
// Write the state the reader would have saved for an EPUB with physical page numbers
let cfi = 'epubcfi(/6/4!/4/2/1:0)';
let directory = Zotero.Attachments.getStorageDirectory(attachment);
await Zotero.Attachments.createDirectoryForItem(attachment);
await IOUtils.writeJSON(PathUtils.join(directory.path, '.zotero-reader-state'), { cfi, pageLabel: "42" });
// No synced position yet, so the saved page is trusted
assert.equal(await Zotero.Reader.getSavedPageLabel(attachment.id), "42");
await attachment.setAttachmentLastPageIndex(cfi);
assert.equal(await Zotero.Reader.getSavedPageLabel(attachment.id), "42");
// The synced position moved on, so the saved page is stale
await attachment.setAttachmentLastPageIndex('epubcfi(/6/8!/4/2/1:0)');
assert.isNull(await Zotero.Reader.getSavedPageLabel(attachment.id));
});
it("should add the suggested page as a locator on ArrowDown and Enter", async function () {
await addItemWithOpenTabPage("12");
keydown("ArrowDown");
assert.isTrue(option.classList.contains("highlighted"));
keydown("Enter");
assert.equal(CitationDataManager.items[0].locator, "12");
assert.equal(CitationDataManager.items[0].label, "page");
assert.equal(panel.state, "closed");
// The bubble no longer awaits a typed locator
assert.notOk(IOManager._justAddedBubbles);
});
it("should add the suggested page as a locator on click", async function () {
await addItemWithOpenTabPage("iv");
option.click();
assert.equal(CitationDataManager.items[0].locator, "iv");
assert.equal(CitationDataManager.items[0].label, "page");
assert.equal(panel.state, "closed");
});
it("should dismiss the suggestion when the bubble is no longer just-added", async function () {
await addItemWithOpenTabPage("12");
IOManager._clearJustAddedBubbles();
assert.equal(panel.state, "closed");
});
it("should update the suggestion when the reader tab changes page", async function () {
let item = await addItemWithOpenTabPage("12");
let attachment = await importPDFAttachment(item);
// Highlight the suggestion to make sure the highlight survives the update
keydown("ArrowDown");
await changeReaderPage(attachment, "13");
assert.equal(option.textContent, pageSuggestionText("13"));
assert.equal(panel.state, "open");
assert.isTrue(option.classList.contains("highlighted"));
// The updated page is what gets added as a locator
keydown("Enter");
assert.equal(CitationDataManager.items[0].locator, "13");
});
});
describe("UI", function () {
beforeEach(function () {
CitationDataManager.items = [];