diff --git a/chrome/content/zotero/elements/bubbleInput.js b/chrome/content/zotero/elements/bubbleInput.js index 6f93259659..f5460f0893 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 @@ -604,6 +604,74 @@ 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) { + 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 c54d5d6b87..f5d54e90ce 100644 --- a/chrome/content/zotero/integration/citationDialog.js +++ b/chrome/content/zotero/integration/citationDialog.js @@ -1375,6 +1375,7 @@ 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; + _id("bubble-input").showJustAddedPlaceholder = DIALOG_STATE.isCitingItems(); } await CitationDataManager.addItems({ bubbleItems, index }); // Refresh the itemTree if in library mode @@ -1693,6 +1694,7 @@ const IOManager = { 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(); @@ -1811,6 +1813,7 @@ const IOManager = { if (event && event.type == "keydown" && !navigationKeys.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 38784ef7df..121dd06cc1 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" + >