citation dlg: display preview of the citation

In Add/Edit Citation mode, display a preview of the citation
in the bottom section. The section can be hidden/displayed
via the toggle in the right corner.

Remove io.preview from editor instance, so that citation
dialog knows not to show the preview even if the preference
is set.

A minor refactor to have resizeWindow() resolve
when the animation is fully over, and clear minHeight
on window in list mode before resizing, restoring
it when resizing animation is done, same as in library mode.
It allows us to fully expand the window in list mode before
showing the preview.

Fixes: zotero#5910
This commit is contained in:
Bogdan Abaev 2026-06-05 16:37:58 -07:00
parent d0507e02c8
commit bca6377230
9 changed files with 208 additions and 39 deletions

View file

@ -131,6 +131,8 @@ async function onLoad() {
await IOManager.toggleDialogMode(initialMode);
// most of IO handling relies on currentLayout being defined so it must follow setInitialDialogMode
IOManager.init();
// set the text of the citation preview
CitationPreview.update();
// explicitly focus bubble input so one can begin typing right away
_id("bubble-input").refocusInput();
// wait to call functions that rely on io.getItems() or io.sort() till all cited data is loaded
@ -258,8 +260,9 @@ async function setDialogType(type) {
_id("keepSorted").disabled = !io.sortable || !DIALOG_STATE.isCitingItems();
_id("keepSorted").checked = !_id("keepSorted").disabled && !io.citation.properties.unsorted;
if (DIALOG_STATE.isCitingItems()) {
_id("settings-button").hidden = !io.sortable;
_id("keepSorted").disabled = !io.sortable;
_id("keepSorted").parentElement.hidden = !io.sortable;
CitationPreview.update();
if (!DIALOG_STATE.loaded) {
_id("keepSorted").checked = io.sortable && !io.citation.properties.unsorted;
}
@ -596,14 +599,16 @@ class LibraryLayout extends Layout {
IOManager.updateBubbleInput();
}
// Resolves once the resize animation has fully completed
async resizeWindow() {
await Helpers.smoothResizingPromise;
let bubbleInputHeight = Helpers.getSearchRowHeight();
let suggestedItemsHeight = _id("library-other-items").getBoundingClientRect().height;
let minTableHeight = 400;
let citationPreview = _id("citation-preview").getBoundingClientRect().height;
let bottomHeight = _id("bottom-area-wrapper").getBoundingClientRect().height;
let minHeight = bubbleInputHeight + suggestedItemsHeight + bottomHeight + minTableHeight;
let minHeight = bubbleInputHeight + suggestedItemsHeight + citationPreview + bottomHeight + minTableHeight;
let targetWidth = Math.max(window.innerWidth, this.MIN_WIDTH);
let targetHeight = Math.max(minHeight, lastSetWindowHeight);
@ -612,13 +617,16 @@ class LibraryLayout extends Layout {
if (needsResize) {
doc.documentElement.style.removeProperty('min-height');
ignoreWindowResizing = true;
Helpers.smoothResize(targetWidth, targetHeight, {
onComplete: () => {
_id("bubble-input").refocusInput();
doc.documentElement.style.minHeight = `${minHeight}px`;
document.documentElement.setAttribute("dialog-layout", this.type);
ignoreWindowResizing = false;
},
await new Promise((resolve) => {
Helpers.smoothResize(targetWidth, targetHeight, {
onComplete: () => {
_id("bubble-input").refocusInput();
doc.documentElement.style.minHeight = `${minHeight}px`;
document.documentElement.setAttribute("dialog-layout", this.type);
ignoreWindowResizing = false;
resolve();
},
});
});
}
// ensure dialog-layout and min-height is set even if window does not need resizing
@ -1092,6 +1100,7 @@ class ListLayout extends Layout {
IOManager.updateBubbleInput();
}
// Resolves only once the resize animation has fully completed
async resizeWindow() {
await Helpers.smoothResizingPromise;
let bubbleInputHeight = Helpers.getSearchRowHeight();
@ -1112,11 +1121,12 @@ class ListLayout extends Layout {
marginOfError = Zotero.isWin ? 6 : 2;
}
// height of the bottom section
// height of citation preview (0 when hidden) and the bottom section
let citationPreview = _id("citation-preview").getBoundingClientRect().height;
let bottomHeight = _id("bottom-area-wrapper").getBoundingClientRect().height;
// set min height and resize the window
let autoHeight = bubbleInputHeight + sectionsHeight + sectionsWrapperPadding + bottomHeight + marginOfError;
let autoHeight = bubbleInputHeight + sectionsHeight + sectionsWrapperPadding + citationPreview + bottomHeight + marginOfError;
// window.resizeTo(X,Y) resizes the window so that it's outerHeight == Y. On mac and windows,
// innerHeight and outerHeight are the same. On linux, the outerHeight > innerHeight, perhaps
// outerHeight there includes chrome, borders, etc. This difference is accounted for below, so that the dialog
@ -1124,24 +1134,39 @@ class ListLayout extends Layout {
if (Zotero.isLinux) {
autoHeight += (window.outerHeight - window.innerHeight);
}
let minHeight = bubbleInputHeight + bottomHeight;
doc.documentElement.style.minHeight = `${minHeight}px`;
let minHeight = bubbleInputHeight + citationPreview + bottomHeight;
// cap window height at the height last set by the user
autoHeight = Math.min(autoHeight, lastSetWindowHeight);
let targetWidth = Math.min(window.innerWidth, this.MIN_WIDTH);
// Skip the resize animation if the window is already at the target size.
let needsResize = Math.round(window.innerWidth) !== Math.round(targetWidth) || Math.round(window.innerHeight) !== Math.round(autoHeight);
if (!needsResize) {
doc.documentElement.style.minHeight = `${minHeight}px`;
document.documentElement.setAttribute("dialog-layout", this.type);
return;
}
// Clear the min-height floor so the window can animate freely (including shrinking);
// it's restored to the new value in onComplete below.
doc.documentElement.style.removeProperty("min-height");
ignoreWindowResizing = true;
// Timeout is required likely to allow minHeight update to settle
setTimeout(() => {
Helpers.smoothResize(targetWidth, autoHeight, {
onComplete: () => {
_id("bubble-input").refocusInput();
document.documentElement.setAttribute("dialog-layout", this.type);
ignoreWindowResizing = false;
},
});
}, 10);
// Timeout is required likely to allow the min-height removal to settle
await new Promise((resolve) => {
setTimeout(() => {
Helpers.smoothResize(targetWidth, autoHeight, {
onComplete: () => {
_id("bubble-input").refocusInput();
doc.documentElement.style.minHeight = `${minHeight}px`;
document.documentElement.setAttribute("dialog-layout", this.type);
ignoreWindowResizing = false;
resolve();
},
});
}, 10);
});
}
_markRoundedCorners() {
@ -1224,6 +1249,7 @@ const IOManager = {
});
_id("includeComments").addEventListener("click", () => this._toggleIncludeComments());
_id("display-preview-button").addEventListener("click", () => this._toggleDisplayPreview());
// open settings popup on btn click
_id("settings-button").addEventListener("click", event => _id("settings-popup").openPopup(event.target, "before_end"));
@ -1267,10 +1293,12 @@ const IOManager = {
let isInitialModeSetting = currentLayout === undefined;
currentLayout = newMode === "library" ? libraryLayout : listLayout;
// Reflect visibility of the citation preview after the layout switch.
CitationPreview.update();
// Wait for window resize before running search to avoid stutter with large libraries
if (!isInitialModeSetting) {
await currentLayout.resizeWindow();
await Helpers.smoothResizingPromise;
}
// do not show View menubar with itemTree-specific options in list mode
@ -1317,6 +1345,7 @@ const IOManager = {
};
}), DIALOG_STATE.type);
_id("accept-button").disabled = !CitationDataManager.items.length;
CitationPreview.update();
},
async addItemsToCitation(items, { noInputRefocus, index } = { index: null }) {
@ -1394,6 +1423,9 @@ const IOManager = {
doc.querySelector("guidance-panel").setAttribute("x", Math.round(width / 2));
IOManager.showFirstRunDialog();
}
// 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.
await CitationPreview.render();
// Always refresh items list to make sure the opened and selected items are up to date
await currentLayout.refreshItemsList();
if (!noInputRefocus) {
@ -1862,6 +1894,31 @@ const IOManager = {
Zotero.Prefs.set("integration.annotationDialogIncludeComments", includeComments);
},
async _toggleDisplayPreview() {
let newShown = !Zotero.Prefs.get("integration.citationPreviewShown");
Zotero.Prefs.set("integration.citationPreviewShown", newShown);
// Reflect the pressed state right away, since revealing the preview is deferred until resize
_id("display-preview-button").setAttribute("aria-pressed", newShown ? "true" : "false");
let preview = _id("citation-preview");
if (newShown) {
// Lay the preview out off-flow via .measuring (real height, but invisible and not
// pushing the list around) so resizeWindow grows the window to fit it; then drop it
// into view once there's room, avoiding a momentary squeeze of the list.
await CitationPreview.render();
preview.classList.add("measuring");
preview.hidden = false;
await currentLayout.resizeWindow();
preview.classList.remove("measuring");
CitationPreview.update();
}
else {
// Hide first, then shrink -- freeing the space before the window contracts looks clean.
preview.classList.remove("measuring");
CitationPreview.update();
currentLayout.resizeWindow();
}
},
// Return focus to where it was before click moved focus.
// If it's not possible, refocus the last input in bubble-input so that
// focus is not just lost.
@ -1939,6 +1996,53 @@ const IOManager = {
}
};
// Manages the citation preview shown in the bottom area of both layouts.
const CitationPreview = {
// Lazily create _renderDebounced on first use
get _renderDebounced() {
delete CitationPreview._renderDebounced;
CitationPreview._renderDebounced = Zotero.Utilities.debounce(() => CitationPreview.render(), 250);
return CitationPreview._renderDebounced;
},
// The rendered text is kept in sync with the cited items even while the preview is hidden,
// so it can be measured and revealed instantly when toggled on.
update() {
let prefShown = Zotero.Prefs.get("integration.citationPreviewShown");
let isCitingItems = DIALOG_STATE.isCitingItems();
let hasPreview = !!io.preview;
let shouldShow = isCitingItems && prefShown && hasPreview;
_id("citation-preview").hidden = !shouldShow;
let isEmpty = !CitationDataManager.items.length;
_id("citation-preview-header").hidden = isEmpty;
_id("citation-preview-empty").hidden = !isEmpty;
_id("citation-preview-content").hidden = isEmpty;
if (isEmpty) {
_id("citation-preview-content").innerHTML = "";
}
// The toggle button only makes sense while citing items with a backing
// preview function. It reflects the pref directly.
let toggleBtn = _id("display-preview-button");
toggleBtn.hidden = !(isCitingItems && hasPreview);
toggleBtn.setAttribute("aria-pressed", prefShown ? "true" : "false");
if (!isEmpty) {
CitationPreview._renderDebounced();
}
},
async render() {
if (!DIALOG_STATE.isCitingItems()) return;
if (!CitationDataManager.items.length) return;
if (!io.preview) return;
CitationDataManager.updateCitationObject();
let html = await io.preview("html");
// Re-check after the await in case the user cleared items
if (!CitationDataManager.items.length) return;
_id("citation-preview-content").innerHTML = html;
},
};
// Representation of a single entry in the citation.
class BubbleItem {
// Can be created from either Zotero.Item or citation item from io.citation.citationItems

View file

@ -105,6 +105,15 @@
</div>
<div id="bottom-area" class="layout">
<div class="divider"></div>
<div id="citation-preview" class="vbox" data-dialog-type="citation">
<div id="citation-preview-header" data-l10n-id="integration-citationDialog-preview-header"></div>
<div id="citation-preview-wrapper" class="vbox">
<div id="citation-preview-content"></div>
<div id="citation-preview-empty" data-l10n-id="integration-citationDialog-preview-empty"></div>
</div>
<div class="divider subtle"></div>
</div>
<div id="bottom-area-wrapper" class="hbox">
<div id="dialog-type-setting" class="segmented-switch" data-arrow-nav="horizontal" data-tabindex="70" role="radiogroup" aria-orientation="horizontal">
<div id="dialog-type-citation" class="option keyboard-clickable" value="citation" role="radio" aria-checked="false" tabindex="-1" data-arrow-nav-enabled="true" data-l10n-id="integration-citationDialog-btn-type-citation">
@ -119,7 +128,9 @@
</div>
<div class="hbox" style="flex:1"></div>
<div id="bottom-btn-group" class="hbox">
<div id="dialog-mode-setting" data-dialog-type="citation,add-note" class="segmented-switch" role="radiogroup" data-arrow-nav="horizontal" data-tabindex="80" aria-orientation="horizontal">
<button id="display-preview-button" class="btn-icon" data-dialog-type="citation" data-l10n-id="integration-citationDialog-btn-displayPreview" aria-pressed="false" tabindex="-1" data-tabindex="80"></button>
<button id="settings-button" class="btn-icon icon-citation-dialog-settings" data-l10n-id="integration-citationDialog-btn-settings" tabindex="-1" data-tabindex="81"></button>
<div id="dialog-mode-setting" data-dialog-type="citation,add-note" class="segmented-switch" role="radiogroup" data-arrow-nav="horizontal" data-tabindex="82" aria-orientation="horizontal">
<div id="dialog-mode-list" class="option keyboard-clickable" value="list" role="radio" aria-checked="false" tabindex="-1" data-arrow-nav-enabled="true">
<span data-l10n-id="integration-citationDialog-mode-list"></span>
</div>
@ -127,7 +138,6 @@
<span data-l10n-id="integration-citationDialog-mode-library"></span>
</div>
</div>
<button id="settings-button" class="btn-icon icon-citation-dialog-settings" data-l10n-id="integration-citationDialog-btn-settings" tabindex="-1" data-tabindex="81"></button>
</div>
</div>
</div>

View file

@ -1226,14 +1226,6 @@ class EditorInstance {
// Otherwise returns `undefined` which makes this function to be
},
/**
* Execute a callback with a preview of the given citation
* @return {Promise} A promise resolved with the previewed citation string
*/
preview: async function () {
// Zotero.debug('CI: preview');
},
/**
* Sort the citationItems within citation (depends on this.citation.properties.unsorted)
* @return {Promise} A promise resolved with the previewed citation string

View file

@ -1564,7 +1564,7 @@ Zotero.Integration.Session.prototype.cite = async function (field, addNote=false
this.updateFromDocument(FORCE_CITATIONS_FALSE).then(() => this.citationsByItemID);
}
var previewFn = async function (citation) {
var previewFn = async function (citation, format) {
let idx = await fieldIndexPromise;
await citationsByItemIDPromise;
@ -1580,7 +1580,7 @@ Zotero.Integration.Session.prototype.cite = async function (field, addNote=false
let citationsPost = citations.slice(sliceIdx);
let citationID = citation.citationID;
try {
var result = this.style.previewCitationCluster(citation, citationsPre, citationsPost, "rtf");
var result = this.style.previewCitationCluster(citation, citationsPre, citationsPost, format || "rtf");
} catch(e) {
throw e;
} finally {
@ -1811,10 +1811,11 @@ Zotero.Integration.CitationEditInterface = function (items, sortable, fieldIndex
Zotero.Integration.CitationEditInterface.prototype = {
/**
* Execute a callback with a preview of the given citation
* @param {String} [format] Override the default output format (e.g. "html" for use in citation dialog)
* @return {Promise} A promise resolved with the previewed citation string
*/
preview: function () {
return this.previewFn(this.citation);
preview: function (format) {
return this.previewFn(this.citation, format);
},
/**

View file

@ -49,6 +49,10 @@ integration-citationDialog-lib-message-annotations = { $search ->
*[other] No selected or open items with annotations
}
integration-citationDialog-settings-keepSorted = Keep sources sorted
integration-citationDialog-preview-header = Preview
integration-citationDialog-preview-empty = Preview of your citation will appear here.
integration-citationDialog-btn-displayPreview =
.title = Display citation preview
integration-citationDialog-btn-settings =
.title = { general-open-settings }
integration-citationDialog-mode-library = Library

View file

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 13H14V10H12.5L13.5 7H12L11 10V13ZM6 10V13H9V10H7.5L8.5 7H7L6 10Z" fill="context-fill"/>
<path d="M3.25 14V16.75H6V18H3.25C2.60285 18 2.07092 17.5082 2.00684 16.8779L2 16.75V14H3.25ZM18 16.75C18 17.3972 17.5082 17.9291 16.8779 17.9932L16.75 18H14V16.75H16.75V14H18V16.75ZM16.75 2C17.4404 2 18 2.55964 18 3.25V6H16.75V3.25H14V2H16.75ZM6 3.25H3.25V6H2V3.25C2 2.55964 2.55964 2 3.25 2H6V3.25Z" fill="context-fill"/>
</svg>

After

Width:  |  Height:  |  Size: 528 B

View file

@ -153,6 +153,7 @@ pref("extensions.zotero.integration.upgradeTemplateDelayedOn", 0);
pref("extensions.zotero.integration.dontPromptMendeleyImport", false);
pref("extensions.zotero.integration.citationDialogMode", "last-used");
pref("extensions.zotero.integration.annotationDialogIncludeComments", true);
pref("extensions.zotero.integration.citationPreviewShown", true);
// Connector settings
pref("extensions.zotero.httpServer.enabled", true);

View file

@ -110,6 +110,11 @@
.divider {
border-bottom: 1px solid var(--color-panedivider);
margin: 0;
&.subtle {
border-bottom-color: var(--fill-quinary);
margin: 0 8px;
}
}
.add-all {
@ -547,8 +552,48 @@
}
}
#citation-preview {
padding-top: 6px;
// While the preview is being toggled on, lay it out off-flow so the window can grow to
// fit it before it's revealed: absolute keeps a real, measurable height without pushing
// the list around, and visibility:hidden keeps it invisible until there's room for it.
// inset-inline: 0 matches the in-flow width (body is the flow parent), so the measured
// height is accurate.
&.measuring {
position: absolute;
inset-inline: 0;
visibility: hidden;
}
#citation-preview-header {
padding: 0px 12px 4px 12px;
color: var(--fill-secondary);
font-weight: 600;
font-size: 0.93rem;
}
#citation-preview-wrapper {
padding: 0px 12px 8px 12px;
min-height: 20px;
max-height: 160px;
overflow-y: auto;
-moz-window-dragging: no-drag;
font-family: "Times New Roman";
#citation-preview-content {
color: var(--fill-primary);
font-size: 1rem;
}
#citation-preview-empty {
padding-top: 2px;
color: var(--fill-secondary);
}
}
}
#bottom-area-wrapper {
border-top: var(--material-panedivider);
padding: 4px 8px;
.segmented-switch {
@ -647,6 +692,12 @@
height: 28px;
gap: 8px;
-moz-window-dragging: no-drag;
#display-preview-button {
@include svgicon("dialog-citation-preview", "universal", "20");
&[aria-pressed="true"] {
background-color: var(--fill-quinary);
}
}
#settings-button {
@include svgicon("dialog-options", "universal", "16");
}

View file

@ -13,6 +13,7 @@ describe("Citation Dialog", function () {
getItems() {
return [];
},
preview: () => {},
allCitedDataLoadedPromise: Promise.resolve(),
};
let dialog, win, doc, IOManager, CitationDataManager, SearchHandler;
@ -661,6 +662,7 @@ describe("Citation Dialog", function () {
getItems() {
return new Zotero.Promise(() => {});
},
preview: () => {},
allCitedDataLoadedPromise: new Zotero.Promise(() => {}),
};