diff --git a/chrome/content/zotero/elements/bubbleInput.js b/chrome/content/zotero/elements/bubbleInput.js index 6f93259659..7492c8cae8 100644 --- a/chrome/content/zotero/elements/bubbleInput.js +++ b/chrome/content/zotero/elements/bubbleInput.js @@ -36,6 +36,7 @@ this._body = this.querySelector('.bubble-input.body'); this._body.addEventListener('click', this._onBodyClick.bind(this)); this._lastFocusedInput = null; + this.showJustAddedPlaceholder = false; Utils.init(this); DragDropHandler.init(this); @@ -123,11 +124,10 @@ if (isOnlyInput) { document.l10n.setAttributes(this._body.firstChild, `integration-citationDialog-single-input-${dialogType}`); } - // otherwise, add a regular aria descriptions and placeholders to all inputs + // otherwise, set default placeholders for all inputs, with special handling + // of the last input after a bubble is added with visible placeholder else { - for (let input of [...this.querySelectorAll(".input")]) { - document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`); - } + Utils.setupInputPlaceholders(this.showJustAddedPlaceholder, dialogType); } // If any two inputs end up next to each other (e.g. after bubble is deleted), // have them merged @@ -343,6 +343,13 @@ if (!Utils.isInputEmpty(input) || !this.contains(event.relatedTarget)) { this._lastFocusedInput = input; } + // Collapse a placeholder input back to regular size once focus leaves. + // The placeholder attributes are reset on the next refresh() + if (input.classList.contains("just-added-placeholder")) { + input.classList.remove("just-added-placeholder"); + input.removeAttribute("title"); + input.style.minWidth = ""; + } }); return input; } @@ -604,6 +611,87 @@ return spanWidth; }, + getTextWidth(text) { + let span = document.createElement("span"); + span.classList = "input"; + span.innerText = text; + this.bubbleInput._body.appendChild(span); + let spanWidth = span.getBoundingClientRect().width; + span.remove(); + return spanWidth; + }, + + // Return the longest prefix of `text` such that prefix + "…" fits within maxWidth, + // or the original text if it already fits. Used for placeholder truncation, since + // text-overflow:ellipsis doesn't work on in Firefox chrome. + truncateToWidth(text, maxWidth) { + if (this.getTextWidth(text) <= maxWidth) return text; + let ellipsis = "…"; + for (let i = text.length - 1; i > 0; i--) { + let candidate = text.slice(0, i) + ellipsis; + if (this.getTextWidth(candidate) <= maxWidth) { + return candidate; + } + } + return ellipsis; + }, + + // Set aria-descriptions and placeholders on every input in the bubble-input. + // For the just-added input (the last one, when showJustAddedPlaceholder is set), the + // placeholder is truncated to the space remaining on its line and passed to Fluent as + // a variable. + setupInputPlaceholders(showJustAddedPlaceholder, dialogType) { + let allInputs = [...this.bubbleInput.querySelectorAll(".input")]; + let lastInput = allInputs[allInputs.length - 1]; + let bodyRight = this.bubbleInput._body.getBoundingClientRect().right; + for (let input of allInputs) { + let isJustAdded = showJustAddedPlaceholder && input === lastInput; + if (!isJustAdded) { + // If the just-added placeholder was dismissed (e.g. by right-arrow at the + // end of the input) while the input is still focused and empty, keep the + // visible placeholder but fall back to the default search prompt, since + // searching still works. It collapses when the input loses focus (see the + // blur handler in _createInputElem()) + if (input.classList.contains("just-added-placeholder") + && document.activeElement == input && !input.value) { + let placeholder = this.truncateToWidth( + " " + Zotero.getString("integration-citationDialog-search-for-items"), + parseFloat(input.style.minWidth) || Infinity); + document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title: "" }); + continue; + } + input.classList.remove("just-added-placeholder"); + document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`); + // Clear any stale title and min-width left over from a previous just-added state + input.removeAttribute("title"); + input.style.minWidth = ""; + } + if (isJustAdded && !input.classList.contains("just-added-placeholder")) { + input.classList.add("just-added-placeholder"); + // Leading NBSP gives a small visual gap between the cursor and the placeholder + // text (CSS padding/text-indent on input or ::placeholder both move the cursor too) + let fullPlaceholder = " " + Zotero.getString("integration-citationDialog-just-added-input-placeholder"); + let availableWidth = bodyRight - input.getBoundingClientRect().left - 20; + let placeholderWidth = this.getTextWidth(fullPlaceholder); + let placeholder, title, minWidth; + if (availableWidth >= placeholderWidth) { + placeholder = fullPlaceholder; + title = ""; + minWidth = placeholderWidth; + } + else { + placeholder = this.truncateToWidth(fullPlaceholder, availableWidth); + title = fullPlaceholder; + minWidth = availableWidth; + } + // min-width keeps the placeholder visible when the input is empty, but + // lets the input grow to fit content the user types beyond the placeholder. + input.style.minWidth = minWidth + 'px'; + document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title }); + } + } + }, + // If a bubble is removed between two inputs we need to combine them combineNeighboringInputs(startingNode) { let node = startingNode; diff --git a/chrome/content/zotero/integration/citationDialog.js b/chrome/content/zotero/integration/citationDialog.js index c35d1365bb..f0100c65fc 100644 --- a/chrome/content/zotero/integration/citationDialog.js +++ b/chrome/content/zotero/integration/citationDialog.js @@ -1206,6 +1206,7 @@ class ListLayout extends Layout { const IOManager = { sectionExpandedStatus: {}, _skipInputAcceptOnEnterUntil: 0, + _timesItemsAdded: 0, // most essential IO functionality that is added immediately on load preInit() { @@ -1406,7 +1407,12 @@ const IOManager = { // If no locator is provided, record which bubbles were just added. // If a locator is typed next, these bubbles will receive it. this._justAddedBubbles = bubbleItems; + // Only show the placeholder guidance on the first add -- after + // that, the user presumably knows about the shortcut + _id("bubble-input").showJustAddedPlaceholder = DIALOG_STATE.isCitingItems() + && this._timesItemsAdded < 1; } + this._timesItemsAdded++; await CitationDataManager.addItems({ bubbleItems, index }); // Refresh the itemTree if in library mode if (currentLayout.type == "library") { @@ -1695,6 +1701,9 @@ const IOManager = { input.value = ""; input.dispatchEvent(new Event('input', { bubbles: true })); this.updateBubbleInput(); + // The typed-locator shortcut has been used, so stop showing the tip + // about it in the item details popup + Zotero.Prefs.set("integration.citationDialogShowLocatorTip", false); return; } } @@ -1735,9 +1744,14 @@ const IOManager = { _deleteItem(dialogReferenceID) { CitationDataManager.deleteItem({ dialogReferenceID }); + // If the citation is emptied, show the placeholder guidance again on the next add + if (!CitationDataManager.items.length) { + this._timesItemsAdded = 0; + } if (currentLayout.type == "library") { libraryLayout.refreshItemsView(); } + this._clearJustAddedBubbles(); this.updateBubbleInput(); // Always refresh items list to make sure the opened and selected items are up to date currentLayout.refreshItemsList(); @@ -1835,9 +1849,14 @@ const IOManager = { bubbleItem.label = "page"; } IOManager._hideLoadingSpinner(); - // Clear the input and update bubbles + // Clear the input and update bubbles. The placeholder stays, since both of its + // suggestions still apply -- typed digits keep appending to the locator, and + // any other input starts a search input.value = ""; IOManager.updateBubbleInput(); + // The typed-locator shortcut has been used, so stop showing the tip + // about it in the item details popup + Zotero.Prefs.set("integration.citationDialogShowLocatorTip", false); // Disable Enter on input from accepting the dialog for the next 500ms; // If one intends to confirmed the numeric locator by pressing Enter (via _handleInputEnter), // we ensure that the Enter keypress won't happen right after when the locator is added to @@ -1851,11 +1870,14 @@ const IOManager = { // and Enter is presses, just-added bubbles get that locator. _clearJustAddedBubbles(event) { if (!this._justAddedBubbles) return; - // on keydown, only proceed if it's an arrow key - let navigationKeys = ["ArrowUp", "ArrowDown", "ArrowRight", "ArrowLeft"]; - if (event && event.type == "keydown" && !navigationKeys.includes(event.key)) return; + // On keydown, only proceed for left/right arrows, which move to another + // reference (e.g. to explicitly search for a year). Up/down arrows just move + // the list selection while focus remains in the input, so locator entry + // stays active. + if (event && event.type == "keydown" && !["ArrowLeft", "ArrowRight"].includes(event.key)) return; // clear just added bubbles and update bubble input to reflect that this._justAddedBubbles = null; + _id("bubble-input").showJustAddedPlaceholder = false; this.updateBubbleInput(); }, diff --git a/chrome/content/zotero/integration/citationDialog.xhtml b/chrome/content/zotero/integration/citationDialog.xhtml index 3e31708baf..a1122cd14d 100644 --- a/chrome/content/zotero/integration/citationDialog.xhtml +++ b/chrome/content/zotero/integration/citationDialog.xhtml @@ -31,7 +31,9 @@ xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" drawintitlebar-platforms="mac,win" - resizable="false"> + resizable="false" + windowtype="zotero:citation-dialog" + > @@ -154,6 +156,9 @@ +
+ +
diff --git a/chrome/content/zotero/integration/citationDialog/popupHandler.mjs b/chrome/content/zotero/integration/citationDialog/popupHandler.mjs index 1d1ce61f34..22e165f528 100644 --- a/chrome/content/zotero/integration/citationDialog/popupHandler.mjs +++ b/chrome/content/zotero/integration/citationDialog/popupHandler.mjs @@ -99,6 +99,9 @@ export class CitationDialogPopupsHandler { popup.style.top = `${bubbleRect.bottom + 10}px`; this._getNode("#itemDetails .show").hidden = !this.bubbleItem.item.id; + // Hide the typed-locator tip once the shortcut has been used + this._getNode("#itemDetails .details-locator-info").hidden + = !Zotero.Prefs.get("integration.citationDialogShowLocatorTip"); let topLevelItem = this.bubbleItem.item.topLevelItem; // Add header and fill inputs with their values diff --git a/chrome/locale/en-US/zotero/integration.ftl b/chrome/locale/en-US/zotero/integration.ftl index fdc2d14062..b7653c4892 100644 --- a/chrome/locale/en-US/zotero/integration.ftl +++ b/chrome/locale/en-US/zotero/integration.ftl @@ -30,6 +30,7 @@ integration-citationDialog-section-cited = { $count -> integration-citationDialog-details-suffix = Suffix integration-citationDialog-details-prefix = Prefix integration-citationDialog-details-suppressAuthor = Omit Author +integration-citationDialog-details-locator-info = Tip: You can also type page numbers and other locators directly into the main field. Learn more integration-citationDialog-details-includeComments = Include Comments integration-citationDialog-details-remove = { general-remove } integration-citationDialog-details-done = @@ -77,6 +78,11 @@ integration-citationDialog-aria-bubble = integration-citationDialog-single-input-citation = .placeholder = { integration-citationDialog-search-for-items } .aria-description = Press Tab to select items to add to this citation. Press Escape to discard the changes and close the dialog. +integration-citationDialog-just-added-input-placeholder = Type “10-15” to cite pages, or search for items +integration-citationDialog-just-added-input-citation = + .placeholder = { $placeholder } + .title = { $title } + .aria-description = { integration-citationDialog-general-instructions } integration-citationDialog-input-citation = .placeholder = { integration-citationDialog-search-for-items } .aria-description = { integration-citationDialog-general-instructions } diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index 3c66fa7089..e9caa7242b 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -152,6 +152,7 @@ pref("extensions.zotero.integration.keepAddCitationDialogRaised", false); pref("extensions.zotero.integration.upgradeTemplateDelayedOn", 0); pref("extensions.zotero.integration.dontPromptMendeleyImport", false); pref("extensions.zotero.integration.citationDialogMode", "last-used"); +pref("extensions.zotero.integration.citationDialogShowLocatorTip", true); pref("extensions.zotero.integration.annotationDialogIncludeComments", true); pref("extensions.zotero.integration.citationPreviewShown", true); diff --git a/scss/components/_citationDialog.scss b/scss/components/_citationDialog.scss index 84810ec27f..298e4675bb 100644 --- a/scss/components/_citationDialog.scss +++ b/scss/components/_citationDialog.scss @@ -786,6 +786,14 @@ height: 24px; // same height as input } } + .details-locator-info { + padding-inline-start: 5px; + grid-column: 2; + // pull closer to the locator input above + margin-top: -4px; + color: var(--fill-secondary); + font-size: .93rem; + } } .buttons { display: flex; diff --git a/scss/elements/_bubbleInput.scss b/scss/elements/_bubbleInput.scss index 2a72c9c675..905dc08706 100644 --- a/scss/elements/_bubbleInput.scss +++ b/scss/elements/_bubbleInput.scss @@ -52,7 +52,7 @@ bubble-input { // inputs initially occupy no width so they don't misalign bubbles at the // start of each line. On focus, set their width for the cursor to appear // and offset it by negative margin to avoid bubbles shifting - &.empty:not(.full-width):focus { + &.empty:not(.full-width):not(.just-added-placeholder):focus { min-width: 1px; margin-inline-start: -1px; }