Citation dialog: display preview of the citation (#5916)

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:
abaevbog 2026-06-09 09:35:30 -07:00 • committed by GitHub
parent dcf5010415
commit fa3e0f683f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 198 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,52 @@ 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-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,14 @@
</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-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 +127,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 +137,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,9 @@ integration-citationDialog-lib-message-annotations = { $search ->
*[other] No selected or open items with annotations
}
integration-citationDialog-settings-keepSorted = Keep sources sorted
integration-citationDialog-preview-empty = Preview
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,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0005 4C14.1006 4.0002 17.6223 6.4688 19.1655 10C17.6223 13.5312 14.1006 15.9998 10.0005 16C5.90005 16 2.37673 13.5315 0.833496 10C2.37673 6.46847 5.90005 4 10.0005 4ZM10.0005 5.25C6.6116 5.25 3.67094 7.17741 2.21729 10C3.67094 12.8226 6.6116 14.75 10.0005 14.75C13.3886 14.7498 16.3271 12.8223 17.7808 10C16.3752 7.27113 13.5816 5.37828 10.3354 5.25586L10.0005 5.25ZM10.0005 6.75C11.7952 6.75026 13.2505 8.20524 13.2505 10C13.2505 11.7948 11.7952 13.2497 10.0005 13.25C8.20556 13.25 6.75049 11.7949 6.75049 10C6.75049 8.20507 8.20556 6.75 10.0005 6.75ZM10.0005 8C8.89592 8 8.00049 8.89543 8.00049 10C8.00049 11.1046 8.89592 12 10.0005 12C11.1048 11.9997 12.0005 11.1044 12.0005 10C12.0005 8.89559 11.1048 8.00026 10.0005 8Z" fill="context-fill"/>
</svg>

After

Width:  |  Height:  |  Size: 863 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,42 @@
}
}
#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-wrapper {
padding: 2px 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: 1.1rem;
}
#citation-preview-empty {
padding-top: 2px;
color: var(--fill-secondary);
font-family: $font-family-base;
}
}
}
#bottom-area-wrapper {
border-top: var(--material-panedivider);
padding: 4px 8px;
.segmented-switch {
@ -647,6 +686,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(() => {}),
};