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(() => {}), };