From bca63772309406abdcd60d0ceed5dbb0d5687177 Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Fri, 5 Jun 2026 16:37:58 -0700 Subject: [PATCH] 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 --- .../zotero/integration/citationDialog.js | 152 +++++++++++++++--- .../zotero/integration/citationDialog.xhtml | 14 +- chrome/content/zotero/xpcom/editorInstance.js | 8 - chrome/content/zotero/xpcom/integration.js | 9 +- chrome/locale/en-US/zotero/integration.ftl | 4 + .../20/universal/dialog-citation-preview.svg | 4 + defaults/preferences/zotero.js | 1 + scss/components/_citationDialog.scss | 53 +++++- test/tests/citationDialogTest.js | 2 + 9 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 chrome/skin/default/zotero/20/universal/dialog-citation-preview.svg diff --git a/chrome/content/zotero/integration/citationDialog.js b/chrome/content/zotero/integration/citationDialog.js index 1dff8c0b15..4b8ad42c88 100644 --- a/chrome/content/zotero/integration/citationDialog.js +++ b/chrome/content/zotero/integration/citationDialog.js @@ -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 diff --git a/chrome/content/zotero/integration/citationDialog.xhtml b/chrome/content/zotero/integration/citationDialog.xhtml index 722af2ae97..926c55bfff 100644 --- a/chrome/content/zotero/integration/citationDialog.xhtml +++ b/chrome/content/zotero/integration/citationDialog.xhtml @@ -105,6 +105,15 @@
+
+
+
+
+
+
+
+
+
-
+ + +
@@ -127,7 +138,6 @@
-
diff --git a/chrome/content/zotero/xpcom/editorInstance.js b/chrome/content/zotero/xpcom/editorInstance.js index 4d10d37834..bc70e51a1a 100644 --- a/chrome/content/zotero/xpcom/editorInstance.js +++ b/chrome/content/zotero/xpcom/editorInstance.js @@ -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 diff --git a/chrome/content/zotero/xpcom/integration.js b/chrome/content/zotero/xpcom/integration.js index ab5235c61d..a474dde978 100644 --- a/chrome/content/zotero/xpcom/integration.js +++ b/chrome/content/zotero/xpcom/integration.js @@ -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); }, /** diff --git a/chrome/locale/en-US/zotero/integration.ftl b/chrome/locale/en-US/zotero/integration.ftl index 36b8d0b9a0..f947cfdedb 100644 --- a/chrome/locale/en-US/zotero/integration.ftl +++ b/chrome/locale/en-US/zotero/integration.ftl @@ -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 diff --git a/chrome/skin/default/zotero/20/universal/dialog-citation-preview.svg b/chrome/skin/default/zotero/20/universal/dialog-citation-preview.svg new file mode 100644 index 0000000000..b3ecf773a5 --- /dev/null +++ b/chrome/skin/default/zotero/20/universal/dialog-citation-preview.svg @@ -0,0 +1,4 @@ + + + + diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index 054b7bfd43..3c66fa7089 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -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); diff --git a/scss/components/_citationDialog.scss b/scss/components/_citationDialog.scss index df915ef9f3..9e7a108354 100644 --- a/scss/components/_citationDialog.scss +++ b/scss/components/_citationDialog.scss @@ -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"); } diff --git a/test/tests/citationDialogTest.js b/test/tests/citationDialogTest.js index 2c8c809fe0..d3e9dd605e 100644 --- a/test/tests/citationDialogTest.js +++ b/test/tests/citationDialogTest.js @@ -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(() => {}), };