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" + >